摘要:你是不是也遇到过这样的情况:好不容易做完的抢券功能,一到活动上线就状况百出 —— 用户吐槽 “点了半天没反应”,运营那边又传来 “券被多领了几百张” 的消息,最后只能熬夜紧急修复,还得面对一堆线上问题反馈?其实啊,不是你技术不行,而是并发抢券设计里藏着不少容易
你是不是也遇到过这样的情况:好不容易做完的抢券功能,一到活动上线就状况百出 —— 用户吐槽 “点了半天没反应”,运营那边又传来 “券被多领了几百张” 的消息,最后只能熬夜紧急修复,还得面对一堆线上问题反馈?其实啊,不是你技术不行,而是并发抢券设计里藏着不少容易被忽略的 “坑”,今天咱们就从实际开发场景出发,把这套逻辑拆透,让你下次做抢券功能时能稳操胜券。
做过电商或活动类开发的朋友都知道,抢券看似是 “领券” 这么一个简单动作,但背后藏着两大核心问题,也是最容易出故障的地方。
第一个是超卖问题。比如运营只放了 1000 张优惠券,结果最后统计发现被领走了 1200 张,这就是典型的超卖。为啥会这样?举个例子,当两个用户同时请求领券时,系统都去查数据库里的券库存,发现还剩 1 张,于是同时给两个用户返回 “领取成功”,但实际库存只能支撑 1 个用户,最后就导致了超卖。别小看这个问题,一旦超卖,要么平台自己承担额外的成本,要么用户领了券用不了引发投诉,对口碑影响特别大。
第二个是性能卡顿问题。大促期间抢券请求峰值能达到每秒几万次,要是直接让所有请求都去查数据库、改库存,数据库很容易被压垮,进而导致系统响应变慢,用户点了 “领取” 按钮后,半天没动静,要么放弃,要么反复点击,反而进一步增加系统压力,形成恶性循环。我之前就见过一个项目,没做任何优化,抢券活动开始 5 分钟,数据库直接崩了,最后只能紧急下架活动,尴尬又被动。
在解决问题之前,咱们得先明确并发抢券的 “底层逻辑”—— 它不只是技术层面的 “抗并发”,还得结合业务需求来设计。
从业务角度看,并发抢券有几个核心约束:首先是库存准确性,必须严格控制 “领券数量 ≤ 实际库存”,不能超卖;其次是用户体验,响应时间要快,一般要求在 1 秒内返回结果,不能让用户等太久;最后是公平性,不能出现 “部分用户能快速领到,部分用户一直抢不到” 的情况,除非是设计了 “会员优先” 这类特殊规则,但也要提前明确,避免争议。
从技术背景来说,并发抢券的核心矛盾是 “高并发请求” 与 “数据一致性” 的冲突。咱们平时开发单用户操作的功能,比如用户修改个人信息,不会有太多并发问题,但抢券是 “多用户同时操作同一份数据(库存)”,这就涉及到了分布式系统中的 “数据一致性” 问题。而且,抢券请求具有 “突发性”—— 平时可能没什么请求,但活动开始的瞬间,请求量会突然暴涨,这种 “脉冲式” 的流量,对系统的弹性能力要求特别高。
另外,不同场景下的抢券需求还不一样。比如 “限时抢券” 需要结合时间判断,过了时间就不能领;“每人限领 1 张” 需要判断用户是否已经领过;“指定用户群体抢券”(比如新用户专享)需要先校验用户身份。这些业务规则都得融入到并发设计里,不能只盯着 “抗并发”,忽略了业务逻辑的准确性。
结合前面的问题和背景,我整理了一套从 “前端到后端再到数据库” 的全链路解决方案,都是经过实际项目验证的,大家可以直接参考。
前端优化是 “第一道防线”,别让无效请求跑到后端去消耗资源。
按钮防重复点击:用户点击 “领取” 按钮后,立即禁用按钮,避免用户反复点击发送多个请求。可以加个倒计时,比如 3 秒后再启用,即使请求失败,也能引导用户稍后重试,而不是疯狂点击。预加载与本地判断:把抢券的基础信息(比如活动开始时间、用户是否已领过券)提前加载到前端,用户点击领取时,先在本地判断 —— 如果活动还没开始,直接提示 “活动未开始”;如果用户已经领过,直接提示 “您已领取过该优惠券”,不用再请求后端,减少无效请求。请求合并与限流:如果是 “批量抢券”(虽然很少见,但部分场景会有),可以把多个用户的请求合并成一个批次发送,而不是单个发送;另外,前端可以做简单的限流,比如同一 IP 在 1 秒内最多发送 2 次抢券请求,过滤掉部分恶意请求。后端是核心,这里要做 “分层拦截”,不要让所有请求都打到数据库,而是用 “缓存” 先扛住大部分请求。
第一步:Redis 预存库存,拦截无效请求
把优惠券的库存提前存到 Redis 里,用户抢券时,先去 Redis 里判断库存 —— 如果库存为 0,直接返回 “券已抢完”,不用再往下走;如果库存还有,再进行下一步。为什么用 Redis?因为 Redis 是内存数据库,读写速度比 MySQL 快得多,每秒能处理几十万次请求,能轻松扛住抢券的峰值流量。
这里有个关键操作:扣减库存时,要用 Redis 的 “原子操作”,比如用DECR命令(递减 1),而不是先查库存再减库存。因为DECR命令是原子性的,不会出现 “两个请求同时查库存、同时扣减” 的问题。举个例子,Redis 里的库存是 100,两个请求同时执行DECR,第一个执行后变成 99,第二个执行后变成 98,不会出现超卖,这就解决了前面说的 “库存并发判断” 问题。
第二步:判断用户是否已领,避免重复领取
用 Redis 的 “集合”(Set)存储已领取优惠券的用户 ID,用户抢券时,先执行SISMEMBER命令,判断用户是否在集合里 —— 如果在,直接返回 “已领取”;如果不在,再执行SADD命令把用户 ID 加入集合,同时扣减库存。这里也要注意,“判断 + 加入集合” 最好用 Redis 的 “事务” 或者 “Lua 脚本” 保证原子性,避免出现 “判断时不在集合,但加入时被其他请求抢先” 的情况。比如用 Lua 脚本写一段逻辑:先判断用户是否在集合,不在的话,扣减库存并加入集合,返回 “成功”;在的话,返回 “已领取”,这样整个过程是原子性的,不会有漏洞。
第三步:异步处理订单,提升响应速度
用户领取成功后,不需要立即去数据库创建 “领券记录”—— 因为创建记录是写数据库操作,速度慢,会影响响应时间。可以把 “创建领券记录” 的任务放到消息队列(比如 RabbitMQ、Kafka)里,后端异步消费消息,去数据库里写入用户 ID、券 ID、领取时间等信息。这样,用户点击 “领取” 后,只要 Redis 操作成功,就立即返回 “领取成功”,响应时间能控制在几百毫秒内,体验会好很多。
这里要注意,消息队列要做 “重试机制”,如果消费失败(比如数据库临时不可用),要重新重试,避免 “Redis 里扣了库存,但数据库没记录” 的情况。另外,要定期校验 Redis 库存和数据库库存的一致性,如果出现差异,及时调整,比如 Redis 里的库存比数据库多,说明有消息没消费成功,要重新消费;如果 Redis 里的库存比数据库少,要排查是否有超卖情况。
第四步:数据库兜底,保证数据最终一致
虽然大部分操作都在 Redis 里完成,但数据库是 “最终数据源”,必须保证数据一致。除了前面说的异步写入领券记录,还要做两件事:
一是定时同步库存:每隔一段时间(比如 1 分钟),把 Redis 里的库存同步到数据库里,避免 Redis 宕机后库存丢失;
二是库存预扣减与回滚:如果用户领了券但没使用,过期后要把库存回滚 —— 比如券的有效期是 7 天,过期后,系统自动查询 “已领取未使用” 的券,把对应的 Redis 库存和数据库库存加 1,同时从 Redis 的 “已领取用户集合” 里删除该用户 ID,让其他用户有机会领取。
就算做了前面的优化,也难免遇到极端情况,比如 Redis 突然宕机,或者消息队列堵塞,这时候得有兜底方案。
Redis 宕机兜底:如果 Redis 宕机,临时把抢券请求切换到 “本地缓存”(比如 Java 里的 Caffeine),同时紧急恢复 Redis,恢复后再把本地缓存的库存同步到 Redis 里。不过本地缓存只适合单节点,如果是分布式系统,多个节点的本地缓存会不一致,所以这只是临时方案,不能长期用。流量过载兜底:用限流组件(比如 Sentinel、Gateway)设置抢券接口的最大 QPS(比如每秒 5 万次),超过 QPS 的请求直接返回 “当前人数过多,请稍后重试”,避免系统被压垮。限流阈值可以根据系统的实际承载能力调整,大促期间可以适当调高,平时可以调低。总结一下,设计并发抢券逻辑,核心是 “分层拦截、缓存优先、异步处理、数据一致”:前端减少无效请求,后端用 Redis 扛住高并发,异步处理非核心流程,最后用数据库保证数据一致,同时做好兜底方案,应对极端情况。
其实,并发抢券不是 “一次性设计”,而是需要持续优化的。比如第一次上线后,可以通过监控工具(比如 Prometheus、Grafana)看系统的响应时间、QPS、错误率,找出瓶颈 —— 如果是 Redis 成为瓶颈,就加 Redis 集群;如果是消息队列堵塞,就调整消费线程数。
最后,想跟大家说:别害怕并发问题,多动手实践,多复盘项目中的问题。如果你在实际开发中遇到了其他并发抢券的坑,或者有更好的优化方案,欢迎在评论区分享,咱们一起交流学习,把技术做得更扎实,下次做抢券功能时,再也不用慌慌张张啦!
来源:从程序员到架构师一点号