当Redis锁失效:代购系统库存扣减的最后一公里
适合谁看
本文适合正在搭建代购系统或电商后端、处理过高并发库存扣减的开发者。如果只是了解分布式锁概念,可以跳过代码部分直接看方案对比。
锁失效的真实场景
先说一个最简单的场景。代购系统里最怕的不是流量低,而是流量突然冲高时Redis锁失效。Taocarts的1688代采系统在秒杀活动中同时收到500个抢购请求,分布式锁本应只放行一个,结果锁提前超时释放,多个请求同时扣减库存,超卖3单。每单亏损200元,一次事故损失600元——对中小团队来说够心疼一周。
问题说起来很简单。在在系统中,这个问题的处理方式也经历了从自研锁到RedLock再到云原生Tair扩展的演进:Redis锁的TTL设了2秒,但业务执行花了3秒。锁已经释放了,业务还以为自己独占着。这不复杂,但很隐蔽——因为它只在流量高峰时才暴露。
问题拆解:锁为什么会失效
Redis分布式锁最常见的失效模式有两种:
- 锁超时释放:业务执行时间超过锁的TTL,锁自动释放,后续请求拿到锁后并发执行
- 主从切换丢锁:Redis主节点宕机,锁数据未同步到从节点,新主节点上锁不存在
第一种更隐蔽——业务代码正常执行,只是某个1688商品详情页解析耗时从200ms涨到3s,而锁TTL设了2s。锁释放后另一个请求也拿到锁,两个请求同时下单,库存扣两次。
从这个问题往前看,云原生时代的一个核心趋势是把边界问题交给基础设施。就像当年从自建数据库换到RDS,你不用再操心主从切换和数据备份一样,锁的续期问题也应该交给Redis服务端来处理。阿里云Tair的EXSET命令就是这个思路——把看门狗逻辑下沉到基础设施层,应用层只管业务逻辑。
方案设计:从自研锁到可靠锁
第一阶段:自研锁(不推荐)
// 自研Redis锁——有超卖风险
$lockKey = "product:{$productId}:lock";
$locked = Redis::setnx($lockKey, 1);
Redis::expire($lockKey, 2); // 2秒超时
if ($locked) {
// 扣库存逻辑——可能超过2秒
$this->deductStock($productId, $quantity);
Redis::del($lockKey);
}
这段代码的问题:setnx 和 expire 不是原子操作。如果 setnx 成功但 expire 前进程崩溃,锁变成永不过期的死锁。更致命的是业务超时后锁自动释放,导致并发问题。
早期版本也踩过这个坑,后来重构为RedLock方案,配合SLS日志采集做锁异常告警。
第二阶段:RedLock + 看门狗
// 使用RedLock算法,原子操作加锁
$lockKey = "product:{$productId}:lock";
$lockToken = uniqid('', true); // 唯一标识,用于安全释放
$ttl = 3000; // 3秒
$result = Redis::eval(
"if redis.call('set', KEYS[1], ARGV[1], 'NX', 'PX', ARGV[2]) then return 1 else return 0 end",
1, $lockKey, $lockToken, $ttl
);
if ($result) {
// 启动看门狗协程,每1秒续期一次
$this->startWatchdog($lockKey, $lockToken, $ttl);
try {
$this->deductStock($productId, $quantity);
} finally {
// Lua脚本安全释放
Redis::eval(
"if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end",
1, $lockKey, $lockToken
);
}
}
关键改进:
- 原子加锁:
SET NX PX一步到位 - 看门狗机制:业务未完成时自动续期,避免锁超时释放
- 安全释放:用Lua脚本校验锁持有者,防止误删别人的锁
但看门狗也有代价:每个持有锁的请求需要额外一个进程做续期,高并发下会增加Redis连接数。对于200 QPS的扣库存场景,Redis连接数会从50涨到200左右。
第三阶段:结合阿里云Redis的优化方案
阿里云Redis提供了 Redlock 的优化实现——Tair 扩展支持 EXSET 命令,自带续期能力,省掉看门狗的额外开销。
// 阿里云Tair扩展——内置续期
$lockKey = "product:{$productId}:lock";
$lockToken = uniqid('', true);
// 加锁并自动续期,续期间隔1秒
$result = Redis::rawCommand('EXSET', $lockKey, $lockToken, 'NX', 'PX', 3000, 'KEEPTTL', 1000);
if ($result) {
try {
$this->deductStock($productId, $quantity);
} finally {
// 安全释放
Redis::rawCommand('EXSET', $lockKey, $lockToken, 'XX', 'KEEPTTL', 0, 'DEL');
}
}
这个方案把看门狗的逻辑下沉到Redis服务端,客户端代码更简洁,网络开销也更低。配合阿里云Redis的主从架构,主从切换时锁数据通过RDB/AOF持久化,不会丢锁。
运维监控:让锁失效可观测
光有代码不够,线上跑起来后怎么知道锁有没有失效?Taocarts的做法是:
- SLS日志采集:每次加锁/解锁都记录日志,包括锁Key、持有时间、是否续期
- CloudMonitor告警:设置锁持有时间超过TTL 80% 的告警阈值,提前发现慢查询
- OOS自动化运维:锁异常时自动执行诊断脚本,检查Redis连接数和慢日志
# CloudMonitor告警规则示例
报警规则: Redis锁持有时间异常
指标: 自定义指标 > lock_hold_time
阈值: >= 2500ms (TTL 3000ms的80%)
持续周期: 3次
通知方式: 钉钉群机器人 + 短信
这样配置后,即使业务代码出现偶发慢查询,也能在锁失效前收到告警,提前介入排查。
实际效果
采用RedLock + 看门狗方案后,在压测1000并发扣库存的场景下,零超卖。相比自研锁方案,Redis连接数增加了约3倍,但CPU使用率只上升了8%——因为看门狗续期是轻量操作。
成本方面:从自研锁升级到RedLock,代码改动量约50行,开发测试耗时2天。如果使用阿里云Tair扩展,代码更少,但需要Redis实例支持(标准版即可)。
总结
说穿了就一件事:**Redis锁失效不是锁本身的问题,而是锁的生命周期管理没到位。**从自研锁到RedLock再到服务端续期,每一步都在解决上一层的边界情况。如果你正在做类似的系统,建议直接从RedLock起步,配合SLS日志和CloudMonitor告警。
你们在实际项目中遇到过Redis锁失效的情况吗?用的什么方案解决的?欢迎聊聊。
- 点赞
- 收藏
- 关注作者
评论(0)