PHP如何实现商城秒杀系统并避免库存超卖并发问题

秒杀系统防御战:如何用 PHP 拦截“超卖”恶魔

各位老铁们,大家晚上好!

欢迎来到今天的“电商技术吐槽大会”。我是你们的老朋友,一名在 PHP 代码堆里摸爬滚打多年的资深程序员。今天我们不聊怎么写优雅的代码,不聊怎么通过 Code Review,我们聊点硬核的、带血的——商城秒杀系统

大家想象一下这个场景:双十一零点,一个限量版的球鞋,库存只有 1 双,价格只要 999 块。全网几千万用户在那一瞬间点击了“购买”。

如果是你,你敢接这个单吗?

如果你只是简单地在数据库里写个 UPDATE goods SET stock = stock - 1 WHERE id = 1,那你今晚可能就要去天桥底下了。为什么?因为并发。因为超卖

在今天的讲座里,我们将化身“守门员”,手拿 PHP,面对汹涌的流量洪流,用代码筑起一道铜墙铁壁。咱们不整那些虚头巴脑的架构图,直接上干货,保证你听完能学会怎么保住你的 KPI。


第一章:什么是“超卖”?那个让老板头秃的恶魔

首先,我们要搞清楚敌人是谁。在电商领域,有两种死法,一种是“少卖”,一种是“多卖”。

  • 少卖(库存扣减多了): 库存剩 1 个,结果卖了 2 个。用户买到了商品,商家却没发货。客服电话被打爆,投诉如雪花般飘来。这是“恐怖故事”。
  • 多卖(库存扣减少了): 库存剩 1 个,结果卖了 -1 个。用户没买着,商家库存还多了。虽然听起来很荒谬,但在并发环境下,这是真实存在的。

超卖发生的原理:

想象一下,这是一个只有一扇门的仓库,库存是门里的 1 个苹果。

  1. 用户 A 到了,问:“还有苹果吗?”
  2. 用户 B 也在这一秒到了,也问:“还有苹果吗?”
  3. 系统 说:“有!”
  4. 用户 A 进去拿了苹果,库存变成了 0。
  5. 系统 刚准备更新数据库说“库存没了”。
  6. 用户 B 也进去了,系统以为库存还是 1(因为还没来得及更新),于是告诉 B “有”。

结果:两个苹果没了,库存是 0(没超卖,但少卖了)。或者更糟,如果 B 在 A 拿走之后,A 退出了,B 拿到了苹果,但 A 那个位置也占了,这就导致库存变成了 -1(超卖)。

在 PHP 这种脚本语言中,由于每次请求都是独立的进程,共享内存很难控制,这简直就是为超卖量身定做的温床。


第二章:第一道防线——数据库的“小心脏” (悲观锁)

既然并发是问题,那最简单粗暴的办法是什么?先锁住,再操作。

在数据库层面,这叫悲观锁。它的核心思想是:“我很悲观,我觉得别人肯定在改数据,所以我先把自己锁住,谁也别想动,等我处理完再说。”

PHP 操作数据库常用 PDO,实现悲观锁很简单,加一句 SQL 就行:

// 错误示范:不要这样写,这会导致严重的性能问题
// $sql = "SELECT * FROM goods WHERE id = 1";
// $row = $pdo->query($sql)->fetch();

// 正确示范:开启事务,并使用 FOR UPDATE 锁住这行数据
$pdo->beginTransaction(); // 1. 开始事务

$sql = "SELECT stock FROM goods WHERE id = 1 FOR UPDATE"; // 2. 加锁!这是关键
$stmt = $pdo->query($sql);
$goods = $stmt->fetch(PDO::FETCH_ASSOC);

if ($goods['stock'] > 0) {
    // 3. 扣减库存
    $updateSql = "UPDATE goods SET stock = stock - 1 WHERE id = 1";
    $pdo->exec($updateSql);
    echo "购买成功!";
} else {
    echo "库存不足";
}

$pdo->commit(); // 4. 提交事务

这里的 FOR UPDATE 是个狠角色。

