PHP 怎么原子操作

wen PHP项目 2

本文目录导读:

PHP 怎么原子操作

  1. 文章标题:PHP 原子操作深度实战:从文件锁到 Redis,彻底解决并发覆盖与超卖问题
  2. 目录导读(Table of Contents)

PHP 原子操作深度实战:从文件锁到 Redis,彻底解决并发覆盖与超卖问题


目录导读(Table of Contents)

  1. 什么是原子操作?为什么 PHP 需要它?
  2. PHP 原生原子操作的局限性与替代方案
  3. 文件锁(flock)实现单机原子性
  4. 数据库事务与悲观/乐观锁
  5. Redis 原子操作(INCR、SETNX、Lua)
  6. 高频坑点:竞态条件、死锁与性能取舍
  7. 实战:秒杀库存扣减的三种代码范式
  8. 常见问题问答(FAQ)

什么是原子操作?为什么 PHP 需要它?

在并发编程中,原子操作(Atomic Operation)指的是不可被中断的一个或一系列操作,要么全部执行成功,要么全部不执行,不会出现“执行到一半被其他线程/进程插队”的情况。

PHP 作为最流行的 Web 开发语言,默认以 多进程(FPM) 模式运行,这意味着多个请求会同时读写同一个文件或数据库记录,如果你执行“读取库存 -> 判断>0 -> 减1 -> 写回”这四步,在并发 100 个请求时,大概率会有多个请求同时读到库存为 1,然后各自减 1,最终导致超卖,这就是典型的竞态条件(Race Condition)

PHP 本身并没有像 i++ 这种跨进程的原子指令(C 语言中有,但 PHP 是解释型语言),我们必须借助外部工具(文件、数据库、Redis)来实现原子性。

PHP 原生原子操作的局限性与替代方案

PHP 提供了一些函数如 pcntl_alarmsem_acquire(System V 信号量),但存在局限性:

  • sem_acquire:依赖 sysvsem 扩展,且仅在同一台服务器上的 PHP 进程间有效。
  • flock:跨平台性好,但性能较低,且容易因忘记释放造成死锁。

核心结论:在分布式架构下,最可靠的原子操作载体是 RedisMySQL 行锁,在单机小流量下,文件锁 是零依赖的救急方案。


方案一:文件锁(flock)实现单机原子性

原理:利用操作系统文件锁的 LOCK_EX(独占锁)保证同一时刻只有一个进程能进入临界区。

<?php
$fp = fopen('/tmp/stock.txt', 'r+');
if (flock($fp, LOCK_EX)) { // 阻塞获取独占锁
    $stock = intval(fgets($fp)); // 读取当前库存
    if ($stock > 0) {
        rewind($fp);
        ftruncate($fp, 0);
        fwrite($fp, $stock - 1); // 减库存
        fflush($fp);
        echo "扣减成功,剩余: " . ($stock - 1);
    } else {
        echo "库存不足";
    }
    flock($fp, LOCK_UN); // 释放锁
} else {
    echo "获取锁失败";
}
fclose($fp);
?>

注意

  • 必须使用 fgets 读取后,用 rewind + ftruncate 重写。
  • 锁要放在 try-finally 中确保释放,防止异常退出导致死锁。

适用场景:单机、低并发(<50 QPS)、无 Redis 的极简环境。


方案二:数据库事务与悲观/乐观锁

悲观锁(SELECT FOR UPDATE)

BEGIN;
SELECT stock FROM products WHERE id=1 FOR UPDATE;
-- 判断 stock > 0
UPDATE products SET stock = stock - 1 WHERE id=1;
COMMIT;

在 PHP 中(PDO 预处理):

$pdo->beginTransaction();
$stmt = $pdo->query("SELECT stock FROM products WHERE id=1 FOR UPDATE");
$stock = $stmt->fetchColumn();
if ($stock > 0) {
    $pdo->exec("UPDATE products SET stock = stock - 1 WHERE id=1");
    $pdo->commit();
} else {
    $pdo->rollBack();
}

注意FOR UPDATE 必须在事务内,且 WHERE 条件必须命中索引,否则会锁全表。

乐观锁(版本号)

UPDATE products SET stock = stock - 1, version = version + 1 
WHERE id = 1 AND version = #{old_version};

如果影响行数为 0,则说明 version 不匹配,重试或返回失败,适合读多写少的场景,性能比悲观锁高,但需要处理重试逻辑。


方案三:Redis 原子操作(INCR、SETNX、Lua)

Redis 是单线程模型,其命令天然具备原子性,这是目前解决高并发超卖最优雅的方案

模式 A:DECR 递减(适合库存是整数)

