../miao-sha-xi-tong-ji-shu-ceng-mian-fen-xi-yu-shi-jian

秒杀系统技术层面分析与实践

背景

秒杀业务特点

  1. 服务承载的访问压力大
    1. 瞬时流量突增:业务促销活动在特定时间开启,大量用户请求等待活动开启后瞬间涌入。
    2. 抢购脚本带来压力:灰产通过抢购脚本薅羊毛,一方面带来额外的系统压力,另一方面影响抢购活动公平性。
    3. DDOS 趁虚而入:可能存在竞对在活动期间使用 DDOS 攻击网站。
  2. 存在明显的访问热点
    1. 热点集中:少量优惠力度大的商品成为抢购热点,比如新款 iPhone 在 1 分钟内售罄。
    2. 热点未知:部门商家和商品可能并不在预计的促销范围内,但是可能因为网络发酵、羊毛党突然成为爆款。
  3. 数据一致性要求高
    1. 一方面优惠商品库存有限,超卖会给商家带来损失。
    2. 另一方面用户抢到商品后如果不能支付,则会引起客诉。

秒杀系统技术优化

「秒杀系统」的建设需要整个系统从前到后全栈的协同配合,也需要产品、运营、业务和策略同学的介入,文章中只涉及研发部分,围绕基础服务讨论秒杀系统的技术挑战与架构优化。

前端与网关层

业务层

缓存层

在抢购业务中,对商品库存数量的更改主要通过数据库进行,但是由于读取流量过大,一般需要通过两级缓存的机制进行优化,即:Java 服务进程内本地缓存 => 分布式缓存服务 => 数据库服务。

由于库存数据更新非常频繁,再加上后面要提到的库存拆分设计,缓存一致性在系统设计时是需要折中考虑的,库存数据的缓存往往被设计为延后定时刷新,而不是在每次成功扣减库存后去刷新,用户可能会看到商品仍有剩余库存,但是实际下单时返回售罄。更进一步甚至可以像 12306 那样只缓存「有余票」或「没有余票」两个状态,降低复杂度节省资源。

数据层

先简单介绍扣减库存在数据库上操作的例子,SQL 可以抽象为这种形式:update stock_table set inventory=inventory-1 where item_id=xxxx and inventory>0 ,即指定商品 ID(item_id)并判断库存充足情况下扣减库存,隔离级别大于等于 ReadCommitted 的关系数据库可以保证这条语句执行的原子性。在处理对少量热点商品高并发扣减库存的业务时,关系数据库都会面临如下几个难题:

  1. 并发冲突代价:当前主流的关系数据库,无论是老牌商业产品 Oracle、流行开源项目 MySQL、还是国产开源新秀 TiDB,它们都使用经典的 WAL(write ahead log)方式来实现数据的持久化,即在事务提交时保证被更新的数据(WAL)写到硬盘后,才能给客户端返回成功。而硬盘写入的 Latency 比内存操作大几个数量级,为了优化性能,大家都引入了组提交机制(group commit),即将同时提交的多个事务的数据合并为一条 WAL 写入硬盘。对于每个事务来说,Latency 还是一次硬盘写入 IO 的耗时;但是对于整个系统来说,可以将 TPS 从原来与硬盘 IOPS 相近的水平,提升几倍甚至几十倍。

    但是并不是所有的并发事务都能够合并成组提交,如果两个事务之间存在冲突(比如并发修改同一行),那么无论是基于悲观锁进行并发控制的 Oracle/MySQL,还是基于乐观锁进行并发控制的 TiDB,对于相互冲突的事务,他们本质上的处理方式,都只能是排队执行,即后一个事务要等前一个事务提交完成后才能执行。使用扣减库存的 SQL 举例如下:

    找到并对商品记录加锁 => 判断库存余额 => 修改库存余额 => 提交 WAL 写盘 => 释放锁

    针对同一个热点商品的多个并发事务,在上面加锁和释放锁之间的这段操作是无法做到并发执行的,因此在不引入任何优化的情况下,在同一个数据表中针对一个热点商品扣减库存 TPS 的天花板就是硬盘的 IOPS,而在大量并发事务都在争抢行锁的情况下,情况会进一步恶化。较高的系统负载叠加上锁冲突检测等额外代价,可能造成系统的整体吞吐降低至个位数。

  2. 可能存在超卖风险:考虑到上述并发事务提交 WAL 的问题,在实际系统上,为了降低写 WAL 的 Latency,保证系统吞吐,一般会将写硬盘和同步备机调整为异步方式,而这个调整又会带来新的问题,即主库宕机情况下的数据不一致,主库重启或者备库切换为主库后,可能存在宕机前部分 WAL 没有被持久化的风险,反映到扣减库存的逻辑上就是已经被扣减的库存又被恢复了回来,最终在业务上形成超卖。

  3. 复杂事务恶化冲突:上面所举的例子是单行事务的 Update,行锁的临界区(找到并对商品记录加锁 => 判断库存余额 => 修改库存余额 => 提交 WAL 写盘 => 释放锁)都在数据库处理的边界之内,但是在某些复杂场景下,在库存扣减的事务中可能存在多条语句的情况,比如「扣减库存 & 生成订单」在一个事务内完成,这种情况下行锁的临界区扩大到受业务网络交互的影响,整体冲突加剧、吞吐进一步降低。

数据库层面对于并发扣减库存的优化思路:在业务层将同一个商品的库存记录拆分为多行甚至多个表里面去,降低在同一行或同一个数据表上的并发冲突,比如针对业务请求中的用户 ID 计算哈希取模后确定要扣减哪个库存记录。这个方案能够很大程度的降低并发冲突,不需要数据库内核配合做修改,是行业内的主流方案。但它的问题是:同一个商品不同库存记录的扣减速度不均衡(热点商品往往在几十秒内被抢光,这个不均衡问题并不严重),给总库余额计数带来的复杂度,业务需要预先感知热点商品并且针对性的进行库存拆分。

业务架构:减库存与生成订单一致性

在上面的例子中,扣减库存与生成订单的事务是在同一个数据库实例完成的,但是随着业务的拆分、业务逻辑的变化,扣减库存与生成订单可能被拆到不同的服务中去,那么如何保证扣减库存与生成订单的一致性,也成为一个有挑战的问题。

需要注意的是这种场景下,产生的数据不一致,不会造成商品超卖,而是会造成用户下单成功,却看不到待支付订单。针对这类问题,一般通过 DRC/DTS 这类中间件来配合实现数据一致性,即扣减库存成功后,MySQL 就会有相应的 binlog,DRC/DTS 订阅库存中心的 binlog,订单中心再根据 DRC/DTS 订阅的数据来生成订单。因为 MySQL binlog 有多份副本不会丢失,所以即使订单中心出现超时抖动等问题,在恢复正常后就能够继续生成订单。

研发层面技术保障