当你在 SQL 里写了这句,数据库就会在这个事务结束之前(也就是 commit 之前),给这行记录加上一个排他锁。别的进程想读这行数据?没门,等着。别的进程想改这行数据?排队去!

优点: 极其安全,绝对不会超卖。
缺点: 性能太差!
试想一下,双十一几万人同时抢一双鞋。每个人来都要 SELECT ... FOR UPDATE。前面的兄弟还没处理完,后面的几万人就堵在门口了。数据库连接池瞬间耗尽,整个网站崩盘。所以,单靠数据库悲观锁扛不住高并发,只能做最后一道兜底防线。


第三章:第二道防线——乐观锁的“博弈” (CAS机制)

既然悲观锁太慢,那能不能乐观一点?乐观锁 的思想是:“我不锁你,但我相信我的判断。我先查,我觉得我有货,我就改。改的时候,我再检查一下库存是不是被别人动了。”

这其实就是CAS (Check-And-Set) 机制。核心是:更新时带上版本号或库存条件。

我们修改一下 SQL:

$pdo->beginTransaction();

// 第一次查询,获取当前的库存
$checkSql = "SELECT stock FROM goods WHERE id = 1";
$stmt = $pdo->query($checkSql);
$goods = $stmt->fetch(PDO::FETCH_ASSOC);
$currentStock = $goods['stock'];

if ($currentStock > 0) {
    // 关键点来了:在更新时,加一个条件,必须是库存大于0才能更新
    // 假设并发来了,A用户把库存改成0了,B用户再执行这个更新语句,因为库存变成了0,条件不满足,更新失败!
    $updateSql = "UPDATE goods SET stock = stock - 1 WHERE id = 1 AND stock > 0";

    $rows = $pdo->exec($updateSql);

    // $pdo->exec() 返回的是受影响的行数
    // 如果 $rows == 1,说明更新成功了
    // 如果 $rows == 0,说明库存已经被别人扣了,或者已经是0了,更新失败
    if ($rows > 0) {
        echo "购买成功!";
    } else {
        echo "手慢了,库存被抢光了!";
    }
} else {
    echo "库存不足";
}

$pdo->commit();

这招高明在哪里?
它利用了数据库的原子性。虽然我们在 PHP 里写了 if 判断,但真正决定生死的,是那条 UPDATE 语句。

但是! 乐观锁也不是银弹。
如果并发量稍微大一点,A 查到了库存 1,准备更新。就在这一瞬间,B 也查到了库存 1。A 更新成功,B 更新失败。
这时候,B 就傻眼了。B 是直接报错呢?还是告诉用户“抢购失败”?还是 B 再去查一次库存,发现已经是 0 了,再重试一次?
如果重试,B 又查到了库存…这就陷入了死循环(或者我们得加一个重试次数的限制,比如重试 3 次)。所以,纯数据库乐观锁在高并发下,也会把数据库 CPU 吃干抹净。


第四章:终极兵器——Redis 的原子操作

老铁们,PHP 是脚本语言,跑得快但不持久。Redis 是内存数据库,跑得飞快,且自带“原子性”外挂。这是秒杀系统的核武器。

我们不需要去和数据库抢锁,我们可以把“库存”放在 Redis 里。

技巧一:利用 Redis 的 decr 命令

Redis 有一个命令叫 decr,它的意思是“自减 1”。这个命令是原子的。也就是说,Redis 保证这一步操作是瞬间完成的,绝对不会出现“我先读了,减了,还没写回去,别人又读了”这种中间状态。

$redis = new Redis();
$redis->connect('127.0.0.1', 6379);

// 1. 初始化库存,比如 1
$redis->set('stock:1001', 1);

// 2. 扣减库存
// $res 是扣减后的值。如果原本是 1,扣减后变成 0。
// 如果原本是 0,扣减后变成 -1。
$res = $redis->decr('stock:1001'); 

if ($res >= 0) {
    echo "抢购成功!剩余库存:{$res}";
    // 3. 下单... 写入数据库
} else {
    echo "没抢到,库存为空";
}

这招帅不帅?
简单粗暴,没有数据库锁,没有事务,没有网络往返。Redis 在内存里把这个动作做完就完了。几万 QPS 轻松扛住。