$redis = new Redis();
$redis->connect('127.0.0.1', 6379);
$redis->set('stock', 100); // 初始化
$ret = $redis->decr('stock'); // 原子减1,返回剩余值
if ($ret >= 0) {
    echo "扣减成功,剩余: $ret";
} else {
    // 超卖了,需要回补
    $redis->incr('stock');
    echo "库存不足";
}

:必须检查 decr 返回值,负值表示超卖,需立即 incr 回补。

模式 B:SETNX + 锁(分布式锁)

$lockKey = 'lock:product:1';
$lockValue = uniqid();
// 获取锁(5秒超时)
if ($redis->set($lockKey, $lockValue, ['NX', 'EX' => 5])) {
    $stock = $redis->get('stock');
    if ($stock > 0) {
        $redis->decr('stock');
        echo "成功";
    }
    // 释放锁(Lua 保证原子性)
    $redis->eval("if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end", [$lockKey, $lockValue]);
}

模式 C:Lua 脚本(强烈推荐) 将“检查库存 + 扣减”封装在一个 Lua 脚本里,Redis 会原子执行整个脚本:

-- script.lua
local stock = tonumber(redis.call('get', KEYS[1]))
if stock > 0 then
    redis.call('decr', KEYS[1])
    return stock - 1
else
    return -1
end

PHP 调用:

$script = <<<LUA
if redis.call('get', KEYS[1]) > 0 then 
    return redis.call('decr', KEYS[1]) 
else 
    return -1 
end
LUA;
$result = $redis->eval($script, ['stock'], 1);
if ($result >= 0) { echo "成功"; } else { echo "失败"; }

优势:无需分布式锁,性能最高,无死锁风险。


高频坑点:竞态条件、死锁与性能取舍

坑点 现象 规避策略
竞态条件 库存扣成负数 使用原子命令(decr)直接返回负数,而非先 get 后 set
死锁 进程卡住,请求超时 flock 设置非阻塞模式 LOCK_NB;2. Redis 锁必须设置过期时间
ABA 问题 乐观锁中版本号被覆盖 使用 version 字段,或 Redis 的 WATCH 命令
性能瓶颈 悲观锁导致数据库并发骤降 混合使用:Redis 做流量过滤,数据库做最终持久化

实战:秒杀库存扣减的三种代码范式

纯文件锁(50 QPS 以下)

  • 优点:零外部依赖。
  • 缺点:多机部署失效。

MySQL 悲观锁(500 QPS 以下)

  • 优点:强一致,数据可靠。
  • 缺点:行锁会阻塞,需控制事务时间在 10ms 内。

Redis Lua + 异步落库(万级 QPS)

// 扣减成功,则发送消息队列(如 RabbitMQ)异步更新 MySQL 库存。
$result = $redis->eval($script, ['stock'], 1);
if ($result >= 0) {
    $mq->publish('order_queue', json_encode(['product_id' => 1, 'deduct' => 1]));
}

注意:Redis 与 MySQL 存在最终一致性问题,需设计补偿机制(如定时对账)。


常见问题问答(FAQ)

Q1:PHP 里有没有类似 atomic() 的函数? ——没有,PHP 是解释型语言,无法在进程级别直接做内存原子操作,必须依赖 flock、数据库锁或 Redis。

Q2:Redis::decr 出现负数就立刻 incr 回补,这种做法 OK 吗? ——可以,但会留下误扣记录,更推荐用 Lua 脚本在 Redis 内部判断,避免网络往返和负值中间状态。

Q3:文件锁 flock 在 Nginx+PHP-FPM 下能跨进程工作吗? ——可以,PHP-FPM 工作进程都是同一 Linux 用户,文件锁由内核维护,跨进程有效。

Q4:Redis 的 WATCH 命令能实现原子操作吗? ——WATCH 是乐观锁,需要配合事务 MULTI/EXECEXEC 时发现 key 被修改,则返回 null,需要重试,但性能不如 Lua 脚本。

Q5:多台服务器(分布式)下最简单可靠的方案是什么? ——首选 Redis Lua 脚本,次选 Redis RedLock 分布式锁,但复杂度高,需谨慎实现。

Q6:如何选择方案? ——单机且并发极低flock单机且要求强一致:MySQL 悲观锁;分布式且高并发:Redis Lua。


原子操作是并发编程的基石,PHP 开发者必须根据系统架构(单机/分布式)、QPS 量级、数据一致性要求来权衡:

  • 不要盲目上 Redis,小项目用文件锁足够。
  • 不要滥用悲观锁,容易拖垮数据库。
  • 最好的实践是Redis 做前置拦截,MySQL 做持久化对账,两者配合才能实现高性能与高可靠的双赢。

没有银弹,只有最合适的组合,赶快根据你的业务量级,选择上述范式进行压测吧!

抱歉,评论功能暂时关闭!