
秒杀场景的核心挑战可以归结为“三高”:高并发、高性能、高可用。
而系统中最脆弱的一环,往往是我们的关系型数据库(如MySQL)。它承载着最终的数据落地,其连接数、IOPS和CPU资源都极其有限。如果任由海啸般的瞬时流量直接冲击数据库,结果必然是连接池耗尽、服务宕机,最终导致整个业务雪崩。
因此,我们的首要任务是设计一道坚固的防线,保护脆弱的数据库。
为了应对高并发,我们的核心思路是:将写操作前置到缓存,通过消息队列异步持久化,实现流量削峰填谷。
1. 前置阵地:Redis + Lua,保证原子性预扣库存
我们选择将库存等热点数据预热到Redis中,利用其卓越的内存读写性能来承接第一波流量。
但简单的 GET -> 业务判断 -> SET 操作在并发环境下存在严重的线程安全问题,极易导致超卖。此时,Lua脚本成为我们的不二之选。
codeLua
-- seckill.lua: 原子性校验与预扣库存
local voucherId = ARGV[1]
local userId = ARGV[2]
local stockKey = 'seckill:stock:' .. voucherId
local orderKey = 'seckill:order:' .. voucherId
-- 1. 检查库存
if(tonumber(redis.call('get', stockKey)) <= 0) then
return 1 -- 库存不足
end
-- 2. 检查用户是否已下单 (利用Set数据结构)
if(redis.call('sismember', orderKey, userId) == 1) then
return 2 -- 已购买过
end
-- 3. 扣减库存 & 记录用户
redis.call('incrby', stockKey, -1) -- 使用 incrby -1 代替 decr,语义更明确
redis.call('sadd', orderKey, userId)
return 0 -- 成功核心优势: Lua脚本能在Redis服务端以原子方式执行,确保了“检查库存”、“判断重复”和“扣减库存”这三个步骤不可分割,从根本上杜绝了并发场景下的超卖和重复下单问题。
2. 流量缓冲带:消息队列(MQ),实现极致的削峰填谷
当Lua脚本执行成功,代表用户已获得购买资格。但我们并不立即操作数据库,而是将包含userId和voucherId的订单信息封装成一条消息,发送到消息队列(如RocketMQ)。
随后,系统可以立刻向前端返回成功响应(例如:“抢购成功,订单正在处理中…”)。
核心优势:
至此,我们构建了一套高性能、高可用的异步架构。但这套架构为了性能,牺牲了数据的强一致性,从而引出了新的、更深层次的挑战。
异步架构带来了两个核心的一致性问题:
1. 保障内部一致性:异步链路的可靠性
这是首先要解决的问题。如果用户在Redis抢到了资格,但因为消费者服务异常导致数据库订单创建失败,对用户来说是不可接受的。
我们的保障措施有:
2. 致命裂痕:外部不一致性带来的脏缓存
解决了内部链路的可靠性,一个更隐蔽的问题浮现了。我们的系统并非只有“秒杀”这一条路径会修改库存。考虑以下场景:
在这两种场景下,数据库成为了数据更新的第一源头,而我们部署在Redis中的库存缓存对此一无所知!
后果是灾难性的: 数据库库存已经补充,但Redis库存仍为0,导致用户无法下单;或者数据库库存已经回滚,但Redis没有,导致商品被超卖。此时,Redis沦为了脏缓存。
3. 终极方案:基于Canal的Binlog订阅模型
为了根治此问题,我们需要一种机制,让缓存能够“感知”到数据库的所有变化,无论这个变化来自哪个业务源头。我们将缓存同步的逻辑与业务逻辑彻底解耦,引入了基于数据库变更日志的同步方案。
核心思想: 数据库是所有数据的最终权威,其Binlog记录了所有的数据变更。我们只需要订阅Binlog,就能精确地知道数据何时、发生了何种变化。
架构流程:
这套方案的巨大优势:
没有100%完美的架构,我们还需要一些“安全网”来应对未知的异常。
高并发秒杀系统的架构设计,是一场在性能、可用性与一致性之间不断权衡与演进的旅程。
我们始于 Redis+MQ 的缓存前置与异步化 架构,解决了高性能与高可用的核心诉求。
随后深入到问题的本质,通过 MQ重试、数据库唯一索引 等手段保障了异步链路的内部一致性,再通过引入 Canal订阅Binlog 的模型,完美解决了因多业务入口导致的外部数据一致性这一灵魂难题。