在进行应用系统开发时一笔交易往往需要多次操作数据库,为了保证交易的一致性、正确性,简单的方式是通过数据库事务来控制,多次数据库操作要么均成功要么均失败。但有时我们不能将多次数据库操作封装在一个事务中,主要有两方面原因:1.多次数据库操作中间需要调用外部服务,而这个外部服务比较耗时,此时如果统一封装在一个事务中会长时间占据数据库连接,可能导致长时间锁定某些资源,从而严重影响系统并发度;2.随着异构系统越来越流行,一个系统可能同时有多个数据库,或是同时有关系数据库、Nosql数据库、分布式缓存、消息队列等,如何保证这些系统之间的一致性,可以使用“两阶段提交”或PAXOS等分布式协议,这样的话会显著增加系统的延时,降低系统的吞吐量,另外当有异常时处理也很复杂,会有超时、死锁等问题。
其实对于应用系统来说,如果不是要求“实时一致性”,只是要求“最终一致性”,是可以通过系统设计做到不使用两阶段协议也可以保证一致性的(参考Idempotency Patterns)。下面先表述几个概念:
- 一致性:其实就是同一系统或多个系统对于某个数据状态的一致意见,这也包括从外部调用者来看状态是一致的。在数据库系统中只要记录了提交日志,事务就相当于成功了。这里日志就相当于合同契约,所以日志必须持久化,否则就没有意义了。在分布式系统协议PAXOS中其实多个系统最终也是得到了一份契约,所以PAXOS也可以作为类似两阶段协议这样使用。
- 最终一致性:系统在运行中或发生故障时处于某个“临时状态”,经过一段时间后会重新进入到一个“一致状态”。注意这里的“临时状态”并不是错误状态,只是说它是一个中间状态,这个中间状态也可以是正确的,只是不是最终状态而已。如果能做到这个中间状态对于客户是透明的就更完美了(数据库系统就做到了)。这里不提什么“弱一致性”,弱一致性其实就是不保证一致性,这只是业务对于系统设计问题的容忍,如果你是做账务系统,就不要考虑弱一致性了。
其实很多系统仅要求“最终一致性”即可,如果系统可以做到正常情况下完全一致,异常的情况下一段时间(如5分钟内)做到交易结果一致,客户应该都没什么问题。时间也是我们的朋友,虽然这个朋友在多个机器上不完全一致,但是误差都很小,在系统设计时可以加以利用。
下面主要介绍在不使用事务的情况下如何保证系统正确性,对于如何使用“两阶段提交”(如JAVA XA规范的分布式事务)可以参考其他文章(推荐这篇)。我们这里主要依靠下面几个工具:
- 状态:当前交易所处的状态,状态要尽量少、明确,要以事务边界为粒度。状态应避免循环,尽量采用顺序前进的方式。对于需要重复或是循环时可以设置子状态,以保证业务逻辑清晰。
- 自动任务:对于中间状态的记录进行处理,使其恢复正常状态。此任务应该考虑时间,防止过早调起,将正常交易作为中间交易进行处理。
- 时间:要记录数据的最后更新时间,通过时间确定记录是真的异常还是正常处理中。不同的系统的时间间隔应该不同,具体根据业务需要和技术实现来定。
- 可测试性/幂等性(重发):可以参考我的另一个文章《系统重发设计》。
系统内部设计
下面先介绍单个系统内的处理,假设一个系统涉及如下处理逻辑:
- 系统收到交易请求,进行交易输入合法性检查,如检查失败直接报错。
- 业务合法性检查,如合法性检查失败则记录交易失败信息。
- 调用外系统交易进行信息登记。(可能会有异常)
- 系统内部进行限额扣减。
- 调用外部系统进行转账处理。
- 记录转账结果
- 如转账失败则回冲限额(这里为简单起见,假设前面的信息登记动作由其他自动任务保证一致性,否则此处需增加外呼冲正)
- 返回交易结果。
针对上面的场景应该如何设计系统呢,最简单的设计是把所有这些全部放在一个事务内,由事务保证一致性,这样的话事务很长,如果遇到外呼超时会严重影响系统性能。如果采用状态来控制应该采用下面步骤:
- 定义状态,根据前面的状态规范,尽量少、明确,以外部系统为边界。可以看出可以分为如下状态:1-待登记;2-处理中(登记完成,限额扣减完成,待转账);3-交易成功;4-交易失败。
- 确定合适的记录数据库时机,延迟保存原则,只在状态需要变更时保存流水,且预计流水(参考数据库的先记日志原则)。系统内尽量通过事务保证一致性。
- 定义自动任务,对各种中间状态(此处为1-待登记;2-处理中)进行处理,通过重发或是查询外系统的结果来确定后续做法。需要和对方系统约定处理方式。
下面展示下设计后的系统处理模式:
- 系统收到交易请求,进行交易输入合法性检查,如检查失败直接报错。(无DB操作)
- 业务合法性检查,如合法性检查失败则记录交易失败信息。(若失败记录4-交易失败)
- 调用外系统交易进行信息登记。(可能会有异常)(外呼前记录1-待登记) (外呼后若失败,则记录4-交易失败)
- 系统内部进行限额扣减。(扣减限额与更新流水放在一个事务中,状态改为2-处理中)
- 调用外部系统进行转账处理。 (外呼后若失败,则记录4-交易失败)
- 记录转账结果(若5成功则更新状态为3-交易成功)
- 如转账失败则回冲限额 (若5失败,则记录4-交易失败,同时回冲限额,放在一个事务中)
- 返回交易结果。
对于上面的状态如果想保存中间结果,例如登记成功的结果、外呼失败的信息,可以通过子状态来做,或是增加状态。但这样会增加自动任务处理的复杂度,需要做更多的判断,好处是可以一定程度减少外呼查询或其他动作。如果系统内部的多数据库操作不想使用事务封装或是无法封装(可能涉及多个模块),也可以通过状态区分开各个阶段。
系统间设计规范
多个系统内一致性控制需要采用分段控制。即每个系统都要保证自身的一致性、正确性。调用方系统保证消息被正确传递,并保证最终更新交易结果;被调用方保证自身的一致性,实现自身状态可测试性或是幂等性。系统之间的消息,采用唯一标识区分,被调用系统提供查询消息结果接口,或支持幂等性(可重发)。此唯一标识由调用方生成,调用方系统在调用外呼系统前记录此唯一标识,被调用方通过此唯一标识实现交易幂等性。如果被调用方支持多个调用方,可以通过调用方编号+唯一标识来作为唯一标识,或是指定统一的标识生成规则,由各方遵守。
如果存在多个系统串联的情况,可以通过这种方式来实现正确性传递。注意这种传递只能是从前->后,而不能由后面的步骤保证正确性,因为后面方法可能会没收到消息。
本文所述方法可以从系统设计层面保证交易的正确性,可简化记为:状态 + 自动任务 + 时间 + 可测性/幂等性 = 最终一致性
。当然了这种方法也有弊端,1.应用设计需要考虑各种状态转移,系统在记录状态同时,需要记录时间、唯一标识等额外信息,增加系统复杂度。2.自动任务需要详细设计,通过时间保证和联机交易不同时执行,另外需通过乐观锁保证状态仅被自动任务更新(建议update时进行原状态对比)。3.后一步结果严格依赖前面的执行结果,只能采用串行模式。4.需保证中间状态对于客户是无害的,包括查询结果和实际业务效果(这一点有时很难做到)。
本文为我个人的一点经验总结,希望能对大家设计系统有帮助,大家有更好方式也欢迎指出。