秒杀简介
同一时刻有大量的客户端请求购买同一个商品、并完成交易的过程,瞬时产生大量的并发读、并发写。
本质上是一个满足高并发、高性能、高可用的分布式系统。
特点:
- 高性能:大量并发读、并发写
- 一致性:通常会使用缓存,需要保证缓存和数据库中库存数据的一致性,保证商品库存的准确性
- 高可用:考虑系统容灾
- 可扩展性:服务到达瓶颈时,需要支持快速扩容
基本策略
架构设计原则
- 数据尽量少(前后端交互数据尽量少)
- 前端请求数量尽量少
- 路径尽量短(用户发出请求到返回数据中间经过的节点数)
- 依赖尽量少(用户下单过程中强依赖的服务)
- 不要有单点服务
前端优化策略
- 页面静态化:数据源动静分离,静态数据(详情图片、文案等)可以放到CDN,动态数据(价格、数量)放到服务器。可以通过url地址作为key来存储静态数据。
- 控制对服务端请求的频率:限流方式:a.秒杀按钮单位时间内只能点击一次;b.答题正确后才能向服务器发起请求;c.验证码
- 控制传参数量,减少交互的数据、降低编解码开销
流量控制手段:
- 账户验证(确保有资格
- 客户端版本安全校验(防止反编译、修改客户端代码等等)
- 秒杀接口动态生成(防止刷单
常见高可用策略:
- 容灾设计(异地多活)
- 降级:容量达到一定程度时,限制或关闭系统的某些非核心功能,把资源留给核心业务。需要前后端配合执行。例如:QPS达到某一个阈值时,将原有分页50个数据量改成10个
- 限流:容量达到瓶颈时,限制一部分流量来保护系统,可以时接口级别、服务级别、ip级别。阈值通过压测结果设置
- 拒绝服务:兜底
限流手段:
- 可以采用固定限流算法:例如秒杀商品库存10件,限流器接收到10n个请求后就停止该商品的所有请求,这样最终到达服务端的单个商品请求数量都不会超过10n。可能存在客户支付超时、释放库存的情况,系统需要能通知限流器接收新的请求。
- 安全验证:黑名单校验、IP地址校验
- 单独的限流器:下单阶段随机舍弃秒杀请求
资源隔离
发现热点数据
静态热点数据:商家报名商品、用户预约商品
动态热点数据
- 异步系统收集交易链路上的热点key,最好采用异步采集日志的形式
- 建立一个热点上报和可以按照需求订阅的热点服务的下发规范,通过交易链路上各个系统(包括详情、购物车、交易、优惠等)访问的时间差,把上游已经发现的热点透传给下游
- 把上游系统收集的热点数据发送到热点服务台,下游系统订阅热点数据,尽量做到实时,为下游提供保护
处理热点数据
独立部署秒杀服务:秒杀持续时间短,并发量高。为了影响原有业务,可以单独部署一套秒杀服务,进行物理级别隔离
优化热点数据:进行缓存,使用LRU淘汰算法替换
限制热点数据:堆被访问的商品ID做一致性Hash,根据Hash做分桶,每个分桶设置一个处理队列、把热点商品限制在一个请求队列里,不会占用过多服务器资源
隔离热点数据:
- 业务隔离:把秒杀作为营销活动,卖家需要单独报名
- 系统隔离:让请求落到不同的集群中,运行时隔离
- 数据隔离:单独的数据库存放热点数据,热门商品分库分表,压力分摊到多个数据库中
可能会出现某个分表库存为0,其他表有库存的情况。1.到其他表重试,会放大流量;2.通过路由组件记录每个分表的库存情况,把请求转发到有库存的表中;3.分布式缓存记录每个分表的库存情况,每次下单时只更新缓存,缓存后续再更新到数据库中
流量削峰
服务流量过大时,可以将请求存入MQ处理,客户端采用轮询方式获取结果
排队:使用消息队列缓冲瞬时流量
答题作用:a. 增加购买复杂度,防止作弊;b.延缓请求
- 题库生成模块,生成问题和答案,防止机器计算即可
- 题库推送模块,在秒杀答题前,把题目提前推送给详情系统和交易系统。推送机制主要是为了保证每次用户请求的题目唯一,防止答题作弊
- 题目的图片生成模块,把题目生成为图片格式、图片中增加干扰因素,防止机器答题,提前把题目的图片推送到CDN进行预热
- 问题key:userId+itemId+question_Id+time+PK
- 答案key:userId+itemId+answer+PK
- 提交答案也需要限制,过短时间可能是机器
分层过滤
对请求进行分层过滤,过滤无效请求
- 大部分数据和流量在用户浏览器或CDN上获取,拦截大部分数据的读取
- 一些读取请求尽量走缓存
- 对写数据进行基于时间的合理分片,过滤掉过期的失效请求
- 后台写系统做数据的二次校验,写请求需要做限流保护
- 最后在数据层完成数据的强一致性校验
分层校验的目的是:
- 在读系统中,尽量减少由于一致性校验带来的系统瓶颈,尽量将不影响性能的检查条件提前,如用户是否具有秒杀资格、商品状态是否正常、用户答题是否正确、秒杀是否已经结束、是否非法请求、营销等价物是否充足等;
- 在写数据系统中,主要对写的数据(如“库存”)做一致性检查,最后在数据库层保证数据的最终准确性(如“库存”不能减为负数)。
减库存设计
用户实际购买流程:下单-->付款。
库存扣减方式:
- 下单减库存:买家下单后,扣减商品总库存。下单时通过数据库的事务机制控制商品库存。潜在问题:恶意刷单、下单后占用库存不付款
- 付款减库存:买家付款后、扣减商品总库存。潜在问题:库存超卖
- 预扣库存:下单后、预扣库存,一定时间内没有付款,库存自动释放。买家付款前需要再次检验库存是否保留、如果没有保留、再次尝试预扣;如果库存不足则不允许继续付款;如果预扣成功,则完成付款并实际减去库存。
预扣库存还是存在一些问题:恶意刷单等等。这需要结合反作弊策略,标识经常下单不付款的买家、限制重复下单不付款的操作次数等等
大型秒杀活动减库存方式:秒杀场景下,卖家对商品库存有严格限制,采用“下单减库存”更合理,性能也更优,为了保证库存数据不能为负数:a.事务判断,不满足则回滚;b.库存字段为无符号整数,异常操作时会报错;
服务端库存处理:
将库存操作的逻辑放在lua脚本中,通过redis的单线程特性,保证脚本执行不会被打断,从而保证库存操作的原子性。
秒杀减库存的极致优化:
- 如果秒杀商品减库存逻辑简单,联动关系少,可以在缓存中减少数据,然后同步到数据库
- 热点数据分库分表,按照商品维度区分,防止热点商品占用太多数据库连接
涉及技术
数据存储方式
缓存
秒杀场景读多写少,可以在服务端增加缓存应对高并发读。缓存可以设置2层:1.本地缓存,失效时间秒级别,属于jvm级别,每次失效后可以从redis缓存中加载;redis缓存要注意缓存失效、缓存击穿、缓存雪崩问题。
- 缓存击穿:缓存中不存在、数据库中存在。解决方式:1.缓存永不过期;2.同步返回null,异步加锁查询数据库、更新缓存
- 缓存穿透:数据在缓存和数据库中都不存在。解决方法:1.业务层进行合法校验、拦截大部分不合法的请求;2.布隆过滤器;3.对空的结果进行缓存、设置较短的过期时间,数据库有变更时,同步刷新缓存
- 缓存雪崩:缓存同时失效。解决方法:1.永不过期;2.失效时间随机;3.多级缓存,A->B,A有过期时间,B没有过期时间,但是更新的时候要同时更新
可以将更新频繁的商品库存信息放在redis缓存处理,最好分成多分放入不同key缓存中,达到更好的读写性能
消息队列
锁
悲观锁
查询数据库的时候获取排他锁,查询、更新库存,此时其他线程等待获取排他锁。前一个线程释放排他锁后,后面线程获取到锁,查询到库存为0.
缺点:
- 处理性能不高、多个用户同时下单同一个商品,每个请求都要获取排他锁,较长时间后才能知道是否下单成功;
- 容易设计死锁,实际上整个下单操作不仅涉及修改库存,还有其他业务,悲观锁每个请求轮流持有锁,更容易发生死锁
乐观锁
乐观锁策略下事务会记录查询时的版本号,事务准备更新时,如果发现版本号不一致,就会回滚事务。
乐观锁不需要等待锁,在事务竞争较少的情况下比悲观锁性能好,在事务竞争多的时候,经常需要回滚事务、性能反而更差
分布式锁
在服务端和数据库之间加上分布式组件保证请求的并发安全,例如redis, zookeeper。每个请求都需要从组件中获取分布式锁后才能继续执行。
优点:
- 功能分离,分布式组件负责解决高并发问题,数据库负责数据存储
缺点:
- 容易引发一致性问题
- 高并发下也会产生锁竞争问题
- 需要维护分布式组件的可靠性
消息队列
请求都进入消息队列,数据库每次从消息队列中取出请求。
服务端根据消息队列里的消息状态返回下单结果
为了保证消息最少一次生效,消费者需要下单成功后才能返回确认ACK,否则有可能会丢失消息
为了防止消息重复消费的情况,需要让下单逻辑变为幂等操作。常见解决方案:1.保证下单请求有全局唯一ID,并在消息队列中对ID进行持久化,在发送给消费者之前检查ID是否已经消费过,重试过程中不能修改。
潜在问题
- 库存少,请求多的时候,大部分用户都浪费时间在队列等待和无意义查询库存上,牺牲了用户体验、增加了服务端压力
- 库存过少,会有大量请求(非法请求,超过一定时间后的请求)都是没机会抢到的,没必要达到服务器
- 大量客户端在下单前同时请求同一个商品的秒杀页面,导致服务器压力升高
实际经验
后端服务器部署采用读写分离方式,库存计算采用lua脚本保证原子性,主库采用redis集群,写入redis成功后将数据转发到MQ,最终写入mysql书库,实现数据的最终一致性。
增加对账系统,校验redis和mysql数据的差异性。