但是! 这里有坑。
如果 Redis 服务器挂了呢?或者 Redis 挂了之后,数据还没同步到 MySQL?
这就导致了数据不一致。Redis 里库存是 0 了,但 MySQL 里还是 1,或者反过来。这就需要极其复杂的“最终一致性”补偿机制,甚至需要“重入机制”。对于追求极致稳定的系统,直接用 decr 留着后门,风险太大。


第五章:核平世界——Lua 脚本

Redis 很强,但它是基于命令的。如果我想做“判断库存是否大于 0,如果不大于 0 就返回 0,否则扣减并返回 1”,用普通命令写,得发三个命令过去:

  1. get stock
  2. 判断 if > 0
  3. decr stock

这就又回到了并发问题。如果命令 1 和命令 3 之间,库存被别人动了怎么办?

解决方法:Lua 脚本。

Lua 脚本是原子性执行的。Redis 会把你的脚本当成一个整体执行,中间不会插入其他命令。

我们在 Redis 里写一段 Lua 代码:

-- KEYS[1] 是库存的 key
-- ARGV[1] 是购买数量,通常就是 1

local stock = tonumber(redis.call('get', KEYS[1]))
local num = tonumber(ARGV[1])

if stock and stock >= num then
    redis.call('decrby', KEYS[1], num)
    return 1
else
    return 0
end

然后在 PHP 里,用 eval 执行这个脚本:

$redis = new Redis();
$redis->connect('127.0.0.1', 6379);

// Lua 脚本内容,记得转义引号
$luaScript = "
    local stock = tonumber(redis.call('get', KEYS[1]))
    if stock and stock > 0 then
        redis.call('decr', KEYS[1])
        return 1
    else
        return 0
    end
";

// 执行脚本,参数:脚本内容,参数个数,参数列表
// 这里我们把 "stock:1001" 作为第一个参数传进去
$result = $redis->eval($luaScript, ['stock:1001'], 1);

if ($result == 1) {
    echo "恭喜你,抢到了!";
    // 然后这里再去操作数据库,写入订单
} else {
    echo "手慢了,没抢到。";
}

这就是终极方案!
PHP 代码里只有一次网络请求,Redis 内部执行的是原子操作。既保证了速度(比数据库锁快得多),又保证了安全(原子性)。这就是目前主流秒杀系统的标配方案。


第六章:削峰填谷——消息队列的救场

虽然 Redis 处理了“抢购成功”这一步,但抢到之后呢?要写入订单表,要扣减真实库存。

如果 Redis 里一下子涌入 10 万个请求,每秒 5 万 QPS,数据库直接挂掉(IO 爆满)。这时候,我们需要消息队列

架构图(脑补一下):

  1. 用户请求 -> Nginx -> Redis (Lua 脚本扣减) -> 返回给用户
  2. 抢购成功的请求 -> 被推入 RabbitMQ / Kafka / Redis Stream
  3. 后台消费者:这里可以用 PHP,也可以用 Java Go。它们排队慢慢处理,每秒只处理几百个订单。
  4. 消费者 -> 写入 MySQL 订单表

为什么要这么做?
这就叫削峰填谷。Redis 是一个尖刀队,负责把最汹涌的浪头拍碎,把剩下的水流慢慢疏导进数据库这条河里。数据库就不会因为瞬间的高并发而断流。

代码示例(伪代码,展示流程):

// Redis 返回成功后,把订单信息扔进队列
$redis->lPush('order_queue', json_encode([
    'user_id' => 1001,
    'goods_id' => 1001,
    'create_time' => time()
]));

// 在另外一个 PHP 脚本(消费者)里
while (true) {
    // 阻塞式弹出,没有数据就歇着
    $data = $redis->brPop('order_queue', 0);

    if ($data) {
        $order = json_decode($data[1], true);

        // 模拟处理订单,写数据库
        $pdo->beginTransaction();
        // 插入订单...
        // 扣减真实库存...
        $pdo->commit();

        // 模拟耗时操作,避免消费者跑太快,超过生产者
        sleep(0.1); 
    }
}

第七章:防御结界——Nginx 与 CDN

