ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

重启秒杀系统避坑指南:超卖、缓存击穿、防刷与削峰实战

重启秒杀系统避坑指南:超卖、缓存击穿、防刷与削峰实战 重启电商秒杀系统时我踩过的那些最常见也最致命的坑每年大促前技术团队最怕的都是同一个环节秒杀。系统一上线流量峰值瞬间压过来库存、订单、支付链路全部被堵在门口CPU、带宽、数据库连接数同时告警。我前后参与过好几次秒杀系统的重构和重新上线每次重启这个系统之前心里都清楚如果不在代码和架构层面提前把那些高频问题堵死上线半小时后一定会在群里收到运维的“连环夺命call”。这篇文章不打算讲那种高深莫测的架构理论就把我做秒杀系统过程中反复遇到、反复趟平的最常见问题做一个系统性的梳理。覆盖超卖、缓存击穿、防刷、削峰、压测、预案这些环节把原理讲透把方案讲细也把实操中那些文档里不会写的坑一并讲出来。无论是刚接手秒杀项目的初级开发还是正在规划大促架构的后端负责人这轮梳理应该能帮你少走不少弯路。1. 秒杀系统到底在解决什么问题1.1 秒杀场景的三个“极端”秒杀本质上是一波极不均衡的流量。平时系统每秒几百上千请求一旦秒杀开始瞬时请求量可能冲到平时的几十倍甚至上百倍。这三个极端是其他业务很少遇到的第一是流量极高且极度集中。用户都在等那个固定的时间点到点后疯狂点击请求瞬间打满。第二个是资源有限且竞争激烈。库存可能就几百件但参与的人可能有几十万绝大多数请求最终都会被拒绝。第三个是状态变化极快。库存从有到无、订单从创建到支付几乎在几十秒内完成系统需要在极短时间内保证数据一致性。这三件事叠加在一起导致秒杀系统跟常规电商系统的思路完全不同。常规系统优化“多做一点”秒杀系统则是“少做一点”。能砍的环节全砍掉能异步的全异步能缓存的全缓存目的只有一个让有限的服务器资源去处理真正有价值的请求。1.2 常规秒杀系统的分层架构实战中跑得通的秒杀架构通常可以拆成四个层级来理解最上层是接入层负责动静分离和流量缓冲。商品详情页、秒杀按钮页面在秒杀开始前就渲染成静态页面放到CDN上用户的绝大多数请求根本不会打到应用服务器。第二层是应用层负责业务逻辑和拦截。判断登录状态、校验秒杀时间、做防刷限制、风控校验、再进入秒杀主流程。这一层是拦截非法请求和无效请求的主力也是部署最灵活的环节。第三层是服务层负责核心资源的保护。商品库存、用户资格、限购数量都归这一层管。这层是真正跟数据打交道的地方也是超卖问题最容易出现的环节。最后一层是数据层负责最终的数据落盘。订单需要落库、库存需要扣减、流水需要记录。这一层的处理速度决定了整个系统的吞吐上限。这个分层不是拍脑袋定的核心逻辑是把不同类型的流量分隔开。静态流量交给CDN和小型静态服务器动态业务流量交给应用层真正的数据热点请求量已经削减到一个可控的范围后再进入服务层和数据层。每一层拦截掉一部分流量越往下压力越小。2. 库存扣减与超卖问题最经典也最致命的坑2.1 超卖是怎么发生的超卖问题通俗讲就是明明只有100件库存最后有几万人显示抢到了。根因在于多个并发请求同时读到相同的库存值然后同时对库存做修改导致实际扣减的次数超过了库存总数。举个例子假设库存是1两个用户的请求同时到达分别在各自的事务里读到库存为1都判断库存大于0然后都执行了扣减最终库存变成了-1。传统的关系型数据库如果没做锁机制或者锁的粒度控制不好这种情况就很容易发生。我在初期的项目里就踩过这个坑。当时图省事直接在Service方法里写了“先查库存再扣库存”两步逻辑没有加锁也没有使用数据库的原子更新。结果压测一跑库存数据直接变成负数运营那边一核对订单数量立刻炸了。从那以后我所有的库存扣减方案都遵循同一个原则不能在应用层里做“先查后改”必须让数据库或缓存层用原子操作来保障一致性。2.2 数据库层的三种扣减方案对比数据库层解决超卖常用方案有三种复杂度递增可靠性也递增。第一种是使用UPDATE语句的原子扣减直接在SQL层面完成判断和扣减UPDATE product_stock SET stock stock - 1 WHERE product_id 123 AND stock 0;这个方案的关键点是利用行锁保证同一时刻只有一个事务能修改这条记录。程序执行后检查影响行数如果为0说明库存不足返回“已售罄”。方案最简单性能也不错适合库存量不大、并发量在千级左右的场景。第二种是在事务中配合悲观锁SELECT stock FROM product_stock WHERE product_id 123 FOR UPDATE;拿到行级排他锁之后再做判断、扣减、提交。这个方案的优点是逻辑直观可以先查出来做各种业务判断再更新。缺点是持有锁期间会阻塞其他事务高并发下容易造成锁等待和死锁问题。第三种是乐观锁方案通过版本号控制并发UPDATE product_stock SET stock stock - 1, version version 1 WHERE product_id 123 AND stock 0 AND version 5;每次更新检查版本号如果版本号不匹配说明数据已经被别人改过了需要重试或放弃。乐观锁适合读多写少的场景但秒杀场景本身写冲突非常严重重试成本很高所以实际用得并不多。三种方案的选择依据很简单如果并发不大用第一种原子UPDATE就够如果业务逻辑复杂需要事务保护用悲观锁如果冲突概率低才考虑乐观锁。秒杀场景默认首选第一种因为它利用数据库自身的原子性代码最简单也不引入额外的锁管理复杂度。2.3 Redis Lua 脚本扣库存的正确姿势当并发量上升到万级以上数据库扣减方案就成了瓶颈。每个请求都打到数据库的行锁上数据库连接池会被迅速耗尽。这时候业界普遍的做法是把库存预热到Redis用Redis的原子操作承担大部分流量。具体做法是用一个Lua脚本来实现扣减和判断的原子性-- KEYS[1]: 库存key -- ARGV[1]: 扣减数量 -- ARGV[2]: 购买数量上限 if tonumber(redis.call(get, KEYS[1])) tonumber(ARGV[1]) then return redis.call(decrby, KEYS[1], ARGV[1]) else return -1 endLua脚本在Redis中是串行执行的天然保证了对库存key的操作原子化。扣减成功返回剩余库存扣减失败返回-1。Java端用Spring Data Redis调用这个脚本代码如下public Long decrementStock(String stockKey, int count) { DefaultRedisScriptLong script new DefaultRedisScript(); script.setLocation(new ClassPathResource(stock.lua)); script.setResultType(Long.class); return redisTemplate.execute(script, Arrays.asList(stockKey), count); }需要强调的是Redis扣减只是第一道防线最终订单创建和库存对账必须依赖数据库。Redis里扣减成功不代表数据库里的真实库存已经扣了所以还需要异步把扣减结果同步到数据库。如果数据库扣减失败得做补偿把Redis里的库存回补。2.4 最终一致性补偿机制Redis扣库存、数据库扣库存这两者之间的短暂不一致是不可避免的。正确的设计思路是允许短暂不一致但要保证最终一致。我常用的模式是用户在秒杀接口请求到达后先从Redis扣减库存扣减成功后把一条“待扣库存”消息发到消息队列下单服务消费消息后去数据库执行真实扣减和订单创建。如果数据库扣减失败比如库存记录不存在、数据库异常就反向发一条补偿消息把Redis库存加回去。这个流程要特别注意消息的幂等性。MQ重试机制下同一条扣减消息可能被消费多次如果没做幂等数据库库存会被重复扣减。我在项目里是通过给每条请求生成一个全局唯一的流水号在数据库扣减表中用这个流水号做唯一索引重复消费时会插入失败从而保证只扣一次。3. 缓存层的高并发痛点击穿、穿透与雪崩3.1 缓存穿透让数据库裸奔缓存穿透指的是查询一个不存在的商品或用户数据每次请求都绕过缓存直接打到数据库。大量请求查的东西根本不存在于缓存中数据库会被无效请求拖垮。最常见的场景是秒杀开始前用户疯狂请求一个还没上架的商品ID数据库里当然查不到缓存里也不会有于是一波请求全部穿透。还有恶意用户故意构造不存在的ID来刷接口。解决办法有三个层面。第一层是缓存空值查询结果为null时也写一个短时间的空缓存比如60秒后续的重复查询直接命中空缓存。第二层是用布隆过滤器把真实存在的商品ID初始化到布隆过滤器里请求进来先判断ID是否存在如果布隆过滤器说不存在就直接返回失败。第三层是参数校验和限流从源头拦截明显不合理的请求。这三个方案可以叠加使用。我做的项目里基础方案是布隆过滤器加空值缓存布隆过滤器的误判率调到千分之一以内兼顾空间和准确率。缓存空值的过期时间不宜太长太长会导致真数据上架后用户仍旧看到“不存在”的结果。3.2 缓存击穿是秒杀最头疼的问题缓存击穿和穿透是两回事指的是缓存中没有但数据库有。正常情况下这个数据存在于缓存中但在某个瞬间缓存过期了热点数据的缓存突然失效大量请求同时来查询这条数据就全部挤压到数据库。秒杀场景最典型的就是库存缓存和商品详情缓存。比如商品信息缓存的过期时间到了恰好秒杀刚开始海量请求发现缓存里没数据全部跑去数据库查询数据库瞬间被打挂。常见的解决方案是互斥锁。当缓存没有命中时并非立即去数据库查询而是先尝试获取一把分布式锁。只有拿到锁的线程才能去数据库查询并把结果回填缓存其他线程拿不到锁就短暂自旋等待等缓存回填后再从缓存读取String value redis.opsForValue().get(productKey); if (value null) { String lockKey lock_ productKey; if (redis.opsForValue().setIfAbsent(lockKey, 1, 3, TimeUnit.SECONDS)) { try { value loadFromDatabase(); redis.opsForValue().set(productKey, value, 30, TimeUnit.SECONDS); } finally { redis.delete(lockKey); } } else { Thread.sleep(50); value redis.opsForValue().get(productKey); } }另一种方案是逻辑过期不给物理缓存设置过期时间而是数据里带一个过期时间戳字段。读到时发现逻辑过期异步去刷新缓存并立即返回旧数据。优点是响应快不会阻塞等待缺点是一小段时间内读到的是旧数据。秒杀场景里我优先用互斥锁方案因为秒杀对数据新鲜度要求高宁可让极少部分请求等一下也不能让多数人读到过期库存。3.3 缓存雪崩的连带效应缓存雪崩指大量缓存key在同一时间段集中过期请求全部打到数据库造成数据库压力瞬间暴涨。跟击穿的区别是击穿是一个热点key失效雪崩是很多key同时失效。解决办法也简单有效给每个key设置不同的过期时间加随机值打散int baseExpire 60 * 30; int randomExpire ThreadLocalRandom.current().nextInt(300); redisTemplate.expire(productKey, baseExpire randomExpire, TimeUnit.SECONDS);另一个思路是不让缓存完全失效。有的团队把秒杀商品缓存做成二级缓存本地缓存配一份Redis再配一份过期时间错开。本地缓存放最近的访问热点Redis失效时本地还能扛住一部分流量。还要提醒一点缓存重建过程要控制并发和效率。如果缓存过期后重建缓存本身的逻辑很重比如查询了多个表聚合数据这段逻辑也应该加分布式锁避免多个线程重复做重活。4. 接口防刷与风控拦住机器人才是第一步4.1 为什么必须设计独立的防刷层秒杀刚开始的时候真正的用户请求还没来得及到脚本机器人的请求已经把带宽和服务器资源耗尽了。这些脚本能每秒钟发送上百个请求速度快、行为一致不处理掉它们后面的技术方案再好也是白搭。防刷不是等出事才做的事情而是在秒杀系统里跟超卖、缓存一样重要的基础能力。我第一次做秒杀时对这个没概念结果上线后每秒几十万请求几乎全是脚本的技术群里全是报警。从那以后我把风控层视为秒杀系统四层架构中不可省略的一环。风控层的核心原则是拦截成本要低误杀率要可控。拦截太松等于没做拦截太紧会伤及真实用户体验。这个尺度需要结合具体业务反复调优。4.2 常用限流方案选型与参数计算限流手段里最常用的有固定窗口计数、滑动窗口计数、漏桶和令牌桶各有适用的场景。固定窗口计数最简单比如限制用户每秒最多10次请求用一个计数器记录每秒请求数超过就拒绝。问题在于窗口边界处容易突增比如统计的是0到1秒用户在第0.9秒发10个请求又在第1.1秒发10个请求实际上1秒内发了20个这就不太公平了。滑动窗口把一个大窗口切小比如把1秒分成10个100毫秒的小格滑动统计最近1秒的请求数避免边界效应。实现可以用Redis里的ZSet统计时间戳或者用Lua脚本来做。下面是基于Redis ZSet的滑动窗口限流Lua脚本local key KEYS[1] local now tonumber(ARGV[1]) local window tonumber(ARGV[2]) local threshold tonumber(ARGV[3]) redis.call(ZREMRANGEBYSCORE, key, 0, now - window) redis.call(ZADD, key, now, now .. _ .. math.random(100000)) local count redis.call(ZCOUNT, key, now - window, now) if count threshold then return 0 end return 1令牌桶算法是另一种常见方案系统按固定速率往桶里放令牌每个请求取走一个令牌没令牌就等待或拒绝。这个算法能允许一定程度的突发流量比如桶容量10个、每秒放1个令牌瞬间20个并发请求时前10个可以立刻通过后续的请求就得按照速率排队。参数怎么定我做过一个测算假设一台应用服务器的处理能力是每秒500个请求接口整体QPS上限设为4000后端有四台服务器那在网关层总的限流阈值可以设置在3500左右留一部分余量。单个用户维度的限流阈值结合业务看比如普通商品秒杀限制每人每秒2次请求在秒杀详情页限制每秒5次。参数不是拍出来的要在压测中逐步调整找到既不伤用户体验又能保护后端的平衡点。4.3 风控规则的落地经验除了限流还要叠加多维度的风控规则。我常用的维度有用户维度同账号高频访问、同设备多账号切换等。IP维度单一IP短时间大量请求。设备维度UA指纹、设备ID的异常聚集。行为维度点击秒杀按钮的频率远超真人水平或者请求路径跟正常用户差异巨大。落地时需要注意规则不要全部走同步拦截。风控判断本身也很耗时如果把风控逻辑放在核心链路的同步环节里会增加接口响应时间。我在项目中把风控分为两层前置同步拦截处理最粗暴的刷量行为比如单IP超频、固定UA深度的行为分析放到异步链路里比如对短时间内大量下单但支付率异常的账号做后置校验和封禁。5. 削峰填谷用异步化扛住瞬时流量5.1 为什么必须“削峰”秒杀的流量特征是瞬间洪峰几十万请求集中在几秒钟内到达。如果让这些请求都在同步链路里处理后面的数据库和外部接口根本承受不住。削峰的本质很简单把瞬时高峰流量拉平将一部分操作延后处理让系统在更长的时间窗口内消化这些请求。减峰有两条路线一条是提前把流量分散掉比如在秒杀开始前让用户通过预约、答题、积分兑换等方式提前锁定资格到真正的秒杀时刻并发量会小很多。另一条是同步转异步用户在界面看到“已提交订单支付中”之后真正创建订单、扣库存的流程放到队列里慢慢执行。我参与过的方案里两者是并用的。秒杀时间可以分两段前段是抢资格后段是支付。抢资格阶段用Redis扣预占库存拿到资格的请求进入队列等待生成正式订单。支付阶段给用户一个比较宽松的支付时间窗口比如15分钟。这样用户流量被拆散在两个时段系统的峰值压力下降不少。5.2 消息队列的正确使用方法消息队列在削峰里的作用是把“接收请求”和“处理请求”解耦。接口收到用户请求后先把请求体作为消息发到MQ然后直接返回“排队中”。后台消费者异步消费消息执行库存二次校验、订单生成、通知用户等操作。用MQ时有一个非常需要注意的地方MQ本身并不是无限容量的。如果发消息的速度远超消费速度积压会越来越严重。代码上要做两个配套动作一是控制入队速度在发消息前做限流,二是设置消息的超时时间和重试次数防止消费失败导致无限重试。我在实际项目中遇到过一种情况秒杀一开始消息生产者疯狂向MQ发送消息消费者因为数据库连接池满消费特别慢消息积压从几万涨到几十万等到秒杀结束时积压的消息还在慢慢消费,用户早就失去耐心退款了。后续调整方案是提前做容量评估根据消费者的处理能力控制生产者的并发度同时针对消息类型分区高优的订单创建消息单独用一个队列避免被其他低优任务拖累。5.3 页面静态化与CDN的流量分担削峰不只是后端的事情前端也能分担大量压力。商品详情页在秒杀日的访问量极大但大部分内容是不变的图片、详情文案、参数列表。把这些内容做成静态页面放到CDN上用户请求在边缘节点就返回了根本不进源站。具体做法是活动开始前把页面渲染成HTML上传到对象存储CDN预热好节点。页面只需要动态请求商品ID、秒杀开始时间、用户参与资格这三个参数由JavaScript通过异步接口获取。这样源站处理的动态请求数量降到了一个很低的量级。还有一个小技巧是把秒杀按钮的点击做成本地节流。用户在秒杀开始前会疯狂刷新页面可以在JavaScript里做倒计时控制没到时间点不发请求到点后单用户只发一次请求后续点击进入排队和结果轮询。这个改动虽然简单但能把无效请求减少90%以上。6. 压测与容量规划别等大促当天才掉链子6.1 压测场景怎么设计才够真实压测最忌讳的是“只测接口不测流程”。很多团队拿JMeter或wrk对着一个接口猛打测出来的QPS好看但上线后照样崩。因为真实用户不是只打一个接口而是同时操作商品查询、库存扣减、下单等多个接口而且流量分布是不均匀的。我建议的压测方案分三档。第一档是接口级压测单个核心接口单独跑看极限性能指标。第二档是链路级压测从网关到应用到缓存再到数据库完整走一遍。第三档是混合流量压测模拟不同比例的用户请求。混合流量更接近真实场景能发现接口间相互影响导致的性能下降。压测数据要尽量真实。如果数据量只有一百条记录数据库索引全在内存里跟有百万条记录时的表现截然不同。我遇到过“压测全绿、上线全红”的典型情况就是因为压测库的表里只有几万条数据索引和数据全在热内存里上线后数据量到百万级别部分查询走了全表扫描性能瞬间劣化。压测工具方面JMeter相对通用但并发模型弱一点wrk适合测接口吞吐上限Locust用Python写脚本比较容易模拟复杂场景。我现在的习惯是wrk测性能极限Locust做链路混合压测两者互补。6.2 具体容量评估怎么做容量规划听起来高大上其实核心就两个问题公司有多少服务器资源以及每个节点的处理上限是多少。我常用一个公式估算应用层的机器规模。假设预估峰值QPS是10万单机实测能扛3000QPS那么应用层的服务器至少需要70000 / 3000约等于34台考虑到单机故障要留冗余再乘1.2到1.5的系数大概准备40到50台。数据库层的估算更保守单库写入能力通常只有几百到几千TPS如果预估峰值下单TPS是5000数据库层面就得考虑分库分表了。压测时重点记录四组数据QPS/TPS、接口响应时间的P99、错误率、系统资源使用率。如果P99超过500毫秒或者错误率超过0.1%就必须停下来定位瓶颈不能带着问题继续压。6.3 预案和演练不能省容量评估给出了规模预案则要规定“出问题了怎么办”。我见过太多团队把预案文档写得厚厚一摞但真正故障来临时没人知道第一步先做哪个操作。所以我的习惯是给预案做编号每个预案一句话说明白“什么条件触发、执行人是谁、第一步做什么”。比如“预案一Redis集群内存使用率超过85%触发扩容执行人张三第一步联系DBA添加节点并做slot迁移。”这样的预案演练时才不会手忙脚乱。演练也很重要。秒杀系统上线前至少做一次全流程预演从创建活动、预热缓存、开启秒杀、模拟用户抢购、观察后台监控到结束清场。演练能发现很多文档层面看不出来的问题——比如某个配置项忘记更新了、某个脚本没有执行权限、监控告警阈值设置不合理。这些问题在真实大促前发现都算是赚到了。7. 常见问题排查实录与速查表7.1 几个真实踩坑案例第一个案例Redis连接池耗尽。上线秒杀后突然大面积报Redis连接超时排查发现连接池默认配置是64秒杀开始时每秒有上万个请求去访问缓存每个请求都需要从连接池获取连接连接池瞬间被打满。解决思路有两个一是调大连接池的最大连接数和最大等待时间二是排查代码里是不是有连接忘归还的问题后者更隐蔽通常是因为某些异常路径没有执行RedisTemplate的close或释放逻辑。说到底Redis连接池的大小不是越多越好但秒杀场景至少需要预估算清楚峰值并发量和每次操作的耗时再反推连接池大小并预留两倍余量。第二个案例数据库连接池被慢查询拖垮。一次秒杀后系统没有崩在流量上而是崩在了一个数据库慢查询上。某个查询商品的SQL没有走索引数据量一大就慢秒杀请求一多数据库连接全部被这个慢查询占着其他正常查询全部排队。这个问题的根因是上线前只做了功能验证没有做慢查询检查。后来我把所有核心SQL都做了EXPLAIN分析强制要求的列都加索引同时设置了数据库连接池的等待超时时间一旦等待超过200毫秒直接快速失败不让请求无限堆积。第三个案例分布式锁没有设置过期时间导致死锁。在更新缓存时用了Redis分布式锁但代码里忘记设置锁的过期时间。结果某次进程在持锁期间崩溃锁一直没释放后续所有请求都卡在获取锁的步骤上。这个问题非常经典所有分布式锁都必须设置过期时间而且过期时间要大于业务执行时间。7.2 秒杀系统问题速查表现象可能原因优先排查项快速处理方案库存变成负数应用层先查后改检查扣减是否走原子操作改用UPDATE原子扣减或Lua脚本压测通过上线崩溃压测数据量过小对比压测库和真实库数据量按真实数据规模重新压测缓存失效后数据库崩热点key过期击穿查看缓存过期策略互斥锁或逻辑过期Redis超时大面积报错连接池耗尽或网络抖动Redis连接池监控调大连接池并按秒杀峰值估算数据库连接被占满慢查询拖垮连接池开启慢查询日志加索引、设置连接池等待超时请求几乎全是脚本防刷能力不足看IP和UA分布加网关限流和多维风控MQ消费积压严重生产速度远超消费看生产消费速率差限流入队、垂直扩容消费者页面加载慢动态内容过多检查接口响应和资源大小静态化页面、上CDN、前端节流用户重复下单缺少幂等机制检查订单创建逻辑生成全局唯一流水号做唯一约束大促后数据不一致缓存与数据库不同步检查补偿流程补对账任务、引入DB和Redis双写校验7.3 上线前必须检查的十件事结合我自己的实践经验每次秒杀系统重启前都会过一遍这个清单踩过的坑基本都能拦住。第一确认秒杀商品已经预热到Redis缓存库存预热脚本必须执行成功。第二确认数据库扣库存SQL走了索引用EXPLAIN看过执行计划。第三确认所有分布式锁都有过期时间并且过期时间大于最长业务耗时。第四确认MQ消费规则有幂等处理全局唯一流水号的唯一索引已建好。第五确认限流阈值已经配好网关和应用层各自有独立限流策略。第六确认缓存过期时间做过随机化没有大范围同时过期的隐患。第七确认压测数据规模与真实数据接近不是“小数据测大系统”。第八确认监控告警已经覆盖Redis、数据库连接池、MQ积压、QPS、错误率等核心指标。第九确认预案文档里每个行动项都有执行人和复核人。第十确认上线前做过至少一次全链路预演并记录了演练出现的所有问题。这不是套话清单而是实打实的保命步骤。哪一项没做大促当天就有对应的概率翻车。秒杀系统最贵的一句话是“我以为没问题”。我个人在实际操作中的体会是秒杀系统同行之间讨论的从来不是某个框架有多新而是每一次大促后复盘时那些当时没有做好预案、没有做足压测、没有搞清楚细节的地方。技术选型可以跟风但容量规划、兜底设计、防刷策略这些基本功必须结合自己的业务场景一步步踏踏实实验证。希望这篇偏实战的梳理能帮你在重启你自己的秒杀系统前心里更有底。
返回列表