PHP如何设计高性能短链接服务避免数据库热点问题

各位同学,大家晚上好!

把门关上,把手机收一收。今天我们不聊“如何用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的环境下,避开数据库热点的“坑”,写出高可用的短链接系统。


第一章:数据库为什么会“猝死”?(痛点分析)

咱们先别急着上代码,先搞清楚敌人是谁。

假设你的短链接服务架构是这样的:

  1. 用户访问 http://t.cn/abc
  2. PHP代码去MySQL里查:SELECT long_url FROM short_links WHERE short_code='abc'
  3. 返回结果,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 的行的排他锁。这就是传说中的“热点数据”

你的数据库就像一个只有一口锅的食堂大妈,所有人都端着碗站在门口喊:“大妈,我要这个菜!”大妈刚把菜给第一个人,第二个人就抢了勺子。最后,所有人都饿着肚子走了(请求超时失败)。

所以,我们的核心任务只有两个:

  1. 把数据库扛下来(不要让数据库死锁)。
  2. 把请求扛下来(让用户觉得快)。

第二章:第一招——Redis 缓冲池(降维打击)

既然数据库是瓶颈,那我们就得把数据库“挤”出去。

策略:
我们引入 Redis。Redis 是内存数据库,那是相当快,比 MySQL 快个几万倍。我们用 Redis 存短链接的映射关系。

核心逻辑:

  1. 用户访问 t.cn/abc
  2. PHP 先查 Redis:GET abc
  3. 如果 Redis 里(比如缓存了5分钟),直接返回,完事,不碰数据库。
  4. 如果 Redis 里没有(缓存过期了),怎么办?
    • 死锁机制(SetNX): 别直接去查数据库!如果有10个请求同时发现 Redis 里没有 abc,它们会一窝蜂去查数据库。这回数据库还是得死。
    • 我们要用 Redis 的 SETNX(Set if Not eXists)命令。第一个拿到锁的请求去查数据库,查到了写回 Redis,然后把锁释放。其他的请求在 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。但是数据库的 id1, 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)与位图

这是计算机科学里的经典数据结构。如果你觉得树太复杂,那我们用最简单的哈希表(数组)思路。

假设我们的短码空间很大,比如 azzzzzz。我们可以在内存里开辟一个巨大的数组。

方案A:静态映射表(简单粗暴)
如果我们的短码空间是固定的,比如 a-z 组合,我们可以预先生成一个巨大的关联数组。

$mapping = [
    'a' => 'uuid_123456',
    'b' => 'uuid_654321',
    // ...
];

用户访问 a,直接查 $mapping['a']。这是 O(1) 时间复杂度,比数据库快一万倍。
但问题是,这个数组太大了。而且我们不能无限扩展。

方案B:基数树(Radix Tree,前缀树)
这是解决短链接字符串匹配的神器。
想象一下,你的短链接是 http://t.cn/abhttp://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,数据库会崩溃。
这时候,我们需要消息队列

架构升级:

  1. API 层: 收到生成请求,不写数据库,而是把数据扔进 Redis 队列(或者 Kafka/RabbitMQ)。
  2. 消费者: 后台有一个 PHP 进程(或者 Worker),源源不断地从队列里取数据。
  3. 持久化层: 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生成、队列都讲完了。现在,我们来组装一个最终版的架构图。

设计理念:

  1. 入链: 用户生成短链 -> 落入 Redis Hash + 异步落库。
  2. 出链: 用户访问短链 -> 先查 Redis Hash。
    • 命中: 读 ID,查 Redis Hash,获取 URL,重定向。
    • 未命中: 使用位图技术(BitMap)做缓存预热,或者直接查数据库。
    • 查库: 因为 ID 是随机的,所以查库不会锁死某一行。
    • 回写: 查到后,写回 Redis Hash。

进阶优化:如何做到“零数据库查询”?

有些公司要求短链接服务在双十一期间,绝对不能有 SQL 语句

这就需要用到 Snowflake(雪花算法)
我们不用数据库存 short_code,我们直接用雪花算法生成一个唯一的 Long ID(比如 1234567890123456789)。
然后,我们把这个 Long ID 转换成 Base62 编码(aZ9...)。

整个系统只依赖 Redis。

  1. 生成短链:直接生成雪花 ID -> Base62 -> 返回。
  2. 访问短链: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 就能扛住!这才是真正的“高性能”。


第七章:常见陷阱与坑爹案例

说了这么多,很多同学还是会在实战中翻车。我总结了几点,大家注意避坑:

  1. 缓存雪崩:

    • 现象: 所有的缓存过期时间都是 60 秒。结果 10:00:00 时,Redis 里的数据全没了。10:00:00.01 秒,100万请求全打库了。
    • 解法: 缓存过期时间加个随机值,比如 60 +/- 10 秒。
  2. 缓存穿透:

    • 现象: 有人故意访问一个不存在的短码 http://t.cn/xxxxx。Redis 里没有,DB 里也没有。每秒查询几次,你的数据库就被刷爆了。
    • 解法: 前面代码里提到的,“查不到时,缓存一个空值(NULL)”,并且给很短的过期时间(比如 10 秒)。
  3. 短码冲突:

    • 现象: 用了随机数生成短码,万一两秒钟内生成了两个相同的 ID 呢?
    • 解法: 生成后先查 Redis 看是否存在,如果存在,重试,或者加个随机后缀。
  4. PHP 进程数:

    • 现象: 你用了 Swoole,开了 1000 个协程。但是数据库连接数只有 100。结果协程在等连接,CPU 空转。
    • 解法: 数据库连接池,或者控制协程数量。

总结(干货时刻)

设计高性能短链接服务,归根结底就是做两件事:

  1. 把“热点”打散。 别让所有人都围着一个 ID 转。
  2. 把“读”挤走,把“写”溜走。 读走 Redis,写走队列。

不要迷信高深的框架,数据结构和算法才是内功。

  • Radix Tree 或者 Hash Map 做内存映射。
  • Snowflake随机数 做无热点 ID。
  • Redis 做第一道防线。
  • 队列 做最后一道缓冲。

只要把这些组合起来,你的短链接服务就能在 PHP 的领域里,跑出火箭的速度。

好了,今天的课就上到这儿。大家回去自己敲两遍代码,把 SETNXRedis Hash 搞懂。下次面试官问你短链接,你就跟他说:“我是用位图和基数树构建的分布式系统架构师。”

下课!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注