大促风暴眼:10万+/秒请求下,百万优惠券如何精准发放不超发?
每一场电商大促,都是一场没有硝烟的技术战争。而“优惠券”作为刺激消费的核心武器,其发放系统的稳定性与准确性,直接关系到用户体验和平台的真金白银。想象一下这样一个场景:平台精心准备了100万张优惠券,作为引爆流量的爆点。活动上线瞬间,汹涌的流量洪峰扑来,每秒超过10万次的请求高喊着:“给我一张券!”。此时,系统后端那记录着“库存:1,000,000”的数据库,成为了风暴的中心。
超发——那个不能承受之痛
所谓“超发”,就是系统发出的优惠券数量超过了预设的库存。这不仅仅是“多发了几张券”那么简单,它会导致:
1. 资损风险:超发的优惠券被用户使用,平台需要承担额外的成本。
2. 用户投诉与舆情危机:抢到券的用户发现无法使用,或订单被取消,会引发大面积的用户不满和信任危机。“玩不起就别玩”的舆论会迅速发酵。
3. 平台信誉受损:一次超发事故,足以让平台长期建立的公信力大打折扣。
那么,在每秒10万次请求的冲击下,我们如何构建一个固若金汤的防超发系统,确保发出去的每一张券都在100万库存之内呢?让我们从最简单的方案开始,逐步深入到能够抵御洪峰的架构。
第一章:天真与陷阱 —— 为什么简单的SQL更新会失灵?
很多开发者的第一反应可能是:这还不简单?在发券时,先查询一下当前库存,如果大于0,再执行库存扣减和发券操作。
对应的SQL伪代码可能是这样:
这个逻辑在单线程或低并发下完美无缺。但在每秒10万请求的并发环境下,它不堪一击。问题就在于竞态条件(Race Condition)。
并发场景模拟:
假设此时库存只剩1张,同时有两个用户A和B发来了请求。
1. 请求A和请求B同时执行了 SELECT 语句,它们都读到了 stock = 1。
2. 两个请求在应用逻辑判断中都顺利通过 (stock > 0)。
3. 请求A先执行了 UPDATE,将库存成功扣减为0。
4. 紧接着,请求B也执行了 UPDATE。由于它不知道库存已经被A修改,这条语句依然会执行成功(MySQL的Update本身是原子的,stock = stock - 1 会导致 stock = -1!)。
结果: 1张库存,发出了2张券,超发了!
问题的根源在于,“查询”和“更新”是两个独立的操作,它们组成的复合逻辑在并发下不是原子性的。
第二章:数据库的铜墙铁壁 —— 悲观锁与乐观锁
要解决原子性问题,我们首先想到的就是求助数据库的“锁”。
方案一:悲观锁 —— “先占坑,再办事”悲观锁的思想是,我认为任何时候都会发生并发冲突,所以我在操作数据之前,先把它锁住,让别人无法操作。
在MySQL中,我们可以使用 SELECT ... FOR UPDATE 来实现。
工作原理: 当请求A执行 SELECT ... FOR UPDATE 时,数据库会为这条记录加上行锁。在事务提交前,请求B执行同样的语句会被阻塞,直到请求A的事务结束释放锁。此时请求B读到的 stock 已经是0,判断失败,不会发券。
优缺点:
• 优点:简单,能有效防止超发。
• 缺点:
性能瓶颈:所有请求串行化,在高并发下,数据库连接迅速被占满,大量请求排队等待,导致系统响应缓慢甚至超时。10万QPS直接压垮数据库。
死锁风险:复杂的锁依赖可能导致死锁。
结论:悲观锁适用于并发量不高的场景,在10万QPS的洪峰下,它不是一个可行的选择。
方案二:乐观锁 —— “相信美好,但验证一下”乐观锁的思想与悲观锁相反,我认为冲突很少发生,所以我不加锁,直接去更新。但在更新时,我会检查一下在我之前有没有人修改过这个数据。
通常我们使用一个版本号(version)字段来实现。
表结构增加一列:version int。
工作原理: 请求A和B都读到了 version = 1。请求A先执行Update,条件 version=1 成立,库存被扣减,同时 version 变为2。请求B再执行Update时,条件 version=1 已经不成立,所以更新影响行数为0,请求B失败。
优缺点:
• 优点:避免了悲观锁的巨大性能开销,适合读多写少的场景。
• 缺点:
高失败率:在极高并发下,大量请求会更新失败,用户体验不佳(明明看到有券,一点就没了)。
需要重试机制:通常需要配合重试(例如在应用层循环重试几次),增加了复杂度。
结论:乐观锁比悲观锁性能好很多,但在瞬时10万QPS的极端场景下,大量的失败和重试对数据库的冲击依然不小,并非最优解。
第三章:迈向巅峰 —— 将库存前置到缓存
数据库终究是持久化存储,其IO性能有上限。要应对10万QPS,我们必须将主战场转移到更快的内存中。这就是引入缓存(如Redis)的原因。
方案三:Redis原子操作 —— “一锤定音”Redis是单线程工作模型,所有的命令都是原子执行的。我们可以利用这个特性,将库存扣减这个核心逻辑放在Redis中完成。
步骤:
1. 预热:活动开始前,将库存数量100万写入Redis。
2. 扣减:用户请求时,使用Redis的 DECR 或 DECRBY 命令。
为什么是原子性的?DECR 命令在Redis内部一步完成“读取-计算-写入”,不存在并发干扰。即使10万个请求同时执行 DECR,Redis也会让它们排队,一个一个执行。第一个请求执行后库存变为999999,第二个变为999998...直到0,然后是-1, -2...
关键点:
• 判断时机:我们通过判断 DECR 后的结果是否 >=0 来决定是否成功。等于0是最后一张,大于0是普通情况,小于0则意味着超发(我们通过后面的 INCR 进行回滚,实际上并未超发)。
• 异步落库:Redis只负责处理最核心的库存扣减逻辑。真正的发券记录(写入用户券表)可以通过消息队列异步化,这样就把数据库的巨大写入压力给化解了。
这个方案已经非常强大了,但它还有一个潜在问题:在库存为1时,瞬间有1万个请求执行了 DECR,实际上只有1个请求会成功(结果=0),另外9999个请求都会失败(结果<0)。虽然逻辑正确,但这9999次对Redis的写操作其实是浪费的,因为库存明明已经没了。
方案四:Redis + Lua脚本 —— “终极武器”我们可以通过Lua脚本,将“判断库存”和“扣减库存”等多个操作在Redis服务端一次性、原子性地完成,从而获得极致的性能和控制力。
Lua脚本在Redis中执行时,可以视为一个事务,不会被其他命令打断。
在Java应用中调用该脚本:
Lua脚本方案的优势:
1. 极致的原子性:所有逻辑在一个脚本中完成,无竞态条件。
2. 极少的网络IO:一次脚本调用代替了多次 GET/DECR 等命令的往返。
3. 可扩展性:可以在脚本内轻松实现更复杂的逻辑,如记录用户ID防止同一用户重复抢购(通过Redis的Set结构)。
4. 性能巅峰:这是应对超高并发读写的终极方案,能够最大限度地发挥Redis的性能。
第四章:构建完整的防御体系
单一的技术方案再强大,也需要一个完整的系统架构来支撑。一个成熟的大促发券系统,还需要考虑以下方面:
1. 网关层限流与防护:在流量入口(如API网关)就进行限流,将超过系统处理能力的请求直接拒绝掉,保护下游服务。例如,设置每秒最多通过15万个请求。
2. 缓存集群与分片:单机Redis可能有性能瓶颈或单点故障风险。我们需要使用Redis集群,并通过合理的分片策略(例如按优惠券ID分片),将不同优惠券的请求分散到不同的Redis节点上。
3. 异步化与消息队列:正如前面提到的,抢券资格判断(Redis操作)和实际发券(数据库操作)必须解耦。使用RabbitMQ、RocketMQ或Kafka,将抢券成功的消息发送到队列,由下游的消费者服务按自己的能力从队列中取出消息,平稳地写入数据库。
4. 令牌桶或漏桶算法:在应用层,可以使用令牌桶算法进一步平滑请求,防止瞬间流量冲垮Redis。例如,每秒只发放500个令牌到令牌桶,请求拿到令牌后才能去执行Lua脚本抢券。
5. 降级与熔断:如果Redis或数据库出现异常,系统需要有自动降级策略(如直接返回“活动太火爆”页面)和熔断机制,防止雪崩效应。
总结
面对“100万库存,10万+/秒请求”的极端场景,我们的技术选型路径是清晰的:
初级方案(不可行):查询再更新 → 必然超发中级方案(不适用):数据库悲观/乐观锁 → 性能瓶颈高级方案(可行):Redis原子操作(DECR) → 性能良好,略有浪费终极方案(推荐):Redis Lua脚本 + 异步消息队列 + 网关限流
这个终极方案的精髓在于:
• 核心逻辑原子化:利用Redis单线程和Lua脚本的原子性,在内存中完成最关键的库存扣减判断,速度快且绝不超发。
• 读写操作解耦:前端快速判断资格,后端异步持久化数据,保护脆弱的关系型数据库。
• 流量层层过滤:通过网关限流、应用层限流等手段,将超出系统设计容量的流量提前拒之门外。
通过这样一套组合拳,我们才能在大促的流量风暴中,真正做到忙而不乱,精准发放,让每一张优惠券都“师出有名”,守护好系统的稳定与平台的声誉。这正是高并发系统设计的艺术与魅力所在。