前言

时至今日单体架构已经是大多数开发者都实践过的一种软件架构,对于小型系统来说单台机器就足以良好运行,但对于工程师来说“大型单体系统”无疑是一个地狱:不可拓展,难以扩展,复杂逻辑难以调试,代码冲突让工程师们深陷其中。

如今流行的是“分布式系统”,“微服务”,“云原生”,“生成式AI”等新技术,这些技术随着时间的推移愈发成熟和稳定。想象之中,微服务架构对于程序员来说应该是友善的,你可以在这个架构体系下选择合适的技术栈,熟悉什么框架就使用什么框架。但是对于整个项目设计来说是复杂的,必然会面临“注册发现”,“负载均衡”,“熔断/降级”,“故障隔离”,“认证授权”,“通信传输”,“扩容”…… 等等问题。

Kubernetes 可能是解决上述问题的一个方案,“云原生”标准让更多的应用天生适合在云端部署,让无数工程师们趋之若鹜。Etcd 解决了注册发现的麻烦,容器化技术解决了故障隔离/扩容,“服务网格”不仅接管了应用所有对外通信,也对数据平面内的通信进行管理,无论是负载均衡还是通信传输,亦或者是熔断降级等。

笔者对于分布式架构系统一直存在疑虑,不得不承认对于分布式架构及微服务架构一直都没有一个系统的了解及分析,借此机会重新梳理。

CAP与ACID

在讨论分布式之前我们需要了解一些特定名词,比如CAP是什么,ACID又是什么?

**CAP(Consistency、Availability、Partition Tolerance Theorem)**是分布式著名定理,在一个分布式系统中三个特征最多只能同时满足其中的两个:

一致性(Consistency):数据无论在何时何地,任何分布式节点中看到的数据都是符合预期的。

可用性(Availability): 代表系统不间断提供服务的能力,不间断这点很重要,代码故障引起的宕机造成系统暂时不可用,会很大程度影响系统的可靠性(Reliability)和可维护性(Serviceability)。可靠性使用平均无故障时间来度量,系统对外宣称我们的可靠性是99.9999%可用,这代表着平均年故障修复时间为32秒。

分区容忍性(Partition Tolerance): 分布式环境中部分节点因为网络原因彼此失联后,与其他节点形成”网络分区”时,系统依旧能正常提供服务的能力。

假设存在一个分布式系统,这个系统存在三个服务节点,每一个节点都使用自己的数据库:

如果我想将服务节点1上的数据先同步到服务节点2,再同步到服务节点3,这个同步过程时间会很长,恰好用户访问该系统时被分配到了服务节点3上,那么此时用户看到的数据是未同步数据,这就是一致性问题;

如果该系统的某一个服务节点崩溃,造成某个节点集群大崩溃,导致用户无法正常使用系统的,这代表着可用性;

如果系统中某一部分节点因为网络问题,导致无法与另外一部分节点交换信息,此时服务集群中无论哪一部分节点对外提供服务都可能是不正确的,整个集群能否承受由于部分节点连接中断,但依旧能正确提供服务,这代表分区容忍性。

CAP定理代表着严谨的数学推理,它告诉我们:不存在满足上述三个特征的分布式系统。上述三个特征中,我们总得放弃一个保证。”选择放弃一致性的AP系统是目前设计分布式系统的主流选择”,如果真要放弃,通常会选择放弃”一致性“。

AP without C 即保留Availability和Partition Tolerance,放弃Consistency 为AP系统。因为分区可容忍性是分布式网络的天然属性,这无法丢弃。而可用性恰好是使用分布式系统的期望(反正都不能保证可用性,如果宕机整个系统都会崩溃那还不如直接使用单体系统),如果添加节点数量反而降低可用性,分布式系统将失去它的价值。Redis 是很好的AP系统,在Redis 集群中如果出现网络分区,也不会妨碍各个节点以本地存储的数据对外提供缓存服务,但是很有可能因为请求分配到不同节点时返回到数据不一致。

虽然AP系统放弃”一致性”,但是也只是代表放弃了”强一致性”,系统要尽可能获得正确的结果。如果一部分数据在一段时间内没有被其他的操作所更改,那么它最终会达成与强一致性过程相同的结果,这被称为”最终一致性”。

ACID的含义

数据库中事务是将多个读写操作合并成一个独立的逻辑单元的一种方式。可以将一组读写操作视为一个单独的操作来执行:要么事务成功提交,要么中止或回滚。事务提供的安全保证,由ACID来描述:原子性(Atomicity)一致性(Consistency)隔离性(Isolation)持久性(Durability)

实际上一致性(Consistency)是目标。而原子性,隔离性和持久性这三个特性共同促成了一致性这个目标。

原子性(Atomic):事务保证对多个数据修改时,要么同时成功,要么同时失败。

隔离性(Isolation): 在不同的业务处理过程中,事务保证各自正在读写的数据相互独立,不会彼此影响。

