其他
万字好文,电商秒杀系统架构分析与实战
上一篇:API 接口应该如何设计?如何保证安全?如何签名?如何防重?
正常电子商务流程
(1)查询商品;
(2)创建订单;
(3)扣减库存;
(4)更新订单;
(5)付款;
(6)卖家发货;
秒杀业务的特性
(1)低廉价格;
(2)大幅推广;
(3)瞬时售空;
(4)一般是定时上架;
(5)时间短、瞬时并发量高;
2 秒杀技术挑战
对现有网站业务造成冲击
解决方案 :将秒杀系统独立部署,甚至 使用独立域名,使其与网站完全隔离 。
高并发下的应用、数据库负载
解决方案 :重新设计秒杀商品页面,不使用网站原来的商品详细页面, 页面内容静态化,用户请求不需要经过应用服务 。
突然增加的网络及服务器带宽
解决方案 :因为秒杀新增的网络带宽,必须和运营商重新购买或者租借。为了减轻网站服务器的压力,
需要将秒杀商品页面缓存在CDN,同样需要和CDN服务商临时租借新增的出口带宽 。
直接下单
解决方案 :为了避免用户直接访问下单页面URL,需要将改URL动态化,即使秒杀系统的开发者也无法在秒杀开始前访问下单页面的URL。办法是在
下单页面URL加入由服务器端生成的随机数作为参数,在秒杀开始的时候才能得到 。
如何控制秒杀商品页面购买按钮的点亮
解决方案 :使用JavaScript脚本控制,
在秒杀商品静态页面中加入一个JavaScript文件引用,该JavaScript文件中包含秒杀开始标志为否
;当秒杀开始的时候生成一个新的JavaScript文件( 文件名保持不变,只是内容不一样 ),更新秒杀开始标志为是,
加入下单页面的URL及随机数参数(这个随机数只会产生一个,即所有人看到的URL都是同一个,服务器端可以用redis这种分布式缓存服务器来保存随机数)
,并被用户浏览器加载,控制秒杀商品页面的展示。
这个JavaScript文件的加载可以加上随机版本号(例如xx.js?v=32353823),这样就不会被浏览器、CDN和反向代理服务器缓存 。这个JavaScript文件非常小,即使每次浏览器刷新都访问JavaScript文件服务器也不会对服务器集群和网络带宽造成太大压力。
如何只允许第一个提交的订单被发送到订单子系统
JavaScript文件,更新秒杀开始标志为否,购买按钮变灰。事实上,由于最终能够成功提交订单的用户只有一个,为了减轻下单页面服务器的负载压力,可以
控制进入下单页面的入口,只有少数用户能进入下单页面,其他用户直接进入秒杀结束页面 。
解决方案
:假设下单服务器集群有10台服务器,每台服务器只接受最多10个下单请求。在还没有人提交订单成功之前,如果一台服务器已经有十单了,而有的一单都没处理,可能出现的用户体验不佳的场景是用户第一次点击购买按钮进入已结束页面,再刷新一下页面,有可能被一单都没有处理的服务器处理,进入了填写订单的页面,
可以考虑通过cookie的方式来应对,符合一致性原则 。当然可以 采用最少连接的负载均衡算法 ,出现上述情况的概率大大降低。
如何进行下单前置检查
下单服务器检查本机已处理的下单请求数目:
如果超过10条,直接返回已结束页面给用户; 如果未超过10条,则用户可进入填写订单及确认页面;
检查全局已提交订单数目:
已超过秒杀商品总数,返回已结束页面给用户; 未超过秒杀商品总数,提交到子订单系统;
秒杀一般是定时上架
有人可以绕过前端的限制,直接通过URL的方式发起购买
,这就需要在前台商品页面,以及bug页面到后端的数据库,都要进行时钟同步。越在后端控制,安全性越高。
减库存的操作
库存会带来“超卖”的问题:售出数量多于库存数量
quantity = #inQuantity#
where auction_id = #itemId# and quantity = #dbQuantity#
quantity = quantity-#count#
where auction_id = #itemId# and quantity >= #count#
秒杀器的应对
秒杀专用验证码,电视公布验证码,秒杀答题 。
3 秒杀架构原则
尽量将请求拦截在系统上游
读多写少的常用多使用缓存
的应用场景【一趟火车其实只有2000张票,200w个人来买,最多2000个人下单成功,其他人都是查询库存,写比例只有0.1%,读比例占99.9%】,
非常适合使用缓存 。
4 秒杀架构设计
,而不是商品详情等用户体验细节,因此秒杀系统的页面设计应尽可能简单。
第一个阶段是秒杀开始前某个时间到秒杀开始, 这个阶段可以称之为 准备阶段,用户在准备阶段等待秒杀 ; 第二个阶段就是秒杀开始到所有参与秒杀的用户获得秒杀结果, 这个就称为 秒杀阶段 吧。
4.1 前端层设计
。这里需要考虑两个问题:
第一个是秒杀页面的展示
js,图片等资源,如果同时有几千万人参与一个商品的抢购,一般机房带宽也就只有1G
10G,网络带宽就极有可能成为瓶颈,所以这个页面上各类静态资源首先应分开存放,然后放到cdn节点上分散压力,由于CDN节点遍布全国各地,能缓冲掉绝大部分的压力,而且还比机房带宽便宜
第二个是倒计时
客户端与服务器时钟不一致可以采用客户端定时和服务器同步时间
,这里考虑一下性能问题,用于同步时间的接口由于不涉及到后端逻辑,只需要将当前web服务器的时间发送给客户端就可以了,因此速度很快,就我以前测试的结果来看,一台标准的web服务器2W+QPS不会有问题,如果100W人同时刷,100W
QPS也只需要50台web,一台硬件LB就可以了~,并且web服务器群是可以很容易的横向扩展的(LB+DNS轮询),这个接口可以只返回一小段json格式的数据,而且可以优化一下减少不必要cookie和其他http头的信息,所以数据量不会很大,
一般来说网络不会成为瓶颈,即使成为瓶颈也可以考虑多机房专线连通,加智能DNS的解决方案 ;web服务器之间时间不同步可以采用统一时间服务器的方式,
比如每隔1分钟所有参与秒杀活动的web服务器就与时间服务器做一次时间同步 。
浏览器层请求拦截
(1) 产品层面 ,用户点击“查询”或者“购票”后,按钮置灰,禁止用户重复提交请求; (2) JS层面 ,限制用户在x秒之内只能提交一次请求;
4.2 站点层设计
(1) 同一个uid,限制访问频度 ,做页面缓存,x秒内到达站点层的请求,均返回同一页面 (2) 同一个item的查询,例如手机车次 ,做页面缓存,x秒内到达站点层的请求,均返回同一页面
4.3 服务层设计
(1)大哥,我是服务层,我清楚的知道小米只有1万部手机,我清楚的知道一列火车只有2000张车票,我透10w个请求去数据库有什么意义呢?
对于写请求,做请求队列,每次只透过有限的写请求去数据层,如果均成功再放下一批,如果库存不够则队列里的写请求全部返回“已售完” ;(2) 对于读请求,还用说么?cache来抗 ,不管是memcached还是redis,单机抗个每秒10w应该都是没什么问题的;
用户请求分发模块 :使用Nginx或Apache将用户的请求分发到不同的机器上。 用户请求预处理模块 :判断商品是不是还有剩余来决定是不是要处理该请求。 用户请求处理模块 :把通过预处理的请求封装成事务提交给数据库,并返回是否成功。 数据库接口模块 :该模块是数据库的唯一接口,负责与数据库交互,提供RPC接口供查询是否秒杀结束、剩余数量等信息。
用户请求预处理模块
import org.apache.http.HttpRequest;
/**
* 预处理阶段,把不必要的请求直接驳回,必要的请求添加到队列中进入下一阶段.
*/
public class PreProcessor {
// 商品是否还有剩余
private static boolean reminds = true;
private static void forbidden() {
// Do something.
}
public static boolean checkReminds() {
if (reminds) {
// 远程检测是否还有剩余,该RPC接口应由数据库服务器提供,不必完全严格检查.
if (!RPC.checkReminds()) {
reminds = false;
}
}
return reminds;
}
/**
* 每一个HTTP请求都要经过该预处理.
*/
public static void preProcess(HttpRequest request) {
if (checkReminds()) {
// 一个并发的队列
RequestQueue.queue.add(request);
} else {
// 如果已经没有商品了,则直接驳回请求即可.
forbidden();
}
}
}
并发队列的选择
ConcurrentLinkedQueue、LinkedBlockingQueue和ArrayBlockingQueue 。
ArrayBlockingQueue是 初始容量固定的阻塞队列
,我们可以用来作为数据库模块成功竞拍的队列,比如有10个商品,那么我们就设定一个10大小的数组队列。ConcurrentLinkedQueue使用的是 CAS原语无锁队列实现,是一个异步队列 ,入队的速度很快,出队进行了加锁,性能稍慢。 LinkedBlockingQueue也是 阻塞的队列,入队和出队都用了加锁 ,当队空的时候线程会暂时阻塞。
,一般不会出现队空的情况,所以我们可以选择ConcurrentLinkedQueue来作为我们的请求队列实现:
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ConcurrentLinkedQueue;
import org.apache.http.HttpRequest;
public class RequestQueue {
public static ConcurrentLinkedQueue<HttpRequest> queue = new ConcurrentLinkedQueue<HttpRequest>();
}
用户请求模块
import org.apache.http.HttpRequest;
public class Processor {
/**
* 发送秒杀事务到数据库队列.
*/
public static void kill(BidInfo info) {
DB.bids.add(info);
}
public static void process() {
BidInfo info = new BidInfo(RequestQueue.queue.poll());
if (info != null) {
kill(info);
}
}
}
class BidInfo {
BidInfo(HttpRequest request) {
// Do something.
}
}
数据库模块
数据库主要是使用一个ArrayBlockingQueue来暂存有可能成功的用户请求。
import java.util.concurrent.ArrayBlockingQueue;
/**
* DB应该是数据库的唯一接口.
*/
public class DB {
public static int count = 10;
public static ArrayBlockingQueue<BidInfo> bids = new ArrayBlockingQueue<BidInfo>(10);
public static boolean checkReminds() {
// TODO
return true;
}
// 单线程操作
public static void bid() {
BidInfo info = bids.poll();
while (count-- > 0) {
// insert into table Bids values(item_id, user_id, bid_date, other)
// select count(id) from Bids where item_id = ?
// 如果数据库商品数量大约总数,则标志秒杀已完成,设置标志位reminds = false.
info = bids.poll();
}
}
}
4.4 数据库设计
4.4.1 基本概念
范围:range
优点:简单,容易扩展 缺点:各库压力不均(新号段更活跃)
哈希:hash 【大部分互联网公司采用的方案二:哈希分库,哈希路由】
优点:简单,数据均衡,负载均匀 缺点:迁移麻烦(2库扩3库数据要迁移)
路由服务:router-config-server
优点:灵活性强,业务与路由算法解耦 缺点:每次访问数据库前多一次查询
4.4.2 设计思路
如何保证数据可用性; 如何提高数据库读性能(大部分应用读多写少,读会先成为瓶颈); 如何保证一致性; 如何提高扩展性;
如何保证数据的可用性?
如何保证站点的可用性?复制站点,冗余站点 如何保证服务的可用性?复制服务,冗余服务 如何保证数据的可用性?复制数据,冗余数据
如何保证数据库“读”高可用?
如何保证数据库“写”高可用?
两个写库使用不同的初始值,相同的步长来增加id:1写库的id为0,2,4,6…;2写库的id为1,3,5,7…; 不使用数据的id,业务层自己生成唯一的id,保证数据不冲突;
读写没有延时; 读写高可用;
不能通过加从库的方式扩展读性能; 资源利用率为50%,一台冗余主没有提供服务;
如何扩展读性能
写库 不建立索引; 线上读库 建立线上访问索引,例如uid; 线下读库 建立线下访问索引,例如time;
从库越多,同步越慢; 同步越慢,数据不一致窗口越大(不一致后面说,还是先说读性能的提高);
。为什么要引入服务层,今天不展开,采用了“服务+数据库+缓存一套”的方式提供数据访问, 用cache提高读性能 。
不管采用主从的方式扩展读性能,还是缓存的方式扩展读性能,数据都要复制多份(主+从,db+cache),一定会引发一致性问题。
如何保证一致性?
中间件
(百度,腾讯,阿里,360等一些公司有)。
强制读主
(1)淘汰cache; (2)写数据库;
(1)读cache,如果cache hit则返回; (2)如果cache miss,则读从库; (3)读从库后,将数据放回cache;
解决办法是“缓存双淘汰”
,写操作时序升级为:
(1)淘汰cache; (2)写数据库; (3)在经过“主从同步延时窗口时间”后,再次发起一个异步淘汰cache的请求;
如何提高数据库的扩展性?
第一步 ,将一个主库提升; 第二步 ,修改配置,2库变4库(原来MOD2,现在配置修改后MOD4),扩容完成;
;数据不需要迁移,同时,双主互相同步,一遍是余0,一边余2,两边数据同步也不会冲突,秒级完成扩容!
将旧的双主同步解除; 增加新的双主(双主是保证可用性的,shadow-master平时不提供服务); 删除多余的数据(余0的主,可以将余2的数据删除掉);
5 大并发带来的挑战
5.1 请求接口的合理设计
。这个后端接口,必须能够支持高并发请求,同时,非常重要的一点,必须尽可能“快”,在最短的时间里返回用户的请求结果。
为了实现尽可能快这一点,接口的后端存储使用内存级别的操作会更好一点 。仍然直接面向MySQL之类的存储是不合适的,
如果有这种复杂业务的需求,都建议采用异步写入 。
,就是说秒杀当下不知道结果,一段时间后才可以从页面中看到用户是否秒杀成功。但是,这种属于“偷懒”行为,同时给用户的体验也不好,容易被用户认为是“暗箱操作”。
5.2 高并发的挑战:一定要“快”
。举个例子,我们假设处理一个业务请求平均响应时间为100ms,同时,系统内有20台Apache的Web服务器,配置MaxClients为500个(表示Apache的最大连接数目)。
在高并发的实际场景下,机器都处于高负载的状态,在这个时候平均响应时间会被大大增加 。
。因此上述的 MaxClient数目,要根据CPU、内存等硬件因素综合考虑,绝对不是越多越好 。可以
通过Apache自带的abench来测试一下,取一个合适的值 。然后,我们
选择内存操作级别的存储的Redis,在高并发的状态下,存储的响应时间至关重要
。网络带宽虽然也是一个因素,不过,这种请求数据包一般比较小,一般很少成为请求的瓶颈。负载均衡成为系统瓶颈的情况比较少,在这里不做讨论哈。
恶性循环最终导致“雪崩”(其中一台Web机器挂了,导致流量分散到其他正常工作的机器上,再导致正常的机器也挂,然后恶性循环) ,将整个Web系统拖垮。
5.3 重启与过载保护
。如果是redis/memcache这种服务也挂了,重启的时候需要注意“预热”,并且很可能需要比较长的时间。
。在前端设置过滤是最简单的方式,但是,这种做法是被用户“千夫所指”的行为。更合适一点的是, 将过载保护设置在CGI入口层,快速将客户的直接请求返回
。
6 作弊的手段:进攻与防守
这种做法的理由也很简单,就是在参与秒杀和抢购的请求中,自己的请求数目占比越多,成功的概率越高 。
6.1 同一个账号,一次性发出多个请求
多个并发请求通过负载均衡服务器,分配到内网的多台Web服务器,它们首先向存储发送查询请求,然后,在某个请求成功写入参与记录的时间差内,其他的请求获查询到的结果都是“没有参与记录”
。这里,就存在逻辑判断被绕过的风险。
实现方案,可以通过Redis这种内存缓存服务,写入一个标志位(只允许1个请求写成功,结合watch的乐观锁的特性),成功写入的则可以继续参加 。
6.2 多个账号,一次性发送多个请求
也导致了出现了一些特殊的工作室,通过编写自动注册脚本,积累了一大批“僵尸账号”,数量庞大,几万甚至几十万的账号不等,专门做各种刷的行为(这就是微博中的“僵尸粉“的来源)
。举个例子,例如微博中有转发抽奖的活动,如果我们使用几万个“僵尸号”去混进去转发,这样就可以大大提升我们中奖的概率。
弹出验证码,最核心的追求,就是分辨出真实用户。
因此,大家可能经常发现,网站弹出的验证码,有些是“鬼神乱舞”的样子,有时让我们根本无法看清。他们这样做的原因,其实也是为了让验证码的图片不被轻易识别,因为强大的“自动脚本”可以通过图片识别里面的字符,然后让脚本自动填写验证码。实际上,有一些非常创新的验证码,效果会比较好,例如给你一个简单问题让你回答,或者让你完成某些简单操作(例如百度贴吧的验证码)。直接禁止IP,实际上是有些粗暴的,因为有些真实用户的网络场景恰好是同一出口IP的,可能会有“误伤“
。但是这一个做法简单高效,根据实际场景使用可以获得很好的效果。
6.3 多个账号,不同IP发送不同请求
这些“工作室”,发现你对单机IP请求频率有控制之后,他们也针对这种场景,想出了他们的“新进攻方案”,就是不断改变IP 。
。还有一些更为黑暗一点的,就是
通过木马黑掉普通用户的电脑,这个木马也不破坏用户电脑的正常运作,只做一件事情,就是转发IP包,普通用户的电脑被变成了IP代理出口
。通过这种做法,黑客就拿到了大量的独立IP,然后搭建为随机IP服务,就是为了挣钱。
通常只能通过设置业务门槛高来限制这种请求了,或者通过账号行为的”数据挖掘“来提前清理掉它们 。
。根据这些特点,适当设置参与门槛,例如限制参与秒杀的账号等级。通过这些业务手段,也是可以过滤掉一些僵尸号 。
7 高并发下的数据安全
(多个线程同时运行同一段代码,如果每次运行结果和单线程运行的结果是一样的,结果和预期相同,就是线程安全的)。
如果是MySQL数据库,可以使用它自带的锁机制很好的解决问题,但是,在大规模并发的场景中,是不推荐使用MySQL的 。
秒杀和抢购的场景中,还有另外一个问题,就是“超发”,如果在这方面控制不慎,会产生发送过多的情况
。我们也曾经听说过,某些电商搞抢购活动,买家成功拍下后,商家却不承认订单有效,拒绝发货。这里的问题,也许并不一定是商家奸诈,而是系统技术层面存在超发风险导致的。
7.1 超发的原因
7.2 悲观锁思路
悲观锁,也就是在修改数据的时候,采用锁定状态,排斥外部请求的修改。遇到加锁的状态,就必须等待。
我们的场景是“高并发”。也就是说,会很多这样的修改请求,每个请求都需要等待“锁”,某些线程可能永远都没有机会抢到这个“锁”,这种请求就会死在那里
。同时,这种请求会很多, 瞬间增大系统的平均响应时间,结果是可用连接数被耗尽,系统陷入异常 。
7.3 FIFO队列思路
高并发的场景下,因为请求很多,很可能一瞬间将队列内存“撑爆”,然后系统又陷入到了异常状态
。或者设计一个极大的内存队列,也是一种方案,但是,系统处理完一个队列内请求的速度根本无法和疯狂涌入队列中的数目相比。也就是说,队列内的请求会越积累越多,最终Web系统平均响应时候还是会大幅下降,系统还是陷入异常。
7.4 乐观锁思路
实现就是,这个数据所有请求都有资格去修改,但会获得一个该数据的版本号,只有版本号符合的才能更新成功,其他的返回抢购失败。这样的话,我们就不需要考虑队列的问题,不过,
它会增大CPU的计算开销 。但是,综合来说,这是一个比较好的解决方案。
8 总结
「顶级架构师」建立了读者架构师交流群,大家可以添加小编微信进行加群。欢迎有想法、乐于分享的朋友们一起交流学习。
扫描添加好友邀你进架构师群,加我时注明【姓名+公司+职位】
版权申明:内容来源网络,版权归原作者所有。如有侵权烦请告知,我们会立即删除并表示歉意。谢谢。
猜你还想看
还在直接用JWT做鉴权?JJWT真香
限流 & 熔断的考量
阿里技术专家:一文教你高效画出技术架构图
和阿里大佬吃了个饭,顺便聊了下阿里内部常用的15款开发者工具