文章目录
- 秒杀系统
- 业务特点 & 技术挑战
- 架构原则 & 设计
- 前端设计
- 网关设计
- 服务端设计
- 数据库层设计
- 服务重启与服务降级
- 其他优化点
- 案例:利用消息中间件和缓存实现简单的秒杀系统
本文旨在系统性的梳理两个经典系统的实现。
秒杀系统
业务特点 & 技术挑战
正常电子商务流程:
(1)查询商品;(2)创建订单;(3)扣减库存;(4)更新订单;(5)付款;(6)卖家发货
秒杀业务流程:
(1)低廉价格;(2)大幅推广;(3)瞬时售空;(4)一般是定时上架;(5)时间短、瞬时并发量高;
结合业务特点,总结技术挑战:
- 对现有网站业务造成冲击
秒杀活动只是网站营销的一个附加活动,这个活动具有时间短,并发访问量大的特点,如果和网站原有应用部署在一起,必然会对现有业务造成冲击,稍有不慎可能导致整个网站瘫痪。 - 高并发下的应用、数据库负载
用户在秒杀开始前,通过不停刷新浏览器页面以保证不会错过秒杀,这些请求如果按照一般的网站应用架构,访问应用服务器、连接数据库,会对应用服务器和数据库服务器造成负载压力。 - 突然增加的网络及服务器带宽
假设商品页面大小200K(主要是商品图片大小),那么需要的网络和服务器带宽是2G(200K×10000),这些网络带宽是因为秒杀活动新增的,超过网站平时使用的带宽。 - 直接下单
秒杀的游戏规则是到了秒杀才能开始对商品下单购买,在此时间点之前,只能浏览商品信息,不能下单。而下单页面也是一个普通的URL,如果得到这个URL,不用等到秒杀开始就可以下单了。 - 如何控制秒杀商品页面购买按钮的点亮
购买按钮只有在秒杀开始的时候才能点亮,在此之前是灰色的。如果该页面是动态生成的,当然可以在服务器端构造响应页面输出,控制该按钮是灰色还是点亮,但是为了减轻服务器端负载压力,更好地利用CDN、反向代理等性能优化手段,该页面被设计为静态页面,缓存在CDN、反向代理服务器上,甚至用户浏览器上。秒杀开始时,用户刷新页面,请求根本不会到达应用服务器。 - 秒杀人数限制,超卖问题
库存会带来“超卖”的问题:售出数量多于库存数量。
架构原则 & 设计
原则:
- 尽量将请求拦截在系统上游。
防止请求都压倒了后端数据层,数据读写锁冲突严重,并发高响应慢,几乎所有请求都超时,流量虽大,下单成功的有效流量甚小。 - 读多写少的常用多使用缓存
这是一个典型的读多写少的应用场景(比如200w个人来买,最多2000个人下单成功,其他人都是查询库存,写比例只有0.1%,读比例占99.9%),非常适合使用缓存。
前端设计
首先要有一个展示秒杀商品的页面, 在这个页面上做一个秒杀活动开始的倒计时, 在准备阶段内用户会陆续打开这个秒杀的页面, 并且可能不停的刷新页面。
这里需要考虑两个问题:
- 秒杀页面的展示
- CDN
我们知道一个html页面还是比较大的,即使做了压缩,http头和内容的大小也可能高达数十K,加上其他的css, js,图片等资源,如果同时有几千万人参与一个商品的抢购,一般机房带宽也就只有1G~10G,网络带宽就极有可能成为瓶颈,所以这个页面上各类静态资源首先应分开存放,然后放到cdn节点上分散压力,由于CDN节点遍布全国各地,能缓冲掉绝大部分的压力,而且还比机房带宽便宜。 - 动静分离
页面彻底动静分离,使得用户秒杀时不需要刷新整个页面,降低刷新请求数(区分了动静数据,就可以将静态数据缓存,提高效率。)。
- 倒计时
出于性能原因这个一般由js调用客户端本地时间,就有可能出现客户端时钟与服务器时钟不一致,另外服务器之间也是有可能出现时钟不一致。
web服务器之间时间不同步可以采用统一时间服务器的方式,比如每隔1分钟所有参与秒杀活动的web服务器就与时间服务器做一次时间同步。
就我以前测试的结果来看,一台标准的web服务器2W+QPS不会有问题,如果100W人同时刷,100W QPS也只需要50台web,一台硬件LB就可以了。
- 浏览器层请求拦截
- 产品层面,用户点击“查询”或者“购买”后,按钮置灰或者做页面跳转,禁止用户重复提交请求;
- JS层面,限制用户在x秒之内只能提交一次请求。
- 浏览器缓存
将动态请求的读数据缓存在浏览器本地,过滤无效数据读。
网关设计
前端层的请求拦截,只能拦住小白用户(99%的用户),脚本刷单根本不吃这一套,写个for循环,直接调用你后端的http请求,怎么整?
- 同一个uid,限制访问频度,做页面缓存,x秒内到达站点层的请求,均返回同一页面
- 同一个item的查询,做页面缓存,x秒内到达站点层的请求,均返回同一页面
秒杀场景下可以限制uid。
服务端设计
假设网关层设计好了,如果有高级黑客控制了几万台肉鸡,网关层限制uid的策略解决不了了。
或者 当秒杀的用户量很大时,即使每个用户只有一个请求,到服务层的请求数量还是很大。
比如我们有100W用户同时抢100台手机,服务层并发请求压力至少为100W。这时候,笔者有三种可能的办法:
- 采用消息队列缓存请求
既然服务层知道库存只有100台手机,那完全没有必要把100W个请求都传递到数据库啊,那么可以先把这些请求都写到消息队列缓存一下,数据库层订阅消息减库存,减库存成功的请求返回秒杀成功,失败的返回秒杀结束。 - 利用缓存应对读请求:对类似于12306等购票业务,是典型的读多写少业务,大部分请求是查询请求,所以可以利用缓存分担数据库压力。
- 利用缓存应对写请求:缓存也是可以应对写请求的,比如我们就可以把数据库中的库存数据转移到Redis缓存中,所有减库存操作都在Redis中进行,然后再通过后台进程把Redis中的用户秒杀请求同步到数据库中。
数据库层设计
数据库层是最脆弱的一层,一般在应用设计时在上游就需要把请求拦截掉,数据库层只承担“能力范围内”的访问请求。所以,上面通过在服务层引入MQ队列和缓存,让最底层的数据库高枕无忧。
数据库可以实现分片和主从,将压力分散到各个节点上。
- 分片
范围:range
优点:简单,容易扩展
缺点:各库压力不均(新号段更活跃)
哈希:hash 【大部分互联网公司采用的方案二:哈希分库,哈希路由】
优点:简单,数据均衡,负载均匀
缺点:迁移麻烦(2库扩3库数据要迁移)
路由服务:router-config-server
优点:灵活性强,业务与路由算法解耦
缺点:每次访问数据库前多一次查询
- 主从一致性
中间件: 如果某一个key有写操作,在不一致时间窗口内,中间件会将这个key的读操作也路由到主库上。这个方案的缺点是,数据库中间件的门槛较高(百度,腾讯,阿里,360等一些公司有)。
缓存+缓存双淘汰:
写操作时序升级为:
(1)淘汰cache;
(2)写数据库;
(3)在经验“主从同步延时窗口时间”后,再次发起一个异步淘汰cache的请求;
这样,即使有脏数据如cache,一个小的时间窗口之后,脏数据还是会被淘汰。带来的代价是,多引入一次读miss(成本可以忽略)。
服务重启与服务降级
如果系统发生“雪崩”,贸然重启服务,是无法解决问题的。最常见的现象是,启动起来后,立刻挂掉。这个时候,最好在入口层将流量拒绝,然后再将重启。如果是redis/memcache这种服务也挂了,重启的时候需要注意“预热”,并且很可能需要比较长的时间。
秒杀和抢购的场景,流量往往是超乎我们系统的准备和想象的。这个时候,过载保护是必要的。如果检测到系统满负载状态,拒绝请求也是一种保护措施。在前端设置过滤是最简单的方式,但是,这种做法是被用户“千夫所指”的行为。更合适一点的是,将过载保护设置在CGI入口层,快速将客户的直接请求返回。
其他优化点
1.发现热点数据:
- 发现静态热点数据:强制让卖家通过报名方式提前把热点数据筛选出来缓存,但是增加了卖家的工作量,也不够实时。也可以根据每日访问数进行统计,然后缓存TOP N的商品。
- 发现动态热点数据:抽离出一个中间件用于收集搜索、商品详情、购物车等关键热点业务的点击数据,然后异步记录到日志,然后根据规则判断是否热点数据后缓存在队列中(因为热点数据一般是临时的,所以可采用LRU算法淘汰)。
2.CPU及线程数量:
不是线程数越多,QPS越高,因为线程上下文切换有消耗,所以需要合理的设置线程数。
一般的计算公式为:线程数 = [(线程等待时间 + 线程CPU时间) / 线程CPU时间] * CPU数量
,当然最好的方式是性能测试来确认。
秒杀系统的大部分瓶颈在CPU,但不一定是CPU,有可能是其他部分,比如QPS达到极限时,CPU使用率是否超过95%,如果不是则可能是锁限制或过多本地I/O等待发生。
3.系统优化:
- 减少编码:java编码速度慢,涉及字符串操作(输入输出操作、I/O操作)比较消耗CPU资源。原因是磁盘、网络IO都需要将字符串转为字节,这个转换必须查表编码。可通过(OutputStream()直接进行流输出),可提高30%.
- 减少序列化:序列化与编码同时发生,所以需要减少。尽量减少RPC,将关联性强的应用服务合并。
- Java极致优化:对大流量Web系统做静态化改造;直接使用Servlet,绕过框架多余处理逻辑;直接输出流数据。
- 并发读优化:秒杀系统单机缓存。不要求读一致性,但是写数据的时候要求强一致性。
4.秒杀系统独立部署:
将秒杀系统服务做独立部署,甚至使用独立域名,使其与网站完全隔离。
案例:利用消息中间件和缓存实现简单的秒杀系统
Redis是一个分布式缓存系统,支持多种数据结构,我们可以利用Redis轻松实现一个强大的秒杀系统。
我们可以采用Redis 最简单的key-value数据结构,用一个原子类型的变量值(AtomicInteger)作为key,把用户id作为value,库存数量便是原子变量的最大值。对于每个用户的秒杀,我们使用 RPUSH key value
插入秒杀请求, 当插入的秒杀请求数达到上限时,停止所有后续插入。
然后我们可以在台启动多个工作线程,使用LPOP key
读取秒杀成功者的用户id,然后再操作数据库做最终的下订单减库存操作。也可以将缓存和消息中间件 组合起来,缓存系统负责接收记录用户请求,消息中间件负责将缓存中的请求同步到数据库。