<code>update auction_auctions setquantity = #inQuantity#where auction_id = #itemId# and quantity = #dbQuantity#</code>秒杀器的应对
<code>package seckill;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();}}}</code>并发队列的选择
<code>package seckill;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>();}</code>用户请求模块
<code>package seckill;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.}}</code>数据库模块
<code>package seckill;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() {// TODOreturn 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();}}}</code>4.4 数据库设计
概念二“分片”
分片解决的是“数据量太大”的问题,也就是通常说的“水平切分”。一旦引入分片,势必有“数据路由”的概念,哪个数据访问哪个库。路由规则通常有3种方法:
范围:range
优点:简单,容易扩展
缺点:各库压力不均(新号段更活跃)
哈希:hash 【大部分互联网公司采用的方案二:哈希分库,哈希路由】
优点:简单,数据均衡,负载均匀
缺点:迁移麻烦(2库扩3库数据要迁移)
路由服务:router-config-server
优点:灵活性强,业务与路由算法解耦
缺点:每次访问数据库前多一次查询
概念三“分组”
分组解决“可用性”问题,分组通常通过主从复制的方式实现。
互联网公司数据库实际软件架构是:又分片,又分组(如下图)
4.4.2 设计思路
数据库软件架构师平时设计些什么东西呢?至少要考虑以下四点:
如何保证数据可用性;
如何提高数据库读性能(大部分应用读多写少,读会先成为瓶颈);
如何保证一致性;
如何提高扩展性;
1. 如何保证数据的可用性?
解决可用性问题的思路是=>冗余
如何保证站点的可用性?复制站点,冗余站点
如何保证服务的可用性?复制服务,冗余服务
如何保证数据的可用性?复制数据,冗余数据
数据的冗余,会带来一个副作用=>引发一致性问题(先不说一致性问题,先说可用性)。
2. 如何保证数据库“读”高可用?
冗余读库
冗余读库带来的副作用?读写有延时,可能不一致
上面这个图是很多互联网公司mysql的架构,写仍然是单点,不能保证写高可用。
3. 如何保证数据库“写”高可用?
冗余写库
采用双主互备的方式,可以冗余写库带来的副作用?双写同步,数据可能冲突(例如“自增id”同步冲突),如何解决同步冲突,有两种常见解决方案:
两个写库使用不同的初始值,相同的步长来增加id:1写库的id为0,2,4,6…;2写库的id为1,3,5,7…;
不使用数据的id,业务层自己生成唯一的id,保证数据不冲突;
实际中没有使用上述两种架构来做读写的“高可用”,采用的是“双主当主从用”的方式:
仍是双主,但只有一个主提供服务(读+写),另一个主是“shadow-master”,只用来保证高可用,平时不提供服务。 master挂了,shadow-master顶上(vip漂移,对业务层透明,不需要人工介入)。这种方式的好处:
读写没有延时;
读写高可用;
不足:
不能通过加从库的方式扩展读性能;
资源利用率为50%,一台冗余主没有提供服务;
那如何提高读性能呢?进入第二个话题,如何提供读性能。
4. 如何扩展读性能
提高读性能的方式大致有三种,第一种是建立索引。这种方式不展开,要提到的一点是,不同的库可以建立不同的索引。
写库不建立索引;
线上读库建立线上访问索引,例如uid;
线下读库建立线下访问索引,例如time;
第二种扩充读性能的方式是,增加从库,这种方法大家用的比较多,但是,存在两个缺点:
从库越多,同步越慢;
同步越慢,数据不一致窗口越大(不一致后面说,还是先说读性能的提高);
实际中没有采用这种方法提高数据库读性能(没有从库),采用的是增加缓存。常见的缓存架构如下:
上游是业务应用,下游是主库,从库(读写分离),缓存。
实际的玩法:服务+数据库+缓存一套
业务层不直接面向db和cache,服务层屏蔽了底层db、cache的复杂性。为什么要引入服务层,今天不展开,采用了“服务+数据库+缓存一套”的方式提供数据访问,用cache提高读性能。
不管采用主从的方式扩展读性能,还是缓存的方式扩展读性能,数据都要复制多份(主+从,db+cache),一定会引发一致性问题。
5. 如何保证一致性?
主从数据库的一致性,通常有两种解决方案:
1. 中间件
如果某一个key有写操作,在不一致时间窗口内,中间件会将这个key的读操作也路由到主库上。这个方案的缺点是,数据库中间件的门槛较高(百度,腾讯,阿里,360等一些公司有)。
2. 强制读主
上面实际用的“双主当主从用”的架构,不存在主从不一致的问题。
第二类不一致,是db与缓存间的不一致:
常见的缓存架构如上,此时写操作的顺序是:
(1)淘汰cache;
(2)写数据库;
读操作的顺序是:
(1)读cache,如果cache hit则返回;
(2)如果cache miss,则读从库;
(3)读从库后,将数据放回cache;
在一些异常时序情况下,有可能从【从库读到旧数据(同步还没有完成),旧数据入cache后】,数据会长期不一致。解决办法是“缓存双淘汰”,写操作时序升级为:
(1)淘汰cache;
(2)写数据库;
(3)在经验“主从同步延时窗口时间”后,再次发起一个异步淘汰cache的请求;
这样,即使有脏数据如cache,一个小的时间窗口之后,脏数据还是会被淘汰。带来的代价是,多引入一次读miss(成本可以忽略)。
除此之外,最佳实践之一是:建议为所有cache中的item设置一个超时时间。
6. 如何提高数据库的扩展性?
原来用hash的方式路由,分为2个库,数据量还是太大,要分为3个库,势必需要进行数据迁移,有一个很帅气的“数据库秒级扩容”方案。
如何秒级扩容?
首先,我们不做2库变3库的扩容,我们做2库变4库(库加倍)的扩容(未来4->8->16)
服务+数据库是一套(省去了缓存),数据库采用“双主”的模式。
扩容步骤:
第一步,将一个主库提升;
第二步,修改配置,2库变4库(原来MOD2,现在配置修改后MOD4),扩容完成;
原MOD2为偶的部分,现在会MOD4余0或者2;原MOD2为奇的部分,现在会MOD4余1或者3;数据不需要迁移,同时,双主互相同步,一遍是余0,一边余2,两边数据同步也不会冲突,秒级完成扩容!
最后,要做一些收尾工作:
将旧的双主同步解除;
增加新的双主(双主是保证可用性的,shadow-master平时不提供服务);
删除多余的数据(余0的主,可以将余2的数据删除掉);
这样,秒级别内,我们就完成了2库变4库的扩展。
5 大并发带来的挑战
5.1 请求接口的合理设计
一个秒杀或者抢购页面,通常分为2个部分,一个是静态的HTML等内容,另一个就是参与秒杀的Web后台请求接口。
通常静态HTML等内容,是通过CDN的部署,一般压力不大,核心瓶颈实际上在后台请求接口上。这个后端接口,必须能够支持高并发请求,同时,非常重要的一点,必须尽可能“快”,在最短的时间里返回用户的请求结果。为了实现尽可能快这一点,接口的后端存储使用内存级别的操作会更好一点。仍然直接面向MySQL之类的存储是不合适的,如果有这种复杂业务的需求,都建议采用异步写入。
当然,也有一些秒杀和抢购采用“滞后反馈”,就是说秒杀当下不知道结果,一段时间后才可以从页面中看到用户是否秒杀成功。但是,这种属于“偷懒”行为,同时给用户的体验也不好,容易被用户认为是“暗箱操作”。
5.2 高并发的挑战:一定要“快”
我们通常衡量一个Web系统的吞吐率的指标是QPS(Query Per Second,每秒处理请求数),解决每秒数万次的高并发场景,这个指标非常关键。举个例子,我们假设处理一个业务请求平均响应时间为100ms,同时,系统内有20台Apache的Web服务器,配置MaxClients为500个(表示Apache的最大连接数目)。
那么,我们的Web系统的理论峰值QPS为(理想化的计算方式):
复制代码 代码如下:
20*500/0.1 = 100000 (10万QPS)
咦?我们的系统似乎很强大,1秒钟可以处理完10万的请求,5w/s的秒杀似乎是“纸老虎”哈。实际情况,当然没有这么理想。在高并发的实际场景下,机器都处于高负载的状态,在这个时候平均响应时间会被大大增加。
就Web服务器而言,Apache打开了越多的连接进程,CPU需要处理的上下文切换也越多,额外增加了CPU的消耗,然后就直接导致平均响应时间增加。因此上述的MaxClient数目,要根据CPU、内存等硬件因素综合考虑,绝对不是越多越好。可以通过Apache自带的abench来测试一下,取一个合适的值。然后,我们选择内存操作级别的存储的Redis,在高并发的状态下,存储的响应时间至关重要。网络带宽虽然也是一个因素,不过,这种请求数据包一般比较小,一般很少成为请求的瓶颈。负载均衡成为系统瓶颈的情况比较少,在这里不做讨论哈。
那么问题来了,假设我们的系统,在5w/s的高并发状态下,平均响应时间从100ms变为250ms(实际情况,甚至更多):
复制代码 代码如下:
20*500/0.25 = 40000 (4万QPS)
于是,我们的系统剩下了4w的QPS,面对5w每秒的请求,中间相差了1w。
然后,这才是真正的恶梦开始。举个例子,高速路口,1秒钟来5部车,每秒通过5部车,高速路口运作正常。突然,这个路口1秒钟只能通过4部车,车流量仍然依旧,结果必定出现大塞车。(5条车道忽然变成4条车道的感觉)。
同理,某一个秒内,20*500个可用连接进程都在满负荷工作中,却仍然有1万个新来请求,没有连接进程可用,系统陷入到异常状态也是预期之内。
其实在正常的非高并发的业务场景中,也有类似的情况出现,某个业务请求接口出现问题,响应时间极慢,将整个Web请求响应时间拉得很长,逐渐将Web服务器的可用连接数占满,其他正常的业务请求,无连接进程可用。
更可怕的问题是,是用户的行为特点,系统越是不可用,用户的点击越频繁,恶性循环最终导致“雪崩”(其中一台Web机器挂了,导致流量分散到其他正常工作的机器上,再导致正常的机器也挂,然后恶性循环),将整个Web系统拖垮。
5.3 重启与过载保护
如果系统发生“雪崩”,贸然重启服务,是无法解决问题的。最常见的现象是,启动起来后,立刻挂掉。这个时候,最好在入口层将流量拒绝,然后再将重启。如果是redis/memcache这种服务也挂了,重启的时候需要注意“预热”,并且很可能需要比较长的时间。
秒杀和抢购的场景,流量往往是超乎我们系统的准备和想象的。这个时候,过载保护是必要的。如果检测到系统满负载状态,拒绝请求也是一种保护措施。在前端设置过滤是最简单的方式,但是,这种做法是被用户“千夫所指”的行为。更合适一点的是,将过载保护设置在CGI入口层,快速将客户的直接请求返回。
6 作弊的手段:进攻与防守
秒杀和抢购收到了“海量”的请求,实际上里面的水分是很大的。不少用户,为了“抢“到商品,会使用“刷票工具”等类型的辅助工具,帮助他们发送尽可能多的请求到服务器。还有一部分高级用户,制作强大的自动请求脚本。这种做法的理由也很简单,就是在参与秒杀和抢购的请求中,自己的请求数目占比越多,成功的概率越高。
这些都是属于“作弊的手段”,不过,有“进攻”就有“防守”,这是一场没有硝烟的战斗哈。
6.1 同一个账号,一次性发出多个请求
部分用户通过浏览器的插件或者其他工具,在秒杀开始的时间里,以自己的账号,一次发送上百甚至更多的请求。实际上,这样的用户破坏了秒杀和抢购的公平性。
这种请求在某些没有做数据安全处理的系统里,也可能造成另外一种破坏,导致某些判断条件被绕过。例如一个简单的领取逻辑,先判断用户是否有参与记录,如果没有则领取成功,最后写入到参与记录中。这是个非常简单的逻辑,但是,在高并发的场景下,存在深深的漏洞。多个并发请求通过负载均衡服务器,分配到内网的多台Web服务器,它们首先向存储发送查询请求,然后,在某个请求成功写入参与记录的时间差内,其他的请求获查询到的结果都是“没有参与记录”。这里,就存在逻辑判断被绕过的风险。
应对方案:
在程序入口处,一个账号只允许接受1个请求,其他请求过滤。不仅解决了同一个账号,发送N个请求的问题,还保证了后续的逻辑流程的安全。实现方案,可以通过Redis这种内存缓存服务,写入一个标志位(只允许1个请求写成功,结合watch的乐观锁的特性),成功写入的则可以继续参加。
或者,自己实现一个服务,将同一个账号的请求放入一个队列中,处理完一个,再处理下一个。
6.2 多个账号,一次性发送多个请求
很多公司的账号注册功能,在发展早期几乎是没有限制的,很容易就可以注册很多个账号。因此,也导致了出现了一些特殊的工作室,通过编写自动注册脚本,积累了一大批“僵尸账号”,数量庞大,几万甚至几十万的账号不等,专门做各种刷的行为(这就是微博中的“僵尸粉“的来源)。举个例子,例如微博中有转发抽奖的活动,如果我们使用几万个“僵尸号”去混进去转发,这样就可以大大提升我们中奖的概率。
这种账号,使用在秒杀和抢购里,也是同一个道理。例如,iPhone官网的抢购,火车票黄牛党。
应对方案:
这种场景,可以通过检测指定机器IP请求频率就可以解决,如果发现某个IP请求频率很高,可以给它弹出一个验证码或者直接禁止它的请求:
弹出验证码,最核心的追求,就是分辨出真实用户。因此,大家可能经常发现,网站弹出的验证码,有些是“鬼神乱舞”的样子,有时让我们根本无法看清。他们这样做的原因,其实也是为了让验证码的图片不被轻易识别,因为强大的“自动脚本”可以通过图片识别里面的字符,然后让脚本自动填写验证码。实际上,有一些非常创新的验证码,效果会比较好,例如给你一个简单问题让你回答,或者让你完成某些简单操作(例如百度贴吧的验证码)。
直接禁止IP,实际上是有些粗暴的,因为有些真实用户的网络场景恰好是同一出口IP的,可能会有“误伤“。但是这一个做法简单高效,根据实际场景使用可以获得很好的效果。
6.3 多个账号,不同IP发送不同请求
所谓道高一尺,魔高一丈。有进攻,就会有防守,永不休止。这些“工作室”,发现你对单机IP请求频率有控制之后,他们也针对这种场景,想出了他们的“新进攻方案”,就是不断改变IP。
有同学会好奇,这些随机IP服务怎么来的。有一些是某些机构自己占据一批独立IP,然后做成一个随机代理IP的服务,有偿提供给这些“工作室”使用。还有一些更为黑暗一点的,就是通过木马黑掉普通用户的电脑,这个木马也不破坏用户电脑的正常运作,只做一件事情,就是转发IP包,普通用户的电脑被变成了IP代理出口。通过这种做法,黑客就拿到了大量的独立IP,然后搭建为随机IP服务,就是为了挣钱。
应对方案:
说实话,这种场景下的请求,和真实用户的行为,已经基本相同了,想做分辨很困难。再做进一步的限制很容易“误伤“真实用户,这个时候,通常只能通过设置业务门槛高来限制这种请求了,或者通过账号行为的”数据挖掘“来提前清理掉它们。
僵尸账号也还是有一些共同特征的,例如账号很可能属于同一个号码段甚至是连号的,活跃度不高,等级低,资料不全等等。根据这些特点,适当设置参与门槛,例如限制参与秒杀的账号等级。通过这些业务手段,也是可以过滤掉一些僵尸号。
7 高并发下的数据安全
我们知道在多线程写入同一个文件的时候,会存现“线程安全”的问题(多个线程同时运行同一段代码,如果每次运行结果和单线程运行的结果是一样的,结果和预期相同,就是线程安全的)。如果是MySQL数据库,可以使用它自带的锁机制很好的解决问题,但是,在大规模并发的场景中,是不推荐使用MySQL的。秒杀和抢购的场景中,还有另外一个问题,就是“超发”,如果在这方面控制不慎,会产生发送过多的情况。我们也曾经听说过,某些电商搞抢购活动,买家成功拍下后,商家却不承认订单有效,拒绝发货。这里的问题,也许并不一定是商家奸诈,而是系统技术层面存在超发风险导致的。
7.1 超发的原因
假设某个抢购场景中,我们一共只有100个商品,在最后一刻,我们已经消耗了99个商品,仅剩最后一个。这个时候,系统发来多个并发请求,这批请求读取到的商品余量都是99个,然后都通过了这一个余量判断,最终导致超发。
在上面的这个图中,就导致了并发用户B也“抢购成功”,多让一个人获得了商品。这种场景,在高并发的情况下非常容易出现。
7.2 悲观锁思路
解决线程安全的思路很多,可以从“悲观锁”的方向开始讨论。
悲观锁,也就是在修改数据的时候,采用锁定状态,排斥外部请求的修改。遇到加锁的状态,就必须等待。
虽然上述的方案的确解决了线程安全的问题,但是,别忘记,我们的场景是“高并发”。也就是说,会很多这样的修改请求,每个请求都需要等待“锁”,某些线程可能永远都没有机会抢到这个“锁”,这种请求就会死在那里。同时,这种请求会很多,瞬间增大系统的平均响应时间,结果是可用连接数被耗尽,系统陷入异常。
7.3 FIFO队列思路
那好,那么我们稍微修改一下上面的场景,我们直接将请求放入队列中的,采用FIFO(First Input First Output,先进先出),这样的话,我们就不会导致某些请求永远获取不到锁。看到这里,是不是有点强行将多线程变成单线程的感觉哈。
然后,我们现在解决了锁的问题,全部请求采用“先进先出”的队列方式来处理。那么新的问题来了,高并发的场景下,因为请求很多,很可能一瞬间将队列内存“撑爆”,然后系统又陷入到了异常状态。或者设计一个极大的内存队列,也是一种方案,但是,系统处理完一个队列内请求的速度根本无法和疯狂涌入队列中的数目相比。也就是说,队列内的请求会越积累越多,最终Web系统平均响应时候还是会大幅下降,系统还是陷入异常。
7.4 乐观锁思路
这个时候,我们就可以讨论一下“乐观锁”的思路了。乐观锁,是相对于“悲观锁”采用更为宽松的加锁机制,大都是采用带版本号(Version)更新。实现就是,这个数据所有请求都有资格去修改,但会获得一个该数据的版本号,只有版本号符合的才能更新成功,其他的返回抢购失败。这样的话,我们就不需要考虑队列的问题,不过,它会增大CPU的计算开销。但是,综合来说,这是一个比较好的解决方案。
有很多软件和服务都“乐观锁”功能的支持,例如Redis中的watch就是其中之一。通过这个实现,我们保证了数据的安全。
8 总结
互联网正在高速发展,使用互联网服务的用户越多,高并发的场景也变得越来越多。电商秒杀和抢购,是两个比较典型的互联网高并发场景。虽然我们解决问题的具体技术方案可能千差万别,但是遇到的挑战却是相似的,因此解决问题的思路也异曲同工。