关注「Java视界」公众号,获取更多技术干货

分布式系统?分布式事务?秒杀设计思路!

一、分布式系统相关概念

分布式:一个业务分拆多个子业务,部署在不同的服务器上(不同的服务器,运行不同的代码,为了同一个目的)

集群:同一个业务,部署在多个服务器上(不同的服务器运行同样的代码,干同一件事)

高并发:相对于分布式来讲,高并发在解决的问题上会集中一些, 其反应的是同时有多少量:比如在线直播服务,同时有上万人观看。高并发可以通过分布式技术去解决,将并发流量分到不同的物理服务器上。除此之外,还可以有很多其他优化手段:比如使用缓存系统,将所有的静态内容放到CDN等;还可以使用多线程技术将一台服务器的服务能力最大化。

分布式是从物理资源的角度去将不同的机器组成一个整体对外服务,技术范围非常广且难度非常大,有了这个基础,高并发、高吞吐等系统很容易构建;

  • 高并发是从业务角度去描述系统的能力,可以采用分布式,也可以采用诸如缓存、CDN等,当然也包括多线程;
  • 多线程则聚焦于如何使用编程语言将CPU调度能力最大化。

CDN:的全称是Content Delivery Network,即内容分发网络。CDN是构建在现有网络基础之上的智能虚拟网络,依靠部署在各地的边缘服务器,通过中心平台的负载均衡、内容分发、调度等功能模块,使用户就近获取所需内容,降低网络拥塞,提高用户访问响应速度和命中率。CDN的关键技术主要有内容存储和分发技术。CDN的基本原理是广泛采用各种缓存服务器,将这些缓存服务器分布到用户访问相对集中的地区或网络中,在用户访问网站时,利用全局负载技术将用户的访问指向距离最近的工作正常的缓存服务器上,由缓存服务器直接响应用户请求。CDN的基本思路是尽可能避开互联网上有可能影响数据传输速度和稳定性的瓶颈和环节,使内容传输的更快、更稳定。通过在网络各处放置节点服务器所构成的在现有的互联网基础之上的一层智能虚拟网络,CDN系统能够实时地根据网络流量和各节点的连接、负载状况以及到用户的距离和响应时间等综合信息将用户的请求重新导向离用户最近的服务节点上。其目的是使用户就近取得所需内容,解决 Internet网络拥挤的状况,提高用户访问网站响应速度。应用:玩某些游戏的时候,经常需要开加速器。就是基于CDN。

二、关于大型分布式系统需要考虑的问题

1.负载均衡服务器

          用于接收请求并将请求均衡发送给应用服务器处理。

2.分布式消息队列服务器

          用于多个应用之间的互相调用和通信(一般为异步)。

3.分布式缓存服务器

       用于提供数据的频繁高速访问,减少直接访问DB的压力。

4.分布式数据存储服务器

       用于数据的安全、快速存储。

在开发高并发系统时有三把利器用来保护系统:缓存、降级和限流

  • 缓存:缓存的目的是提升系统访问速度和增大系统处理容量。
  • 降级:降级是当服务器压力剧增的情况下,根据当前业务情况及流量对一些服务和页面有策略的降级,以此释放服务器资源以保证核心任务的正常运行。
  • 限流:限流的目的是通过对并发访问/请求进行限速,或者对一个时间窗口内的请求进行限速来保护系统,一旦达到限制速率则可以拒绝服务、排队或等待、降级等处理。

三、分布式一致性

1、CAP理论(又被叫作布鲁尔定理):一个分布式系统不可能同时满足一致性(C:Consistency)、可用性(A:Availability)和分区容错性(P:Partition tolerance)这三个基本需求,最多只能同时满足其中两项。
 C:数据一致性(consistency) , 所有节点拥有数据的最新版本。其中,一致性又分为强一致性、弱一致性、最终一致性。
 A:可用性(availability) ,数据具备高可用性。非故障的节点在合理的时间内返回合理的响应(不是错误和超时的响应)。可用性的两个关键一个是合理的时间,一个是合理的响应。合理的时间指的是请求不能无限被阻塞,应该在合理的时间给出返回。合理的响应指的是系统应该明确返回结果并且结果是正确的,这里的正确指的是比如应该返回50,而不是返回40。

 P:分区容错性(partition-tolerance),容忍网络出现分区,当出现网络分区后,系统能够继续工作。打个比方,这里个集群有多台机器,有台机器网络出现了问题,但是这个集群仍然可以正常工作。具体的例子:

分布式系统由多个节点组成,就像mysql集群,由多个mysql节点组成。节点间的网络通信总是不可靠的,所以我们总是要保证分布式系统节点间出现网络故障时,分布式系统还是可用的,这种系统才有意义

一致性:读操作总是能读取到之前完成的写操作结果,满足此条件的系统为强一致系统,这里的“之前”是对同一个客户端而言;

可用性:读写操作在单台机器发生故障的时仍然能正常执行,不需要等待发生故障的机器重启或者其上的服务迁移到其他机器;是针对非故障节点,如主mysql节点挂了,但从mysql没有挂,而且从mysql照样提供服务,就说明此分布式系统具有可用性。

分区可容忍性:某台机器故障、网络故障、机房停电等异常情况下,不影响集群,集群仍然能够满足一致性和可用性。是各个节点出现网络问题时,系统依然可用。如主Mysql和从Mysql 之间没法通信时,系统可用。

三者不能共有,如果感兴趣可以搜索CAP的证明,在分布式系统中,网络无法100%可靠,分区其实是一个必然现象,如果我们选择了CA而放弃了P,那么当发生分区现象时,为了保证一致性,这个时候必须拒绝请求,但是A又不允许,所以分布式系统理论上不可能选择CA架构,只能选择CP或者AP架构。 

对于CP来说,放弃可用性,追求一致性和分区容错性,我们的zookeeper其实就是追求的强一致。

对于AP来说,放弃一致性(这里说的一致性是强一致性),追求分区容错性和可用性,这是很多分布式系统设计时的选择,后面的BASE也是根据AP来扩展。

CAP理论中是忽略网络延迟,也就是当事务提交时,从节点A复制到节点B,但是在现实中这个是明显不可能的,所以总会有一定的时间是不一致。同时CAP中选择两个,比如你选择了CP,并不是叫你放弃A。因为P出现的概率实在是太小了,大部分的时间你仍然需要保证CA。就算分区出现了你也要为后来的A做准备,比如通过一些日志的手段,是其他机器回复至可用。

2、分布式一致性。数据的一致性模型可以分成以下 3 类:

  • 强一致性:数据更新成功后,任意时刻所有副本中的数据都是一致的,一般采用同步的方式实现。
  • 弱一致性:数据更新成功后,系统不承诺立即可以读到最新写入的值,也不承诺具体多久之后可以读到。
  • 最终一致性:弱一致性的一种形式,数据更新成功后,系统不承诺立即可以返回最新写入的值,但是保证最终会返回上一次更新操作的值。

四、分布式事务

分布式事务是指会涉及到操作多个分布式节点(服务器)上的多个数据库的事务。如果想让分布式部署的多台机器中的数据保持一致性,那么就要保证在所有节点的数据写操作,要不全部都执行,要么全部的都不执行。

分布式事务的例子:比如,进行一次下单操作,在A节点的库存模块对应的库存表会减少一个库存,而在B节点的订单模块中的订单表会增加一条订单,这个就必须保证一致性。要满足分布式事务,要么全部都成功,要么全部都不成功。

简单的说,就是一次大的操作由不同的小操作组成,这些小的操作分布在不同的服务器上,且属于不同的应用,分布式事务需要保证这些小操作要么全部成功,要么全部失败。

分布式事务和分布式一致性的关系:为了满足分布式一致性,多节点上的数据库操作要保证分布式事务。本质上来说,分布式事务就是为了保证不同数据库的数据一致性。

4.1 相关说明

事务:事务是由一组操作构成的可靠的独立的工作单元,事务具备ACID的特性,即原子性、一致性、隔离性和持久性。

本地事务:当事务由资源管理器本地管理时被称作本地事务。本地事务的优点就是支持严格的ACID特性,高效,可靠,状态可以只在资源管理器中维护,应用编程模型简单。但是本地事务不具备分布式事务的处理能力,隔离的最小单位受限于资源管理器。

全局事务:当事务由全局事务管理器进行全局管理时成为全局事务,事务管理器负责管理全局的事务状态和参与的资源,协同资源的一致提交回滚。

BASE理论:

BASE 是 Basically Available(基本可用)、Soft state(软状态)和 Eventually consistent (最终一致性)三个短语的缩写。是对CAP中AP的一个扩展。

  1. 基本可用:分布式系统在出现故障时,允许损失部分可用功能,保证核心功能可用。
  2. 软状态:允许系统中存在中间状态,该状态不影响系统可用性,这里指的是CAP中的不一致。
  3. 最终一致:最终一致是指经过一段时间后,所有节点数据都将会达到一致。

BA指的是基本业务可用性,支持分区失败,S表示柔性状态,也就是允许短时间内不同步,E表示最终一致性,数据最终是一致的,但是实时是不一致的。原子性和持久性必须从根本上保障,为了可用性、性能和服务降级的需要,只有降低一致性和隔离性的要求。

BASE解决了CAP中理论没有网络延迟,在BASE中用软状态和最终一致,保证了延迟后的一致性。BASE和 ACID 是相反的,它完全不同于ACID的强一致性模型,而是通过牺牲强一致性来获得可用性,并允许数据在一段时间内是不一致的,但最终达到一致状态。

分布式事物基本理论:基本遵循CPA理论,采用柔性事物特征,软状态或者最终一致性特点保证分布式事物一致性问题。 

分布式事物常见解决方案:

  1. 2PC两段提交协议

  2. 3PC三段提交协议(弥补两端提交协议缺点)

  3. TCC或者GTS(阿里)

  4. MQ消息中间件最终一致性

  5. 使用LCN解决分布式事物,理念“LCN并不生产事务,LCN只是本地事务的搬运工”。

4.2 2PC(两阶段提交

两阶段提交又称2PC,2PC是一个非常经典的强一致、中心化的原子提交协议。

在分布式系统中,每个节点虽然可以知晓自己的操作时成功或者失败,却无法知道其他节点的操作的成功或失败

这里所说的中心化是指协议中有两类节点:一个是中心化 协调者节点(coordinator)和 N个参与者节点(partcipant)。

第一阶段:事务管理器要求每个涉及到事务的数据库预提交(precommit)此操作,并反映是否可以提交

第二阶段:事务协调器要求每个数据库提交数据,或者回滚数据。

例如:订单服务A,需要调用 支付服务B 去支付,支付成功则处理购物订单为待发货状态,否则要将购物订单处理为失败状态。

1、第一阶段:事务管理器要求每个涉及到事务的数据库预提交(precommit)此操作,并反映是否可以提交

第一阶段主要分为3步

1)事务询问

协调者 向所有的 参与者 发送事务预处理请求,称之为Prepare,并开始等待各 参与者 的响应。

预处理的意思是先提交sql语句到mysql服务端,执行预编译,客户端执行sql语句时,只需上传输入参数即可,这点和存储过程有点相似。

* 预处理工作原理

  • 1. 预处理:发送SQL 语句模板到数据库。预留值使用参数 "?" 标记 。如:INSERT INTO MyGuests VALUES(?, ?, ?)
  • 2. 数据库解析,编译,对SQL语句模板执行查询优化,并保存起来
  • 3. 执行:最后将绑定的值传递给参数("?" 标记),数据库执行语句。

* 预处理优点

  • 1. 预处理的执行效率相对于一般的sql执行操作,效率比较高,因为第二次执行只需要发送查询的参数,而不是整个语句
  • 2. 预处理可以防止sql注入,因为预处理将sql语句与数据分开发送。

2)执行本地事务

各个 参与者 节点执行本地事务操作,但在执行完成后并不会真正提交数据库本地事务,而是先向 协调者 报告说:“我这边可以处理了/我这边不能处理”。

3)各参与者向协调者反馈事务询问的响应

如果 参与者 成功执行了事务操作,那么就反馈给协调者 Yes 响应,表示事务可以执行,如果没有 参与者 成功执行事务,那么就反馈给协调者 No 响应,表示事务不可以执行。

第一阶段执行完后,会有两种可能。1、所有都返回Yes. 2、有一个或者多个返回No。

2、第二阶段:事务协调器要求每个数据库提交数据,或者回滚数据。

当所有参与者都返回Yes。

第二阶段主要分为两步

​ 1)所有的参与者反馈给协调者的信息都是Yes,那么就会执行事务提交

​ 协调者 向 所有参与者 节点发出Commit请求.

​ 2)事务提交

​ 参与者 收到Commit请求之后,就会正式执行本地事务Commit操作,并在完成提交之后释放整个事务执行期间占用的事务资源。

成功条件:所有 参与者 在 commit 后向 协调者 均返回了 YES

3、第二阶段:事务协调器要求每个数据库提交数据,或者回滚数据。

异常条件任何一个 参与者 向 协调者 反馈了 No 响应,或者等待超时之后,协调者尚未收到所有参与者的反馈响应。

异常流程第二阶段也分为两步

1)发送回滚请求

​ 协调者 向所有参与者节点发出 RoollBack 请求.

​ 2)事务回滚

​ 参与者 接收到RoollBack请求后,会回滚本地事务。

2PC的缺陷:

通过上面的演示,很容易想到2pc所带来的缺陷

1)性能问题

无论是在第一阶段的过程中,还是在第二阶段,所有的参与者资源和协调者资源都是被锁住的,只有当所有节点准备完毕,事务 协调者 才会通知进行全局提交,参与者 进行本地事务提交后才会释放资源。这样的过程会比较漫长,对性能影响比较大

2)单节点故障

由于协调者的重要性,一旦 协调者 发生故障。参与者 会一直阻塞下去。尤其在第二阶段,协调者 发生故障,那么所有的 参与者 还都处于锁定事务资源的状态中,而无法继续完成事务操作。(虽然协调者挂掉,可以重新选举一个协调者,但是无法解决因为协调者宕机导致的参与者处于阻塞状态的问题)

2PC出现单点问题的三种情况及解决方案

(1)协调者正常,参与者宕机

​ 由于 协调者 无法收集到所有 参与者 的反馈,会陷入阻塞情况。

​ 解决方案:引入超时机制,如果协调者在超过指定的时间还没有收到参与者的反馈,事务就失败,向所有节点发送终止事务请求。

(2)协调者宕机,参与者正常

​ 无论处于哪个阶段,由于协调者宕机,无法发送提交请求,所有处于执行了操作但是未提交状态的 参与者 都会陷入阻塞情况.

​ 解决方案:引入协调者备份,同时协调者需记录操作日志.当检测到协调者宕机一段时间后,协调者备份取代协调者,并读取操作日志,向所有参与者询问状态。

(3)协调者和参与者都宕机

1)发生在第一阶段: 因为第一阶段,所有参与者都没有真正执行commit,所以只需重新在剩余的参与者中重新选出一个协调者,新的协调者在重新执行第一阶段和第二阶段就可以了。

2)  发生在第二阶段 并且 挂了的参与者在挂掉之前没有收到协调者的指令。也就是上面的第4步挂了,这是可能协调者还没有发送第4步就挂了。这种情形下,新的协调者重新执行第一阶段和第二阶段操作。

3)  发生在第二阶段 并且 有部分参与者已经执行完commit操作。就好比这里订单服务A和支付服务B都收到协调者 发送的commit信息,开始真正执行本地事务commit,但突发情况,Acommit成功,B确挂了。这个时候目前来讲数据是不一致的。虽然这个时候可以再通过手段让他和协调者通信,再想办法把数据搞成一致的,但是,这段时间内他的数据状态已经是不一致的了! 2PC 无法解决这个问题。