持久性(Durability): 事务应该保证所有成功被提交的数据修改都能正确的被持久化,不丢失数据。

当一个服务只使用一个数据源时,获得一致性是相对容易的。因为多个并发的事务在读写时能感到是否存在冲突,并发事务的读写在时间线上的最终顺序是由数据源来确定的,这种事务间的一致性被称为“内部一致性”。

当使用多个数据源时,问题就会被放大多倍,不同服务并发写入多个不同数据源时,这些数据源可能在不同的机房,不同的城市,甚至不同的国家。确定时间顺序不由任何一个数据源来确定,在这种情况下“网络是不可信的”,“时钟是不可靠的”,“复制可能会延迟”,“部分数据可能会失效”……

解决上述问题会付出很大的代价,但是确实是分布式系统中必然会遇到的问题,所以对于架构师来说,在确保代价可承受的前提下尽可能保持一个较高的一致性保证。

在单体应用中我们可能不需要考虑这么多,最基础的同一数据源下事务解决方案是最基础的,但只适用于单个数据源头的场景,从代码层面上来说,工程师也不能深入到事务运行过程中。这些全都依赖底层的数据源提供支持。

在分布式应用中,使用分布式事务其实是独立于ACID获得强一致性之外,获得”最终一致性”的途径。

分布式事务

TCC 事务

TCC是由”Try-Confirm-Cancel”三个单词的缩写,它是一种业务入侵较强的事务方案,要求业务处理过程必须拆分为”预留业务资源”和“确认/释放消费资源”两个子过程。TCC方案适用于需要强隔离性的分布式事务,比如解决”超售”问题:两个不同的用户在同一时间段内成功订购同一件商品,并且购买数量都不超过目前的库存,但购买数量之和却超过了库存。(使用消息队列无法保证避免”超售”情况出现)

Try: 尝试执行阶段,完成所有业务可执行性的检查(保障一致性),并且预留好全部需用到业务资源(保障隔离性)。

Confirm: 确认执行阶段,不进行任何业务检查,直接使用Try阶段准备的资源来完成业务处理。Confirm 阶段可能会重复执行,因此本阶段所执行的操作需要具备幂等性。

Cancel: 取消执行阶段,释放Try阶段预留的业务资源。Cancel 阶段可能会重复执行,也需要满足幂等性。

TCC位于用户代码层,不在基础设施层面,在业务执行时只操作预留资源,几乎不会涉及锁和资源的争用。但是TCC会带来更高的开发成本和业务入侵,如果需求版本迭代场景发生变化时更改方案的成本也会愈高。所以如果需要实现TCC,可以基于某些分布式事务中间件来完成,这样会降低一部分的成本。

SAGA 事务

SAGA事务由两部分操作:

1.大事务拆分成若干个子事务,每个子事务都被看作成一个原子操作。如果可以正常提交,那若干个子事务最终会达成与大事务期望的一致性。

2.为每一个子事务设计对应的补偿动作,并且要满足交换律,无论是执行子事务还是执行某个补偿动作效果都是一致的,且补偿动作必须持续重试直至成功(或能允许人工介入)。

SAGA必须保证所有子事务都可以提交或补偿,但是系统本身也可能会崩溃,所以它必须设计成数据库类似的日志机制,用来保证系统恢复后的恢复机制。

SAGA事务一般不会靠裸编码来实现,可以依靠事务中间件的基础上完成,比如Seata(这种方案在JAVA中应用的最为广泛)。

两阶段提交

两阶段提交协议(2PC:Two-Phase Commit)

两阶段提交协议是将一个分布式的事务拆分成两个阶段:投票或事物提交。选择一个单点的协调者来协调整个集群的运行。

1.投票:事务协调者首先会给参与者发送消息,参与事务的节点要么直接返回失败,要么直接在本地执行事务(写本地日志)但不提交。

2.提交:协调者向所有参与者发出”正式提交”请求,参与者完成操作,释放资源并向协调者发送”完成”消息。协调者收集所有完成的消息,完成整个事务。

实现案例

Go语言中可以使用DTM(开源的分布式事务管理器)来解决跨数据库,跨服务,跨语言栈更新数据的一致性问题。它提供了跨服务事务能力,一组服务要么全部成功,要么全部回滚。

它基于SAGA事务的思想,使用独立的子事务屏障,用于处理子事务乱序的问题。

1
2
3
4
5
6
7
8
9
10
oneServer := "http://localhost:8000/api/v1" // 第一个微服务业务地址
twoServer := "http://localhost:8001/api/v1" // 第二个微服务业务地址

dtmServer := "http://localhost:36789/api/dtmsvr"
saga := dtmcli.NewSaga(DtmServer, shortuuid.New()).
Add(qsBusi+"/TransOut", qsBusi+"/TransOutCompensate", req). //添加第一个子事务
Add(qsBusi+"/TransIn", qsBusi+"/TransInCompensate", req) //添加第二个子事务
// 提交saga事务,dtm会完成所有的子事务/回滚所有的子事务
err := saga.Submit()

