一、前言
秒杀系统其实是一个比较复杂的设计,文章先介绍设计秒杀系统的思路脉络和设计系统的原则。后面章节再详细介绍使用中的工具、中间件、设计方案。由于本人并没有真正实践过秒杀系统落地,了解过和实践过完全是两个概念,实践才能更好的了解某个知识,所以收集过来的观点很有可能不太正确,还请见谅,整理这篇文章的目的还是出于自身学习,也由于没有实战经验导致我挺难通过一个案例把这些知识点统一整合,知识看上去是碎片化的,但是多少能起到帮助作用。
二、秒杀系统需要解决的常见问题
1)瞬时:一提到秒杀系统给人最深刻的印象是超大的瞬时并发,如果系统没有经过限流或者熔断处理,那么系统瞬间就会崩掉,就好像被DDos攻击一样。
2)超卖:秒杀除了大并发这样的难点,还有一个所有电商都会遇到的痛,那就是超卖,电商搞大促最怕什么?最怕的就是超卖,产生超卖了以后会影响到用户体验,会导致订单系统、库存系统、供应链等等,产生的问题是一系列的连锁反应,所以电商都不希望超卖发生,但是在大并发的场景最容易发生的就是超卖,不同线程读取到的当前库存数据可能下个毫秒就被其他线程修改了,如果没有一定的锁库存机制那么库存数据必然出错,都不用上万并发,几十并发就可以导致商品超卖。
3)性能:当遇到大并发和超卖问题后,必然会引出另一个问题,那就是性能问题,如何保证在大并发请求下,系统能够有好的性能,让用户能够有更好的体验,不然每个用户都等几十秒才能知道结果,那体验必然是很糟糕的。
三、设计系统的整体思路
从整体上看设计秒杀系统需要解决的两个核心问题,一个是并发读,一个是并发写,优化时也可以基于这两个核心问题进行优化。
下面是系统设计时的几个战术要点:
并发读的核心优化理念是尽量减少用户到服务端来“读”数据,或者让他们读更少的数据
并发写的处理原则也一样,它要求我们在数据库层面独立出来一个库,做特殊的处理
针对意料之外的情况设计的planB兜底方案
需要遵循几个原则:用户请求的数据尽量少、请求数尽量少、路径尽量短、依赖尽量少,并且不要有单点
另外我们也可以围绕高性能,一致性,高可用 这几个角度来展开架构设计
高性能设计:动静分离方案、热点的发现与隔离、请求的削峰与分层过滤、服务端的极致优化
一致性设计:秒杀减库存的方案设计保证一致性
高可用设计:兜底planB方案
四、架构原则
一、用户请求的数据尽量少
所谓“数据要尽量少”,首先是指用户请求的数据能少就少。请求的数据包括上传给系统的数据和系统返回给用户的数据(通常就是网页)。
为啥“数据要尽量少”呢?因为首先这些数据在网络上传输需要时间,其次不管是请求数据还是返回数据都需要服务器做处理,而服务器在写网络时通常都要做压缩(gzip)和字符编码,这些都非常消耗 CPU,所以减少传输的数据量可以显著减少 CPU 的使用。例如,我们可以简化秒杀页面的大小,去掉不必要的页面装修效果,等等。
其次,“数据要尽量少”还要求系统依赖的数据能少就少,包括系统完成某些业务逻辑需要读取和保存的数据,这些数据一般是和后台服务以及数据库打交道的。调用其他服务会涉及数据的序列化和反序列化,而这也是 CPU 的一大杀手,同样也会增加延时。而且,数据库本身也容易成为一个瓶颈,所以和数据库打交道越少越好,数据越简单、越小则越好。
二、请求数要尽量少
用户请求的页面返回后,浏览器渲染这个页面还要包含其他的额外请求,比如说,这个页面依赖的 CSS/JavaScript、图片,以及 Ajax 请求等等都定义为“额外请求”,这些额外请求应该尽量少。因为浏览器每发出一个请求都多少会有一些消耗,例如建立连接要做三次握手,有的时候有页面依赖或者连接数限制,一些请求(例如 JavaScript)还需要串行加载等。另外,如果不同请求的域名不一样的话,还涉及这些域名的 DNS 解析,可能会耗时更久。所以你要记住的是,减少请求数可以显著减少以上这些因素导致的资源消耗。
例如,减少请求数最常用的一个实践就是合并 CSS 和 JavaScript 文件,把多个 JavaScript 文件合并成一个文件,在 URL 中用逗号隔开(??module-preview/index.xtpl.js,module-jhs/index.xtpl.js,module-focus/index.xtpl.js)。这种方式在服务端仍然是单个文件各自存放,只是服务端会有一个组件解析这个 URL,然后动态把这些文件合并起来一起返回。
三. 路径要尽量短
所谓“路径”,就是用户发出请求到返回数据这个过程中,需求经过的中间的节点数。
通常,这些节点可以表示为一个系统或者一个新的 Socket 连接(比如代理服务器只是创建一个新的 Socket 连接来转发请求)。每经过一个节点,一般都会产生一个新的 Socket 连接。
然而,每增加一个连接都会增加新的不确定性。从概率统计上来说,假如一次请求经过 5 个节点,每个节点的可用性是 99.9% 的话,那么整个请求的可用性是:99.9% 的 5 次方,约等于 99.5%。
所以缩短请求路径不仅可以增加可用性,同样可以有效提升性能(减少中间节点可以减少数据的序列化与反序列化),并减少延时(可以减少网络传输耗时)。
要缩短访问路径有一种办法,就是多个相互强依赖的应用合并部署在一起,把远程过程调用(HTTP/RPC)变成 JVM 内部之间的方法调用。
四. 依赖要尽量少
所谓依赖,指的是要完成一次用户请求必须依赖的系统或者服务,这里的依赖指的是强依赖。
举个例子,比如说你要展示秒杀页面,而这个页面必须强依赖商品信息、用户信息,还有其他如优惠券、成交列表等这些对秒杀不是非要不可的信息(弱依赖),这些弱依赖在紧急情况下就可以去掉。
要减少依赖,我们可以给系统进行分级,比如 0 级系统、1 级系统、2 级系统、3 级系统,0 级系统如果是最重要的系统,那么 0 级系统强依赖的系统也同样是最重要的系统,以此类推。注意,0 级系统要尽量减少对 1 级系统的强依赖,防止重要的系统被不重要的系统拖垮。例如支付系统是 0 级系统,而优惠券是 1 级系统的话,在极端情况下可以把优惠券给降级,防止支付系统被优惠券这个 1 级系统给拖垮。
五. 不要有单点
关键点是避免将服务的状态和机器绑定,即把服务无状态化,这样服务就可以在机器中随意移动。如何那把服务的状态和机器解耦呢?这里也有很多实现方式。例如把和机器相关的配置动态化,这些参数可以通过配置中心来动态推送,在服务启动时动态拉取下来,我们在这些配置中心设置一些规则来方便地改变这些映射关系。
参考样例介绍
淘宝早期架构
秒杀详情成为了一个独立的新系统,另外核心的一些数据放到了缓存(Cache)中,其他的关联系统也都以独立集群的方式进行部署。
淘宝早期秒杀系统
这个系统架构有下面几个要点:
把秒杀系统独立出来单独打造一个系统,这样可以有针对性地做优化,例如这个独立出来的系统就减少了店铺装修的功能,减少了页面的复杂度;
在系统部署上也独立做一个机器集群,这样秒杀的大流量就不会影响到正常的商品购买集群的机器负载;
将热点数据(如库存数据)单独放到一个缓存系统中,以提高“读性能”;
增加秒杀答题,防止有秒杀器抢单。
淘宝进一步升级的架构
为了满足更高的性能,淘宝对秒杀系统做了进一步升级来满足更高的性能,但是事实上也造成了系统的不通用性增加,系统复杂度升高。
进一步升级架构主要改造点有:
对页面进行彻底的动静分离,使得用户秒杀时不需要刷新整个页面,而只需要点击抢宝按钮,借此把页面刷新的数据降到最少。
在服务端对秒杀商品进行本地缓存,不需要再调用依赖系统的后台服务获取数据,甚至不需要去公共的缓存集群中查询数据,这样不仅可以减少系统调用,而且能够避免压垮公共缓存集群 。
增加系统限流保护,防止最坏情况发生。
进一步升级后的系统
实战演练 - 疫情期间秒杀口罩
我们设计秒杀系统时,需要根据不同级别的流量,由简单到复杂的场景打造的不同的系统架构,同时要平衡到系统的通用性和维护性,降低影响性,使系统或者其中的模块能够更好的被复用,运维简单也是需要考虑的事项。
在设计系统前我们需要先收集下面这些信息:
场景用例:
谁用这个系统?
用户如何用这个系统?
量级规模:
每秒查询请求?
每个查询请求多少数据?
每秒处理多少个订单?
高峰流量是多少?
性能:
预期从写入到读取数据的延迟?
预期99%请求的延迟是多少?
高可用性要求?
成本:
公司现有的可复用的类似需求的成品资产有哪些?
开发成本有没有限制,物理资源,开发人员,测试人员有没有限制?
运维成本有没有限制?
实战演练 - “减库存”设计
下单减库存
即当买家下单后,在商品的总库存中减去买家购买数量。下单减库存是最简单的减库存方式,也是控制最精确的一种,下单时直接通过数据库的事务机制控制商品库存,这样一定不会出现超卖的情况。但是要知道,有些人下完单可能并不会付款。
优点:控制精确不会超卖
缺点:恶意下单,会影响卖家的商品销售
付款减库存
即买家下单后,并不立即减库存,而是等到有用户付款后才真正减库存,否则库存一直保留给其他买家。
优点:可以不免恶意下单
缺点:库存会超卖,或者买家下单后付不了款的情况
预扣库存
这种方式相对复杂一些,买家下单后,库存为其保留一定的时间(如 10 分钟),超过这个时间,库存将会自动释放,释放后其他买家就可以继续购买。在买家付款前,系统会校验该订单的库存是否还有保留:如果没有保留,则再次尝试预扣;如果库存不足(也就是预扣失败)则不允许继续付款;如果预扣成功,则完成付款并实际地减去库存。
优点:一定程度上避免恶意下单
缺点:要结合安全和反作弊的措施来制止,系统设计比较复杂
减库存设计选择
针对疫情抢购口罩这个功能,我的理解是可以允许适当的超卖(当然这个还得由运营的伙伴决定),原因是口罩的数量和单价比较低,超卖对企业损失不大,抢购失败对客户体验的影响度也有限,惠民活动保证库存能够全部卖出就行,所以付款减库存和预扣库存是两个可以考虑的的减库存方案,库存方案的选择主要是影响库存、订单、支付模块的交互方式。
并发减库存的问题
上面的是功能设计方案选择,那么如何保证并发减库这个最后的操作正确性,我理解主要的方式是加锁。加锁有两个层面:一个是程序层面,另一个是数据库层面。
加锁设计
分布式锁
这里加分布式锁就是将多线程请求转成单线程请求,因为每次只有一个线程获得锁并执行,其余都被阻塞了。
在使用分布式锁的时候需要注意当锁遇到数据库事务的情况,mysql默认的事务隔离级别是REPEATABLE-READ,在这种隔离级别下,同一个事务中多次读取,返回的数据是一样的。
同时,Spring声明式事务默认的传播特性REQUIRED 如下图所示:
REQUIRED传播方式
Spring声明式事务是Spring AOP最好的例子,Spring是通过AOP代理的方式来实现事务的,也就是说在调用reduceStock()方法的之前就已经开启了事务。
publicenumPropagation{/**
* 需要事务,默认传播性行为。
* 如果当前存在事务,就沿用当前事务,否则新建一个事务运行子方法
*/REQUIRED(0),/**
* 支持事务,如果当前存在事务,就沿用当前事务,
* 如果不存在,则继续采用无事务的方式运行子方法
*/SUPPORTS(1),/**
* 必须使用事务,如果当前没有事务,抛出异常
* 如果存在当前事务,就沿用当前事务
*/MANDATORY(2),/**
* 无论当前事务是否存在,都会创建新事务允许方法
* 这样新事务就可以拥有新的锁和隔离级别等特性,与当前事务相互独立
*/REQUIRES_NEW(3),/**
* 不支持事务,当前存在事务时,将挂起事务,运行方法
*/NOT_SUPPORTED(4),/**
* 不支持事务,如果当前方法存在事务,将抛出异常,否则继续使用无事务机制运行
*/NEVER(5),/**
* 在当前方法调用子方法时,如果子方法发生异常
* 只回滚子方法执行过的SQL,而不回滚当前方法的事务
*/NESTED(6);......}
那么,在并发情况下可能会存在这样的情况,假设线程T1和T2都执行到这里,于是它们都开启了事务S1和S2,T1先执行,T2后执行,由于T2执行的时候事务已经创建了,根据隔离级别,这个时候事务S2读取不到S1已提交的数据,于是就会出现T1和T2读取到的值是一样的,即T2读取的是T1更新前的库存数据。
数据库乐观锁
数据增加一个版本标识,一般是通过为数据库表增加一个数字类型的 “version” 字段来实现。当读取数据时,将version字段的值一同读出,数据每更新一次,对此version值加一。当我们提交更新的时候,判断数据库表对应记录的当前版本信息与第一次取出来的version值进行比对,如果数据库表当前版本号与第一次取出来的version值相等,则予以更新,否则认为是过期数据。更新的时候带上版本号,只有当前版本号与更新之前查询时的版本一致,才会更新。
避免超卖
对于普通商品,秒杀只是一种大促手段,即使库存超卖,商家也可以通过补货来解决。而对于一些商品,秒杀作为一种营销手段,完全不允许库存为负,也就是在数据一致性上,需要保证大并发请求时数据库中的库存字段值不能为负。这里有下面几种方法:
通过事务来判断,即保证减后库存不能为负,否则就回滚。
直接设置数据库字段类型为无符号整数,这样一旦库存为负就会在执行 SQL 时报错。
使用 CASE WHEN 判断语句:UPDATE item SET inventory CASE WHEN inventory xxx THEN inventory xxx ELSE inventory
实战演练 - 动静分离
那到底什么才是动静分离呢?所谓“动静分离”,其实就是把用户请求的数据(如 HTML 页面)划分为“动态数据”和“静态数据”。我这里就简单的把css,js,图片等信息归纳为“静态数据”,由后台接口产生的数据归纳为“动态数据”。
那么,怎样对静态数据做缓存呢?这里总结了几个重点。
第一,你应该把静态数据缓存到离用户最近的地方。 静态数据就是那些相对不会变化的数据,因此我们可以把它们缓存起来。缓存到哪里呢?常见的就三种,用户浏览器里、CDN 上或者在服务端的 Cache 中。你应该根据情况,把它们尽量缓存到离用户最近的地方。
第二,静态化改造就是要直接缓存 HTTP 连接。 相较于普通的数据缓存而言,你肯定还听过系统的静态化改造。静态化改造是直接缓存 HTTP 连接而不是仅仅缓存数据,如下图所示,Web 代理服务器根据请求 URL,直接取出对应的 HTTP 响应头和响应体然后直接返回,这个响应过程简单得连 HTTP协议都不用重新组装,甚至连 HTTP 请求头也不需要解析。
静态化改造
第三,让谁来缓存静态数据也很重要。不同语言写的 Cache 软件处理缓存数据的效率也各不相同。以 Java 为例,因为 Java 系统本身也有其弱点(比如不擅长处理大量连接请求,每个连接消耗的内存较多,Servlet 容器解析 HTTP 协议较慢),所以你可以不在 Java 层做缓存,而是直接在 Web 服务器层上做,这样你就可以屏蔽 Java 语言层面的一些弱点;而相比起来,Web 服务器(如 Nginx、Apache、Varnish)也更擅长处理大并发的静态文件请求。
动静分离的几种架构方案
1)实体机单机部署
Nginx+Cache+Java 结构实体机单机部署
优点:
没有网络瓶颈,而且能使用大内存;
既能提升命中率,又能减少 Gzip 压缩;
减少 Cache 失效压力,因为采用定时失效方式,例如只缓存 3 秒钟,过期即自动失效。
缺点:
但是一定程度上也造成了 CPU 的浪费,因为单个的 Java 进程很难用完整个实体机的 CPU
一个实体机上部署了 Java 应用又作为 Cache 来使用,这造成了运维上的高复杂度,所以这是一个折中的方案。
2)统一 Cache 层
所谓统一 Cache 层,就是将单机的 Cache 统一分离出来,形成一个单独的 Cache 集群。统一 Cache 层是个更理想的可推广方案,该方案的结构图如下:
统一 Cache
优点:
单独一个 Cache 层,可以减少多个应用接入时使用 Cache 的成本。这样接入的应用只要维护自己的 Java 系统就好,不需要单独维护 Cache,而只关心如何使用即可。
统一 Cache 的方案更易于维护,如后面加强监控、配置的自动化,只需要一套解决方案就行,统一起来维护升级也比较方便。
可以共享内存,最大化利用内存,不同系统之间的内存可以动态切换,从而能够有效应对各种攻击
缺点:
Cache 层内部交换网络成为瓶颈。
缓存服务器的网卡也会是瓶颈。
机器少风险较大,挂掉一台就会影响很大一部分缓存数据。
3)上 CDN
当前比较理想的一种 CDN 化方案
在将整个系统做动静分离后,我们自然会想到更进一步的方案,就是将 Cache 进一步前移到 CDN 上,因为 CDN 离用户最近,效果会更好。
优点:
离用户最近,效果会更好。
强制刷新整个页面,也会请求 CDN,这样一来,系统只是向服务端请求很少的有效数据,而不需要重复请求大量的静态数据。
缺点:
需要处理失效问题、命中率问题、发布更新问题
总结:使用CDN最好满足下面几个条件:
靠近访问量比较集中的地区
离主站相对较远
节点到主站间的网络比较好,而且稳定
节点容量比较大,不会占用其他 CDN 太多的资源
还有一点也很重要,那就是:节点不要太多
实战演练 - 流量削峰
流量削峰 - 消息队列
要对流量进行削峰,最容易想到的解决方案就是用消息队列来缓冲瞬时流量,把同步的直接调用转换成异步的间接推送,中间通过一个队列在一端承接瞬时的流量洪峰,在另一端平滑地将消息推送出去。在这里,消息队列就像“水库”一样, 拦蓄上游的洪水,削减进入下游河道的洪峰流量,从而达到减免洪水灾害的目的。
用消息队列来缓冲瞬时流量
流量削峰 - 其它方案
1)利用线程池加锁等待也是一种常用的排队方式。
2)先进先出、先进后出等常用的内存排队算法的实现方式。
3)把请求序列化到文件中,然后再顺序地读文件(例如基于 MySQL binlog 的同步机制)来恢复请求等方式。
流量削峰总结
流量削峰共同特征,就是把“一步的操作”变成“两步的操作”,其中增加的一步操作用来起到缓冲的作用。
实战演练 - 一致性
这里先介绍下分布式系统中的一致性相关概念和常用的几个解决方案,最后结合之前的秒杀场景选择相关的解决方案。
缓存一致性 - 缓存与数据库一致性
TODO
业务数据一致性 - 分布式事务和最终一致
分布式事务
一、两阶段提交(2PC)
两阶段提交就是使用XA协议的原理,我们可以从下面这个图的流程来很容易的看出中间的一些比如commit和abort的细节。
2PC
两阶段提交这种解决方案属于牺牲了一部分可用性来换取的一致性。
优点: 尽量保证了数据的强一致,适合对数据强一致要求很高的关键领域。(其实也不能100%保证强一致)
缺点: 实现复杂,牺牲了可用性,对性能影响较大,不适合高并发高性能场景,如果分布式系统跨接口调用。
二、补偿事务(TCC)
TCC模型完全交由业务实现,每个子业务都需要实现Try-Confirm-Cancel三个接口,对业务侵入大,资源锁定交由业务方。
1、Try:尝试执行业务,完成所有业务检查(一致性),预留必要的业务资源(准隔离性)。
2、Confirm:确认执行业务,不再做业务检查。只使用Try阶段预留的业务资源,Confirm操作满足幂等性。
3、Cancel:取消执行业务释放Try阶段预留业务资源。
补偿事务(TCC
举个例子,假如 Bob 要向 Smith 转账,我们有一个本地方法,里面依次调用,思路大概是:
1、首先在 Try 阶段,要先调用远程接口把 Smith 和 Bob 的钱被冻结起来。
2、在 Confirm 阶段,执行远程调用的转账的操作,转账成功进行解冻。
3、如果第2步执行成功,那么转账成功,如果第二步执行失败,则调用远程冻结接口对应的解冻方法 (Cancel)。
优点: 跟2PC比起来,实现以及流程相对简单了一些,但数据的一致性比2PC也要差一些
缺点: 缺点还是比较明显的,在2,3步中都有可能失败。TCC属于应用层的一种补偿方式,所以需要程序员在实现的时候多写很多补偿的代码,在一些场景中,一些业务流程可能用TCC不太好定义及处理。
相对于 2PC,TCC 适用的范围更大,但是开发量也更大,毕竟都在业务上实现,而且有时候你会发现这三个方法还真不好写。不过也因为是在业务上实现的,所以TCC可以跨数据库、跨不同的业务系统来实现事务。
三、本地消息表(异步确保)
本地消息表这种实现方式应该是业界使用最多的,其核心思想是将分布式事务拆分成本地事务进行处理,如下图所示:
本地消息表
消息生产方,需要额外建一个消息表,并记录消息发送状态。消息表和业务数据要在一个事务里提交,也就是说他们要在一个数据库里面。然后消息会经过MQ发送到消息的消费方。如果消息发送失败,会进行重试发送。
消息消费方,需要处理这个消息,并完成自己的业务逻辑。此时如果本地事务处理成功,表明已经处理成功了,如果处理失败,那么就会重试执行。如果是业务上面的失败,可以给生产方发送一个业务补偿消息,通知生产方进行回滚等操作。
生产方和消费方定时扫描本地消息表,把还没处理完成的消息或者失败的消息再发送一遍。如果有靠谱的自动对账补账逻辑,这种方案还是非常实用的。
优点: 一种非常经典的实现,避免了分布式事务,实现了最终一致性。
缺点: 消息表会耦合到业务系统中,如果没有封装好的解决方案,会有很多杂活需要处理。
四、MQ 事务消息
以阿里的 RocketMQ 中间件为例,其思路大致为:
第一阶段Prepared消息,会拿到消息的地址。
第二阶段执行本地事务,第三阶段通过第一阶段拿到的地址去访问消息,并修改状态。
也就是说在业务方法内要向消息队列提交两次请求,一次发送消息和一次确认消息。如果确认消息发送失败了RocketMQ会定期扫描消息集群中的事务消息,这时候发现了Prepared消息,它会向消息发送者确认,所以生产方需要实现一个check接口,RocketMQ会根据发送端设置的策略来决定是回滚还是继续发送确认消息。这样就保证了消息发送与本地事务同时成功或同时失败。
RocketMQ事务消息
优点: 实现了最终一致性,不需要依赖本地数据库事务。
缺点: 需要引入新的中间件,主流MQ不支持。
五、Sagas 事务模型
Saga事务模型又叫做长时间运行的事务(Long-running-transaction), 它描述的是另外一种在没有两阶段提交的的情况下解决分布式系统中复杂的业务事务问题。
Saga事务是一个长事务,整个事务可以由多个本地事务组成,每个本地事务有相应的执行模块和补偿模块,当Saga事务中任意一个事务出错了,可以调用相关事务进行对应的补偿恢复,达到事务的最终一致性。
它与2PC不同,2PC是同步的,而Saga模式是异步和反应性的。在Saga模式中,分布式事务由所有相关微服务上的异步本地事务完成。微服务通过事件总线相互通信。
Saga
Saga也是一种补偿协议,在 Saga 模式下,分布式事务内有多个参与者,每一个参与者都是一个冲正补偿服务,需要用户根据业务场景实现其正向操作和逆向回滚操作。
优点: Saga模式的一大优势是它支持长事务。因为每个微服务仅关注其自己的本地原子事务,所以如果微服务运行很长时间,则不会阻止其他微服务。这也允许事务继续等待用户输入。此外,由于所有本地事务都是并行发生的,因此任何对象都没有锁定。
缺点:Saga模式很难调试,特别是涉及许多微服务时。此外,如果系统变得复杂,事件消息可能变得难以维护。Saga模式的另一个缺点是它没有读取隔离。例如,客户可以看到正在创建的订单,但在下一秒,订单将因补偿交易而被删除。
国外AxonFramework的框架很好的实现了Saga感兴趣的可以研究下
六、分布式事务总结
我们综合对比下几种分布式事务解决方案: ps: 感觉不怎么对 - -!
一致性保证:2PC > TCC = SAGA > 事务消息 > 本地消息表
业务友好性:2PC > 事务消息 > SAGA > TCC > 本地消息表
行 能 损 耗:2PC > TCC > SAGA = 事务消息 > 本地消息表
在柔性事务解决方案中,虽然SAGA和TCC看上去可以保证数据的最终一致性,但分布式系统的生产环境复杂多变,某些情况是可以导致柔性事务机制失效的,所以无论使用那种方案,都需要最终的兜底策略,人工校验,修复数据。
事务最终一致性的几种做法
最终一致性的几种做法
一、单数据库情况下的事务 - 对应上图的A流程
如果应用系统是单一的数据库,那么这个很好保证,利用数据库的事务特性来满足事务的一致性,这时候的一致性是强一致性的。对于java应用系统来讲,很少直接通过事务的start和commit以及rollback来硬编码,大多通过spring的事务模板或者声明式事务来保证。
二、基于事务型消息队列的最终一致性 - 对应上图中的C流程
借助消息队列,在处理业务逻辑的地方,发送消息,业务逻辑处理成功后,提交消息,确保消息是发送成功的,之后消息队列投递来进行处理,如果成功,则结束,如果没有成功,则重试,直到成功,不过仅仅适用业务逻辑中,第一阶段成功,第二阶段必须成功的场景。对应上图中的C流程。
三、基于消息队列+定时补偿机制的最终一致性 - 对应上图的E流程
前面部分和上面基于事务型消息的队列,不同的是,第二阶段重试的地方,不再是消息中间件自身的重试逻辑了,而是单独的补偿任务机制。其实在大多数的逻辑中,第二阶段失败的概率比较小,所以单独独立补偿任务表出来,可以更加清晰,能够比较明确的直到当前多少任务是失败的。对应上图的E流程。
四、异步回调机制的引入 - 上图中的B流程
A应用调用B,在同步调用的返回结果中,B返回成功给到A,一般情况下,这时候就结束了,其实在99.99%的情况是没问题的,但是有时候为了确保100%,记住最起码在系统设计中100%,这时候B系统再回调A一下,告诉A,你调用我的逻辑,确实成功了。其实这个逻辑,非常类似TCP协议中的三次握手。
五、类似double check机制的确认机制 - 上图中的D流程
还是上图中异步回调的过程,A在同步调用B,B返回成功了。这次调用结束了,但是A为了确保,在过一段时间,这个时间可以是几秒,也可以是每天定时处理,再调用B一次,查询一下之前的那次调用是否成功。例如A调用B更新订单状态,这时候成功了,延迟几秒后,A查询B,确认一下状态是否是自己刚刚期望的。
六、最大努力通知 - 幂等性控制
重试会面临问题,重试之后不能给业务逻辑带来影响,例如创建订单,第一次调用超时了,但是调用的系统不知道超时了是成功了还是失败了,然后他就重试,但是实际上第一次调用订单创建是成功了的,这时候重试了,显然不能再创建订单了。
查询: 查询的API,可以说是天然的幂等性,因为你查询一次和查询两次,对于系统来讲,没有任何数据的变更,所以,查询一次和查询多次一样的。
MVCC方案: 多版本并发控制,update with condition,更新带条件,这也是在系统设计的时候,合理的选择乐观锁,通过version或者其他条件,来做乐观锁,这样保证更新及时在并发的情况下,也不会有太大的问题。
例如 :
update tablexxx set name=#name#,version=version+1 where version=#version#
或者是
update tablexxx set quality=quality-#subQuality# where quality-#subQuality# >= 0
3. 单独的去重表:如果涉及到的去重的地方特别多,例如ERP系统中有各种各样的业务单据,每一种业务单据都需要去重,这时候,可以单独搞一张去重表,在插入数据的时候,插入去重表,利用数据库的唯一索引特性,保证唯一的逻辑。
4. 分布式锁:还是拿插入数据的例子,如果是分布是系统,构建唯一索引比较困难,例如唯一性的字段没法确定,这时候可以引入分布式锁,通过第三方的系统,在业务系统插入数据或者更新数据,获取分布式锁,然后做操作,之后释放锁,这样其实是把多线程并发的锁的思路,引入多多个系统,也就是分布式系统中得解决思路。
5. 删除数据:删除数据,仅仅第一次删除是真正的操作数据,第二次甚至第三次删除,直接返回成功,这样保证了幂等。
6. 插入数据的唯一索引:插入数据的唯一性,可以通过业务主键来进行约束,例如一个特定的业务场景,三个字段肯定确定唯一性,那么,可以在数据库表添加唯一索引来进行标示。
7. API层面的幂等:这里有一个场景,API层面的幂等,例如提交数据,如何控制重复提交,这里可以在提交数据的form表单或者客户端软件,增加一个唯一标示,然后服务端,根据这个UUID来进行去重,这样就能比较好的做到API层面的唯一标示。
8. 状态机幂等:在设计单据相关的业务,或者是任务相关的业务,肯定会涉及到状态机,就是业务单据上面有个状态,状态在不同的情况下会发生变更,一般情况下存在有限状态机,这时候,如果状态机已经处于下一个状态,这时候来了一个上一个状态的变更,理论上是不能够变更的,这样的话,保证了有限状态机的幂等。
秒杀系统的一致性设计
针对秒杀的场景,由于性能要求比较高、一致性要求比较低,所以我并不推荐使用XA、TCC、Saga的方式,推荐最终一致性方案。秒杀系统可能有的模块有“订单”、“库存”、“支付”、“物流”、“短信”,最常用的下单减库存的库存设计场景下,在业务领域划分上“订单”、“库存”其实都属于下单服务,我觉得可以在一个下单服务中完成减库存和订单操作这两步动作,“支付”、“物流”、“短信” 属于其它领域。然后通过消息队列通知订单处理服务解耦物流、支付、短信能服务。如下图所示:
订单流程示意图
在这个设计下我们就可以采取基于事务型消息队列的最终一致性(对应上图的C流程) 或 基于消息队列+定时补偿机制的最终一致性(对应上图的E流程) 的最终一致性方案。 TODO 后面有机会再补充付款减库存和预扣库存的设计思路。
但是下单服务的事务的处理上我们需要有下面这些优化思路:
1. 结合业务场景,使用低级别事务隔离
在高并发业务中,为了保证业务数据的一致性,操作数据库时往往会使用不同级别的事务隔离,隔离等级越高,并发性能就越低。
那在实际的业务中,我们要如何选择呢,下边举两个例子:
在修改用户的最后登录时间,或者用户的个人资料等数据时,这些数据都只有用户自己登录和登陆后才会修改,不存在一个事务提交的信息被覆盖的可能,所以这样的业务我们就最低的隔离级别。
如果账户的余额或者积分的消费,就可能存在多个客户端同事消费一个账户的情况,此时我们应该选择可重复读隔离级别,来保证当一个客户端在操作的时候,其他客户端不能对该数据进行操作。
2. 避免行锁升级表锁
我们知道,InnoDB中行锁是通过索引实现的,当不通过索引条件检索数据时,行锁就会升级成表锁,我们知道表锁会严重影响我们对整张表的操作,应该避免这种情况。
3. 控制事务的大小,减少锁定的资源和锁定的时间
下边这个SQL异常相比很多并发比较高的系统里都会遇见,比如抢购系统的日志中:
MySQLQueryInterruptedException: Query execution was interrupted
由于抢购系统中,提交订单业务开启了事务,在并发环境中对一条记录进行更新操作的情况下,由于更新记录所在的事务还可能存在其他操作,导致一个事务比较长,当大量请求进入时,就可能导致一些请求同时进入事务中,由于锁的竞争是不公平的,当多个事务同时对一条记录进行更新时,极端情况下,一个更新操作进去排队系统后,可能会一直拿不到锁,最后因超市被系统中断,就会抛出上边这个异常。
在上面的下单服务中,提交订单需要创建订单和扣减库存,两种不同顺序的执行方式,结果都一样,但是性能确实不一样的:
执行顺序1执行顺序2
1.开启事务
2.查询库存,判断库存是否满足
3.创建订单
4.扣除库存
5.提交或回滚
1.开启事务
2.查询库存,判断库存是否满足
3.扣除库存
4.创建订单
5.提交或回滚
这两种不同的执行方式,虽然这些操作都在一个事务中,但是锁的申请不在同一时间,锁只有当其他操作都执行完成才会释放锁。扣减库存是更新操作,属于行锁,如果先扣减库存会影响到其他操作该数据的事务,所以我们应该尽可能的避免长时间持有该锁,尽快的释放锁。
因为创建订单和扣除库存不管先执行哪一步都不影响业务,所以我们可以先执行新增操作,把扣除库存放到最后,也就是使用执行顺序1 ,来减少锁的持有时间。
实战演练 - DB设计
数据结构
TODO
SQL语句注意事项
1)一定要避免全表扫描,如果扫一张大表的数据就会造成慢查询,导致数据的连接池直接塞满,导致事故。
首先考虑在where和order by设计的列上建立索引例如:
where 子句中对字段进行 null 值判断
应尽量避免在 where 子句中使用!=或<>操作符
应尽量避免在 where 子句中使用 or 来连接条件
in 和 not in 也要慎用
在优化大表连接查询的时候,有一个方法就是join操作拆分为in查询
而select id from t where name like abc% 才用到索引,慢查询一般在测试环境不容易复现,若要提高效率,可以考虑全文检索。
应尽量避免在 where 子句中对字段进行表达式操作例如:where num/2 , num=100*2
2)合理的使用索引,索引并不是越多越好,使用不当会造成性能开销。
3)尽量避免大事务操作,提高系统并发能力。
4)尽量避免向客户端返回大量数据,如果返回则要考虑是否需求合理。
实战演练 - 高可用方案
说到系统的高可用建设,它其实是一个系统工程,需要考虑到系统建设的各个阶段,也就是说它其实贯穿了系统建设的整个生命周期,如下图所示:
高可用生命周期
高可用是一个比较大的议题,我们这里就几个常用的限流,熔断,服务拒绝这几个方面简单做下介绍。
1)限流
限流就是当系统容量达到瓶颈时,我们需要通过限制一部分流量来保护系统,并做到既可以人工执行开关,也支持自动化保护的措施。这里推荐一个比较好的限流开源框架Guava-RateLimiter
2)熔断
熔断就是切断项目对指定服务的调用。举个例子在分布式环境下有A,B,C,D四个个服务,A依赖B,C,D。在调用的过程中发现D服务异常了,为了不拖垮整个集群,我们会选择不调用D服务,进行服务降级。这里推荐一个比较好的限流开源框架Hystrix和基于dubbo的samples
3)服务拒绝
当系统负载达到一定阈值时,例如 CPU 使用率达到 90% 或者系统 load 值达到 2*CPU 核数时,系统直接拒绝所有请求,这种方式是最暴力但也最有效的系统保护方式。例如秒杀系统,我们在如下几个环节设计过载保护:
在最前端的 Nginx 上设置过载保护,当机器负载达到某个值时直接拒绝 HTTP 请求并返回 503 错误码,在 Java 层同样也可以设计过载保护。
4)降级非核心功能
就是当系统的容量达到一定程度时,限制或者关闭系统的某些非核心功能,从而把有限的资源保留给更核心的业务。但是这种功能最好有一个配置开关。例如:当秒杀流量达到 5w/s 时,把成交记录的获取从展示 20 条降级到只展示 5 条。“从 20 改到 5”这个操作由一个开关来实现,也就是设置一个能够从开关系统动态获取的系统参数。又例如:商品详情的优惠信息展示,把有限的系统资源用在保障交易系统正确展示优惠信息上,即保障用户真正下单时的价格是正确的。
实战演练 - 其它议题和知识储备
在实施落地秒杀一个落地系统时,我们可能还需也要很多知识储备。例如:分布式缓存Redis知识,Nginx优化,HTTP/PRC知识,DB设计、秒杀安全、分库分表、多数据源,分布式锁知识,分布式事务和补偿知识等等。足够的知识储备,适当的选择使用才能帮助我们更好的落地系统。