4.3 3PC(三阶段提交

三阶段提交协议(3PC)主要是为了解决两阶段提交协议的阻塞问题,2pc存在的问题是当协作者崩溃时,参与者不能做出最后的选择。因此参与者可能在协作者恢复之前保持阻塞。三阶段提交(Three-phase commit),是二阶段提交(2PC)的改进版本。

与两阶段提交不同的是,三阶段提交有两个改动点:

  • 1、引入超时机制。同时在协调者和参与者中都引入超时机制。
  • 2、在第一阶段和第二阶段中插入一个准备阶段。保证了在最后提交阶段之前各参与节点的状态是一致的。(因为协调者下达commit命令后,各参与者在提交时,并不能保证所有的参与者都提交成功,若有一个提交不成功就产生了不一致性,3PC相当于将commit命令拆成了PreCommit和CanCommit 两个命令

也就是说,除了引入超时机制之外,3PC把2PC的准备阶段再次一分为二,这样三阶段提交就有CanCommitPreCommitDoCommit三个阶段。

1、CanCommit 阶段

之前2PC的一阶段是本地事务执行结束后,最后不Commit,等其它服务都执行结束并返回Yes,由协调者发生commit才真正执行commit。而这里的CanCommit指的是 尝试获取数据库锁 如果可以,就返回Yes。

这阶段主要分为2步:

事务询问: 协调者 向 参与者 发送CanCommit请求。询问是否可以执行事务提交操作。然后开始等待 参与者 的响应。
响应反馈: 参与者 接到CanCommit请求之后,正常情况下,如果其自身认为可以顺利执行事务,则返回Yes响应,并进入预备状态。否则反馈No

2、PreCommit 阶段

假如协调者在CanCommit阶段从所有的参与者获得的反馈都是Yes响应,那么就会执行事务的预执行。

  • 1.发送预提交请求 协调者向参与者发送PreCommit请求,并进入Prepared阶段。
  • 2.事务预提交 参与者接收到PreCommit请求后,会执行事务操作,并将undo和redo信息记录到事务日志中,对资源加锁。
  • 3.响应反馈 如果参与者成功的执行了事务操作,则返回ACK响应,同时开始等待最终指令。

假如有任何一个参与者向协调者发送了No响应,或者等待超时之后,协调者都没有接到参与者的响应,那么就执行事务的中断:

  • 1.发送中断请求 协调者向所有参与者发送abort请求。
  • 2.中断事务 参与者收到来自协调者的abort请求之后(或超时之后,仍未收到协调者的请求),执行事务的中断。

3、DoCommit 阶段

该阶段进行真正的事务提交,即对所有资源进行释放,也可以分为以下两种情况:

执行提交

  • 1.发送提交请求 协调者在PreCommit阶段接收到参与者发送的ACK响应,那么他将从预提交状态进入到提交状态。并向所有参与者发送doCommit请求。
  • 2.事务提交 参与者接收到doCommit请求之后,执行正式的事务提交。并在完成事务提交之后释放所有事务资源。
  • 3.响应反馈 事务提交完之后,向协调者发送Ack响应。
  • 4.完成事务 协调者接收到所有参与者的ack响应之后,完成事务。

中断事务

协调者在PreCommit阶段没有接收到某个参与者发送的ACK响应(可能是接受者发送的不是ACK响应,也可能响应超时),那么就会执行中断事务。

  • 1.发送中断请求 协调者向所有参与者发送abort请求
  • 2.事务回滚 参与者接收到abort请求之后,利用其在阶段二记录的undo信息来执行事务的回滚操作,并在完成回滚之后释放所有的事务资源。
  • 3.反馈结果 参与者完成事务回滚之后,向协调者发送ACK消息
  • 4.中断事务 协调者接收到参与者反馈的ACK消息之后,执行事务的中断。

在doCommit阶段,如果参与者无法及时接收到来自协调者的doCommit或者rebort请求时,会在等待超时之后,会继续进行事务的提交。(其实这个应该是基于概率来决定的,当进入第三阶段时,说明参与者在第二阶段已经收到了PreCommit请求,那么协调者产生PreCommit请求的前提条件是他在第二阶段开始之前,收到所有参与者的CanCommit响应都是Yes。(一旦参与者收到了PreCommit,意味他知道大家其实都同意修改了)所以,一句话概括就是,当进入第三阶段时,由于网络超时等原因,虽然参与者没有收到commit或者abort响应,但是他有理由相信:成功提交的几率很大。 )

2PC/3PC小结:

相比较2PC而言,3PC对于协调者(Coordinator)和参与者(Partcipant)都设置了超时时间,而2PC只有协调者才拥有超时机制。这解决了一个什么问题呢?这个优化点,主要是避免了参与者在长时间无法与协调者节点通讯(协调者挂掉了)的情况下,无法释放资源的问题,因为参与者自身拥有超时机制会在超时后,自动进行本地commit从而进行释放资源。而这种机制也侧面降低了整个事务的阻塞时间和范围。

另外,通过CanCommit、PreCommit、DoCommit三个阶段的设计,相较于2PC而言,多设置了一个缓冲阶段保证了在最后提交阶段之前各参与节点的状态是一致的。

以上就是3PC相对于2PC的一个提高(相对缓解了2PC中的前两个问题),但是3PC依然没有完全解决数据不一致的问题。为什么这么说?其实前面讲过了,因为在doCommit阶段,如果参与者无法及时接收到来自协调者的doCommit或者rebort请求时,会在等待超时之后,会继续进行事务的提交。由于网络原因,协调者发送的abort响应没有及时被参与者接收到,那么参与者在等待超时之后执行了commit操作。这样就和其他接到abort命令并执行回滚的参与者之间存在数据不一致的情况。

4.4 补偿机制TCC

前面我们先了解了2pc,知道了2pc强一致性导致的资源被长时间锁住的问题。而后,我们又了解了3pc,3pc在2pc的基础上增加了超时机制,企图解决强一致性带来的问题,但是超时机制明显会造成真正的数据不一致的可能,而且3pc也没有真的解决2pc的数据一致性问题。

TCC是支付宝提出的分布式事务解决方案,用于解决跨服务调用场景下的分布式事务问题,即解决跨库操作的数据一致性问题;

TCC是服务化的两阶段编程模型,其Try、Confirm、Cancel 3个方法均由业务编码实现。每个分布式事务的参与者都需要实现3个接口:try、confirm、cancel(confirm 对应2PC的事务提交,cancel 对应2PC的事务回滚)。

其中Try操作作为一阶段,尝试执行,完成所有业务检查(一致性),预留必需业务资源(准隔离性);Confirm操作作为二阶段提交操作,执行真正的业务,确认真正执行业务,不作任何业务检查,只使用 Try 阶段预留的业务资源,Confirm 操作满足幂等性(也不能重复购买机票啊),要求具备幂等设计,Confirm 失败后需要进行重试;Cancel是预留资源的取消,取消执行,释放 Try 阶段预留的业务资源,Cancel 操作满足幂等性。Cancel 阶段的异常和 Confirm 阶段异常处理方案基本上一致。

4.4.1 场景案例

以航班预定的案例,来介绍TCC要解决的事务场景。

准备从合肥出发,到云南大理去游玩,然后使用美团App(机票代理商)来订机票。发现没有从合肥直达大理的航班,需要到昆明进行中转。

 从图中我们可以看出来,从合肥到昆明乘坐的是四川航空,从昆明到大理乘坐的是东方航空。

 由于使用的是美团App预定,当选择了这种航班预定方案后,美团App要去四川航空和东方航空各帮我购买一张票。如下图: 

考虑最简单的情况:美团先去川航买票,如果买不到,那么东航也没必要买了。如果川航购买成功,再去东航购买另一张票。

现在问题来了:假设美团先从川航成功买到了票,然后去东航买票的时候,因为天气问题,东航航班被取消了。那么此时,美团必须取消川航的票,因为只有一张票是没用的,不取消就是浪费我的钱。那么如果取消会怎样呢?如果读者有取消机票经历的话,非正常退票,肯定要扣手续费的。在这里,川航本来已经购买成功,现在因为东航的原因要退川航的票,川航应该是要扣代理商的钱的。那么美团就要保证,如果任一航班购买失败,都不能扣钱,怎么做呢?

两个航空公司都为美团提供以下3个接口:机票预留接口、确认接口、取消接口。美团App分2个阶段进行调用,如下所示: 

F58C00A0-976A-4295-818C-C9F96B191060.png

在第1阶段:

美团分别请求两个航空公司预留机票,两个航空公司分别告诉美图预留成功还是失败。航空公司需要保证,机票预留成功的话,之后一定能购买到。

在第2阶段:

如果两个航空公司都预留成功,则分别向两个公司发送确认购买请求。

如果两个航空公司任意一个预留失败,则对于预留成功的航空公司也要取消预留。这种情况下,对于之前预留成功机票的航班取消,也不会扣用户的钱,因为购买并没实际发生,之前只是请求预留机票而已。

通过这种方案,可以保证两个航空公司购买机票的一致性,要不都成功,要不都失败,即使失败也不会扣用户的钱。如果在两个航班都已经已经确认购买后,再退票,那肯定还是要扣钱的。

4.4.2 原理

Try阶段:

完成所有业务检查(一致性),预留业务资源(准隔离性)

回顾上面航班预定案例的阶段1,机票就是业务资源,所有的资源提供者(航空公司)预留都成功,try阶段才算成功。

Confirm阶段:

确认执行业务操作,不做任何业务检查, 只使用Try阶段预留的业务资源。

回顾上面航班预定案例的阶段2,美团APP确认两个航空公司机票都预留成功,因此向两个航空公司分别发送确认购买的请求。

Cancel阶段:

取消Try阶段预留的业务资源。

回顾上面航班预定案例的阶段2,如果某个业务方的业务资源没有预留成功,则取消所有业务资源预留请求。 

经过上面的类比就好理解了,整个 TCC 业务分成两个阶段完成:

第一阶段:主业务服务分别调用所有从业务的 try 操作(看看是不是都有机票),并在活动管理器中登记所有从业务服务。当所有从业务服务的 try 操作都调用成功或者某个从业务服务的 try 操作失败,进入第二阶段。

第二阶段:活动管理器根据第一阶段的执行结果来执行 confirm 或 cancel 操作。如果第一阶段所有 try 操作都成功,则活动管理器调用所有从业务活动的 confirm操作。否则调用所有从业务服务的 cancel 操作。

需要注意的是第二阶段 confirm 或 cancel 操作本身也是满足最终一致性的过程,在调用 confirm 或 cancel 的时候也可能因为某种原因(比如网络)导致调用失败,所以需要活动管理支持重试的能力,同时这也就要求 confirm 和 cancel 操作具有幂等性

TCC 模式也不能百分百保证一致性,如果业务服务向 TCC 服务框架提交 confirm后,TCC 服务框架向某个工作服务提交 confirm 失败(比如网络故障),那么就会出现不一致,一般称为 heuristic exception。另外 heuristic exception 是不可杜绝的,但是可以通过设置合适的超时时间,以及重试频率和监控措施使得出现这个异常的可能性降低到很小。如果出现了heuristic exception 是可以通过人工的手段补救的。

幂等性:

一般认为,服务的幂等性,是指针对同一个服务的多次(n>1)请求和对它的单次(n=1)请求,二者具有相同的副作用。

在TCC事务模型中,Confirm/Cancel业务可能会被重复调用,其原因很多。比如,全局事务在提交/回滚时会调用各TCC服务的Confirm/Cancel业务逻辑。执行这些Confirm/Cancel业务时,可能会出现如网络中断的故障而使得全局事务不能完成。因此,故障恢复机制后续仍然会重新提交/回滚这些未完成的全局事务,这样就会再次调用参与该全局事务的各TCC服务的Confirm/Cancel业务逻辑。

那么,应该由TCC事务框架来提供幂等性保障?还是应该由业务系统自行来保障幂等性呢?

幂等操作实现方式有:
1、 利用唯一请求编号去重,只要请求有唯一的请求编号,那么就能借用 Redis 做去重。只要这个唯一请求编号在 Redis 存在,证明处理过,那么就认为是重复的。但是该方案能解决具备唯一请求编号的场景,例如每次写请求之前都是服务端返回一个唯一编号给客户端,客户端带着这个请求号做请求,服务端即可完成去重拦截。
2、 缓存所有请求和处理的结果,已经处理的请求则直接返回结果。
3、 在数据库表中加一个状态字段(未处理,已处理),数据操作时判断未处理时再处理

4、使用Post/Redirect/Get模式:在提交后执行页面重定向,这就是所谓的Post-Redirect-Get (PRG)模式。简言之,当用户提交了表单后,你去执行一个客户端的重定向,转到提交成功信息页面。这能避免用户按F5导致的重复提交,而其也不会出现浏览器表单重复提交的警告,也能消除按浏览器前进和后退按导致的同样问题。

TCC与2PC比较:

TCC与2PC二阶段提交机制类似,区别在于层面不同,2PC是在数据库层面解决数据库之间的分布式事务,TCC是在应用层面解决分布式系统中的分布式事务。

4.5 消息队列实现最终一致

下边以下单减少库存为例来说明:
1、订单服务库存服务完成检查和预留资源。
2、订单服务在本地事务中完成添加订单表记录和添加“减少库存任务消息”。
3、由定时任务根据消息表的记录发送给MQ通知库存服务执行减库存操作。
4、库存服务执行减少库存,并且记录执行消息状态(为避免重复执行消息,在执行减库存之前查询是否执行过此消息)。
5、库存服务向MQ发送完成减少库存的消息。
6、订单服务接收到完成库存减少的消息后删除原来添加的“减少库存任务消息”。
实现最终事务一致要求:预留资源成功理论上要求正式执行成功,如果执行失败会进行重试,要求业务执行方法实现幂等。
优点 :由MQ按异步的方式协调完成事务,性能较高。不用实现try/confirm/cancel接口,开发成本比TCC低。
缺点:基于关系数据库本地事务来实现,会出现频繁读写数据库记录,浪费数据库资源,另外对于高并发操作不是最佳方案。

总结:

上面的不论任何一种方案都会增加你系统的复杂度,这样的成本实在是太高了,千万不要因为追求某些设计,而引入不必要的成本和复杂度。

最好的方案是把需要事务的微服务聚合成一个单机服务,使用数据库的本地事务。 能不用分布式事务就不用,如果非得使用的话,结合自己的业务分析,看看自己的业务比较适合哪一种,是在乎强一致,还是最终一致即可。上面对解决方案只是一些简单介绍,如果真正的想要落地,其实每种方案需要思考的地方都非常多,复杂度都比较大。

4.6 如何利用事务消息实现分布式事务?

消息队列中的事务主要解决的是消息生产者和消息消费者的数据一致性问题。

拿电商来举个例子,一般来说,用户在电商APP上购物时,先把商品加到购物车里,然后几件商品一起下单,最后支付,完成购物流程,就可以等待收货了。这个过程中有一个需要用到消息队列的步骤,订单系统创建订单后,发消息给购物车系统,将已下单的商品从购物车中删除。

因为从购物车删除已下单商品这个步骤,并不是用户下单支付这个主要流程中必需的步骤,使用消息队里来异步清理购物车是更加合理的设计。

图片

对于订单系统来说,它创建订单的过程中实际上执行了2个步骤的操作:

  • 在订单库中插入一条订单数据,创建订单

  • 发消息给消息队列,消息的内容就是刚刚创建的订单

购物车系统订阅相应的主题,接收订单创建的消息,然后清理购物车,在购物车中删除订单中的商品。

问题的关键点集中在订单系统,创建订单和发送消息这两个步骤要么都操作成功,要么都操作失败,不允许一个成功而另一个失败的情况出现。

回到订单和购物车这个例子,来看下如何用消息队列来实现分布式事务

图片

首先,订单系统在消息队列上开启了一个事务。然后订单系统给消息服务器发送一个半消息,这个半消息包含的内容是完整的消息内容,和普通消息的唯一区别是,在事务提交之前,对于消费者来说,这个消息是不可见的

半消息发送成功后,订单系统就可以执行本地事务了,在订单库中创建一条订单记录,并提交订单库的数据库事务。然后根据本地事务的执行结果决定提交或者回滚事务消息。如果订单创建成功,那就提交事务消息,购物车系统就可以消费到这条消息继续后续的流程。如果订单创建失败,那就回滚事务消息,购物车系统就不会收到这条消息。这样就基本实现了要么都成功,要么都失败的一致性要求

如果在第四步提交事务消息时失败了,Kafka会直接抛出异常,让用户自行处理,可以在业务代码中反复重试提交,直到提交成功,或者删除之前创建的订单进行补偿。

RocketMQ中的分布式事务实现

在RocketMQ中的事务实现中,增加了事务反查的机制来解决事务消息提交失败的问题。如果Producer也就是订单系统,在提交或者回滚事务消息时发生网络异常,RocketMQ的Broker没有收到提交或者回滚的请求,Broker会定期去Producer上反查这个事务对应的本地事务的状态,然后根据反查结果决定提交或者回滚这个事务。

为了支撑这个事务反查机制,业务代码中需要实现一个反查本地事务状态的接口,告知RocketMQ本地事务是成功还是失败。

在订单系统的例子中,反查本地事务的逻辑只要根据消息中的订单ID,在订单库中查询这个订单是否存在即可,如果订单存在则返回成功,否则返回失败。RocketMQ会自动根据事务反查的结果提交或者回滚事务消息。

这个反查本地事务的实现,并不依赖消息的发送方,也就是订单服务的某个实例节点上的任何数据。这种情况下,即使是发送事务消息的那个订单服务节点宕机了,RocketMQ依然可以通过其他订单服务的节点来执行反查,确保事务的完整性。

使用RocketMQ事务消息功能实现分布式事务的流程如下图:

图片

四、BASE理论

BASE理论是实现分布式事务最终一致性的有效解决方案。

BASE是Basically Available(基本可用)、Soft state(软状态)和Eventually consistent(最终一致性)三个短语的缩写。

BASE理论是对CAP中一致性和可用性权衡的结果,来源于对大规模互联网系统分布式实践总结。

BASE理论的核心思想是:即使无法做到强一致性,但每个应用都可以根据自身业务特点,采用适当的方式来使系统达到最终一致性。 

BASE理论三要素:

1、基本可用

基本可用是指分布式系统在出现不可预知故障的时候,允许损失部分可用性----注意,这绝不等价于系统不可用。比如:

(1)响应时间上的损失。正常情况下,一个在线搜索引擎需要在0.5秒之内返回给用户相应的查询结果,但由于出现故障,查询结果的响应时间增加了1~2秒。

(2)系统功能上的损失:正常情况下,在一个电子商务网站上进行购物的时候,消费者几乎能够顺利完成每一笔订单,但是在一些节日大促购物高峰的时候,由于消费者的购物行为激增,为了保护购物系统的稳定性,部分消费者可能会被引导到一个降级页面。

2、软状态

软状态指允许系统中的数据存在中间状态,并认为该中间状态的存在不会影响系统的整体可用性,即允许系统在不同节点的数据副本之间进行数据同步的过程存在延时。

3、最终一致性

  最终一致性强调的是所有的数据副本,在经过一段时间的同步之后,最终都能够达到一个一致的状态。因此,最终一致性的本质是需要系统保证最终数据能够达到一致,而不需要实时保证系统数据的强一致性。

总的来说,BASE理论面向的是大型高可用可扩展的分布式系统,和传统的事物ACID特性是相反的,严格遵守ACID的分布式事务我们称为刚性事务,它完全不同于ACID的强一致性模型,而是通过牺牲强一致性来获得可用性,并允许数据在一段时间内是不一致的,但最终达到一致状态。但同时,在实际的分布式场景中,不同业务单元和组件对数据一致性的要求是不同的,因此在具体的分布式系统架构设计过程中,ACID特性和BASE理论往往又会结合在一起。

五、高并发

5.1 海量数据的解决方案

  •  使用缓存
  • 页面静态化技术
  • 数据库优化
  • 分离数据库中活跃的数据
  • 批量读取和延迟修改
  • 读写分离
  • 使用NoSQL和Hadoop等技术
  • 分布式部署数据库
  • 应用服务和数据服务分离
  • 使用搜索引擎搜索数据库中的数据
  • 进行业务的拆分

5.2 高并发情况下的解决方案:

  • 应用程序和静态资源文件进行分离
  • 页面缓存
  • 集群与分布式
  • 反向代理
  • CDN

5.3 高并发场景下的限流策略:

  • 信号量
  • 计数器(限制请求数量)
  • 滑动窗口;漏桶算法
  • 令牌桶算法
  • 分布式限流

六、分布式锁

6.1 为什么要用分布式锁

单机应用中,如果需要对某一个共享变量进行多线程同步访问的时候,可以使用Java多线程技术去处理,实现需求。但这是单机应用,也就是所有的请求都会分配到当前服务器的JVM内部,然后映射为操作系统的线程进行处理,而这个共享变量只是在这个JVM内部的一块内存空间。

随着业务发展,如果是集群,一个应用需要部署到几台机器上然后做负载均衡,如下:

变量A存在JVM1、JVM2、JVM3三个JVM内存中,如果不加任何控制的话,变量A同时都会在JVM分配一块内存,三个请求发过来同时对这个变量操作,结果A变量就会出现不一致的现象。即使不是同时发过来,三个请求分别操作三个不同JVM内存区域的数据,但分布式的变量A之间不存在共享,也不具有可见性,处理的结果也是不对的!

为了保证一个方法或属性在高并发情况下的同一时间只能被同一个线程执行,在传统单体应用单机部署的情况下,可以使用Java并发处理相关的API(如ReentrantLock或Synchronized)进行互斥控制。在单机环境中,Java中提供了很多并发处理相关的API。但是,随着业务发展的需要,原单体单机部署的系统被演化成分布式集群系统后,由于分布式系统多线程、多进程并且分布在不同机器上,这将使原单机部署情况下的并发控制锁策略失效,单纯的Java API并不能提供分布式锁的能力。为了解决这个问题就需要一种跨JVM的互斥机制来控制共享资源的访问,这就是分布式锁要解决的问题!

6.2 分布式锁应该具备哪些条件

1、在分布式系统环境下,一个方法在同一时间只能被一个机器的一个线程执行; 
2、高可用的获取锁与释放锁; 
3、高性能的获取锁与释放锁; 
4、具备可重入特性; 
5、具备锁失效机制,防止死锁; 
6、具备非阻塞锁特性,即没有获取到锁将直接返回获取锁失败。

6.3 分布式锁的三种实现方式

  • 基于数据库实现分布式锁; 
  • 基于缓存(Redis等)实现分布式锁; 
  • 基于Zookeeper实现分布式锁;

6.3.1 基于数据库实现分布式锁

表结构:

获取锁:

INSERT INTO method_lock (method_name, desc) VALUES ('methodName', 'methodName');

对method_name做了唯一性约束,这里如果有多个请求同时提交到数据库的话,数据库会保证只有一个操作可以成功。

6.3.2 基于缓存(Redis等)实现分布式锁

分布式锁实现的关键是在分布式的应用服务器外,搭建一个存储服务器,存储锁信息,这时候我们很容易就想到了Redis。首先我们要搭建一个Redis服务器,用Redis服务器来存储锁信息。在 Redis 中设置一个值表示加了锁,然后释放锁的时候就把这个 Key 删除。

在实现的时候要注意的几个关键点:

1、锁信息必须是会过期超时的,不能让一个线程长期占有一个锁而导致死锁;

2、同一时刻只能有一个线程获取到锁。

实现 Redis 的分布式锁,除了自己基于 Redis Client 原生 API 来实现之外,还可以使用开源框架:Redission。

Redisson 是一个企业级的开源 Redis Client,也提供了分布式锁的支持。

Config config = new Config(); 
config.useClusterServers() 
.addNodeAddress("redis://192.168.31.101:7001") 
.addNodeAddress("redis://192.168.31.101:7002") 
.addNodeAddress("redis://192.168.31.101:7003") 
.addNodeAddress("redis://192.168.31.102:7001") 
.addNodeAddress("redis://192.168.31.102:7002") 
.addNodeAddress("redis://192.168.31.102:7003"); 
 
RedissonClient redisson = Redisson.create(config); 
 
RLock lock = redisson.getLock("anyLock"); 
lock.lock(); 
lock.unlock(); 

我们只需要通过它的 API 中的 Lock 和 Unlock 即可完成分布式锁,它帮我们考虑了很多细节:

  • Redisson 所有指令都通过 Lua 脚本执行,Redis 支持 Lua 脚本原子性执行。
  • Redisson 设置一个 Key 的默认过期时间为 30s,如果某个客户端持有一个锁超过了 30s 怎么办?
  • Redisson 中有一个 Watchdog 的概念,翻译过来就是看门狗,它会在你获取锁之后,每隔 10s 帮你把 Key 的超时时间设为 30s。这样的话,就算一直持有锁也不会出现 Key 过期了,其他线程获取到锁的问题了。
  • Redisson 的“看门狗”逻辑保证了没有死锁发生。(如果机器宕机了,看门狗也就没了。此时就不会延长 Key 的过期时间,到了 30s 之后就会自动过期了,其他线程可以获取到锁)

1、加锁机制

现在某个客户端要加锁。如果该客户端面对的是一个redis cluster集群,他首先会根据hash节点选择一台机器。紧接着,就会发送一段lua脚本到redis上(为啥要用lua脚本呢?因为一大坨复杂的业务逻辑,可以通过封装在lua脚本中发送给redis,保证这段复杂业务逻辑执行的原子性。)

如何加锁呢?很简单,用下面的命令:

hset myLock 
    8743c9c0-0795-4907-87fd-6c719a6b4586:1 1

通过这个命令设置一个hash数据结构,这行命令执行后,会出现一个类似下面的数据结构:

上述就代表“8743c9c0-0795-4907-87fd-6c719a6b4586:1”这个客户端对“myLock”这个锁key完成了加锁。

接着会执行“pexpire myLock 30000”命令,设置myLock这个锁key的生存时间是30秒。

2、锁互斥机制

如果客户端2来尝试加锁,执行了同样的一段lua脚本,会咋样呢?

第一个if判断会执行“exists myLock”,发现myLock这个锁key已经存在了。

接着第二个if判断myLock锁key的hash数据结构中,是否包含客户端2的ID,但是明显不是的,因为那里包含的是客户端1的ID。

客户端2会获取到pttl myLock返回的一个数字,这个数字代表了myLock这个锁key的剩余生存时间。比如还剩15000毫秒的生存时间。此时客户端2会进入一个while循环,不停的尝试加锁。

3、watch dog自动延期机制

获取锁之后,每隔 10s 帮你把 Key 的超时时间设为 30s。这样的话,就算一直持有锁也不会出现 Key 过期了,其他线程获取到锁的问题了。

客户端1加锁的锁key默认生存时间才30秒,如果超过了30秒,客户端1还想一直持有这把锁,怎么办呢?

只要客户端1一旦加锁成功,就会启动一个watch dog看门狗,他是一个后台线程,会每隔10秒检查一下,如果客户端1还持有锁key,那么就会不断的延长锁key的生存时间。

 4、可重入加锁机制

如果客户端1都已经持有了这把锁了,结果可重入的加锁会怎么样呢?

第一个if判断肯定不成立,“exists myLock”会显示锁key已经存在了。

第二个if判断会成立,因为myLock的hash数据结构中包含的那个ID,就是客户端1的那个ID,也就是“8743c9c0-0795-4907-87fd-6c719a6b4586:1”

此时就会执行可重入加锁的逻辑,用:

incrby myLock 
    8743c9c0-0795-4907-87fd-6c71a6b4586:1 1

通过这个命令,对客户端1的加锁次数,累加1。

5、释放锁机制

如果执行lock.unlock(),就可以释放分布式锁,此时的业务逻辑也是非常简单的。

每次都对myLock数据结构中的那个加锁次数减1。

如果发现加锁次数是0了,说明这个客户端已经不再持有锁了,此时就会用“del myLock”命令,从redis里删除这个key。

另外的客户端2就可以尝试完成加锁了。

以上就是分布式锁的开源Redisson框架的实现机制。

 Redis分布式锁的缺点:

如果对某个redis master实例,写入了myLock这种锁key的value,此时会异步复制给对应的master slave实例。

但是这个过程中一旦发生redis master宕机,主备切换,redis slave变为了redis master。

接着就会导致,客户端2来尝试加锁的时候,在新的redis master上完成了加锁,而客户端1也以为自己成功加了锁。

此时就会导致多个客户端对一个分布式锁完成了加锁。这时系统在业务语义上一定会出现问题,导致各种脏数据的产生

所以这个就是redis cluster,或者是redis master-slave架构的主从异步复制导致的redis分布式锁的最大缺陷:在redis master实例宕机的时候,可能导致多个客户端同时完成加锁。

6.3.3  基于zookeeper实现

Zookeeper节点介绍:

Zookeeper的数据存储结构就像一棵树,这棵树由节点组成,这种节点叫做Znode。

Znode分为四种类型:

1.持久节点 (PERSISTENT)

默认的节点类型。创建节点的客户端与zookeeper断开连接后,该节点依旧存在 。

2.持久节点顺序节点(PERSISTENT_SEQUENTIAL)

所谓顺序节点,就是在创建节点时,Zookeeper根据创建的时间顺序给该节点名称进行编号

3.临时节点(EPHEMERAL)

和持久节点相反,当创建节点的客户端与zookeeper断开连接后,临时节点会被删

4.临时顺序节点(EPHEMERAL_SEQUENTIAL)

临时顺序节点结合和临时节点和顺序节点的特点:在创建节点时,Zookeeper根据创建的时间顺序给该节点名称进行编号;当创建节点的客户端与zookeeper断开连接后,临时节点会被删除。

Zookeeper分布式锁的原理:Zookeeper分布式锁应用了临时顺序节点。

详细步骤:

1、获取锁

首先,在Zookeeper当中创建一个持久节点ParentLock。当第一个客户端想要获得锁时,需要在ParentLock这个节点下面创建一个临时顺序节点 Lock1。

之后,Client1查找ParentLock下面所有的临时顺序节点并排序,判断自己所创建的节点Lock1是不是顺序最靠前的一个。如果是第一个节点,则成功获得锁。

这时候,如果再有一个客户端 Client2 前来获取锁,则在ParentLock下载再创建一个临时顺序节点Lock2。

Client2查找ParentLock下面所有的临时顺序节点并排序,判断自己所创建的节点Lock2是不是顺序最靠前的一个,结果发现节点Lock2并不是最小的。于是,Client2向排序仅比它靠前的节点Lock1注册Watcher,用于监听Lock1节点是否存在。这意味着Client2抢锁失败,进入了等待状态。

这时候,如果又有一个客户端Client3前来获取锁,则在ParentLock下载再创建一个临时顺序节点Lock3。

Client3查找ParentLock下面所有的临时顺序节点并排序,判断自己所创建的节点Lock3是不是顺序最靠前的一个,结果同样发现节点Lock3并不是最小的。于是,Client3向排序仅比它靠前的节点Lock2注册Watcher,用于监听Lock2节点是否存在。这意味着Client3同样抢锁失败,进入了等待状态。

这样一来,Client1得到了锁,Client2监听了Lock1,Client3监听了Lock2。这恰恰形成了一个等待队列,很像是Java当中ReentrantLock所依赖的。

2、释放锁

释放锁分为两种情况:

(1)任务完成,客户端显示释放

当任务完成时,Client1会显示调用删除节点Lock1的指令。

(2)任务执行过程中,客户端崩溃

获得锁的Client1在任务执行过程中,如果Duang的一声崩溃,则会断开与Zookeeper服务端的链接。根据临时节点的特性,相关联的节点Lock1会随之自动删除。

由于Client2一直监听着Lock1的存在状态,当Lock1节点被删除,Client2会立刻收到通知。这时候Client2会再次查询ParentLock下面的所有节点,确认自己创建的节点Lock2是不是目前最小的节点。如果是最小,则Client2顺理成章获得了锁。

同理,如果Client2也因为任务完成或者节点崩溃而删除了节点Lock2,那么Client3就会接到通知。

最终,Client3成功得到了锁。

总结:

性能上可能并没有缓存服务那么高。因为每次在创建锁和释放锁的过程中,都要动态创建、销毁瞬时节点来实现锁功能。ZK中创建和删除节点只能通过Leader服务器来执行,然后将数据同不到所有的Follower机器上。

其实,使用Zookeeper也有可能带来并发问题,只是并不常见而已。考虑这样的情况,由于网络抖动,客户端可ZK集群的session连接断了,那么zk以为客户端挂了,就会删除临时节点,这时候其他客户端就可以获取到分布式锁了。就可能产生并发问题。这个问题不常见是因为zk有重试机制,一旦zk集群检测不到客户端的心跳,就会重试,Curator客户端支持多种重试策略。多次重试之后还不行的话才会删除临时节点。(所以,选择一个合适的重试策略也比较重要,要在锁的粒度和并发之间找一个平衡。)

三种方案的比较

上面几种方式,哪种方式都无法做到完美。就像CAP一样,在复杂性、可靠性、性能等方面无法同时满足,所以,根据不同的应用场景选择最适合自己的才是王道。

从理解的难易程度角度(从低到高)

数据库 > 缓存 > Zookeeper

从实现的复杂性角度(从低到高)

Zookeeper >= 缓存 > 数据库

从性能角度(从高到低)

缓存 > Zookeeper >= 数据库

从可靠性角度(从高到低)

Zookeeper > 缓存 > 数据库 

七、分布式任务调度平台Xxl-job

项目开发中,常常以下场景需要分布式任务调度:

  1. 同一服务多个实例的任务存在互斥时,需要统一协调
  2. 定时任务的执行需要支持高可用、监控运维、故障告警
  3. 需要统一管理和追踪各个服务节点定时任务的运行情况,以及任务属性信息,例如任务所属服务、所属责任人

对于第一个,比如你的项目里有一个定时任务,每天早晨8点触发,给管理员发个消息。这种任务场景在项目只有一个部署实例的时候没有问题,但若这个项目有多个部署实例,那每天到了8点所有该服务的部署实例都会触发这个任务,那管理员会收到多个一样的消息,这样明显不合理。

我们希望的是虽然有多个服务实例但只会有一个去执行这个定时提醒任务,那怎么办?大家可以想到加分布式锁,利用redis的setIfAbsent(key, value,timeout)设置一个锁,并设置合理的过期时间,这样保证在8点的时候只有一个实例会执行任务,这样当然是可以的,但是这里timeout过期时间需要根据业务好好设计一下。除了分布式锁还有一种就是第三方分布式任务调服务,比如Xxl-job。

Xxl-job是一个开源的轻量级分布式任务调度平台,其核心设计目标是开发迅速、学习简单、轻量级、易扩展、开箱即用,其中“Xxl”是主要作者,大众点评许雪里名字的缩写

7.1 Xxl-job的功能特性

  • 简单灵活
    提供Web页面对任务进行管理,管理系统支持用户管理、权限控制;
    支持容器部署;
    支持通过通用HTTP提供跨平台任务调度;

  • 丰富的任务管理功能
    支持页面对任务CRUD操作;
    支持在页面编写脚本任务、命令行任务、Java代码任务并执行;
    支持任务级联编排,父任务执行结束后触发子任务执行;
    支持设置任务优先级;
    支持设置指定任务执行节点路由策略,包括轮询、随机、广播、故障转移、忙碌转移等;
    支持Cron方式、任务依赖、调度中心API接口方式触发任务执行

  • 高性能
    调度中心基于线程池多线程触发调度任务,快任务、慢任务基于线程池隔离调度,提供系统性能和稳定性;
    任务调度流程全异步化设计实现,如异步调度、异步运行、异步回调等,有效对密集调度进行流量削峰;

  • 高可用
    任务调度中心、任务执行节点均 集群部署,支持动态扩展、故障转移
    支持任务配置路由故障转移策略,执行器节点不可用是自动转移到其他节点执行
    支持任务超时控制、失败重试配置
    支持任务处理阻塞策略:调度当任务执行节点忙碌时来不及执行任务的处理策略,包括:串行、抛弃、覆盖策略

  • 易于监控运维
    支持设置任务失败邮件告警,预留接口支持短信、钉钉告警;
    支持实时查看任务执行运行数据统计图表、任务进度监控数据、任务完整执行日志;

7.2 系统设计

将调度行为抽象形成“调度中心”公共平台,而平台自身并不承担业务逻辑,“调度中心”负责发起调度请求;

将任务抽象成分散的JobHandler,交由“执行器”统一管理,“执行器”负责接收调度请求并执行对应的JobHandler中业务逻辑;
因此,“调度”和“任务”两部分可以相互解耦,提高系统整体稳定性和扩展性;

7.3 原理

  • 任务执行器根据配置的调度中心的地址,自动注册到调度中心
  • 达到任务触发条件,调度中心下发任务
  • 执行器基于线程池执行任务,并把执行结果放入内存队列中、把执行日志写入日志文件中
  • 执行器的回调线程消费内存队列中的执行结果,主动上报给调度中心
  • 当用户在调度中心查看任务日志,调度中心请求任务执行器,任务执行器读取任务日志文件并返回日志详情

补充一、CAP原则为什么只能满足其中两项?而不能同时满足

我们了解了CAP中的三个定义,CAP定理是表示分布式系统只能满足三项中的两项,而不可能满足全部三项。即分布式系统只能满足三种情况:CA、AP、CP

我们来分析一下,我们先看P,也就是分区容错性;在分布式系统中,网络异常是不可避免的,所以如果不保证分区容错性,除非节点间网络不会发生异常,这个是不可能的(除非单机系统,单机系统就不是分布式系统)。

所以,分布式系统肯定要实现P,那其实CA是理论上面的,其实不存在。

那三个都满足呢?看一下图:

 主Mysql和从Mysql之间出现了网络异常,那研发Mysql的工程师如何去做?

场景一:更新操作主Mysql成功了,就返回成功

 写请求把用户姓名【张三】改为【李四】,写请求写入主Mysql成功后,系统就直接返回成功;然后再通过主Mysql的binlog日志方式把数据同步到从Mysql。

这种方式其实是放弃了数据一致性。因为如果出现网络延迟,数据没有及时同步到从Mysql,那就导致了主Mysql值为李四,而从Mysql值为张三,导致数据不一致。但主从mysql照样可以提供服务,也就是保证了可用性A

即此方案为AP。

场景二:更新操作主从mysql都成功了,才返回成功

写请求把用户姓名【张三】改为【李四】,写请求一定要等到主从mysql都写入成功了,系统才能成功返回。

这种方式保证了数据一致性,因为主从mysql更新数据都成功才算成功,但网络出现问题时,主mysql无法访问从节点,导致写操作一直不成功

其实就是放弃了可用性,只满足CP原则,系统只能提供读服务。

小伙伴们会说不是系统能够提供读服务吗?应该系统是可用的啊。我们再看看可用性的定义:非故障节点,要能够提供服务。而这里主Mysql节点是正常的(符合非故障节点),而不能提供写请求,不符合可用性原则。

综合来看,再满足P的前提下,是不可能同时满足C和A的。

补充二、开发分布式系统时,如何去权衡CAP?

在我们架构师开发分布式系统时,是需要根据业务进行权衡的。

在我们大型互联网公司,因为机器数量庞大,网络故障是常态,一般选择AP原则牺牲掉数据一致性。(一些金融产品对数据一致性要求很高的,就会选择CP)。

小伙伴们会问,那数据很重要啊,不一致那怎么搞?当然有别的方案会保证数据最终一致性,也就是BASE理论的提出。

我们看看常用的分布式系统的权衡

1、Redis中间件 ----> AP

2、RocketMQ中间件 -----> AP

3、分布式事务-2pc ----> CP

4、分布式事务-最大努力尝试 ---> AP

5、Eureka ---> AP

补充三、秒杀设计思路

秒杀系统场景特点

  • 秒杀一般是访问请求数量远远大于库存数量,只有少部分用户能够秒杀成功
  • 秒杀时大量用户会在同一时间同时进行抢购,网站瞬时访问流量激增
  • 秒杀业务流程比较简单,一般就是下订单减库存

秒杀架构设计理念

一般的秒杀系统架构: 

限流: 鉴于只有少部分用户能够秒杀成功,所以要限制大部分流量,只允许少部分流量进入服务后端秒杀程序。

削峰:对于秒杀系统瞬时会有大量用户涌入,所以在抢购一开始会有很高的瞬间峰值。高峰值流量是压垮系统很重要的原因,所以如何把瞬间的高流量变成一段时间平稳的流量也是设计秒杀系统很重要的思路。实现削峰的常用的方法有前端添加一定难度的验证码后端利用缓存和消息中间件等技术。

异步处理:秒杀系统是一个高并发系统,采用异步处理模式可以极大地提高系统并发量,其实异步处理就是削峰的一种实现方式。

内存缓存:秒杀系统最大的瓶颈一般都是数据库读写,由于数据库读写属于磁盘IO,性能很低,如果能够把部分数据或业务逻辑转移到内存缓存,效率会有极大地提升。

物理可拓展:当然如果我们想支持更多用户,更大的并发,最好就将系统设计成弹性可拓展的,如果流量来了,拓展机器就好了。像淘宝、京东等双十一活动时会增加大量机器应对交易高峰。

具体落地:

(1) 将请求拦截在系统上游,降低下游压力:秒杀系统特点是并发量极大,但实际秒杀成功的请求数量却很少,所以如果不在前端拦截很可能造成数据库读写锁冲突,甚至导致死锁,最终请求超时

(2) 充分利用缓存:利用缓存预减库存,拦截掉大部分请求 

(3) 消息队列:这是一个异步处理过程,后台业务根据自己的处理能力,从消息队列中主动的拉取请求消息进行业务处理

前端方案

(3) 页面静态化:将活动页面上的所有可以静态的元素全部静态化,并尽量减少动态元素。通过CDN来抗峰值。 
(4) 禁止重复提交:用户提交之后按钮置灰,禁止重复提交 
(5) 用户限流:在某一时间段内只允许用户提交一次请求,比如可以采取IP限流

后端方案

(6) 服务端控制器层(网关层)

限制uid(UserID)访问频率:我们上面拦截了浏览器访问的请求,但针对某些恶意攻击或其它插件,在服务端控制层需要针对同一个访问uid,限制访问频率。

(7) 服务层

上面只拦截了一部分访问请求,当秒杀的用户量很大时,即使每个用户只有一个请求,到服务层的请求数量还是很大。比如我们有100W用户同时抢100台手机,服务层并发请求压力至少为100W。

      1、把需要秒杀的商品的主要信息以及库存初始化到redis缓存中

      2、做请求合法性的校验(比如是否登录),如果请求非法,直接给前端返回错误码,进行相应的提示

      3、进行内存标识的判断(true 已经秒杀结束,false 未秒杀结束,下面第4步会写入),如果内存标识为true,直接返回秒杀结束

      4、redis中使用decr 进行预减库存操作,判断:如果decr后库存量小于0,则把内存标记置为true(已经秒杀结束,第3步会用到),且返回秒杀结束

      5、用redis的布隆过滤器来判断是否已经秒杀到了(下面第7步会写入),防止重复秒杀,如果重复秒杀,直接返回重复秒杀的错误码。具体做法是:先用redis的布隆过滤器来判断是否秒杀过,如果布隆过滤器判断已经秒杀过了, 则再次查库确认是否秒杀过了,之所以再次查库确认是因为布隆过滤器对可能存在的数据是有误判率的;但是它对不存在的数据的判断是百分百准确的,所以如果redis的布隆过滤器判断没秒杀过,就直接放过去进行秒杀

      6、发送成功秒杀到的MQ消息给相应的业务端进行处理,并给用户端返回排队中,如果客户端收到排队中的消息,则自动进行轮询查询,直到返回秒杀成功或者秒杀失败为止

      7、相应的业务端进行处理:真正处理秒杀的业务端,再次进行校验(比如秒杀是否结束,库存是否充足等)、将用户和商品id作为key存入redis的布隆过滤器来标识该用户秒杀该商品成功(第5步会用到)、减库存(这里的是真正的减库存,操作数据库的库存)、生成秒杀订单、返回秒杀成功

        注意:就算请求走到了真正处理业务的这一端,也有可能秒杀失败,比如秒杀结束,库存不足,真正减库存失败,秒杀单生成失败等等,一旦失败,则返回秒杀结束

优化:将秒杀接口隐藏:用户点击秒杀按钮的时候,根据用户id生成唯一的加密串存入缓存并返回给客户端,然后客户端再次请求的时候带着加密串过来,后端进行校验是否合法,若不合法,直接返回请求非法;

       限制某个接口的访问频率:可以用拦截器配合自定义注解来实现,这么做可以和具体的业务分离减少入侵,使用起来也非常方便

数据库层
数据库层是最脆弱的一层,一般在应用设计时在上游就需要把请求拦截掉,数据库层只承担“能力范围内”的访问请求。所以,上面通过在服务层引入队列和缓存,让最底层的数据库高枕无忧

为防止秒杀出现负数订单数大于真正的库存数,所以在真正减库存,update库存的时候应该加上where 库存>0,而且需要给秒杀订单表加上用户id和商品id联合的唯一索引。

补充四、分布式ID生成器设计与实现

一、为什么需要分布式ID?

在软件系统演进过程中,随着业务规模的增长,我们需要进行集群化部署来分摊计算、存储压力,应用服务我们可以很轻松做到无状态、弹性伸缩。 但是仅仅增加服务副本数就够了吗?显然不够,因为性能瓶颈往往是在数据库层面,那么这个时候我们就需要考虑如何进行数据库的扩容、伸缩、集群化,通常使用分库、分表的方式来处理。 那么我如何分片(水平分片,当然还有垂直分片不过不是本文需要讨论的内容)呢,分片得前提是我们得先有一个ID,然后才能根据分片算法来分片。

现实中很多业务都有生成唯一ID的需求,例如:

  • 用户ID
  • 微博ID
  • 聊天消息ID
  • 帖子ID
  • 订单ID

 二、分布式ID方案的核心指标

  • 全局唯一(unique)
  • 按照时间粗略有序(sortable by time)
  • 尽可能短
  • 自治性:对外部环境有无依赖

三、解决方案

经常用到的解决方案有以下几种:

  • 微软公司通用唯一识别码(UUID)
  • Twitter公司雪花算法(SnowFlake)
  • 基于数据库的id自增
  • 对id进行缓存

四、UUID

UUID是一类算法的统称,具体有不同的实现。UUID的有点是每台机器可以独立产生ID,不依赖任何第三方中间件,理论上保证不会重复,所以天然是分布式的,缺点是生成的ID太长,不仅占用内存,而且索引查询效率低。但是性能高。

UUID最大的缺陷是随机的、无序的,当用于主键时会导致数据库的主键索引效率低下(为了维护索引树,频繁的索引中间位置插入数据,而不是追加写)。这也是UUID不适用于数据库主键的最为重要的原因。

五、SnowFlake

snowflake是Twitter开源的分布式ID生成算法,结果是一个long型的ID。 

 其核心思想是:使用41bit作为毫秒数,10bit作为机器的ID(5个bit是数据中心,5个bit的机器ID),12bit作为毫秒内的流水号,最后还有一个符号位,永远是0。

整个结构是64位,所以我们在Java中可以使用long来进行存储。

该算法实现基本就是二进制操作,单机每秒内理论上最多可以生成1024*(2^12),也就是409.6万个ID(1024 X 4096 = 4194304)

Spring boot集成

1.引入hutool依赖

<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-captcha</artifactId>
    <version>${hutool.version}</version>
</dependency>

2.ID 生成器

public class IdGenerator {

    private long workerId = 0;

    @PostConstruct
    void init() {
        try {
            workerId = NetUtil.ipv4ToLong(NetUtil.getLocalhostStr());
            log.info("当前机器 workerId: {}", workerId);
        } catch (Exception e) {
            log.warn("获取机器 ID 失败", e);
            workerId = NetUtil.getLocalhost().hashCode();
            log.info("当前机器 workerId: {}", workerId);
        }
    }

    /**
     * 获取一个批次号,形如 2019071015301361000101237
     * <p>
     * 数据库使用 char(25) 存储
     *
     * @param tenantId 租户ID,5 位
     * @param module   业务模块ID,2 位
     * @return 返回批次号
     */
    public synchronized String batchId(int tenantId, int module) {
        String prefix = DateTime.now().toString(DatePattern.PURE_DATETIME_MS_PATTERN);
        return prefix + tenantId + module + RandomUtil.randomNumbers(3);
    }

    @Deprecated
    public synchronized String getBatchId(int tenantId, int module) {
        return batchId(tenantId, module);
    }

    /**
     * 生成的是不带-的字符串,类似于:b17f24ff026d40949c85a24f4f375d42
     *
     * @return
     */
    public String simpleUUID() {
        return IdUtil.simpleUUID();
    }

    /**
     * 生成的UUID是带-的字符串,类似于:a5c8a5e8-df2b-4706-bea4-08d0939410e3
     *
     * @return
     */
    public String randomUUID() {
        return IdUtil.randomUUID();
    }

    private Snowflake snowflake = IdUtil.createSnowflake(workerId, 1);

    public synchronized long snowflakeId() {
        return snowflake.nextId();
    }

    public synchronized long snowflakeId(long workerId, long dataCenterId) {
        Snowflake snowflake = IdUtil.createSnowflake(workerId, dataCenterId);
        return snowflake.nextId();
    }

    /**
     * 生成类似:5b9e306a4df4f8c54a39fb0c
     * <p>
     * ObjectId 是 MongoDB 数据库的一种唯一 ID 生成策略,
     * 是 UUID version1 的变种,详细介绍可见:服务化框架-分布式 Unique ID 的生成方法一览。
     *
     * @return
     */
    public String objectId() {
        return ObjectId.next();
    }
}

测试类:

public class IdGeneratorTest {

    @Autowired
    private IdGenerator idGenerator;

    @Test
    public void testBatchId() {
        for (int i = 0; i < 100; i++) {
            String batchId = idGenerator.batchId(1001, 100);
            log.info("批次号: {}", batchId);
        }
    }

    @Test
    public void testSimpleUUID() {
        for (int i = 0; i < 100; i++) {
            String simpleUUID = idGenerator.simpleUUID();
            log.info("simpleUUID: {}", simpleUUID);
        }
    }

    @Test
    public void testRandomUUID() {
        for (int i = 0; i < 100; i++) {
            String randomUUID = idGenerator.randomUUID();
            log.info("randomUUID: {}", randomUUID);
        }
    }

    @Test
    public void testObjectID() {
        for (int i = 0; i < 100; i++) {
            String objectId = idGenerator.objectId();
            log.info("objectId: {}", objectId);
        }
    }

    @Test
    public void testSnowflakeId() {
        ExecutorService executorService = Executors.newFixedThreadPool(20);
        for (int i = 0; i < 20; i++) {
            executorService.execute(() -> {
                log.info("分布式 ID: {}", idGenerator.snowflakeId());
            });
        }
        executorService.shutdown();
    }
}

在项目中我们只需要注入
@Autowired private IdGenerator idGenerator;即可

然后设置id
order.setId(idGenerator.snowflakeId() + "");

六、基于数据库的id自增

既然MySQL可以产生自增ID,那么用多台MySQL服务器,能否组成一个高性能的分布式发号器呢? 显然可以。

假设用8台MySQL服务器协同工作,第一台MySQL初始值是1,每次自增8,第二台MySQL初始值是2,每次自增8,依次类推。前面用一个 round-robin load balancer 挡着,每来一个请求,由 round-robin balancer 随机地将请求发给8台MySQL中的任意一个,然后返回一个ID。 

Flickr就是这么做的,仅仅使用了两台MySQL服务器。可见这个方法虽然简单无脑,但是性能足够好。不过要注意,在MySQL中,不需要把所有ID都存下来,每台机器只需要存一个MAX_ID就可以了。这需要用到MySQL的一个REPLACE INTO特性。 

这个方法跟单台数据库比,缺点是ID是不是严格递增的,只是粗略递增的。不过这个问题不大,我们的目标是粗略有序,不需要严格递增。 

posted @ 2022-06-25 14:02  沙滩de流沙  阅读(157)  评论(0编辑  收藏  举报

关注「Java视界」公众号,获取更多技术干货