Lua 脚本保证原子性案例
解读
国内互联网大厂(如阿里、腾讯、字节)在 PHP 面试中,Redis 几乎是标配缓存。Redis 4.0+ 支持 Lua 脚本,官方承诺“整个脚本执行期间不会被任何其他命令插入”,天然具备原子性。面试官问“Lua 脚本保证原子性案例”,并不是想听你背概念,而是想看你是否能把 PHP 业务里“并发扣库存、并发抢券、并发打款”这类高危场景,用 Lua 脚本一次性解决,同时能自证“为什么 PHP 代码做不到”。因此,回答必须给出:
- 一段可落地的 Lua 脚本;
- 对应 PHP 调用代码;
- 量化论证(并发测试数据或失败回滚方案);
- 线上灰度与回滚策略。
知识点
- Redis 单线程模型与原子性语义:EVAL/EVALSHA 命令执行期间,Redis 不会切换时间片。
- Lua 脚本内只能调用 Redis 提供的函数,禁止任何 I/O、阻塞、递归,否则会被 Redis 直接杀死。
- PHP 侧使用
Redis::eval()或Redis::evalSha(),必须预加载脚本到 Redis 脚本缓存,避免每次传输整段脚本带来的网络开销。 - 脚本超时配置:redis.conf 中
lua-time-limit 5000(毫秒),超时后只能SCRIPT KILL或SHUTDOWN NOSAVE,不能优雅回滚,因此脚本必须保持 O(1) 或 O(logN) 复杂度。 - 库存扣减的三种异常:
a. 并发超卖(库存变负);
b. 重复扣减(幂等);
c. 业务回滚(订单取消后恢复库存)。
Lua 脚本必须同时解决 a 和 b,c 由 PHP 侧异步补偿。 - 压测指标:单实例 Redis 4C8G 下,Lua 扣库存脚本 QPS≈6w,99 线 < 2ms;同等逻辑用 PHP+WATCH+MULTI 实现,QPS≈1.2w,且失败重试率 3%~7%。
答案
场景:电商秒杀,商品 ID=10086,库存 key=stock:10086,已售 key=sold:10086,用户已购标记 key=bought:10086:{uid},防止重复购买。
Lua 脚本(预加载到 Redis,SHA1=da39a3ee5e6b4b0d3255bfef95601890afd80709)
-- 参数:KEYS[1]=stock:10086, KEYS[2]=sold:10086, KEYS[3]=bought:10086:{uid}
-- 参数:ARGV[1]=购买数量, ARGV[2]=用户ID
local stockKey = KEYS[1]
local soldKey = KEYS[2]
local boughtKey = KEYS[3]
local buyNum = tonumber(ARGV[1])
local uid = ARGV[2]
-- 幂等检查
if redis.call('EXISTS', boughtKey) == 1 then
return {-1, "already bought"}
end
-- 库存检查
local remain = tonumber(redis.call('GET', stockKey) or 0)
if remain < buyNum then
return {-2, "stock not enough"}
end
-- 扣库存、加已售、写用户标记
redis.call('DECRBY', stockKey, buyNum)
redis.call('INCRBY', soldKey, buyNum)
redis.call('SET', boughtKey, 1, 'EX', 86400)
-- 返回剩余库存
local left = tonumber(redis.call('GET', stockKey))
return {0, left}
PHP 调用封装(Laravel 风格,可平滑移植到 ThinkPHP、Hyperf)
class SecKillService
{
private \Redis $redis;
private string $luaSha;
public function __construct()
{
$this->redis = app('redis'); // 连接池长连接
$lua = file_get_contents(storage_path('lua/secKill.lua'));
$this->luaSha = $this->redis->script('load', $lua);
}
/**
* 下单接口
* @return array ['code'=>0,'left'=>99] 或 ['code'=>-2,'msg'=>'stock not enough']
*/
public function grab(int $uid, int $skuId, int $num = 1): array
{
$keys = [
"stock:{$skuId}",
"sold:{$skuId}",
"bought:{$skuId}:{$uid}"
];
$reply = $this->redis->evalSha($this->luaSha, $keys, $num, $uid);
$code = (int)$reply[0];
if ($code === 0) {
return ['code' => 0, 'left' => (int)$reply[1]];
}
return ['code' => $code, 'msg' => $reply[1]];
}
}
线上灰度与回滚
- 脚本 SHA1 写进配置中心,PHP 容器启动时一次性加载;若脚本需热更新,采用“双 SHA1 版本”方案:老版本脚本保留 24h,新版本逐步灰度,通过开关控制流量比例。
- 库存回滚:订单超时未支付,PHP 异步任务把库存加回,并删除
bought:{uid}标记;回滚操作不再走 Lua,直接INCRBY即可,因为此时已无线程安全问题。 - 监控:Prometheus 采集
redis_script_kills指标,若 1 分钟内出现 >0 次,立即告警并降级到 PHP+分布式锁方案。
拓展思考
- 如果库存分仓(北京仓、上海仓),Lua 脚本需要一次性读多个分仓库存并做加权分配,此时脚本复杂度升高,需评估是否拆分多个 Redis 实例;若跨实例,则 Lua 原子性失效,必须引入 Redlock 或分布式事务消息。
- 大促峰值 Redis 实例 CPU 打满,Lua 脚本虽好,但单线程模型会成为瓶颈;国内主流做法是“本地缓存漏斗 + Redis 原子兜底”:Nginx+Lua 层先过滤 80% 流量,剩余 20% 再走 PHP+Redis Lua,整体 QPS 可提升 5 倍。
- 2023 年起阿里云 Tair、腾讯云 CRS 推出“多线程 Lua”企业版,官方宣称 QPS 可线性扩展,但语义仍是“脚本级原子”,并非“跨槽位事务”;面试时可以主动提及,显示对国内云厂商特性的敏感度。