秒杀系统设计
秒杀其实主要解决两个问题,一个是并发读,一个是并发写。并发读的核心优化理念是尽量减少用户到服务端来“读”数据,或者让他们读更少的数据;并发写的处理原则也一样,它要求我们在数据库层面独立出来一个库,做特殊的处理。另外,我们还要针对秒杀系统做一些保护,针对意料之外的情况设计兜底方案,以防止最坏的情况发生。
秒杀系统架构原则
- 数据尽量少: 可以简化秒杀页面的大小,去掉不必要的页面装修效果,等等。
- 请求数尽量少: 减少请求数最常用的一个实践就是合并 CSS 和 JavaScript 文件,把多个 JavaScript 文件合并成一个文件,在 URL 中用逗号隔开。
- 路径要尽量短: 缩短访问路径有一种办法,就是多个相互强依赖的应用合并部署在一起,把远程过程调用(RPC)变成 JVM 内部之间的方法调用。
- 依赖要尽量少: 0 级系统要尽量减少对 1 级系统的强依赖,防止重要的系统被不重要的系统拖垮。例如支付系统是 0 级系统,而优惠券是 1 级系统的话,在极端情况下可以把优惠券给降级,防止支付系统被优惠券这个 1 级系统给拖垮。
- 不要有单点: 应用无状态化。
动静分离
“动态数据”和“静态数据”的主要区别就是看页面中输出的数据是否和 URL、浏览者、时间、地域相关,以及是否含有 Cookie 等私密数据。所谓“动态”还是“静态”,并不是说数据本身是否动静,而是数据中是否含有和访问者相关的个性化数据。
静态数据
怎样对静态数据做缓存呢?
- 第一,你应该把静态数据缓存到离用户最近的地方。
- 第二,静态化改造就是要直接缓存 HTTP 连接。静态化改造是直接缓存 HTTP 连接而不是仅仅缓存数据,如下图所示,Web 代理服务器根据请求 URL,直接取出对应的 HTTP 响应头和响应体然后直接返回,这个响应过程简单得连 HTTP 协议都不用重新组装,甚至连 HTTP 请求头也不需要解析。
- 第三,让谁来缓存静态数据也很重要。
动静分离的改造
- URL 唯一化。
- 分离浏览者相关的因素。
- 分离时间因素。
- 异步化地域因素。
- 去掉
Cookie
。
动态内容的处理通常有两种方案:ESI(Edge Side Includes)方案和 CSI(Client Side Include)方案。
动态内容
- ESI 方案(或者 SSI):即在 Web 代理服务器上做动态内容请求,并将请求插入到静态页面中,当用户拿到页面时已经是一个完整的页面了。这种方式对服务端性能有些影响,但是用户体验较好。
- CSI 方案。即单独发起一个异步 JavaScript 请求,以向服务端获取动态内容。这种方式服务端性能更佳,但是用户端页面可能会延时,体验稍差。
热点数据
发现热点数据
发现静态热点数据:
静态热点数据可以通过商业手段,例如强制让卖家通过报名参加的方式提前把热点商品筛选出来,实现方式是通过一个运营系统,把参加活动的商品数据进行打标,然后通过一个后台系统对这些热点商品进行预处理,如提前进行缓存。你还可以通过技术手段提前预测,例如对买家每天访问的商品进行大数据计算,然后统计出 TOP N 的商品,我们可以认为这些 TOP N 的商品就是热点商品。
发现动态热点数据:
- 构建一个异步的系统,它可以收集交易链路上各个环节中的中间件产品的热点 Key,如 Nginx、缓存、RPC 服务框架等这些中间件。
- 建立一个热点上报和可以按照需求订阅的热点服务的下发规范,主要目的是通过交易链路上各个系统(包括详情、购物车、交易、优惠、库存、物流等)访问的时间差,把上游已经发现的热点透传给下游系统,提前做好保护。
- 将上游系统收集的热点数据发送到热点服务台,然后下游系统(如交易系统)就会知道哪些商品会被频繁调用,然后做热点保护。
处理热点数据
- 优化热点数据最有效的办法就是缓存热点数据,如果热点数据做了动静分离,那么可以长期缓存静态数据。但是,缓存热点数据更多的是“临时”缓存,即不管是静态数据还是动态数据,都用一个队列短暂地缓存数秒钟,由于队列长度有限,可以采用 LRU 淘汰算法替换。
- 限制更多的是一种保护机制,限制的办法也有很多,例如对被访问商品的 ID 做一致性 Hash,然后根据 Hash 做分桶,每个分桶设置一个处理队列,这样可以把热点商品限制在一个请求队列里。
- 秒杀系统设计的第一个原则就是将这种热点数据隔离出来,不要让 1% 的请求影响到另外的 99%。
解决单点瓶颈
单个热卖商品落在一个
slot
中,容易瞬间被撑爆。
(1) Redis 集群多写多读方案
- 单写改为多写:操作实现数据的冗余存储。
- 多写如何保证数据一致性?可以使用 ZooKeeper 来保证,一旦 Watch 到 Znode 的变化,那么所有客户端都全量更新。
(2) LocalCache 结合 Redis 集群的多级 Cache 方案
流量削峰
削峰从本质上来说就是更多地延缓用户请求的发出,以便减少和过滤掉一些无效请求,它遵从“请求数要尽量少”的原则。
排队
用消息队列来缓冲瞬时流量,把同步的直接调用转换成异步的间接推送,中间通过一个队列在一端承接瞬时的流量洪峰,在另一端平滑地将消息推送出去。
答题
- 第一个目的是防止部分买家使用秒杀器在参加秒杀时作弊。
- 第二个目的其实就是延缓请求,起到对请求流量进行削峰的作用,从而让系统能够更好地支持瞬时的流量高峰。这个重要的功能就是把峰值的下单请求拉长,从以前的 1s 之内延长到 2s~10s。
分层过滤
在不同的层次尽可能地过滤掉无效请求,让“漏斗”最末端的才是有效请求。
- 将动态请求的读数据缓存(Cache)在 Web 端,过滤掉无效的数据读;
- 对读数据不做强一致性校验,减少因为一致性校验产生瓶颈的问题;
- 对写数据进行基于时间的合理分片,过滤掉过期的失效请求;
- 对写请求做限流保护,将超出系统承载能力的请求过滤掉;
- 对写数据进行强一致性校验,只保留最后有效的数据。
系统优化
配置线程数
很多多线程的场景都有一个默认配置,即 “线程数 = 2 * CPU 核数 + 1” 。除去这个配置,还有一个根据最佳实践得出来的公式:线程数 = [(线程等待时间 + 线程 CPU 时间) / 线程 CPU 时间] × CPU 数量。
最好的办法是通过性能测试来发现最佳的线程数。
发现 CPU 瓶颈
JProfiler 和 Yourkit 这两个工具。
减库存
目前来看,业务系统中最常见的就是预扣库存方案,像你在买机票、买电影票时,下单后一般都有个“有效付款时间”,超过这个时间订单自动释放,这都是典型的预扣库存方案。
由于参加秒杀的商品,一般都是“抢到就是赚到”,所以成功下单后却不付款的情况比较少,再加上卖家对秒杀商品的库存有严格限制,所以秒杀商品采用“下单减库存”更加合理。
“下单减库存”在数据一致性上,主要就是保证大并发请求时库存数据不能为负数,也就是要保证数据库中的库存字段值不能为负数,一般我们有多种解决方案:一种是在应用程序中通过事务来判断,即保证减后库存不能为负数,否则就回滚;另一种办法是直接设置数据库的字段数据为无符号整数,这样减后库存字段值小于零时会直接执行 SQL 语句来报错;
由于 MySQL 存储数据的特点,同一数据在数据库里肯定是一行存储(MySQL),因此会有大量线程来竞争 InnoDB 行锁,而并发度越高时等待线程会越多,TPS(Transaction Per Second,即每秒处理的消息数)会下降,响应时间(RT)会上升,数据库的吞吐量就会严重受影响。
单个热点商品会影响整个数据库的性能, 导致 0.01% 的商品影响 99.99% 的商品的售卖,这是我们不愿意看到的情况。一个解决思路是遵循前面介绍的原则进行隔离,把热点商品放到单独的热点库中。另外一个就是应用层做排队、数据库层做排队 (阿里的数据库团队开发了针对这种 MySQL 的 InnoDB
层上的补丁程序(patch),可以在数据库层上对单行记录做到并发排队)。
乐观锁扣减
public void testStock() {
// 判断重试次数是否满了
SELECT stock, version FROM item WEHRE item_id = 1;
if (商品存在) {
if (stock 可以扣减) {
UPDATE item SET version = version + 1, stock = stock - 1 WEHRE item_id = 1 AND version = version;
if (扣件库存失败) {
testStock(); // 重试
} else {
logger.info('success')
}
} else {
logger.war('指定商品已经售罄')
}
}
}
实际库存 >= 扣减库存
UPDATE ITEM SET stock = stock - 1 WHERE item_id = 1 AND stock >= 1;
上述两种方案,并发大的话扛不住,查询队列中等待拿锁的线程:
SELECT * FROM information_schema.INNODB_TRX WHERE trx_state = 'LOCK_WAIT';
Redis 扣减
并发较大,Redis 扣减库存一定会超卖,可以引入分布式锁来解决,分布式锁不应该成为系统瓶颈:
tryLock(long waitTime, long leaseTime, TimeUnit unit)
变化后的实时库存通过消息队列写入到数据库中。
兜底方案
降级
当秒杀流量达到 5w/s 时,把成交记录的获取从展示 20 条降级到只展示 5 条。“从 20 改到 5”这个操作由一个开关来实现,也就是设置一个能够从开关系统动态获取的系统参数。
限流
限流既可以是在客户端限流,也可以是在服务端限流。此外,限流的实现方式既要支持 URL 以及方法级别的限流,也要支持基于 QPS 和线程的限流。
在限流的实现手段上来讲,基于 QPS 和线程数的限流应用最多,最大 QPS 很容易通过压测提前获取,例如我们的系统最高支持 1w QPS 时,可以设置 8000 来进行限流保护。线程数限流在客户端比较有效,例如在远程调用时我们设置连接池的线程数,超出这个并发线程请求,就将线程进行排队或者直接超时丢弃。
拒绝服务
当系统负载达到一定阈值时,例如 CPU 使用率达到 90% 或者系统 load 值达到 2 * CPU
核数时,系统直接拒绝所有请求,这种方式是最暴力但也最有效的系统保护方式。
在最前端的
Nginx
上设置过载保护,当机器负载达到某个值时直接拒绝 HTTP 请求并返回 503 错误码,在 Java 层同样也可以设计过载保护。
参考
- 《人人都是架构师》