商协会的活动报名有个特点:名额有限、开放时间集中。一场限五十人的活动,通知下午三点发出去,三点零几分流量就上来了。早先扣减逻辑是查库存、判断、更新三步,压测一跑就超卖——五十个名额卖出去五十六个。
这篇把改造过程整理一遍,重点不是贴代码,而是每一步为什么这么改。
第一版是纯数据库操作:SELECT 查剩余名额,大于零就 UPDATE 减一。并发下"查再改"不是原子的,两个请求同时查到还剩一个,都会去减。第二版加乐观锁,UPDATE 时带 stock > 0 的条件,用影响行数判成败。并发不高时够用,但冲突多了大量请求要重试,响应时间不可预测。
第三版换成 Redis,直接用 DECR 扣库存。这版有两个新毛病:DECR 会减成负数,只能事后发现;而且不知道是谁抢到的,补记录操作和扣减不是原子的,失败就出现"扣了库存但没报名记录"。
最终版把整个判断和扣减塞进一段 Lua。Redis 执行 Lua 是单线程的,脚本内多条命令不会被打断,天然原子。
-- KEYS[1]: 活动库存键 KEYS[2]: 已报名会员集合
-- ARGV[1]: 会员ID ARGV[2]: 集合过期时间(秒)
-- 返回值: 1 成功 / 0 已售罄 / -1 库存未初始化 / -2 重复报名
local stock = tonumber(redis.call('GET', KEYS[1]))
if stock == nil then return -1 end
if stock <= 0 then return 0 end
if redis.call('SISMEMBER', KEYS[2], ARGV[1]) == 1 then return -2 end
redis.call('DECR', KEYS[1])
redis.call('SADD', KEYS[2], ARGV[1])
redis.call('EXPIRE', KEYS[2], tonumber(ARGV[2]))
return 1
判库存、判重、扣减加记名,一次调用完成。判重放在集合里做,同一会员重复提交直接返回 -2,不用依赖数据库的联合索引兜底。
服务端用 EVALSHA 调用,脚本首次加载后复用 SHA,减少网络传输。NOSCRIPT 报错时重新加载再执行一次即可。
服务端用 EVALSHA 调用,脚本首次加载后复用 SHA,减少网络传输,遇到 NOSCRIPT 报错时重新加载再执行一次即可。调用侧还包了一层分布式锁:锁的粒度是会员加活动,用 SET NX PX 拿锁,拿不到就返回并发冲突;执行完在 finally 里释放,释放前用 Lua 比对令牌,避免误删其他请求持有的锁。扣减成功后异步写库,写库失败要把库存加回去。
几个坑值得单独说。
一是锁不是用来扣库存的。库存扣减已由 Lua 保证原子,锁只挡同一会员在极短时间内的重复点击,粒度是会员加活动,不是整个活动。把锁加到活动上等于把并发串行化,跟没上 Redis 一样。
二是锁要带过期时间,释放时比对令牌。设了值不设过期,进程崩了锁就永远在那儿;释放时不比对令牌,可能删掉别的请求刚拿到的锁。这两条是老规矩,线上出问题的多半还是它们。
三是异步落库失败必须回滚。扣减成功、写库失败不 INCR 回去,名额就凭空少一个。回滚本身也可能失败,所以还要一层对账:定时任务比对 Redis 库存和数据库报名数,不一致时以数据库为准。
四是库存初始化要有保护。活动创建时把名额写进 Redis,这步失败或者 Redis 重启后键丢了,脚本返回 -1。这时回落到数据库查真实报名数,重设库存键再走一次流程,别直接把请求拒掉。
五是别把 Redis 当可靠存储。它是加速层,最终以数据库为准,报名记录入库时仍然依赖数据库的联合约束防重复。
改造之后压测,五十个名额并发抢购超卖为零,接口 P99 从四百多毫秒降到六十毫秒左右。收获不在性能,而在逻辑变得可解释:库存在哪、谁抢到了、失败原因是什么,每条都有出处。
我们这边在跑的商会管理系统叫未来漫城·商会互联平台,活动报名这块就是这套实现,热门活动开放报名时不再需要人为盯着库存。
最后提醒一句:Lua 脚本要保持短小。脚本执行期间 Redis 单线程阻塞,里面放循环或慢操作会拖垮整个实例。像"批量扣减多个活动"这种需求,宁可调用多次,也不要写成一次大循环。