大促风暴眼:10万+/秒请求下,百万优惠券如何精准发放不超发?

每一场电商大促,都是一场没有硝烟的技术战争。而“优惠券”作为刺激消费的核心武器,其发放系统的稳定性与准确性,直接关系到用户体验和平台的真金白银。想象一下这样一个场景:平台精心准备了100万张优惠券,作为引爆流量的爆点。活动上线瞬间,汹涌的流量洪峰扑来,每秒超过10万次的请求高喊着:“给我一张券!”。此时,系统后端那记录着“库存:1,000,000”的数据库,成为了风暴的中心。

超发——那个不能承受之痛

所谓“超发”,就是系统发出的优惠券数量超过了预设的库存。这不仅仅是“多发了几张券”那么简单,它会导致:

1. 资损风险:超发的优惠券被用户使用,平台需要承担额外的成本。

2. 用户投诉与舆情危机:抢到券的用户发现无法使用,或订单被取消,会引发大面积的用户不满和信任危机。“玩不起就别玩”的舆论会迅速发酵。

3. 平台信誉受损:一次超发事故,足以让平台长期建立的公信力大打折扣。

那么,在每秒10万次请求的冲击下,我们如何构建一个固若金汤的防超发系统,确保发出去的每一张券都在100万库存之内呢?让我们从最简单的方案开始,逐步深入到能够抵御洪峰的架构。

第一章:天真与陷阱 —— 为什么简单的SQL更新会失灵?

很多开发者的第一反应可能是:这还不简单?在发券时,先查询一下当前库存,如果大于0,再执行库存扣减和发券操作。

对应的SQL伪代码可能是这样:

复制
-- 1. 查询库存 SELECT stock FROM coupon WHERE id = #{couponId}; -- 2. 应用层判断 if (stock > 0) { // 3. 扣减库存 UPDATE coupon SET stock = stock - 1 WHERE id = #{couponId}; // 4. 给用户发券 INSERT INTO user_coupon (user_id, coupon_id) VALUES (#{userId}, #{couponId}); }1.2.3.4.5.6.7.8.9.10.

这个逻辑在单线程或低并发下完美无缺。但在每秒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 来实现。

复制
BEGIN; -- 开启事务 -- 1. 查询并锁定这条优惠券记录 SELECT stock FROM coupon WHERE id = #{couponId} FOR UPDATE; -- 2. 判断库存 if (stock > 0) { // 3. 扣减库存 UPDATE coupon SET stock = stock - 1 WHERE id = #{couponId}; // 4. 给用户发券 INSERT INTO user_coupon (user_id, coupon_id) VALUES (#{userId}, #{couponId}); } COMMIT; -- 提交事务,释放锁1.2.3.4.5.6.7.8.9.10.11.12.13.14.

工作原理: 当请求A执行 SELECT ... FOR UPDATE 时,数据库会为这条记录加上行锁。在事务提交前,请求B执行同样的语句会被阻塞,直到请求A的事务结束释放锁。此时请求B读到的 stock 已经是0,判断失败,不会发券。

优缺点:

• 优点:简单,能有效防止超发。

• 缺点:

性能瓶颈:所有请求串行化,在高并发下,数据库连接迅速被占满,大量请求排队等待,导致系统响应缓慢甚至超时。10万QPS直接压垮数据库。

死锁风险:复杂的锁依赖可能导致死锁。

结论:悲观锁适用于并发量不高的场景,在10万QPS的洪峰下,它不是一个可行的选择。

方案二:乐观锁 —— “相信美好,但验证一下”

乐观锁的思想与悲观锁相反,我认为冲突很少发生,所以我不加锁,直接去更新。但在更新时,我会检查一下在我之前有没有人修改过这个数据。

通常我们使用一个版本号(version)字段来实现。

表结构增加一列:version int。

复制
-- 1. 查询当前库存和版本号 SELECT stock, version FROM coupon WHERE id = #{couponId}; -- 2. 应用层判断库存 if (stock > 0) { // 3. 扣减库存,但附加上版本号条件 UPDATE coupon SET stock = stock - 1, version = version + 1 WHERE id = #{couponId} AND version = #{oldVersion}; // 4. 判断UPDATE是否成功 if (affected_rows > 0) { // 更新成功,说明没有并发冲突,发券 INSERT INTO user_coupon (user_id, coupon_id) VALUES (#{userId}, #{couponId}); } else { // 更新失败,说明在我查询之后,库存已经被别人修改。重试或返回失败。 } }1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.

工作原理: 请求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。

复制
SET coupon_stock:123 10000001.

2. 扣减:用户请求时,使用Redis的 DECR 或 DECRBY 命令。

复制
// 伪代码示例 public boolean tryAcquireCoupon(Long couponId, Long userId) { // 使用 DECR 原子性扣减库存 Long currentStock = redisTemplate.opsForValue().decrement("coupon_stock:" + couponId); if (currentStock >= 0) { // 扣减成功,库存>=0,说明用户抢到了资格 // 此时可以异步地向数据库写入发券记录 asyncService.sendMessageToMQ("coupon_acquired", userId, couponId); return true; } else { // 扣减后库存小于0,说明已抢光,需要回滚刚才的扣减 redisTemplate.opsForValue().increment("coupon_stock:" + couponId); return false; } }1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.

为什么是原子性的?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中执行时,可以视为一个事务,不会被其他命令打断。

复制
-- try_acquire_coupon.lua local stockKey = KEYS[1] -- 库存Key local userId = ARGV[1] -- 用户ID local couponId = ARGV[2] -- 券ID -- 1. 获取当前库存 local stock = tonumber(redis.call(GET, stockKey)) -- 2. 库存不足,直接返回 if stock <= 0 then return -1 -- 库存不足的标识 end -- 3. 库存充足,执行扣减 redis.call(DECR, stockKey) -- 这里理论上还可以做更多事情,比如将用户ID写入一个“抢到券的用户集合”,用于防重复抢购 -- redis.call(SADD, coupon_winner_set, userId) return 1 -- 抢券成功的标识1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.

在Java应用中调用该脚本:

复制
// 预加载脚本,获取一个sha1标识 String script = "lua脚本内容..."; String sha = redisTemplate.scriptLoad(script); public boolean tryAcquireCoupon(Long couponId, Long userId) { List<String> keys = Arrays.asList("coupon_stock:" + couponId); Object result = redisTemplate.execute( new RedisCallback<Object>() { @Override public Object doInRedis(RedisConnection connection) throws DataAccessException { // 使用evalsha执行脚本,性能更好 return connection.evalSha(sha, ReturnType.INTEGER, 1, keys.get(0).getBytes(), userId.toString().getBytes(), couponId.toString().getBytes()); } } ); Long res = (Long) result; if (res == 1) { // 成功,异步落库 asyncService.sendMessageToMQ("coupon_acquired", userId, couponId); return true; } else { // 失败,res == -1 return false; } }1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.28.29.

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脚本的原子性,在内存中完成最关键的库存扣减判断,速度快且绝不超发。

• 读写操作解耦:前端快速判断资格,后端异步持久化数据,保护脆弱的关系型数据库。

• 流量层层过滤:通过网关限流、应用层限流等手段,将超出系统设计容量的流量提前拒之门外。

通过这样一套组合拳,我们才能在大促的流量风暴中,真正做到忙而不乱,精准发放,让每一张优惠券都“师出有名”,守护好系统的稳定与平台的声誉。这正是高并发系统设计的艺术与魅力所在。

阅读剩余
THE END