背景
- 2024 年天猫双 11,订单峰值达到 58.3 万笔/秒,是 2009 年第一次双 11 的 1457 倍。
- 2024 年京东 618,18 日 0 点 -1 点下单量峰值达到每秒约 10 万笔。
- 2025 年 7 月,美团在外卖大战补贴高峰日,即时零售订单峰值达到每秒约 14 万笔。
秒杀业务特点
- 服务承载的访问压力大
- 瞬时流量突增:业务促销活动在特定时间开启,大量用户请求等待活动开启后瞬间涌入。
- 抢购脚本带来压力:灰产通过抢购脚本薅羊毛,一方面带来额外的系统压力,另一方面影响抢购活动公平性。
- DDOS 趁虚而入:可能存在竞对在活动期间使用 DDOS 攻击网站。
- 存在明显的访问热点
- 热点集中:少量优惠力度大的商品成为抢购热点,比如新款 iPhone 在 1 分钟内售罄。
- 热点未知:部门商家和商品可能并不在预计的促销范围内,但是可能因为网络发酵、羊毛党突然成为爆款。
- 数据一致性要求高
- 一方面优惠商品库存有限,超卖会给商家带来损失。
- 另一方面用户抢到商品后如果不能支付,则会引起客诉。
秒杀系统技术优化
「秒杀系统」的建设需要整个系统从前到后全栈的协同配合,也需要产品、运营、业务和策略同学的介入,文章中只涉及研发部分,围绕基础服务讨论秒杀系统的技术挑战与架构优化。
前端与网关层
- 前端动静分离,把 90% 的静态数据缓存在用户端或者 CDN 上,当真正秒杀时用户只需要点击特殊的按钮「刷新」即可,而不需要刷新整个页面,这样只向服务端请求很少的有效数据,而不需要重复请求大量静态数据。
- 网站负载均衡层或业务网关层需要能够对访问请求按用户粒度进行流量限制,以降低抢购脚本对系统带来的压力。
- 在安全方面,通过高防 CDN 或高防 IP,降低 DDOS 攻击的影响。
- 在业务方面,通过引入答题、滑动块等,将突然涌入的压力平滑到 3s 左右的时间段内。
业务层
- 通过对后台系统的微服务化改造和数据库层面的拆分(SOA),实现微服务之间的隔离,避免相互影响,实现不同核心服务相互独立的容量评估和紧急情况下的限流熔断。
- 在活动进行过程中,如果业务流量过大,业务需要紧急扩容,底层容器服务需要能够支持分钟内的快速弹性扩容,因此容器调度、镜像分发、服务发现的效率都需要相应的进行提升和优化。
- 在处理业务弹性扩容的过程中,还有一点也需要考虑到 —— 即数据库的连接数风险,在没有类似 DBProxy 这样的服务进行连接池收敛的情况下,业务的弹性扩容能力需要考虑数据库的对连接数的承载力。
缓存层
在抢购业务中,对商品库存数量的更改主要通过数据库进行,但是由于读取流量过大,一般需要通过两级缓存的机制进行优化,即:Java 服务进程内本地缓存 => 分布式缓存服务 => 数据库服务。
由于库存数据更新非常频繁,再加上后面要提到的库存拆分设计,缓存一致性在系统设计时是需要折中考虑的,库存数据的缓存往往被设计为延后定时刷新,而不是在每次成功扣减库存后去刷新,用户可能会看到商品仍有剩余库存,但是实际下单时返回售罄。更进一步甚至可以像 12306 那样只缓存「有余票」或「没有余票」两个状态,降低复杂度节省资源。
数据层
先简单介绍扣减库存在数据库上操作的例子,SQL 可以抽象为这种形式:update stock_table set inventory=inventory-1 where item_id=xxxx and inventory>0
,即指定商品 ID(item_id)并判断库存充足情况下扣减库存,隔离级别大于等于 ReadCommitted 的关系数据库可以保证这条语句执行的原子性。在处理对少量热点商品高并发扣减库存的业务时,关系数据库都会面临如下几个难题:
-
并发冲突代价:当前主流的关系数据库,无论是老牌商业产品 Oracle、流行开源项目 MySQL、还是国产开源新秀 TiDB,它们都使用经典的 WAL(write ahead log)方式来实现数据的持久化,即在事务提交时保证被更新的数据(WAL)写到硬盘后,才能给客户端返回成功。而硬盘写入的 Latency 比内存操作大几个数量级,为了优化性能,大家都引入了组提交机制(group commit),即将同时提交的多个事务的数据合并为一条 WAL 写入硬盘。对于每个事务来说,Latency 还是一次硬盘写入 IO 的耗时;但是对于整个系统来说,可以将 TPS 从原来与硬盘 IOPS 相近的水平,提升几倍甚至几十倍。
但是并不是所有的并发事务都能够合并成组提交,如果两个事务之间存在冲突(比如并发修改同一行),那么无论是基于悲观锁进行并发控制的 Oracle/MySQL,还是基于乐观锁进行并发控制的 TiDB,对于相互冲突的事务,他们本质上的处理方式,都只能是排队执行,即后一个事务要等前一个事务提交完成后才能执行。使用扣减库存的 SQL 举例如下:
找到并对商品记录加锁 => 判断库存余额 => 修改库存余额 => 提交 WAL 写盘 => 释放锁
针对同一个热点商品的多个并发事务,在上面加锁和释放锁之间的这段操作是无法做到并发执行的,因此在不引入任何优化的情况下,在同一个数据表中针对一个热点商品扣减库存 TPS 的天花板就是硬盘的 IOPS,而在大量并发事务都在争抢行锁的情况下,情况会进一步恶化。较高的系统负载叠加上锁冲突检测等额外代价,可能造成系统的整体吞吐降低至个位数。
-
可能存在超卖风险:考虑到上述并发事务提交 WAL 的问题,在实际系统上,为了降低写 WAL 的 Latency,保证系统吞吐,一般会将写硬盘和同步备机调整为异步方式,而这个调整又会带来新的问题,即主库宕机情况下的数据不一致,主库重启或者备库切换为主库后,可能存在宕机前部分 WAL 没有被持久化的风险,反映到扣减库存的逻辑上就是已经被扣减的库存又被恢复了回来,最终在业务上形成超卖。
-
复杂事务恶化冲突:上面所举的例子是单行事务的 Update,行锁的临界区(找到并对商品记录加锁 => 判断库存余额 => 修改库存余额 => 提交 WAL 写盘 => 释放锁)都在数据库处理的边界之内,但是在某些复杂场景下,在库存扣减的事务中可能存在多条语句的情况,比如「扣减库存 & 生成订单」在一个事务内完成,这种情况下行锁的临界区扩大到受业务网络交互的影响,整体冲突加剧、吞吐进一步降低。
数据库层面对于并发扣减库存的优化思路:在业务层将同一个商品的库存记录拆分为多行甚至多个表里面去,降低在同一行或同一个数据表上的并发冲突,比如针对业务请求中的用户 ID 计算哈希取模后确定要扣减哪个库存记录。这个方案能够很大程度的降低并发冲突,不需要数据库内核配合做修改,是行业内的主流方案。但它的问题是:同一个商品不同库存记录的扣减速度不均衡(热点商品往往在几十秒内被抢光,这个不均衡问题并不严重),给总库余额计数带来的复杂度,业务需要预先感知热点商品并且针对性的进行库存拆分。
业务架构:减库存与生成订单一致性
在上面的例子中,扣减库存与生成订单的事务是在同一个数据库实例完成的,但是随着业务的拆分、业务逻辑的变化,扣减库存与生成订单可能被拆到不同的服务中去,那么如何保证扣减库存与生成订单的一致性,也成为一个有挑战的问题。
需要注意的是这种场景下,产生的数据不一致,不会造成商品超卖,而是会造成用户下单成功,却看不到待支付订单。针对这类问题,一般通过 DRC/DTS 这类中间件来配合实现数据一致性,即扣减库存成功后,MySQL 就会有相应的 binlog,DRC/DTS 订阅库存中心的 binlog,订单中心再根据 DRC/DTS 订阅的数据来生成订单。因为 MySQL binlog 有多份副本不会丢失,所以即使订单中心出现超时抖动等问题,在恢复正常后就能够继续生成订单。
研发层面技术保障
- 业务全链路压测:由于线上线下环境多少都会有些不同,很多问题只有在实际生产环境才能暴露,对于秒杀类业务,线上压测也能够实际评估出系统的真实承载力,为容量预估给出重要参考。
- 准实时监控:这里的技术挑战主要是在海量业务和数据库的场景下,如何做到全局有效而实时的监控数据采集和分析。
- 实时热点发现:与准实时的监控类似,技术团队需要及时发现系统中的热点和瓶颈,并作出调整。实时热点的发现,需要业务层监控、数据库层监控一起配合改进优化,才能准确分析出热点。
- 容灾与高可用:业务容器宕机、数据库主库宕机、机房级宕机都可能出现,技术团队需要通过有效的容灾规划、Set 化、分库分表等,降低「爆炸半径」,并且要做到快速切换。因此这里的技术挑战是容器的快速扩容,容器镜像快速分发,数据库分库分表尽量降低单个集群主备切换的影响,业务层面的 Set 化和灵活的流量切换。
- 系统预热:大量流量会在大促开始的第 0 秒集中涌入,活动开始前需要完成 JVM 预加载代码、缓存预热、数据库连接池预热等系统预热工作。同时在各个系统的设计时也要做到避免对单点的依赖,原则仍然是降低「爆炸半径」,防止大量流量进入后,把系统中的某个单点压垮。