ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

把秒杀从Redis移到数据库:活动报名系统的原子校验实战

把秒杀从Redis移到数据库:活动报名系统的原子校验实战 先交代个背景我之前负责一个企业内部周年庆活动报名系统峰值流量不算夸张一万多人同时抢两百个线下名额。第一版方案就是网上最常见的套路——Redis做库存预扣减报名接口先查缓存、再扣库存、再写数据库用户报名成功后拿一个带签名和有效期的动态二维码去现场核销。这套方案在压测环境里一切正常结果一上真实业务当天就暴露出一连串问题。后来我把秒杀逻辑整体从Redis里移了出来二维码也从动态改成了静态凭证核心就靠数据库状态机做原子校验。这篇文章把我趟过的坑、背后的思路变化和最终落地方案完整写一遍。1. 项目背景一个活动报名系统是怎么一步步被Redis方案绑架的1.1 最初为什么要用Redis秒杀当时选Redis做秒杀理由和大多数团队一样活动报名是典型的读多写少、瞬间流量集中场景。两百个名额几千人同时抢如果全部打到MySQL上光是查当前名额这个读操作就能把数据库连接池打满。Redis基于内存单机QPS能到十万级别天然适合这种查一下还有没有名额的高频读需求。所以最初方案设计得很常规活动开始前把总名额预热到Redis键里比如event:stock:1001存的是剩余名额。用户点击报名后端先执行DECR event:stock:1001如果返回值大于等于0说明抢到了继续走后续数据库落单流程。数据库写一条报名记录状态为待确认。用户前端拿到报名成功后下发的动态二维码现场核销时实时校验。这个流程在demo演示时非常顺畅因为demo里不会同时有一万个人在点。但真实业务场景里问题并不出在Redis本身而是Redis和数据库一旦割裂两边的一致性就全靠运气和补偿逻辑撑着。1.2 动态二维码是怎么加上去的动态二维码的引入是因为最初觉得静态二维码容易被截图分享。当时的安全思路是二维码里加时间戳和用户ID再用服务端密钥做HMAC签名这串签名每分钟变化一次。用户打开核销页面时前端每秒轮询后端请求最新的二维码内容。这样即使有人截了图两分钟后这个二维码就过期了理论上是绝对安全的。现在回看这个思路本身没毛病但它混淆了两个完全不同的问题凭证是否属于本人和凭证是否有效。动态二维码在解决的是前者但报名核销场景真正需要解决的是后者。一个每分钟变一次的二维码并不能阻止别人在你本人扫码之前用截图抢先核销它唯一能做到的是让截图过一段时间失效。可是活动核销现场用户从打开手机到完成扫码通常就几秒钟截图在失效之前已经够用了。1.3 整套方案的故障表现真实活动当天的情况是这样的活动开始前一秒Redis的QPS瞬间冲高各服务节点都在执行DECR。数据库落单是异步批量处理的结果部分用户在Redis扣减成功了但数据库落单因为死锁、超时等原因失败系统的补偿任务要去回滚Redis里的库存。补偿任务本身又和新的报名请求并发执行导致Redis剩余名额出现跳动一会儿显示还剩3个过一秒变回5个。动态二维码服务在活动当天也成了新的瓶颈。每个用户打开核销页面前端每秒钟请求一次二维码接口服务端要做HMAC签名、Base64编码、二维码图片生成。这个接口的CPU消耗比普通JSON接口高一个量级而它只是个展示功能并不承载任何实际业务状态变更。等活动结束后我们对账发现Redis剩余名额与数据库实际已报名人数对不上差了11个人。这就是让我下决心重构整个方案的根本原因。2. Redis秒杀在真实业务场景中暴露的四类问题2.1 两套存储的状态割裂补偿逻辑永远写不完Redis秒杀方案的本质是把库存扣减这个单一操作拆成了Redis扣减和数据库落单两步。只要拆成两步就会存在中间状态——Redis扣成功了数据库还没写。为了修复这种中间状态你会自然而然地写出补偿逻辑数据库落单失败时把Redis里的库存加回去。听起来挺简单但实际写起来会发现这条路根本没有尽头如果补偿任务执行的时候Redis已经又扣了一部分库存那补偿加回去的值是否准确如果补偿任务重复执行呢需要一个幂等标记字段。如果补偿的不是原用户Redis DECR不记录用户身份怎么精确回滚某个用户扣掉的那一个名额我在项目里踩得最深的一个坑是DECR这个操作本身不记录是哪个人扣的它只返回一个数字。你只知道自己扣成功了但不知道自己扣的是第几个。一旦要回滚只能做库存1而这个加回去的数量马上又会被下一个请求DECR掉。在多并发下这个补偿逻辑会因为时序问题反复横跳最终对不上账。数据库表结构里加上订单号后补偿可以做得更精确但代码复杂度已经翻了一倍不止。而这些复杂的补偿、对账、幂等逻辑恰恰是为了弥补一个本来就可以避免的问题为什么不让数据库自己执行原子扣减2.2 缓存击穿与预热名额的假性库存Redis在秒杀里另一个让人掉以轻心的问题是库存预热后Redis里的数字就是个孤立的假性库存。假设活动名额200个预热时Redis里存了200。但实际上报名前可能有员工黑名单、内部保留名额、测试账号等数据库里真正的可报名总名额可能只有180。上线时要保证Redis和数据库两边都准确就得在活动前做一次全量同步。活动结束后还要把已报名人数和Redis的剩余名额做对账。每对账一次你就会发现Redis里的衰减速度和数据库里已报名人数总是对不上因为在并发下不是所有DECR都会成功落单也不是只有报名接口会改动这个键。更典型的问题是缓存击穿一旦Redis里的库存键恰好过期或者被运维手动清理所有的请求全部穿透到数据库数据库瞬间被压垮。为了避免这个问题又要加互斥锁、加永不过期、加逻辑过期——这不是在解决问题这是在给Redis方案打补丁。2.3 分布式锁的粒度与性能悖论很多团队的秒杀方案里还加了分布式锁目的是保护查库存扣库存落单这个复合操作。但分布式锁本身在秒杀场景里是有矛盾的。如果用粗粒度锁一个活动一把锁所有请求串行化执行Redis的高性能优势完全被抵消了还不如直接请求数据库。如果用细粒度锁按用户维度加锁同一个用户重复请求被挡掉了但不同用户之前依然可以并发扣减数据库层面的超卖风险依然存在。我在压测时测过一组数据200个并发报名请求如果每个请求都走Redisson分布式锁先获取锁再执行MySQL事务平均响应时间从30ms飙升到700ms锁等待导致的超时异常占了总异常的30%。为了降低锁竞争又得做分段锁、拆分库存位图——复杂度继续螺旋上升。这让我意识到在一个中低并发、强一致性优先的活动报名场景里高并发架构的经典套路并不等于最优解。Redis分布式锁在抢红包、抽奖这类丢了也无所谓的场景里够用但报名名额是强约束一旦对不上账后果是活动出乱子、用户投诉你得背锅。2.4 Redis宕机后的库存恢复陷阱最后让我彻底放弃Redis秒杀的一根稻草是Redis宕机后的恢复问题。某次内测运维在活动期间重启了Redis就因为参数调整RDB持久化是在最后同步保存的。重启完成后Redis里的库存键恢复到重启前五分钟的状态——已经卖出的20个名额又变回来了。前端页面显示名额还剩180个但数据库里已经报了20个人。如果不做额外处理这20个人会视为未成功但他们的钱和报名数据已经进去了。理论上可以用AOF、可以开混合持久化、可以配合RDBAOF双重保险但所有这些配置都是额外的运维成本而且也只能保证尽量少丢无法做到强一致。任何一个脑图教程里都不会告诉你Redis的持久化机制在秒杀场景里其实是个不可靠的承诺。所以我的结论是Redis的定位应该是缓存和加速器而不是状态源。凡是涉及名额、资格、状态这类绝对不能丢的数据都应该回到数据库这个唯一状态源上。Redis只负责加速读取不负责做最终决策。3. 动态二维码为什么在核销场景里站不住脚3.1 动态二维码的实时生成是有计算成本的动态二维码从设计思路上就有一个隐含假设每次展示都需要后端参与后端必须在极短时间内完成签名生成和图片编码。这个假设在低并发场景下没有问题但在活动核销高峰——成百上千人同时打开核销页面——二维码生成接口就成了新的热点接口。我们当时的二维码服务用的是Google的ZXing库。压测数据显示单张动态二维码的生成耗时大约12ms到20ms看起来不大但它的特点是每个请求都要生成一张新图片并且前端每秒钟轮询一次。按500人在线计算就是每秒500次二维码请求其中约480次是重复生成相同用户的新二维码。这些请求白白消耗了20%的服务端CPU而业务本身没有任何推进。更麻烦的是为了在签名里绑定时间戳有效期设成了120秒。前端为了确保显示的是最新二维码每秒刷新一次用户的手机电量被这个页面疯狂消耗现场工作人员听到最多的反馈就是我二维码怎么打不开页面一直在转圈。3.2 二维码过期策略带来的现场矛盾动态二维码设置有效期后在活动现场会触发一种极其尴尬的情况用户排到了核销台打开二维码但二维码刚好在这一秒过期了。前端刷新需要网络请求一旦现场WiFi信号不好用户就卡在加载二维码的页面上后面排队的人越积越多。有些团队的做法是将有效期延长到5分钟但这本质上又回到了截图可抢跑的问题。你延长时间被截图的风险增大你缩短时间现场加载失败的风险增大。动态二维码是在安全和体验之间不断走钢丝而它并没有提供真正的安全——因为截图在生效期内依然有效。3.3 动态二维码的实时性在核销场景里是不必要的核销这个动作的本质是什么是核销员在某个时间点确认这个人的报名资格有效然后把状态从待入场改为已入场。这个动作的重点是在核销那一刻检查状态并变更状态而不是在二维码展示的那一刻做状态加密。动态二维码把校验提前到了二维码生成这个环节却无法在生成时预知核销时的状态。举个例子用户报名后生成了动态二维码但在活动开始前被取消了资格。他打开手机动态二维码还会正常生成因为后端在签名时只校验该用户是否有报名记录没有实时校验报名状态是否已取消。等到现场核销二维码一扫才提示该用户已被取消资格。用户只会觉得是你们现场系统坏了而不是自己资格没了。这就解释了为什么说动态二维码在核销场景里站不住脚它把本该在核销瞬间做的状态检查前置到了二维码生成阶段但二维码本身又无法承载状态变更的实时性。它既没有提升安全性也没有简化流程只是增加了一个不必要的复杂度层。3.4 动态二维码适合什么场景并不是说动态二维码毫无用处。在我后来的项目里它被用在了一个更合理的场景线下活动签到台的身份交换。用户出示动态二维码工作人员扫码后扫码枪拿到的是二维码内容由扫码端设备立即向服务器发起校验服务器返回用户信息到工作人员的终端上。这里的二维码只是一个传输用户身份的临时载体它不承载入场资格状态真正的状态校验完全发生在扫码后的服务端请求中。而在报名核销场景里正确的做法是让二维码本身不变把状态的变完全收拢在服务端。也就是下文要详细讲的静态凭证方案。核心一句话凭证的不变性和状态的可变性应该分层管理而不是把可变状态塞进凭证里。4. 状态原子校验把并发控制从缓存层拉回数据库层4.1 核心思路一个SQL完成检查与扣减我把秒杀逻辑从Redis移出后的第一件事就是设计数据库层面的原子校验。核心并不复杂把查一下还有没有名额和扣掉一个名额这两个操作合并成一个原子SQL。以MySQL为例报名表event_registration里维护一个字段stock初始值对应活动总名额。用户报名时执行UPDATE event_registration SET stock stock - 1 WHERE event_id ? AND stock 0;这一步是行锁级别的原子操作。UPDATE语句在执行时会锁定这一行并发请求会被MySQL内部的行锁串行化。每个请求都读到的stock都是最新的WHERE stock 0保证了只有还有名额时才允许扣减。MySQL官方的return值就是受影响行数——返回1说明扣减成功返回0说明名额已经被抢完了。这个方案的技术含量并不高但它解决了之前两步操作的问题不再有先查Redis、再扣Redis、再落库这种需要补偿的中间状态。名额的扣减和检查在同一个事务里原子完成不存在中间状态也就没有补偿逻辑这回事了。4.2 报名资格与名额扣减的状态流转设计名额扣减解决的是能不能进来的问题但报名成功之后用户还涉及后续的取消、到场、核销等状态变化。我重新设计了状态机将报名状态和核销状态统一到一个字段里常用状态只有五个PENDING名额已扣减但用户未完成后续确认比如需要填写附加信息。CONFIRMED报名确认成功可以正常参加活动。CANCELLED用户主动取消或后台取消名额会被回补。CHECKED_IN已到现场核销完成。EXPIRED未到场活动结束后自动从未核销状态转入。状态机的核心约束是状态只能沿固定方向流转。例如CONFIRMED之后只能流转到CHECKED_IN或CANCELLED活动前不能从CHECKED_IN转回CONFIRMED。这个约束直接在数据库层用状态字段加迁移检查来控制而不是在应用层写一堆if-else。每次报名请求进来应用的事务里先执行上面的UPDATE扣减名额再插入一条报名记录状态PENDING。这两个操作在同一个数据库事务里要么一起成功要么一起失败。插入报名记录的字段中有一个event_id user_id的唯一索引这个唯一索引保证了同一个用户同一场活动只能报名一次——就算请求重试也不会产生重复报名。4.3 原子校验的具体落地事务边界与隔离级别很多人在自己的上报里把这段代码写成了先SELECT查一次名额再INSERT一条记录这其实存在超卖风险。正确做法是像我上面那样用UPDATE ... WHERE stock 0作为唯一的门槛判断INSERT只是记录结果并不作为资格的判定。事务的隔离级别也要稍微注意一下。我建议把报名接口所在的数据库会话隔离级别设为READ COMMITTED而不是REPEATABLE READ。原因是REPEATABLE READ下如果事务先SELECT了一条记录另一个事务又修改了这条记录并提交当前事务后续的SELECT可能读到旧版本快照读这会让你在同一个事务里做二次检查时读到过期数据。READ COMMITTED的每次普通SELECT都读最新已提交数据逻辑更直观。写入主流程的代码大概是这样的我用Spring的Transactional来表示事务边界Transactional public RegistrationResult register(RegisterRequest req) { // 1. 原子扣减stock 0 判断和扣减在同一语句内完成 int affected eventRegistrationMapper.deductStock(req.getEventId()); if (affected 0) { throw new SoldOutException(活动名额已满); } // 2. 如果名额扣减成功尝试插入报名记录 try { registrationMapper.insert(new Registration( req.getEventId(), req.getUserId(), Status.PENDING )); } catch (DuplicateKeyException e) { // 3. 唯一索引命中说明用户已经报过名回补名额 eventRegistrationMapper.addStock(req.getEventId()); throw new DuplicateRegistrationException(您已报名过该活动); } return RegistrationResult.success(); }这里有一个关键点第2步插入如果因为唯一索引冲突抛出异常必须回补第1步扣减的名额否则会出现报名失败但名额减少的情况。回补就是执行UPDATE event_registration SET stock stock 1 WHERE event_id ?这个操作同样在事务里失败会连同整个事务一起回滚。有人会问如果用户重复报名第一次报名成功扣了名额第二次又进来怎么办唯一索引保证了第二次INSERT必然失败但此时第一个名额已经被扣掉了所以要回补。这个扣了再回补和补偿本质上不太一样回补是在同一个事务内的一个分支逻辑不需要异步任务去处理也不存在并发时序问题数据库行锁在同一行上会串行化所有对这些字段的修改。4.4 核销操作的原子性FOR UPDATE与版本号报名成功后用户去现场核销核销员扫用户的静态凭证码后端执行核销操作。核销本身也是一次状态流转从CONFIRMED流转到CHECKED_IN同时要保证同一个人不能被核销两次。有两种实现方式第一种是数据库乐观锁给报名记录加一个version字段核销时执行UPDATE event_registration SET status CHECKED_IN, version version 1, checkin_time NOW() WHERE id ? AND status CONFIRMED AND version #{version};返回受影响行数为1则核销成功为0则说明状态已经被改成CHECKED_IN了直接提示已核销请勿重复扫码。第二种是悲观锁在核销事务里先SELECT ... FOR UPDATE锁定报名记录再检查状态再更新。在核销这个低频场景里现场几百个人每秒并发个位数悲观锁完全够用代码也更直观SELECT * FROM event_registration WHERE id #{id} FOR UPDATE; -- 在应用层检查 status 是否等于 CONFIRMED -- 如果是执行 UPDATE SET status CHECKED_IN我在生产环境里最终用了悲观锁因为核销并发低FOR UPDATE带来的行锁阻塞可以忽略不计但代码逻辑和错误处理都简单得多。FOR UPDATE把查状态和改状态之间的间隙给锁死了任何并发核销请求都会等待前一个事务提交后才能看到最新状态不会出现两个人同时扫码都显示核销成功。4.5 为什么要坚持数据库原子校验回到核心问题为什么不用Redis做原子校验很多人会觉得Redis的DECR也是原子操作和数据库的UPDATE效果不是一样吗从原子性本身来说两者确实都保证了扣减不超卖。但关键在于校验通过之后你还要写业务状态。Redis扣减了库存业务状态写在MySQL里两边的写入一旦割裂就需要补偿机制。而MySQL的原子校验把名额扣减和业务数据写入放进了同一个本地事务这是Redis方案做不到的。如果你的秒杀场景是扣减后不需要写任何持久化数据比如纯抽奖发积分积分本身也在Redis里那用Redis没问题。但只要涉及报名记录、订单、支付流水这种需要落到数据库里的数据都应该把名额扣减一并放进数据库事务里。数据库在这个场景下的性能瓶颈可以通过分表、异步队列、读写分离等手段去缓解但一致性必须靠ACID事务来兜底。Redis的合适定位是限制性校验的加速层但不能是状态源。比如活动开始前可以用Redis做一个前置的计数器拦截明显超出的流量比如名额200个Redis计数器到200以后直接返回已满这个前置拦截可以挡住99%的无效请求。但真实的资格判定必须走数据库原子操作。这种情况下Redis帮MySQL挡了大部分压路MySQL也不会有压力两边互不干扰这是最优形态。5. 静态凭证的设计原则与核销边界5.1 静态凭证是什么、怎么生成静态凭证指的是用户报名成功后服务端生成的一串不变的、绑定用户身份的字符串通常编码成二维码展示这个字符串在整个活动周期内保持不变。核销员扫码后把凭证码发到后台后台根据凭证码查询对应的报名记录做状态流转。生成规则我建议遵循包含业务信息但不可猜测的原则。不要用纯自增ID做凭证码否则用户把凭证码改成1001就能查别人的信息。我用的方案是两端拼接的业务码 一段足够长的随机数 一个校验位。具体来说前缀活动编码比如ACT1001主体由用户ID和一个高强度随机字符串拼成的SEED例如U_12345_ 16字节随机数3f9a0c2b...对上述内容做一个CRC32或简单校验位编码最终展示给用户的凭证码用Base32或Base62编码避免出现0和O、1和I这种容易混淆的字符。二维码内容就是这串固定字符串二维码图片只需要生成一次存到静态文件服务或对象存储里用户后续查看都不需要再请求后端。5.2 不把报名状态写进二维码的设计哲学静态凭证在设计上有一个核心原则凭证内容只标识你是谁不包含你当前是什么状态。状态永远在服务端数据库里实时查询。这和动态二维码形成了鲜明对比。动态二维码把状态编码进了凭证内容时间戳、签名有效期一旦状态改变凭证必须重新生成。静态凭证则相反凭证不变状态变。用户被取消资格、用户已核销、用户从未报名扫出来都是同一张二维码图片但后台返回的结果完全不同。这种设计带来的一个实际好处是核销端对网络的要求大幅降低。扫码枪只需要把凭证码传给后端后端返回一个可核销/不可核销/已核销的结果整个过程只需要一次HTTP请求而且这个接口可以被CDN或网关缓存吗不行核销状态查询不能缓存。但它简单不涉及二维码图片生成响应时间从十几毫秒降到了1到2毫秒。5.3 核销边界静态凭证的防转发、防重、防篡改很多人担心静态凭证最大的问题是可转发——有人把你的二维码截图发给别人别人就能冒用。这个担忧是有道理的但解决方案不是把凭证变成动态的而是把核销这个动作变成有状态的。防重复核销这是静态凭证最重要的保障。由于状态机中CHECKED_IN状态只能从CONFIRMED流转一次重复扫码时第二次查询会看到状态已经是CHECKED_IN返回此凭证已使用过。这里的核销接口必须保证原子性也就是我前面讲FOR UPDATE的原因。防提前截图使用静态凭证用户在场外截图分享如果活动是先到先得扫码入场那理论上前一个人排队先用截图核销了后一个人的凭证就无效了——这是所有电子凭证方案的固有特性动态二维码也不例外。动态二维码的两分钟过期只是把冒用窗口缩短到两分钟但核销场景里排队时间远超两分钟实际并没有解决问题。更好的做法是入场闸机配合是否确认本人的辅助手段比如核销员对比身份证/工牌或者凭证码页面显示用户头像和姓名。凭证的动态性并不能替代核销员的身份核验。如果企业内活动真的对冒用安全有高要求应该投入人脸比对或工牌验证而不是在二维码上做文章。防篡改静态凭证的编码里含随机数和校验位。如果有人把凭证码中间的字符改一下校验位对不上服务端直接返回凭证无效。如果对方尝试暴力枚举随机数16字节随机数的空间足够大暴力枚举的成本远超收益。5.4 静态凭证与动态二维码的边界讨论做了这个项目之后我复盘了一下什么样的场景适合静态凭证什么样的场景真的需要动态二维码。我列了一张对比表方便后续有类似需求的同学参考对比维度静态凭证动态二维码凭证内容固定不变只标识用户每段时间变化包含时间戳/签名状态变更位置服务端数据库凭证内容本身服务端双重控制生成成本一次生成后续只读每次展示都要实时生成离线可用支持图片可缓存不支持每次都要联网请求防转发弱依赖核销动作比静态略强但有效期窗口内同样可被利用典型场景报名凭证、入场券、优惠券核销支付授权、登录扫码、一次性令牌动态二维码的真正价值在于凭证本身就是动作的一部分。比如扫码登录时二维码内容是一个短时效的token服务端在用户扫码确认后立即使token失效token本身的短暂有效期可以显著缩小被重放的时间窗口。而核销场景的核心动作是核销那一刻的状态变更不是展示凭证那一瞬间的token值。所以我的最终结论是凭证层只负责稳定地标识一个身份状态层负责确保每次流转都是原子且唯一的。两者各司其职系统的复杂度是最低的。6. 迁移与灰度过程中的注意事项和踩坑记录6.1 灰度策略先切报名接口再切核销接口重构方案确定后我没有一次性把所有流量都切过去而是分了两步灰度第一步先切报名接口。原因是报名是核心链路风险最高核销是低频操作风险相对低。报名接口切换后我观察了整整一个完整活动周期的数据Redis剩余名额不再发生变化数据库报名记录和实际扣减名额完全对得上。第二步切核销接口。把用户端的动态二维码展示替换成静态凭证码。这一步的关键在于老用户已经持有的动态二维码怎么办我做了兼容处理——核销接口同时接受老的动态二维码内容和新的静态凭证码通过凭证码的格式前缀区分。老用户即使扫码时展示的还是动态二维码只要后台能解析出用户ID依然能正常完成核销。这个兼容层保留了两个星期等所有存量用户都过了活动窗口后才下线。6.2 事务边界里的坑不要把远程调用放在事务里我当时犯过一个很典型的错误报名事务里除了扣名额和插入报名记录还塞了一个发送通知的调用。压测环境因为通知服务响应快问题不明显。生产环境一发活动通知服务因为下游邮件网关超时导致整个报名事务在数据库层面回滚用户明明抢到了名额却收到报名失败的提示。正确做法是事务里只保留数据库写操作事务提交成功后再发通知。如果通知发送失败可以失败重试但绝不能影响报名结果。重构后我把所有外部调用都挪到了事务提交之后用Spring的TransactionSynchronizationManager.registerSynchronization在事务提交后回调发送通知。这样既保证了不丢通知也避免了远程调用卡住事务。6.3 回补名额时的并发坑小心库存回补超过总数在实现报名失败回补名额时如果只是简单执行stock stock 1在高并发下可能出现库存超过总数的情况。比如200个名额已经扣到0了此时有10个请求因为唯一索引冲突回补了10次库存就变成10。按理说这10个请求并没有成功报名回补是合理的。但这里有个潜在问题如果一个请求的扣减成功(第1步)而插入记录失败回补后业务侧没有妥善处理异常导致前端显示报名失败用户再刷新页面又点了一次此时名额已经被别人抢走了用户会以为系统有问题。这个坑我在灰度时实际遇到过。解决方法是在回补前再次检查该用户是否已经存在成功的报名记录如果存在则不应回补。另外回补操作本身要加一个最大不超过初始库存的约束防止异常情况下库存涨到天上。MySQL里可以对stock字段加CHECK约束新版本支持或者在回补SQL里加条件SET stock LEAST(stock 1, #{initialStock})确保库存永远不超过初始值。6.4 Redis最终在系统里的位置重构之后Redis并没有从系统里完全移除只是退到了它应该在的边界上活动配置信息、报名开放状态、用户常用资料全部缓存到Redis加速读取。活动开始瞬间用一个简单的Redis计数器做流量拦截如果计数器大于某个阈值比如5000直接返回活动太火爆避免无效请求全部打到数据库。用户行为埋点、日志聚合继续走Redis做短暂缓冲。真正和名额、状态相关的操作全部走MySQL事务。Redis不再担任决策者而是做防火墙和缓存加速器。这个边界划分清楚之后系统的稳定性上了很大一个台阶。后来的两场大型活动没有再出现过库存对不上账的问题报名接口的P99响应时间稳定在80ms以内核销接口单次响应在10ms以内。6.5 一个容易被忽略的点MYSQL行锁的隐式KPI最后提醒一个运维层面的细节。UPDATE event_registration SET stock stock - 1 WHERE event_id ? AND stock 0这种写法对索引有要求。event_id字段必须有索引否则这个UPDATE会被MySQL升级为表锁全表所有报名记录全部锁死其他活动的报名也会被阻塞。我在建表时给event_registration表的event_id字段建了普通索引同时user_id和event_id的组合唯一索引已经存在它本身也能覆盖到event_id的查询。但由于字段顺序是(user_id, event_id)在某些查询条件下可能无法走复合索引的最左前缀。我单独给event_id建了索引后锁粒度从表锁降为行锁并发能力立刻提升了一个数量级。这一条不会出现在任何Redis教程里但在真正的数据库原子校验方案里它才是决定成败的关键。个人经验是把秒杀这种强一致性、高并发写的业务从Redis移到MySQL看上去是性能退步实际上是用更合理的复杂度替换了表面高效但需要大量补偿逻辑的架构。技术和业务场景匹配比技术本身多高级更重要。
返回列表