如果子事务出现失败,则需要依靠补偿Callback

分布式缓存

对于分布式缓存来说,处理与网络相关的操作是对吞吐量影响更大的因素。

复制式缓存从理论上更适合少更新,频繁读的数据:缓存中所有数据在分布式集群的每个节点里面都存在一份副本,读数据时无需网络访问,直接从当前节点的内存中返回,理论上可以做到与进程内缓存一样高的读取性能。当数据发生变化时,必须遵从复制协议,将变更同步到集群的每个节点中,复制性能随着节点的增加呈现平方级下降,变更数据的代价十分高。

集中式缓存(目前分布式缓存主流形式),读写都需要网络访问,优点是不会随着节点数量增加而产生额外的负担,缺点则是它不太可能达到进程内存中读写的高性能。

分布式缓存与进程内缓存各有所有,也各有局限,如果可以同时把进程内缓存和分布式缓存搭配,构建成透明多级缓存(Transparent Multilevel Cache,TMC)。但是这里要注意,这种方式代码侵入性太大,不方便管理,超时,刷新等策略需要设置多遍,更新数据也较为麻烦。常见的设计原则以变更分布式缓存中的数据为准,访问以进程内缓存的数据优先,如果数据发生变动,在集群内推送通知,当各个节点的一级缓存失效,当访问缓存时,提供一个封装好一,二级缓存联合的查询接口,外部节点只查询一次,接口内部自动实现优先查询一级缓存,未获取到数据再自动查询二级缓存。

缓存穿透

缓存的目的是为了缓解CPU或者I/O的压力,如果有一类请求的流量每次都不会命中缓存内数据,这样每次都会触及到数据库,那么缓存就无法起到缓解压力的作用,这种情况叫做缓存穿透。

有两种办法可以解决缓存穿透:

1.如果业务逻辑本身无法避免缓存穿透,可以约定一定时间内返回为空的Key值进行缓存,这样在一段时间内缓存最多被穿透一次。

2.布隆过滤器,用最小代价判断某个元素是否存在某个集合的办法。如果依然判断为请求数据不存在,直接返回即可。

缓存击穿

缓存中热点数据如果因为某些原因失效,同时又接收到多个针对该数据的请求,这些请求全部未能命中缓存,导致压力剧增。这种现象被称为缓存击穿。

有两种方式可以解决缓存击穿:

1.加锁同步,以请求该数据的Key值为锁,让第一个请求可以流入到真实的数据源中,其他线程阻塞或重试。如果是进程内缓存可以使用互斥锁,如果是分布式缓存则需要施加分布式锁,这样数据源不会同时收到大量针对一个数据的请求了。

2.手动管理,直接由工程师通过代码完成更新,失效,避免缓存策略自动管理。

缓存雪崩

大批不同的数据在短时间内失效,导致这些数据请求击穿了缓存达到数据源,从而令数据源在短时间压力剧增。这种情况可能是冷操作加载的,还可能是缓存服务由某种故障崩溃后重启,这被称为缓存雪崩。

1.提升缓存系统可用性,设计分布式缓存的集群

2.使用透明多级缓存,各个服务器节点一级缓存中的数据通常加载时间不一样,分散它们的过期时间,避免同一时间内同时失效。

3.更改缓存的过期时间,改为一个随机时间。

缓存污染

缓存污染是指缓存中数据与真实数据源中的数据不一致的现象。缓存污染大部分都是因为工程师更新缓存不规范造成的。更新缓存可以遵从设计模式:Cache Aside,Read/Write trough,Write Behind Caching等。

分布式限流

普通的限流算法,例如令牌桶,漏桶等方式直接在单体架构上进行限流是可行的。但是在微服务架构下,最多应用于流量入口的网关上,这样对整个服务集群都进行了限流,没办法更颗粒度地针对内部的流量。

能够精细控制每个服务的限流算法被称为分布式限流。

常见的简单分布式限流是将服务的统计结果存入Redis中,实现在集群内共享,通过分布式锁,信号量等机制,解决这些数据等读写访问时并发控制问题。但是这样每次都需要额外增加一次网络开销,当流量压力过大时,限流本身会显著降低系统的处理能力。

按照《凤凰架构》分布式限流中建议的处理方式:在令牌桶限流的基础上进行改造。

当请求进入集群时,在API网关处领取一定限额的资源,任何一个服务在响应请求时都需要消耗这类资源。例如领取到额度为100的“资源”,每次响应消耗1的“资源”,然后把剩余的资源当作内部限流的额度,当资源为0或小于0时,就拒绝访问其他服务,必须重新申请到一定额度的资源才允许继续访问,如果申请失败则进入降级逻辑。

这种方案只是一种并发性能和限流效果上相对折衷可行的分布式限流方案。

学习资源

共识算法

《凤凰架构》