各位同学,大家晚上好!
把门关上,把手机收一收。今天我们不聊“如何用PHP写个Hello World”,我们要聊点硬核的——如何用PHP设计一个能抗住“双十一”流量的短链接服务,并且绝不让你的数据库因为大家都在抢同一个入口而原地去世。
大家都知道短链接吧?就是把 https://www.google.com/search?q=how+to+make+php+high+performance 变成 http://t.cn/xyz123。
这玩意儿看着简单,是不是?甚至你随便写个PHP脚本,把数据库里的URL存起来,把长URL存成短字符串,看起来没问题。但是!一旦你上了首页Banner,一旦你搞了朋友圈转发,瞬间,你的服务器CPU直接飙升到100%,数据库连接池被“捅成筛子”,报错信息满天飞:“SQLSTATE[HY000]: General error: 1205 Lock wait timeout exceeded; try restarting transaction”。
别慌,作为你们的“老司机”,今天我就带大家拆解一下,如何在PHP的环境下,避开数据库热点的“坑”,写出高可用的短链接系统。
第一章:数据库为什么会“猝死”?(痛点分析)
咱们先别急着上代码,先搞清楚敌人是谁。
假设你的短链接服务架构是这样的:
- 用户访问
http://t.cn/abc。 - PHP代码去MySQL里查:
SELECT long_url FROM short_links WHERE short_code='abc'。 - 返回结果,PHP重定向。
这看起来完美无缺,对吧?
错!大错特错!
场景模拟:
假设你的短链接服务刚上线,大家都爱用 abc。突然,全网的流量、爬虫、恶意刷子,一窝蜂地涌向你的 http://t.cn/abc 这个接口。
一秒钟内,可能有10万个人同时访问 t.cn/abc。
这时候,数据库里哪一行是 abc?是 (id=1, short_code='abc')。
好,这10万个请求同时杀到数据库。它们都在干什么?
它们都在执行 SELECT ... WHERE short_code='abc'。
重点来了:
如果你的数据库表是 InnoDB 引擎,所有的读请求,在锁机制里,本质上都是“读锁”(或者叫共享锁)。
虽然理论上读锁不会互相阻塞,但是!如果你的查询请求非常多,超过了数据库的 max_connections 限制,或者因为锁等待时间过长,你的PHP连接池就空了。
更可怕的是什么?
有时候我们会更新访问次数:UPDATE short_links SET clicks=clicks+1 WHERE short_code='abc'。这时候,所有的 UPDATE 请求会争抢同一个 id=1 的行的排他锁。这就是传说中的“热点数据”。
你的数据库就像一个只有一口锅的食堂大妈,所有人都端着碗站在门口喊:“大妈,我要这个菜!”大妈刚把菜给第一个人,第二个人就抢了勺子。最后,所有人都饿着肚子走了(请求超时失败)。
所以,我们的核心任务只有两个:
- 把数据库扛下来(不要让数据库死锁)。
- 把请求扛下来(让用户觉得快)。
第二章:第一招——Redis 缓冲池(降维打击)
既然数据库是瓶颈,那我们就得把数据库“挤”出去。
策略:
我们引入 Redis。Redis 是内存数据库,那是相当快,比 MySQL 快个几万倍。我们用 Redis 存短链接的映射关系。
核心逻辑:
- 用户访问
t.cn/abc。 - PHP 先查 Redis:
GET abc。 - 如果 Redis 里有(比如缓存了5分钟),直接返回,完事,不碰数据库。
- 如果 Redis 里没有(缓存过期了),怎么办?
- 死锁机制(SetNX): 别直接去查数据库!如果有10个请求同时发现 Redis 里没有
abc,它们会一窝蜂去查数据库。这回数据库还是得死。 - 我们要用 Redis 的
SETNX(Set if Not eXists)命令。第一个拿到锁的请求去查数据库,查到了写回 Redis,然后把锁释放。其他的请求在 Redis 里排队等锁。
- 死锁机制(SetNX): 别直接去查数据库!如果有10个请求同时发现 Redis 里没有
代码演示(PHP + Predis):
<?php
require 'vendor/autoload.php';
use PredisClient;
class ShortLinkService {
private $redis;
private $db;
private $lockKey = "lock:short_code:";
public function __construct() {
// 模拟连接
$this->redis = new Client([
'scheme' => 'tcp',
'host' => '127.0.0.1',
'port' => 6379,
]);
$this->db = new PDO('mysql:host=localhost;dbname=short_url_db', 'user', 'pass');
}
public function redirect($shortCode) {
// 1. 先查 Redis
$longUrl = $this->redis->get("url:$shortCode");
if ($longUrl) {
// 缓存命中,直接重定向,不写日志,不查库,极速!
header("Location: $longUrl");
exit;
}
// 2. 缓存未命中,进入“抢夺”模式
$lockKey = $this->lockKey . $shortCode;
// 设置锁,过期时间3秒,防止死锁
$isLocked = $this->redis->setnx($lockKey, time());
if ($isLocked) {
// 走到这里说明我是第一个抢到锁的,我去查库
$stmt = $this->db->prepare("SELECT long_url FROM short_links WHERE short_code = ?");
$stmt->execute([$shortCode]);
$result = $stmt->fetch(PDO::FETCH_ASSOC);
if ($result) {
$longUrl = $result['long_url'];
// 3. 查到了,写回 Redis,设置 TTL(比如5分钟),过期自动失效
$this->redis->setex("url:$shortCode", 300, $longUrl);
} else {
// 4. 查不到,说明是非法链接,可能被删了或者根本不存在
// 这种情况下,为了防止“缓存穿透”,最好给个空值缓存
$this->redis->setex("url:$shortCode", 60, 'NULL');
}
// 5. 释放锁
// 注意:这里有个小坑,释放锁的时候最好用 Lua 脚本保证原子性,防止别人拿错锁释放了
$this->redis->del($lockKey);
} else {
// 锁被别人拿了,那我怎么办?等!
// 哪怕等个几百毫秒,也比数据库挂掉强。
// 这时候可以用 Redis 的 BLPOP 做阻塞队列,或者简单的 sleep + retry
// 为了简单演示,我们直接 sleep 50ms 再试一次
usleep(50000);
return $this->redirect($shortCode); // 递归重试
}
// 拿到数据了,重定向
if ($longUrl && $longUrl !== 'NULL') {
header("Location: $longUrl");
exit;
} else {
die("404 Not Found");
}
}
}
点评:
这套方案虽然能解决大部分“热点”问题,但它有个小毛病:并发高的场景下,可能会有很多请求卡在 usleep 这里。
如果瞬间有1000个请求都发现没缓存,它们都会去抢 SETNX。抢到的去查库,抢不到的就在那里傻等。虽然数据库不用死,但 PHP 进程也空转。
这就引出了我们的进阶方案。
第三章:第二招——避免自增ID(换个作弊码)
我们再来看看数据的存储结构。
很多新手设计短链接的时候,表结构是这样的:
CREATE TABLE `short_links` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`short_code` varchar(10) NOT NULL,
`long_url` text NOT NULL,
`clicks` int(11) DEFAULT '0',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_code` (`short_code`)
) ENGINE=InnoDB;
注意那个 AUTO_INCREMENT。这东西是短链接服务的大忌!
为什么?因为 short_code 是用户看到的,通常是 a, b, c,或者是 ABCD。但是数据库的 id 是 1, 2, 3, 4, 5...。
如果用户喜欢点击 a,那么数据库里就只有 (id=1, short_code='a') 这一行。
这就是“热点”!
所有的 UPDATE short_links SET clicks=clicks+1 WHERE id=1,所有的人都在争抢这根针。
解决方案:
不要用自增ID做主键!用随机字符串做主键!
我们用 uuid() 或者自定义的哈希算法,把 id 变成 a1b2c3d4 这样的一串乱码。
这样,数据库里的数据就是均匀分布的。虽然我们在 Redis 里存的是 abc,但我们在数据库里存的 a1b2c3d4 是唯一的。为了保持“短”,我们在输出 a1b2c3d4 的时候,取它前面的几个字符或者做一次 Base62 编码。
这就叫“无状态”或者“离散化”设计。
改进后的表结构:
CREATE TABLE `short_links` (
`uuid` char(32) NOT NULL COMMENT '随机唯一标识,比如雪花算法生成的ID',
`short_code` varchar(10) NOT NULL COMMENT '给用户看的短码',
`long_url` text NOT NULL,
`clicks` int(11) DEFAULT '0',
PRIMARY KEY (`uuid`),
UNIQUE KEY `uk_code` (`short_code`)
) ENGINE=InnoDB;
现在,不管有多少人访问 http://t.cn/abc,它们对应的数据库行号都是不一样的(假设我们做了哈希取模),数据库就不会死锁了。
但是,还有一个问题:如何快速从 abc 找到 uuid?
我们还是得查数据库,或者查 Redis。如果查数据库,虽然不会死锁,但 WHERE short_code='abc' 这个索引查找,如果 abc 被频繁访问,索引页可能也会被读满。
这时候,我们需要更高级的数据结构。
第四章:第三招——基数树(Radix Tree)与位图
这是计算机科学里的经典数据结构。如果你觉得树太复杂,那我们用最简单的哈希表(数组)思路。
假设我们的短码空间很大,比如 a 到 zzzzzz。我们可以在内存里开辟一个巨大的数组。
方案A:静态映射表(简单粗暴)
如果我们的短码空间是固定的,比如 a-z 组合,我们可以预先生成一个巨大的关联数组。
$mapping = [
'a' => 'uuid_123456',
'b' => 'uuid_654321',
// ...
];
用户访问 a,直接查 $mapping['a']。这是 O(1) 时间复杂度,比数据库快一万倍。
但问题是,这个数组太大了。而且我们不能无限扩展。
方案B:基数树(Radix Tree,前缀树)
这是解决短链接字符串匹配的神器。
想象一下,你的短链接是 http://t.cn/ab 和 http://t.cn/abc。
普通树:根 -> a -> b -> 长链1;根 -> a -> b -> c -> 长链2。
基数树:根 -> a/b -> 长链1,再往下接 c -> 长链2。
它极大地节省了空间,并且查询速度极快。在 PHP 里,我们可以用 SPL 的 SplFixedArray 或者 SplObjectStorage,或者直接用 ArrayObject 来模拟这种结构。
但是,在 PHP 里,最实用的“高性能”方案其实是:
利用 Redis 的有序集合 + 位图
或者更简单的:Hash 结构化存储。
把所有的短链接存到 Redis 的 Hash 结构里。Key 是服务器集群的标识,Field 是短码,Value 是 UUID。
HSET link_cluster:0 abc uuid_123
这样,所有关于 abc 的读写操作,都在一个 Hash 哈希表里。Hash 表在内存里是分散存储的,不会像数组那样死板,也不会像 B+ 树那样深度太深。读写效率非常高。
第五章:第四招——异步队列(削峰填谷)
现在我们有了 Redis 缓存,有了随机 ID。但是,短链接生成本身是个写操作(用户转发一个长链接,我们需要生成一个短链接,并存入数据库)。
假设有个爆款视频,大家都在发:“这个链接太好玩了, http://old.long.url…”。
瞬间有 100,000 个并发请求打进来:“请帮我生成短链接!”
这时候,如果这些请求都直接写 MySQL,数据库会崩溃。
这时候,我们需要消息队列。
架构升级:
- API 层: 收到生成请求,不写数据库,而是把数据扔进 Redis 队列(或者 Kafka/RabbitMQ)。
- 消费者: 后台有一个 PHP 进程(或者 Worker),源源不断地从队列里取数据。
- 持久化层: Worker 把数据写入 MySQL。
代码演示(基于 Redis 的简单队列):
// 生产者代码(API 接口)
public function generateShortLink($longUrl) {
// 1. 随机生成一个短码和 UUID
$uuid = $this->generateUUID();
$shortCode = $this->encode($uuid);
// 2. 保存到 Redis 队列
$this->redis->rpush('task_queue', json_encode([
'uuid' => $uuid,
'long_url' => $longUrl,
'short_code' => $shortCode
]));
// 3. 立即返回给用户一个“生成中”的页面,或者重定向到原始链接
// 现在的体验是:马上就能用,因为Redis里的数据可能还没落库,但逻辑上已经“生成”了
return [
'status' => 'ok',
'short_code' => $shortCode
];
}
// 消费者代码(后台 Worker)
public function processQueue() {
while (true) {
// 阻塞式读取,没有数据就不空转 CPU
$data = $this->redis->blpop('task_queue', 10); // 10秒超时
if ($data) {
list($queueName, $json) = $data;
$item = json_decode($json, true);
try {
// 写入数据库
$stmt = $this->db->prepare("INSERT INTO short_links (uuid, short_code, long_url) VALUES (?, ?, ?)");
$stmt->execute([$item['uuid'], $item['short_code'], $item['long_url']]);
// 也可以顺便写缓存
$this->redis->hset('links_hash', $item['short_code'], $item['uuid']);
} catch (Exception $e) {
// 处理失败,放回队列或者记录日志
$this->redis->rpush('task_queue', $json);
}
}
}
}
这种异步写入的策略,彻底解决了写操作的数据库热点问题。不管前端来多少请求,后端默默处理。
第六章:终极架构——雪球算法 + 混合缓存
讲到这里,我们已经把缓存、ID生成、队列都讲完了。现在,我们来组装一个最终版的架构图。
设计理念:
- 入链: 用户生成短链 -> 落入 Redis Hash + 异步落库。
- 出链: 用户访问短链 -> 先查 Redis Hash。
- 命中: 读 ID,查 Redis Hash,获取 URL,重定向。
- 未命中: 使用位图技术(BitMap)做缓存预热,或者直接查数据库。
- 查库: 因为 ID 是随机的,所以查库不会锁死某一行。
- 回写: 查到后,写回 Redis Hash。
进阶优化:如何做到“零数据库查询”?
有些公司要求短链接服务在双十一期间,绝对不能有 SQL 语句。
这就需要用到 Snowflake(雪花算法)。
我们不用数据库存 short_code,我们直接用雪花算法生成一个唯一的 Long ID(比如 1234567890123456789)。
然后,我们把这个 Long ID 转换成 Base62 编码(aZ9...)。
整个系统只依赖 Redis。
- 生成短链:直接生成雪花 ID -> Base62 -> 返回。
- 访问短链:Base62 解码 -> 雪花 ID -> 查 Redis。
代码示例:PHP 雪花算法
class SnowflakeIdGenerator {
private $workerId;
private $datacenterId;
private $sequence = 0;
private $lastTimestamp = -1;
// 42位时间戳 + 5位机器ID + 5位数据中心ID + 12位序列号
private $twepoch = 1609459200000; // 2021-01-01 00:00:00
public function __construct($workerId, $datacenterId) {
$this->workerId = $workerId;
$this->datacenterId = $datacenterId;
}
public function nextId() {
$timestamp = $this->getTime();
if ($timestamp < $this->lastTimestamp) {
throw new Exception("Clock moved backwards!");
}
if ($this->lastTimestamp == $timestamp) {
$this->sequence = ($this->sequence + 1) & 0xFFF; // 12 bits
if ($this->sequence === 0) {
$timestamp = $this->waitForNextMillis($this->lastTimestamp);
}
} else {
$this->sequence = 0;
}
$this->lastTimestamp = $timestamp;
// 拼接位
return (($timestamp - $this->twepoch) << 22) |
($this->datacenterId << 17) |
($this->workerId << 12) |
$this->sequence;
}
private function getTime() {
return (int)(microtime(true) * 1000);
}
private function waitForNextMillis($lastTimestamp) {
$timestamp = $this->getTime();
while ($timestamp <= $lastTimestamp) {
$timestamp = $this->getTime();
}
return $timestamp;
}
}
// 使用
$generator = new SnowflakeIdGenerator(1, 1);
$id = $generator->nextId();
echo "Generated ID: " . $id . "n";
echo "Short Code: " . base_convert($id, 10, 36) . "n"; // 转 base36 更短
效果:
这样,我们就没有了数据库!只有 Redis!
100万 QPS 的请求,只要你的 Redis 集群扛得住,PHP 就能扛住!这才是真正的“高性能”。
第七章:常见陷阱与坑爹案例
说了这么多,很多同学还是会在实战中翻车。我总结了几点,大家注意避坑:
-
缓存雪崩:
- 现象: 所有的缓存过期时间都是 60 秒。结果 10:00:00 时,Redis 里的数据全没了。10:00:00.01 秒,100万请求全打库了。
- 解法: 缓存过期时间加个随机值,比如 60 +/- 10 秒。
-
缓存穿透:
- 现象: 有人故意访问一个不存在的短码
http://t.cn/xxxxx。Redis 里没有,DB 里也没有。每秒查询几次,你的数据库就被刷爆了。 - 解法: 前面代码里提到的,“查不到时,缓存一个空值(NULL)”,并且给很短的过期时间(比如 10 秒)。
- 现象: 有人故意访问一个不存在的短码
-
短码冲突:
- 现象: 用了随机数生成短码,万一两秒钟内生成了两个相同的 ID 呢?
- 解法: 生成后先查 Redis 看是否存在,如果存在,重试,或者加个随机后缀。
-
PHP 进程数:
- 现象: 你用了 Swoole,开了 1000 个协程。但是数据库连接数只有 100。结果协程在等连接,CPU 空转。
- 解法: 数据库连接池,或者控制协程数量。
总结(干货时刻)
设计高性能短链接服务,归根结底就是做两件事:
- 把“热点”打散。 别让所有人都围着一个 ID 转。
- 把“读”挤走,把“写”溜走。 读走 Redis,写走队列。
不要迷信高深的框架,数据结构和算法才是内功。
- 用 Radix Tree 或者 Hash Map 做内存映射。
- 用 Snowflake 或 随机数 做无热点 ID。
- 用 Redis 做第一道防线。
- 用 队列 做最后一道缓冲。
只要把这些组合起来,你的短链接服务就能在 PHP 的领域里,跑出火箭的速度。
好了,今天的课就上到这儿。大家回去自己敲两遍代码,把 SETNX 和 Redis Hash 搞懂。下次面试官问你短链接,你就跟他说:“我是用位图和基数树构建的分布式系统架构师。”
下课!