前面说了数据库、Redis、队列。但这还不够。如果 100 万人同时访问你的 PHP 后端,你的 CPU 瞬间就炸了。

这时候,我们要请出NginxCDN

1. 静态资源分离

秒杀页面上肯定有很多图片、CSS、JS。这些不需要 PHP 处理。
在 Nginx 里配置:

server {
    listen 80;
    server_name seckill.com;

    # 所有的静态文件,直接返回,别找 PHP
    location ~* .(jpg|png|gif|css|js)$ {
        root /var/www/static;
        expires 1y;
        access_log off;
    }

    # 只有 /api/ 路径才转发给 PHP
    location /api/ {
        fastcgi_pass php_pool;
        include fastcgi_params;
    }
}

2. CDN 加速

把秒杀页面中的静态资源(Logo、图片)扔到 CDN 节点上。用户请求图片时,不走你的服务器,走离他最近的 CDN 节点。这能节省你服务器 50% 的带宽。

3. 限流

即使有 Redis,也不能让所有人都进来。Nginx 可以设置限流:

# 在 http 块里定义限流区域,比如每个 IP 每秒只能请求 5 次
limit_req_zone $binary_remote_addr zone=seckill_limit:10m rate=5r/s;

server {
    location /api/buy {
        # 指定使用哪个限流区域,如果不通过则返回 503
        limit_req zone=seckill_limit burst=10 nodelay;

        fastcgi_pass php_pool;
        # ...
    }
}

第八章:实战中的那些“坑”

讲到这里,大家觉得这事儿就完了?太天真了。

坑一:前端不让用“刷新”
很多用户抢购失败,第一反应就是按 F5 刷新页面,或者狂点“立即购买”按钮。这就瞬间造成了 1000 QPS。
解法: 前端按钮要禁用(disabled),抢购成功后直接跳转,抢购失败弹窗,并且要在前端做节流处理。

坑二:Redis 持久化导致的主从延迟
如果你用 Redis 做库存,又用了 RDB 或 AOF 持久化。主库处理完请求,还没来得及写磁盘,从库还没同步过来,主库就挂了怎么办?
解法: 秒杀场景下,Redis 最好是只做内存缓存,不强制持久化,或者采用更激进的集群方案(如 Codis)。

坑三:分布式锁的失效
如果用户 A 调用 Lua 脚本抢到了,Redis 返回成功。但就在这时候,Redis 宕机了,数据还没落盘。用户 A 就“中奖”了,但后面没有库存了。
解法: 这就回到了数据库的最终一致性。Redis 是为了扛流量,不是为了保数据。数据一定要落到数据库里才算数。


总结:怎么才算一个靠谱的秒杀系统?

好了,老铁们,咱们来复盘一下今天讲的防御体系。一个完美的 PHP 秒杀系统,通常是这样的:

  1. 入口层: Nginx + CDN,把那些不重要的静态文件和多余流量挡在外面,只放行 API 请求。
  2. 缓存层: Redis。这里用 Lua 脚本处理 decr,保证原子性,拦截 90% 的流量。
  3. 缓冲层: 消息队列。把 Redis 成功的数据排个队,慢慢处理,别把数据库搞挂。
  4. 持久层: 数据库。做最后的库存扣减和订单写入,保证数据不丢失。
  5. 兜底层: 数据库乐观锁(UPDATE ... WHERE stock > 0)。作为 Redis 挂掉或 Lua 脚本出错的最后防线。

在这个体系中,PHP 的角色是什么?
PHP 是那个最灵活、开发最快、部署最简单的调度员。它负责指挥 Redis 去抢,指挥 MQ 去存,指挥数据库去写。

切记:
不要迷信某一种技术。

  • 纯数据库扛不住并发。
  • 纯 Redis 挡不住数据不一致。
  • 没有消息队列,数据库必死。

只有组合拳,才能打赢这场秒杀战役。

好了,今天的讲座就到这里。希望大家以后在写代码的时候,脑子里多转转“并发”两个字。如果你觉得这篇讲座对你有用,记得转发给身边的实习生,告诉他们:别再写 UPDATE ... 不带条件的 SQL 了!

下课!

发表回复

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