172-分布式金融架构

开篇词 | 如何成为金融级人才？

你好，我是任杰。从今天开始，我将带你深入探索分布式金融系统架构。

说起金融，这是一块硬骨头，我甚至觉得，金融系统是架构界的珠穆朗玛峰，登峰不易，但是一旦攀登上来了，就会发现这边风景独好。

我这么说，也不仅仅是因为自己在金融领域摸爬滚打了十几年，亲身体验了所以盲目自信，这几乎也是一种共识。

我还记得很多年前大学软件工程课上，老师介绍过几个最复杂的软件系统，有军用软件、操作系统，还有金融行业的软件。

军用软件的复杂度在于需要实时处理武器信号，操作系统的复杂度在于需要在功能的多样性和效率之间做一个良好的平衡，而金融软件的复杂度在于如何在软件系统的演进过程中保持并证明系统的正确性。

金融软件这么复杂，那它独特的魅力又在哪儿呢？

这就得从 08 年金融危机前夜的时候聊了，那会儿我刚刚毕业，阴差阳错地进入摩根士丹利做了一名程序员，一呆就是近十年。

这期间，我目睹了华尔街金融公司由盛转衰的过程。盈利压力迫使公司对系统做了大刀阔斧地升级，我也有幸全程参与了这个改造过程，了解了金融公司大部分的系统和几乎所有金融产品。

之后我便从面向企业的金融业务转向了面向大众的普惠金融。这期间我担任过很多角色。作为机构用户，我对接过第三方支付系统；在蚂蚁金服亲身了解过成熟的第三方支付系统；在 eBay 呢，我带着团队从头实验了一个更好的第三方支付系统。

这十多年来，我每天都在解决金融系统大大小小的各种问题，同时，我也在这件又复杂又困难的事情中获得了极大的成就感。在这个过程中，我渐渐地把零碎庞杂的知识和经验都串联了起来，形成了自己的一套体系。

正是因为这些经历，最近几年，身边越来越多的朋友找到我，他们的金融系统也希望通过重构来解决历史遗留问题，但是踩坑不断，希望我能给一些系统的经验。这样的交流多了，我发现，金融软件似乎已经成了某种行业标准，现在大家都在谈“金融级软件”和“金融级人才”了。

那到底什么是金融级软件呢？怎样才能成为金融级人才呢？

首先，我们得知道金融软件要解决的核心问题是什么？没错，就是钱！钱一旦处理错了，那可就是真金白银的损失，所以任何一家金融机构对系统错误都是零容忍的。

这样的特殊性，使得金融级软件系统区别于很多其它的大型软件系统，它对正确性以及速度都提出了更高的要求。

那面对这样的金融级软件，金融级人才又需要怎样的能力呢？

假如你现在是一个金融系统的负责人，你需要在系统正确性和吞吐量之间做个选择。很显然你会毫不犹豫地选择正确性。

但是如果有人告诉你，系统吞吐量出现了问题，用户可能就转不了钱了，这样会造成恶意挤兑，从而形成恶性群体性事件。那么你又该如何选择呢？你很有可能会进退维谷，在两难之间犹豫。

其实这个例子就反映了我们对金融软件质量的要求。衡量软件质量有很多种不同角度，一般的软件我们会选择在矛盾中取舍，但是金融软件则要求我们尽可能在所有的地方都做到最好。

这种在矛盾中同时追求极致的要求，就是对金融级人才最大的挑战。这也是你进阶资深架构师乃至公司技术决策者的必经之路。

如果你想同时改善两个相互矛盾的需求，比如我们前面说的系统正确性和吞吐量，就需要付出巨大的代价。这个代价不仅仅是指开发成本或者运营成本，还包括了你的思考成本。你不仅仅要在有限的资源下，思考如何调和各种矛盾的需求，还要考虑怎么保证未来系统迭代之后，还能坚守现在的系统承诺。

这就考验到金融级人才的一个核心能力——透过现象看本质。了解了本质，也就能推演出事物发展的规律，从而把握先机，未雨绸缪。

总结一下，金融级系统需要滴水不漏，而金融级人才要求见微知著。

这门课是怎么设计的？

我们这门课的目的就是带你实现金融级架构，帮助你成为金融级人才。

我是从对事和对人这两个角度来设计课程的。

对事的角度比较简单。我希望你能在学完所有课程之后，对金融行业需要怎样的系统建立一个比较全面的认识，知道系统里都包括哪些重要组成部分，以及每个部分的技术挑战点在哪里，常见的技术解决方案都有哪些。

最重要的是，你会掌握金融软件架构的整体思路，知道都有可能出现哪些矛盾，以及出现这些矛盾的时候你都有哪些选择。

对人的角度比较困难，也是我个人的一些期许。在这短短的二十多讲里，我会带你初步了解金融业务为什么会有这么多分类，金融软件究竟解决的是什么业务问题，金融软件系统是怎么一步步发展到现在这个样子的。还有最重要的一点，我会为你剖析上述问题的本质究竟是什么。

为了帮助你循序渐进地学习，我把这个专栏分为 3 大部分。

第一部分，金融与业务系统。

这部分我们将围绕常见的金融业务生态及其系统架构需求进行讲解，重点包括第三方支付、交易所、券商、银行和投资银行等等。搞懂了这些金融机构和业务的特点，我们还要总结共性和技术逻辑，给你分析如何利用领域驱动设计的思想来更好地解决金融软件的复杂度问题。

第二部分，系统的正确性保障。

了解了金融业务以后，我们就可以对金融软件质量提出要求了。这部分我们重点学习如何保证金融系统架构的正确性，具体包括业务处理的正确性以及数据处理的正确性，它们是金融系统的必选项。最后，我还会讲到系统优化，让你在保证系统高正确性的同时，也能合理追求速度。

第三部分，分布式正确性及高可用。

针对复杂系统一定是重在实践的，所以这部分我会以分布式系统环境为背景，重点讨论分布式一致性的存在条件、分布式共识算法、分布式的事件溯源架构、分布式数据方案的设计原理以及数据系统的实时动态分库等等。

这部分的每节课都是线上环境中会遇到的关键问题。学习难度逐渐升级，结合实际案例，寻找问题本质，落地前面所学。另外，我还会分享一个金融领域绕不开的话题——容灾，重点讲解跨机房实时容灾以及如何提高系统稳定性。

好了，关于课程设计我就交代到这里。我真的希望能够和你在课程里相遇，我们一起死磕金融系统。课程的内容确实覆盖广、难度也不低，课程里讲解方案、思路的内容会比较容易一些，而深入原理的部分会有一定挑战性。所以我也先给你一些学习小窍门。

如果原理讲解的内容，你学起来感觉很吃力，建议你可以先通读一下，对整体思路有一个认识以后，再深入学习难点部分。如果你想对金融架构的理解更上一层楼，就绕不开这爬坡的过程，这也是我自己学习的一点经验。当然了，有什么问题你可以通过留言反馈给我，我们一同交流探讨。总之，我希望你碰到困难不要放弃。

最后，我想和你说几句我的心里话，也是我这几年一直在思考的事儿。

金融行业属于国之重器。但是，金融软件和很多其它行业专用软件一样，一直以来都处在国外垄断、国内追赶的过程。

这些年，金融改革和开放步伐也越来越快了，我们既会碰到国内日益增长的金融需求，也会碰到国外成熟金融系统的降维打击。这个行业太需要人才了，如果你有兴趣加入，那么这大概是一个最好的时代，和一百多年前一样，我们可以师夷长技以制夷，从容应对，大展身手。

好了，事虽难做则必成，就让我们开始金融系统的探索之旅吧。最后的最后，送给你一句话，作为我们的开课寄语。

金融系统是一条通往软件工程巅峰的少有人走的路，坚持下去，最终一定能会当凌绝顶，一览众山小。

01 | 业务初探：扫了二维码之后发生了什么？

你好，我是任杰。今天我们进入第一个模块：金融业务及系统。

通过这个模块，我会带你了解常见的金融业务有哪些，盈利模式又是怎样的。了解了业务端的规律，我们就可以沿着这个线索推导出相应的系统核心需求以及具体组件，推测出相对应的系统设计要点。有了这些铺垫，后面我们动手去设计金融架构的时候，你的思路就会更加清晰。

金融业务有很多种，按业务模式划分，包括交易类业务和信贷类业务两种。今天是课程的第一讲，我想带你了解一个标志性的交易类业务，也就是扫码支付。

虽然扫码支付非常普遍，但是它并不简单。把这个内容安排在第一讲，我有这样三点考量：

第一，是扫码支付最具有代表性，扫码支付是现在最常见的金融场景。

第二，传统银行业务的标志性机构大多都参与到了扫码支付的过程当中，你可以通过扫码支付来了解国家金融系统的运作原理。

第三，是扫码业务同时具有互联网应用和机构应用的技术特点。扫码支付既要对接互联网用户，也要对接金融机构。你可以了解不同的架构设计思想和他们之间沟通的方式。

总之，一旦你理解了扫码支付，也就在金融技术这个领域入了门。现在就让我们从第三方支付出发，一起来探索扫了二维码之后发生了什么吧。

情景假设

扫码支付其实也分很多场景。但我们是不可能把所有的场景都讲一遍，我们在这里需要做更进一步的选择。一方面要典型，另一方面又要能给你多介绍一些参与机构。综合考虑后，我选择了与跨境电商相关的扫码跨境支付场景，具体的假设有这些：

付款方用户支付的是人民币。

付款方的借记卡是国内银行 A 发行的，简称买家开户行。

第三方支付公司的备付金账户在国内银行 B，简称第三方开户行。

收款方接受的是美元。

收款方的借记卡是国外银行 C 发行的，简称卖家开户行。

第三方公司是通过银行 D 进行外币兑换业务，简称汇兑提供行。

由于国内对人民币相关的外汇交易有管制，本外币交易需要满足一定要求，比如要求电商平台有对应的交易明细。为了方便你理解，我们假设已经在其他部分完成了相应的合规流程。

那么接下来，我会分 4 个部分给你讲解整个支付过程：用户扫码支付、第三方公司进行本币代收、外汇交易以及外币代付。

用户扫码

扫二维码支付显然是以用户扫码作为整个业务的起点。从终端用户的角度来看，扫码由鉴权、支付和拉取状态三个步骤组成。接下来，我们就来详细看看这几个步骤。

鉴权

扫码支付最终会用买家的银行卡进行支付。在你开始扫码支付之前，第三方公司需要核实你是否有这张卡的使用权，俗称“绑卡”。

那第三方公司是怎么验证用户的使用权呢？在国内，我们一般采用下面这 4 个要素来进行验证：

用户姓名

用户身份证号码

银行卡号码

银行注册的手机号

这 4 个要素都是银行记录的信息，因此虽然看起来你是在第三方支付公司的 App 上进行绑卡操作，其实是银行在背后进行相关信息的验证工作。

由于这 4 个要素都是电子信息，可能会被人盗用，所以为了进一步增强安全性，银行在验证手机号码的时候还需要验证你是否拥有这个手机号码。具体的方式是发一条验证码给在你在银行柜台办借记卡时注册的手机号码。

结合前面的内容，我们可以把鉴权的过程分为 4 步：第 1 步，用户填写前 3 个要素和手机号码；第 2 步，银行发短信验证码给用户手机号；第 3 步，用户将前 3 个要素和短信验证码发给第三方支付公司；第 4 步，第三方支付公司再将所有信息发送给银行进行确认。

所以鉴权的过程其实是验证了 5 个信息，其中 4 个是静态信息，1 个是动态信息。

在用户绑卡通过之后，银行会返回给第三方支付公司这个用户的内部 ID 信息（也叫 Token）。之后第三方支付公司就可以拿这个 ID 进行所有合法的操作。

刚才给你讲解的流程示意图如下：

支付

鉴权完成之后，就可以扫二维码，进行支付了。二维码其实是一个图形化的字符串，背后是这笔交易对应的订单。当用户点击“确认”之后，就会开始整个支付流程。

拉取支付状态

那为什么需要拉取支付状态呢？我们还是从台前转到幕后，从系统功能的角度思考。

用户 App 的支付确认按钮是有局限的，它只能确认后台是否已经收到了支付请求，并不能确认支付是否已经成功。这是因为支付后台需要花一些时间和银行沟通，在这个期间后台并不知道银行的支付流程进行到了哪一步。

由于不知道支付什么时候才能完成，用户 App 需要每隔一段时间就向支付后台拉取交易情况，我们通常会把这个过程叫作轮询。这个过程一般在几百毫秒内就能结束，所以你一般察觉不到延时。

那为什么会出现轮询这种系统对接方式呢？金融机构每天会面对大量的用户资金操作，这些操作的时间和频率有很大的偶然性。

为了应对用户操作的峰值情况，金融机构普遍通过异步消息处理的架构来对极端流量进行削峰填谷。如果流量突然增大，异步消息架构会缓存所有请求，慢慢处理。这样就能避免核心金融系统超载。异步消息架构的结果就是用户不会及时得到处理结果，需要自己不断地去查询处理情况。

当银行处理完支付后，银行会把支付成功的消息推送给用户和第三方支付公司。第三方支付公司也会推送给你支付成功的消息。所以你在扫码支付成功后，通常还能听到两个手机消息通知的声音。

到这里我们看到了两种不同的获取最新状态的方式。一种是用户定期去拉取状态，另一种是服务器将状态消息实时推送给用户。这种推拉结合的消息通知方式，其实是架构设计中常见的异步系统处理方式。支付状态获取的流程图如下：

本币代收

前面我们假设了这笔支付涉及到外汇交易，由于买家的和卖家使用的币种不同，就无法直接转账。这时候就需要第三方支付公司这个中间人来帮忙了。中间人角色要做 3 件事情：

本币代收

外汇交易

外币代付

我先给你说说本币代收，也就是第三方支付公司代收用户资金。通俗一点来说，本币代收就是将你该付的钱先打到第三方支付公司账上。

由于第三方支付公司的账号和买家的银行卡在两家不同的银行，本币代收需要进行跨行转账。跨行转账会涉及到整个银行系统的大小额系统和超级网银等，非常复杂。所以这里我们对场景进行一些简化，但是会和现实有一些出入，主要是方便你理解。

央行和清算机构

跨行转账的时候，钱是在不同的银行。因此我们想要实现跨行转账，就需要解决两个问题。第一个问题是怎么将钱在两家银行之间转来转去，另一个问题是转的金额是多少。

我们先看第一个问题，那就是怎么跨行搬钱。最直接的方法是用汽车将钱从一家银行的金库搬到另一家银行。但这个方法其实不太实用，汽车能运的钱重量有限，路上也不太安全。所以钱最好不要挪动地方。

这时候又需要另一个第三方机构出马了。所有银行都在这个新的第三方机构里放足够多的钱，一般叫做存款准备金。当两家银行之间需要转账的时候，第三方机构在内部搬运一下就好。比如美国的黄金交易所就是这种工作模式，每个客户都有自己专属的黄金仓库，很多小车在仓库之间搬运黄金。

如果这个第三方机构足够可信，那么连内部搬运都不需要。这个第三方机构只需要记录一下谁的钱有多少，以及从哪里搬了多少到另一个地方就行。信用级别最高的金融机构就是国家的中央银行，简称央行。所以央行解决了真实资金的转移问题。

我们再来看另一个问题，那就是怎么知道转移的金额有多少。会有这个问题的原因是每天银行之间的跨行交易非常多，不可能每一笔都通过央行转一次钱。所以银行系统对跨行转账的流程进行了优化。那就是在白天只做记录，不进行任何实质性的跨行转账。等每天结束的时候计算一下两个银行之间交易金额的差额是多少，最后通过央行进行一笔跨行转账就可以了。这种计算交易差额的方式叫做轧差。

这个记录白天跨行转账细节和晚上进行交易轧差的第三方机构叫作清算机构。你熟悉的银联及网联，以及国外的万事达，它们都是清算机构。

前面我们在介绍拉取支付状态的时候，讲过金融系统采用异步消息处理架构应对支付流量。轧差是另一种金融机构应对大流量的一种处理方式。轧差的本质是实时消息的批量处理，从某种程度来讲是延时更大的异步处理框架。

跨行转账流程

讲完央行和清算机构之后，我们就可以对跨行转账过程做一个详细的梳理了。整个过程分为七步：

第一步，第三方支付公司发送指令给第三方开户行，要求将钱从用户的买家开户行转到第三方开户行。

第二步，第三方支付公司拥有用户在买家开户行的 Token，所以可以合法发起这笔转账。跨行转账流程开始。

接着到了第三步，第三方开户行将所有信息交给清算机构。清算机构作为第三方负责记录这些信息，并通知买家开户行和第三方开户行记录这笔转账。

第四步，买家开户行记录的结果是对用户的账号进行扣款。扣款结束后用短信的方式通知用户。

然后是第五步，第三方开户行记录的结果是对第三方支付公司的账号进行打款。打款结束后第三方支付公司可以通过银行网页看到对公账户金额发生变化。白天的工作到此结束。此时买家开户行的账面上的资金虽然减少，但是减少的钱并没有实质性到达第三方开户行。

第六步，到了晚上，清算机构对白天发生的交易进行盘存，发现有一笔从买家开户行到第三方开户行的跨行转账还没有真正完成。清算机构会将这笔未完成的跨行转账信息发送给央行。

还有最后一步，央行收到信息之后，将买家开户行在央行的存款准备金调低，并将第三方开户行在央行的存款准备金调高。这样钱就真正地从买家开户行转到了第三方开户行。

这七步的示意图如下：

外汇交易

转账交易的第二步是第三方支付公司进行外汇交易。当第三方支付公司完成了用户的本币代扣之后，第三方支付公司账上就有了对应的人民币。接下来的一步是将这些人民币变成美元，这样才能将美元转给国外的卖家。

那么外汇交易的过程又是怎样的呢？按照交易量的大小，可以分为 C 端外汇零售业务和 B 端外汇批发业务两个部分。

C 端外汇零售业务

外汇交易和电商一样，也是一个买卖的过程。第三方支付公司作为中间人，需要用人民币购买美元。那美元从哪里购买呢？人民币有外汇管制，不能随意买卖，需要通过一些有特殊资质的银行才行。如果外汇不涉及到人民币，那么选择面会宽泛很多，银行、投行或者其他金融机构都可以。

账务原理建议一个账号只处理同一个币种的交易。外汇交易涉及到两个币种的货币，因此需要两个不同的账号。

在这节课的最开始，我假设了第三方支付公司是通过汇兑提供行进行外汇交易的。那么第三方公司需要在汇兑提供行里建两个账号，一个人民币账号和一个美元账号。同时，汇兑提供行内部也需要有对应两个币种的账号，一个对应着人民币资金池，另一个则是美元资金池。

所以，一笔外币的购买涉及到 4 个账号之间的 2 笔支付订单。交易过程的示意图如下：

外汇交易完成之后，第三方支付公司在汇兑提供行的人民币账户金额减少，美元账户金额增加。这样第三方支付公司就有了足够的美元来支付给卖家。

我们需要注意的是，外汇交易是有成本的。第一个成本是时间成本。当天购买的外汇可能隔天才到账。另一个成本是交易成本。外汇交易一般会按照交易次数收费。因此为了节省成本，第三方支付公司通常会提前购买大量的外汇，用来应对日间的业务。只有当外汇储备下降到警戒线之后再做下一笔大额外汇的购买。

讲到这里，我们解决了第三方支付公司美元账户不足的问题，但是它用来购汇的人民币账户一直在往外出钱，总会有枯竭的一天，又该怎么办呢。

所以我们还需要考虑从外部调资金进来。由于第三方支付公司的备付金账户在第三方开户行，因此需要做从第三方开户行到汇兑提供行的跨行转账，示意图如下：

但是第三方公司在第三方开户行的账户也在一直出钱，我们往上推演一步，就会发现第三方开户行账户也需要有进来资金的渠道。这个是由前面我们提到的本币代收的过程实现的。我们把买家出资的流程补充完整。整个过程的示意图如下：

这样梳理下来，你是不是觉得我们已经考虑得足够全面了呢？

其实这里还有一个问题需要我们考虑：汇兑提供行帮助第三方支付公司实现了外汇购买。但是汇兑提供行的美元账户一直在出钱。那这个美元账户钱不够了怎么办？这时候汇兑提供行需要从其他银行寻求帮助。这个过程就涉及到了 B 端外汇批发交易。

B 端外汇批发业务

前面提到的电商相关的外汇交易属于外汇的零售业务。银行、投行和其他外汇提供商之间形成了一个有层级的跨国组织，专门从事外汇的批发业务。批发业务的业务量非常巨大，通常每天都有几万亿美元的规模。

外汇市场是按照交易量大小来划分层次的。最底层的是面对终端用户的外汇零售商。这些零售商负责给一般用户提供小额的外汇交易。这些小笔的外汇交易汇集在一起之后，就会形成一笔大的外汇订单，然后继续往上层交易。

和底层的外汇零售商一样，上一层的机构将所有外汇交易汇集在一起之后，形成更大的外汇订单，再往更上一层交易。

一直往上汇集这种事情不会永无止境地进行下去。这个流程的尽头是全球最大的外汇做市商，一般是巨型的跨国商业银行。

这些跨国商业银行面对的是全球不同国家大量的储蓄用户，所以它们手上就拥有不同币种的巨额存款。这些做市商之间通过交换不同币种的大额固定利息存折来实现外汇交易，从而决定最终汇率。示意图如下：

之前我给你提到过外汇交易有时间成本，当天购买的外汇需要隔天才能到账。在这一天的时间间隔之内，外汇市场可能会有巨大的波动，从而造成金融机构账面上的资金亏损。所以，参与外汇业务的金融机构都会处理外汇相关的市场风险，比如用期货、期权等衍生品来对冲风险。这些金融衍生品，我们会在后面的章节详细展开，这里你先有个印象就行。

为了方便你理解，我们把到目前为止的流程画一个简单的示意图：

外币代付

外币代付流程和本币代收流程在原则上是一样的。不同点在于外币代付的金额是美元，流出账号是第三方支付公司的美元账号。由于卖家的账号在卖家开户行，第三方支付的美元账号在汇兑提供行，这时候需要走的是国际的清结算过程。

流程的核心思想和之前的类似，只是具体的细节会更加复杂。下图展示了简化版的支付流程。

小结

用户在扫码支付前需要证明自己合法拥有银行卡，需要给开卡行提供 4 要素：姓名、身份证、银行卡号和手机号。验证通过后便可以开始支付流程。支付完成后用户可以通过轮询的方式异步获取支付状态。异步处理是金融机构应对支付流量的一种架构设计。

在第三方支付公司收到支付请求后，开始进行本币代收业务。由于账号设立的关系，需要进行跨行转账。此时清算中心和央行一起提供了跨行转账功能。跨行转账一般采用了日间交易、日终轧差结算的方式进行。轧差处理是金融机构应对支付流量的另一种架构设计。

第三方支付公司在完成本币代收业务之后，还要进行汇兑业务，具体分为外汇零售业务和批发业务。如果涉及外币代付业务，第三方支付公司还需要借助国际清结算组织的相关功能。

你需要注意的是，二维码支付涉及的大多数环节都是异步系统，比如用户 App 的异步支付状态查询，以及清算中心和央行及银行之间的跨行转账清结算过程。异步系统不会将结果同步返回给调用方。因此，我们在设计系统的时候，就需要支持状态的查询以及状态消息的推送功能。

本节课的知识点整体的结构图如下：

思考题

第三方支付公司在做外汇交易的时候，不会只碰到单一方向的汇兑业务。既然有人民币到美元的转换，那么也会碰到美元到人民币的转换。汇兑提供商的收费只和交易数量有关，和交易币种无关。如果你是负责第三方支付公司的资金管理，你有什么方法能进一步降低汇兑成本吗？

欢迎留言和我分享你的想法。如果学了这节课让你有所收获，也欢迎把这篇文章分享给你的朋友，一起交流和讨论支付系统的问题。

02 | 原理解读：如何理解第三方支付的业务逻辑和系统组件？

你好，我是任杰。这节课我们聊一聊如何理解支付业务逻辑和系统组件。

在上一节课中，我已经从宏观角度带你梳理了扫码支付从前到后的整个流程。我们发现，虽然扫码支付是一个非常便捷的用户产品，但是它后面涉及到了很多的机构和业务流程。

你有没有好奇过，第三方支付公司是怎么解决这些复杂业务问题的呢？如果你在金融行业呆过一段时间，就会发现不只是扫码支付，其他的金融业务都很复杂。那我们有没有可能复用第三方支付的这些经验，来解决其他的金融业务问题呢？

和我们解数学应用题一样，要应对第三方支付这类复杂的业务问题，我们首先要分析它里面的核心原理是什么。接下来尝试通过核心原理来推算出可能的规律。这些规律就决定了系统架构的演进规律。如果你掌握了这些分析问题的方法，碰到其他复杂的金融业务问题时就能游刃有余地解决了。

所以，接下来我们先搞懂支付涉及的核心金融原理，然后按照架构由简到难的顺序，逐步学习点券系统、支付系统和第三方支付公司的支付系统。这样你就能理解支付系统是如何遵循核心金融原理，一步一步从简单的几个组件发展到全面的系统架构了。

信息流与资金流分离

我们都知道，作为架构师，我们在做架构设计的时候，需要确保架构原理和业务原理一致。每一行都有自己独有的业务原理，支付业务也不例外。

那说到业务原理，在支付业务中最核心的概念是信息流与资金流分离。那什么是信息流和资金流呢？用一句话来概括，信息流指的是想象中钱的流转过程，资金流指的是钱的实际流转过程。

具体怎么理解呢？我给你举一个简单的例子。

假设你（用户 A）和你的朋友（用户 B）做生意。你的银行账户里有一块钱。白天的时候，你给你朋友转了一块钱。但是你并没有把钱实际转给你朋友，而是给你朋友一张字条，上面记下了你转给你朋友一块钱。同样的，你朋友过了一会儿也通过字条转回给了你一块钱。在白天你们俩就这么来回转来转去。

到了晚上你和你朋友对了一下白天所有的交易，发现你一共要转给你朋友 51 元，而你朋友一共需要转给你 50 元。

显然你们俩之间的这 50 元是可以互相抵消掉的。抵消之后，你只需要给你朋友转一块钱就行。于是你通过银行将这一块钱转给了你朋友。过程示意图如下：

我们分析一下上面这个过程。你账上的一块钱在白天的时候一直没动。这一块钱是真实的资金，你在白天的时候一直都有这一块钱的所有权。

但是在白天你和朋友通过纸条的形式将这一块钱来回转来转去，这个过程其实转移的是这一块钱的使用权。这种资金使用权的转移过程就是信息流。

只有晚上你们俩对完账，并通过银行转完账之后，这一块钱的所有权才真正属于你朋友。这种资金所有权的转移过程就是资金流。

顺便提一下，我们在前一节课介绍跨行转账时提到了清算中心和央行。这个例子中用字条转账的过程就是清算中心每天做的事情，最后通过银行转账的过程就是央行在做的事情。我在后面还会再讲一个类似的例子。

当然了，原理只是说信息流和资金流可以分离，通常这两者也是处在分离的状态。但是它们俩不一定需要分离，比如有个实时清算的概念，可以实时保证信息流和资金流一致。

现代支付系统中普遍采用了信息流和资金流分离的做法，这里的原因有很多。如果从系统架构的角度分析，我们会发现，实时清结算对软件系统的吞吐量和延时要求非常高，同时还要求所有参与的支付公司都具有实时清结算能力，任何一家达不到都不行。

这样一来，就需要整个国内和国际金融系统都重新设计系统底层的核心架构。短期来说，这么巨大的技术投资并没有相应规模的经济收益作为支撑。

所以，目前我们在支付系统架构设计中需要假设这两者是分离的。理解了这个概念，我们再来看看它会给架构设计带来哪些影响。主要有以下三点：

第一，支付环节的非银行参与者只能产生信息流，不能产生资金流。这是因为大多数金融机构并没有物理的现金，纸钞都放在了银行里。

所谓合久必分，分久必合。虽然信息流和资金流分开了，但它们俩最终还是需要同步的。同步的过程就需要再次和银行系统进行对接。这意味着支付系统需要通过网关的形式与外部支付系统进行状态同步。

第二，资金流和信息流分开之后，这两者将以不同速度在不同时间的不同主体分开流转。这意味着支付系统从本质上是一个异步处理系统，资金流和信息流的统一具有最终一致性（Eventual Consistency）。这就是为什么在扫码支付状态拉取时，银行对接是异步消息处理的。这个我们上节课也提到了，你可以仔细体会一下。

第三，资金流和信息流最终需要同步，即需要一个核算系统来确认同步过程准确无误。

好了，讲到这里，你应该理解了支付系统架构的核心原理，就是内部信息流系统与外部资金流系统的异步交互。

那怎么把这个原理转化成技术架构呢？接下来我们先来看看最简单的点券系统架构是怎样的。

点券系统

在点券系统里资金流和信息流是一致的。这是金融业务最简单的情况，也是所有相关架构的基础。

顾名思义，点券系统就是管理点券的系统。因为重点是讲解架构原理，所以在这里对点券业务和系统做一些简化。

现在我们假设只有代金券这一种点券，而且你只能使用代金券来购买产品。

具体的购买流程是这样的。首先，业务系统需要发起一笔交易订单：用户 A 用 10 元的代金券从用户 B 购买一支笔。

交易订单接下来会变成支付订单。支付订单只记录了用户账号的变动关系，不包含物品交换的关系。简单来说，交易订单包括财产交换和物品交换两种信息，支付订单只包含财产交换的信息。

这笔支付订单会发给支付系统。支付系统在收到这笔支付订单后，需要对用户 A 及用户 B 的代金券账号进行处理。

假设最开始用户 A 拥有 100 元代金券，用户 B 拥有 10 元代金券。那么在购买后，用户 A 的代金券账号需要减少 10 元的代金券，同时用户 B 的代金券账号需要增加 10 元的代金券。

好了，到目前为止，我们可以看到，支付过程至少需要 3 个系统：

1. 业务系统，负责生成交易订单和支付订单；

2. 支付网关，负责处理支付订单；

3. 账务系统，负责维护用户账户情况。

系统架构图如下：

点券支付到这里就结束了。但是作为一个完整的系统好像还缺了些什么功能。比如，在用代金券支付完成后，用户可能需要检查自己的代金券余额，以及与代金券相关的账单。所以我们还需要一个查询系统来完成相关的查询。

另外从产品体验角度看，现在的系统还有一点不足：用户 B 并不知道自己账户收到了点券。这时候及时通知用户 B 可能是一个更合适的产品设计。这时还需要一个账户变动的通知系统。通知系统通常采用异步通知的方式。

为了方便理解，我们假设所有和用户相关的系统都在业务系统内。更新之后的架构图如下所示（红色标注的是更新的节点）：

账务系统、查询系统以及消息系统处理的都是用户的点券数据。很显然，数据传输除了上图这种基于服务的实现方式外，还可以直接通过数据库。所以在实现的时候有两种不同选择，如下图所示：

支付系统

刚才我给你讲过了信息流和资金流一致的点券系统。但现实中，绝大多数支付业务场景是资金流和信息流不一致的情况。所以你必须知道，什么样的架构系统，才能处理好信息流与资金流不一致的情况。

接下来我们来看一个典型的例子——电商公司的支付系统。

电商公司一般没有第三方支付牌照，需要通过支付系统来对接第三方支付公司。支付系统负责的业务种类也非常多。我们讲讲最常用的业务，通过第三方支付完成的银行卡支付。

这个例子也和前面一样，用户 A 用 10 元钱从用户 B 那里购买一支笔。区别是这次用户 A 用银行卡付款，而不是用点券。

在推导系统架构之前，我们照例先做业务分析。银行卡是属于用户所有的资产，电商公司没有权力处理。所以银行卡这个资产只能通过第三方支付公司进行操作（其实是通过第三方公司背后的银行及卡组织）。

另外，第三方公司提供的 API 接口都是异步的。异步接口除了常见的成功和失败两种状态外，还有第三种状态，那就是不确定状态。这就意味着点券系统到支付系统的架构演进其实是从同步系统到异步系统的演进。

我们还是按照业务流程逐步分析，看看基础的 4 个系统之上，还缺了哪些功能，需要增加哪些组件。

和点券系统一样，业务系统还是照例发起一笔支付订单给支付网关：用户 A 将银行卡上的 10 元转给用户 B。不同点在于，支付网关在收到这笔支付订单后，需要判断支付系统能否独立完成资产的转移。点券这种内部资产是可以内部解决的，但是银行卡属于用户，是外部资产，支付系统不能自主解决。

所以支付网关还需要实现路由能力，将内部资产和外部资产两种操作，分发给不同的资产处理组件。因此，这里我们还需要增加内部和外部资产管理系统两个组件，如下图所示。

左边是前面介绍的点券系统的架构图，你可以对比看看：

外部资产管理系统需要对接第三方支付公司来完成银行卡转账业务。这个对接任务通过金融网关来实现。金融网关主要是实现二进制协议的对接，比如证书签名、加解密、协议传输、数据校验等。

通常金融网关会对接多家第三方支付公司或者银行。这样当一家支付公司临时连接不上时可以切换到下一家，或者当一家支付公司费率相对较优时可以临时切换。选择哪一家支付公司来对接的过程也叫作路由，通过当前情况智能选择路由的过程叫智能路由。因此，架构图上需要再增加一个金融网关服务：

需要注意的是，金融网关和第三方支付公司之间走的是异步通讯协议。前面讲过，异步通讯有额外的不确定状态。那架构上应该如何处理呢？

不确定状态的处理方法分为两步。第一步是在规定时间内重复查询支付状态，一般把这种行为叫作轮询。我们在前一节课提到的用户 App 通过轮询来获取支付状态，这是同一个道理。

规定时间内的轮询如果失败，支付过程并不一定失败，我们还有补救的机会。每天晚上电商公司会有一个与第三方机构进行对账的机会，在这个时候双方会对白天所有交互的明细进行对比，查漏补缺。

这就是异步系统对接时的架构设计原则，需要将同步系统的一次调用拆分成三个步骤：异步调用、轮询和对账。

我们在这里温习一下前面讲过的信息流与资金流的分离。在电商的场景下，电商支付系统的外部资产管理系统处理的是信息流，金融网关对接的第三方支付公司处理的是资金流。信息流和资金流分离之后，两者的状态就不再一致。

所以从信息流系统角度来看，资金流系统会存在不确定的状态，这就是为什么除了成功和失败以外，会出现第三种状态的原因。信息流和资金流虽然分开了，最终它们还是需要同步，因此需要通过轮询和对账这两种方式来实现同步。

这就是由支付业务原理所推导出的系统架构设计原理。

最后我们还需要把剩下的一些关键组件补充完整。你会发现上面的架构图无法保存信息流的相关信息，所以也无法处理和资金流的同步。

信息流是通过记账方式来保存的，因此我们还要加回账务系统。这个账务系统和点券系统的账务系统功能类似，但是覆盖面不同。更新后的架构图如下：

其实到这里还没有结束。我们在前面提到过如果轮询失败，需要在晚上与第三方支付公司进行对账。这个对账的任务一般是由核算系统来完成的。除了和第三方公司进行对账之外，核算系统还需要核对账务系统与业务系统之间的一致性关系。

添加了核算系统的架构图如下：

支付系统大的架构演进到这一步，就基本上能满足电商公司常用的支付需求了。

第三方支付系统

最后我们来重点讲讲第三方支付系统。第三方支付系统和电商公司的支付系统的核心组件都差不多，主要是因为它们俩都不能管理实际资金，因此都是信息流的处理系统。

电商公司会通过支付系统将信息流交给第三方支付系统处理。第三方支付系统会将这个信息流再转交给银行处理。在做跨境交易的时候我们甚至还能看到不同国家第三方支付公司之间的彼此合作。

那相同的地方我就不复述了。这里我重点讲三点不同，分别是流量支持、资金池和清算系统。

系统的流量支持

第三方支付公司有很多家客户，有可能会面临非常大的支付流量。举个例子，比如第三方支付公司负责代发工资或者代缴水电费，一到月底就会面临非常大的流量。

除了这些可以预测的高流量外，第三方支付公司还可能会面临电商促销这种非常突然的高支付流量。所以第三方支付公司需要有能力处理这种常见的互联网应用高并发问题。

这里就是金融系统架构和互联网架构结合得非常好的地方。应对高并发场景，互联网有非常成熟的解决方案。比如我们前面提到的异步消息处理，就能削峰填谷，降低峰值流量的压力。

如果流量再高，还可以选择熔断降流等手段来进行服务降级。如果存储能力支撑不了这么高流量，还可以使用各种不同的缓存技术降低查询操作对数据库的压力，或者使用分库分表的方式来进一步降低每个数据库上面的压力。

备付金资金池

第三方支付公司在调用银行接口的时候会产生费用。我接下来给你讲讲如何利用备付金资金池减少一些交易费用，同时还能提高用户体验。

资金池是一种常见的用户资金管理手段。资金池就是将属于用户的钱都放在一个大的池子里。池子里的钱不分你我，你是将资金池看作一个整体来操作的。但是你还留着一个账本，上面记载了每个人原来在资金池里放了多少钱。这样虽然钱是混在一起，但是账面上是清楚的。

资金池有很大的资金挪用风险，因此金融业对资金池的设立有很严格的监管。一般有了支付牌照之后，第三方公司才可以建立用户的备付金资金池。备付金资金池是一种新的金融产品，因此也需要新的系统组件来支持。下面举两个简单的例子。

你在用第三方支付公司 App 的时候应该见过一种叫作余额或者钱包的东西。你可以将银行卡里的钱转到余额账户。之后就可以直接使用余额里的钱。但是第三方支付公司并不一定会将你和我的余额分开存储，很有可能是放在一个资金池里处理。

比如下图展示了一种资金池的管理方式。ABCD 四个用户在第三方支付公司都有自己的余额账户。但是这 4 个余额账户并不是实际存在的，只是 4 个虚拟账户而已。真正的钱其实还是存在第三方支付公司在银行的账号里。

第二个例子是第一个例子的升级版。我们在上一节课说过外汇市场是一个一个有层级的市场。资金池也一样，多个资金池也可以拼成一个更大的资金池。于是第三方支付公司可以在多家银行开设很多资金池账户，所有这些资金池账户的钱形成更大的资金池（监管机构正在逐步限制这种行为）。

当把资金池分散在多家银行之后，第三方支付公司就不再受制于单独某家银行。这样就可以利用不同银行的费率情况来进一步降低运营成本。

举个例子，比如跨行转账需要多家银行之间配合，还可能需要支付一定的跨行交易费用。但是如果第三方支付公司在每家银行都有资金池，就可以直接在两家银行内部完成用户资金和资金池资金之间的操作了。

利用资金池来优化跨行转账的例子也有升级版。在进行跨境贸易的时候也可以使用多个资金池来降低交易成本，但这不是主要原因。跨境转账一个不太友好的问题是时间非常久，需要好几天才能到账。这时候如果你在每家银行都有资金池账号，跨境转账问题就变成多个银行内部转账的问题了，这样就能实时到账，大幅提升用户体验。

备付金资金池要求账务系统有层级式的账户体系，并且有相应的账户和资金操作。升级版的资金池看起来是下图这个样子：

清结算能力

清结算中心处理的是多家银行之间的跨行转账。当第三方支付公司有了多个资金池之后，这些资金池之间的转账关系其实和跨行转账一样。既然是一样的，那么第三方支付公司有没有可能做一些清算公司的事情，从而进一步降低交易成本呢？

的确如此。所以成熟的第三方支付公司内部都会有一个自己的清算中心。这个清算中心把自己当作一个外人，对资金池之间的转账交易进行清结算工作。这里要注意清算中心的结算过程涉及到资金流操作，需要通过内部支付网关来操作外部资金。

这样我们就把第三方支付公司最后一个组件补完了。现在的架构图如下：

跨银行备付金和清算中心合在一起后，第三方支付机构就具备了跨行清算的能力。由于这种业务模式不容易监管，容易出现洗钱等非法行为，国家已经逐步取消了这种资金管理模式。

但是清算中心这个组件还是保留了下来。尽管不能再进行跨行清算，不过有了清算中心之后就有了清算的概念，这让一些常见的内部信息流和资金流处理的方式变得更加清晰。

这个例子告诉我们，由于监管条例在不断完善，支付业务和系统架构也要随之改变。比如你在欧洲从事第三方支付业务的时候，很有可能会碰到欧盟制定的“通用数据保护条例”，这就要求你对系统架构的信息存储做进一步的划分。因此我们在设计支付系统的时候，需要根据当时当地的监管条例合理选择架构。

总结

这节课我们一起梳理了支付业务逻辑，最终推导出 C 端支付核心组件。

C 端支付需要解决的核心问题是信息流与资金流分离。我们先分析了最简单的点券系统，这种系统信息流与资金流不分离。点券系统需要账务系统来对点券这种资产进行管理，用户需要通过支付网关来对接点券系统。

那资金流与信息流分离的系统又是啥样的呢？电商的支付系统就很有启发性。点券系统需要处理的同步消息，支付系统则需要处理异步消息。所以支付系统除了需要复用点券系统的核心组件外，还需要核算系统来保证异步消息的正确处理。

有了前面的基础，再去分析第三方支付系统的核心组件就很容易了。第三方支付在业务上需要用资金池来降低业务成本，因此在架构上需要有核心组件来对资金池进行操作，同时也需要用清算中心来简化资金池操作的优化管理。

这节课的知识点整体的结构图如下：

思考题

现在跨境电商越来越普遍了。通过跨境电商，一位中国买家可以购买一支在美国销售的铅笔，这时买家付的是人民币，但是卖家收到的却是美元。

假设支持外汇业务的第三方支付公司自己拥有大量的人民币和美元储备，可以在外汇支付过程中充当买家和买家共同的对手方，即买家的人民币支付给电商公司，随即电商公司把自己拥有的美元支付给卖家。

那么为了支持外汇业务，支付公司的架构应该怎么做调整呢？

欢迎留言和我分享你的想法，也欢迎你把这篇文章分享给你的朋友，一起交流和讨论支付系统的问题。

03 | 产品大观：不同金融业务都有哪些技术实现要点？

你好，我是任杰。这节课我和你聊一聊不同的金融业务都有哪些技术实现要点。

和前两节课一样，在聊技术之前我们先聊一聊业务。提到金融，你可能第一个想到的是金融行业很赚钱。金融行业赚钱的方法有很多种，但是最核心的原理只有一个，那就是利用信息不对称赚钱。

信息有很多不对称的方式，用到的系统工具也都不一样。所以接下来我会带你了解常见的金融业务以及它们需要的技术，你了解了这些典型案例之后，无论再碰到多困难的金融问题，都能做到胸有成竹，游刃有余了。

信贷类业务

传统信贷业务

我们先来说一说最常见的信贷类金融业务。信贷业务俗称放贷，传统的银行主要从事的就是这个业务。信贷类业务的表现形式有很多，比如有面向企业的贷款，或者给你的房贷，以及 P2P，花呗、借呗、白条等等。

那信贷类业务利用了哪些信息不对称来挣钱呢？

比如说你的公司需要一大笔临时贷款来买原材料，但是资金量太大，找不到人借给你。但是世界上可能有一位有钱人，他手上有一大笔钱，却一直都贷不出去，没有办法增值。

这时候银行作为中介机构就站了出来。有钱人把手上的钱用低的利息存进银行，银行马上转手把钱用高的利息贷款给你。通过将利息低买高卖，银行就能“躺着赚钱”了。下面这幅图展示了银行通过信贷业务赚钱的过程：

你应该知道贷款是有可能出现违约的。一旦违约，银行亏的就不仅仅是利息，还包括所有本金。违约表示银行对还款人的个人或者公司信息了解还不全面，也是一种信息不对称。

银行需要解决这个信息不对称问题来防止亏欠。毕竟不亏钱就是赚钱。所以传统银行需要通过收集数据来评价借款人的还款能力，也就是借款人的信用评级。很长时间以来，银行的信用评级过程主要还是依靠信贷员对借款方的熟悉程度。

所以，这种传统银行信贷业务买卖利息的操作，其实对信息系统的要求不高。它只要求结果正确，对时间和吞吐量都没有什么要求，信息技术并不是传统信贷业务的核心竞争力。

不过这一点因为互联网和大数据的出现而发生了改变。有了互联网和大数据，银行就能快速全面而且低成本地了解借款方的情况。你平时在电商网站上的各种消费，玩游戏时充的点券，以及出行旅游的地点和酒店级别，都可以用来描述你的还款能力。

这时候信贷行业的核心竞争力变成了怎么才能更好地收集和处理数据。这就是大数据能发挥价值的地方。

对于系统架构来说，信贷业务的特点是交易频率低，而且用户评级在短时间内不会发生大的变化，因此整个系统架构不需要实时组件，常用的批处理、大数据处理框架都能很好地发挥作用。

当然了，如果你想要提高用户体验，给用户一种实时放款的感觉，系统可以提前算好用户的信用评级。你经常见到的各种信用分就是这个提前计算的结果。

次贷危机后的信贷业务

你有没有想过，你的银行定息存折是可以抵押给银行，然后再贷款的呢？这个过程就是资产证券化。

2008 年发生的次贷危机就是用个人的房贷来抵押贷款。简单的抵押赚不了多少钱，所以有一些聪明人把一大堆房贷打个大包，然后按照信用评级拆分成几个小包。类似的小包还可以堆在一起，然后再继续拆分。最后再将拆分好的小包卖给投资人。

这时候奇迹出现了，如果按照数学公式来计算，房贷总量虽然没变，但是分分拆拆之后总价值反而增加了很多，这样金融公司就能躺着赚钱了。下面这幅图是次贷将资产打包拆分的示意图：

现在常见的 P2P、花呗、借呗和白条等，背后大多都是这种资本运作方式，只不过它们将资产证券化的资产从房贷换成了个人消费贷。那这种资产证券化的金融业务，对信息系统有什么要求呢？

这里我们需要了解一些必需的金融数学原理。我们在前面提到过贷款可能违约，这个违约的大小是用信用评级来衡量的。所以违约率其实是一个数学上的概率。把不同资产放在一起，打包后再拆分的过程，从数学上来看就是用一组随机变量生成一个新的随机变量。

你应该听过次贷危机产生的原因其实是区域性的房贷违约，这意味着从概率上来讲房贷违约率之间有很强的相关性。这些相关性让最后生成的新随机变量的计算公式变得非常复杂。

这意味着资产证券化的定价过程对系统有两个挑战。

一个挑战是计算复杂。数学公式有可能一页纸都写不下，那么开发人员怎么正确实现就是一个很困难的问题。就算你觉得自己实现的是正确的，那你又怎么证明呢？我们会在第二个模块“系统的正确性保障”里给你详细讲讲如何保证计算的正确性，这里你先有个印象就行。

另一个挑战是数据量大。资产证券化到一定程度之后就算不出来数学公式了，只能通过暴力求解的方式来穷举所有可能的场景。这可能是一个天文数字，所以需要成千上万台机器同时计算才能及时算出来。这就涉及到如何保证分布式计算的正确性。我们在第三个模块“分布式正确性及高可用”中还会详细展开。

交易类业务

前面讲的信贷类业务，主要还是由传统银行来完成，对信息技术的应用比较有限。金融行业也是有很多高科技的，它们主要体现在投资银行和其他新兴金融机构身上。它们多半从事着另一个大的业务，也就是交易类业务。

交易类业务其实是一个非常巨大的隐形的金融市场，一般人很难了解全貌。那我们就来看看典型的交易类业务都有哪些。

场内交易

我们先来看看最常见的交易类业务。就算你没有买过股票，也应该听过股票交易所这个金融机构。在交易所内的交易叫作场内交易，交易的场所就是交易所。

交易所角度

很多企业家定义自己是否成功，就是看公司能不能上市。上市指这家公司的股票可以在股票交易所内交易。稍微学术一点来说，上市就是股票在二级市场交易。

既然有二级市场，那么肯定还有一个一级市场。一级市场其实就是公司股东之间的私下交易。

一级市场的信息不对称主要体现在如何匹配大额股票的买家和卖家。投资银行解决信息不对称的方式是通过公司和自己的人脉来撮合买卖双方。由于解决方案是靠人而不是靠技术，所以这一阶段对信息系统要求不高。

就算投资银行撮合了一级市场的买卖双方，依然还有信息不对称的情况，那就是股票的真正价值是多少？

你学过金融理论的话就会知道，公开交易市场能发现合理的价格信息。所以为了解决价格信息不对称的问题，投资银行会帮助客户公司将股票在流动性高的二级市场上销售。由于二级市场是个公开市场，靠大量的交易来解决价格信息不对称问题，而不是靠人脉关系。这时候就对信息系统有很高的要求。

信息沟通得越快，就越能发现资产的合理价格。所以股票交易强调交易的速度，也就是系统延时。对开发人员来说，股票交易所是一个秒杀系统。只不过和电商秒杀的区别在于股票交易所每时每刻都在秒杀。所以股票交易所需要有一个极低延时、极高吞吐量的系统架构。

交易所技术

你如果参与过互联网应用的开发的话，应该了解很多互联网行业用来对付高流量的解决方案，比如分库分表、缓存、最终一致性等等。遗憾的是，这些互联网方案都是靠牺牲延时来换取流量。对于股票交易所来说，高延时是完全不可以接受的。

从事过开发工作的人都知道软件处理很难实现微秒级延时，就算达到这个延时了系统吞吐量也上不去。但是股票交易所确确实实既有低延时又有高吞吐量。那它是怎么做到的呢？

答案很简单，就是用硬件，比如用 FPGA 来实现。理论上硬件能实现和所有软件一样的功能，但是硬件研发成本高，而且非常耗时。股票交易所恰好不怕这些问题。交易所别的没有，就是钱多，所以只要投资回报比足够，再多的钱都能拿出来。而研发时间久在交易所里问题也不大，主要是因为交易所业务逻辑非常简单。

交易所的主要功能是撮合买方和卖方。交易所在系统内维护了还没有成交的卖方订单和买方订单。当一个新的订单进来的时候，交易所会查看能不能成交。如果不能成交就等待下一笔订单。这个撮合逻辑非常简单直白，示意图如下：

交易所用户角度

股票上市之后依然存在信息不对称的问题。有一些金融机构会故意增加信息不对称，而另一些金融机构则在努力发现和消除这些信息不对称。那背后又有哪些故事呢？

如果你和我一样是个股票小散户的话，在股市上是掀不起多大风浪的。但是如果你掌管了一家大型机构，机构里每次买卖都是上亿的资金，那很有可能会影响到市场价格。而且通常是负面影响，也就是说会让你买得更贵，或者卖得更便宜。那怎样才能避免这种情况呢？

这时候，投资银行或者券商会给你提供一个拆单的服务。他们会将你的大订单拆成很多小订单，并且选择在不同的时间发送到股票交易所，这样就不会产生剧烈的市场波动。这意味着投资银行需要有一个算法交易平台。这个平台需要实时对市场数据进行分析，用算法来拆解和执行订单。

拆单服务的本质是造成信息不对称。一般用户无法获取你正在大量交易股票这个信息。前面提到过，金融机构是靠信息不对称赚钱。既然投资银行赚钱的方式是通过信息技术来造成信息不对称，那么有没有可能通过消除这些信息不对称来赚钱呢？

做高频交易的对冲基金就是一类通过发现和消除信息不对称来赚钱的金融机构。他们的核心竞争力就是极低的系统延时。当延时高的时候，股票体现的是宏观规律，比如你会看公司的基本面，或者猜是不是庄家在出货。但是当延时低的时候，股票体现的是统计规律。

比如前面提到的将股票拆单的操作。如果你发现了一个小的买单，那么接下来很有可能会有很多小的买单。如果你系统的延时足够低，就可以挤在这些小单的前面买入，然后马上卖出，从而通过超短线操作获利。所以高频交易系统里也有算法交易平台。这个平台也需要以极低的延时来分析实时市场数据，并且也要以极低的延时来执行订单。

讲到这里你会发现，交易所的机构和用户之间互相在玩猫鼠游戏，谁的系统速度快，谁就更有可能发现赚钱的先机。所以交易所相关的金融机构对系统要求非常高，一些极其领先的软硬件技术在这里都能找到身影，这也是为什么大型金融机构都说自己是高科技公司。

交易所用户技术

交易所用户都很关心系统延时。那么延时究竟需要有多低呢？一般来说，机构用户要求系统的消息处理时间在毫秒和微秒之间（1/1000～1/1000000 秒）。那这么低的延时会怎么影响系统架构呢？

首先影响的是编程语言的选择。一般来说 C 是首选，核心代码用汇编语言实现。要求不高的地方用 C++ 也可以。至于互联网常见的编程语言，比如 Java、Go、Python、Javascript 等，都不适合。

其次影响了软件架构，这也是金融软件和互联网软件架构的最大不同。

互联网软件通常会使用 SOA 或微服务架构。这种架构导致业务的调用链很长，每次调用都有网络延时。

交易所用户架构则完全相反。系统会用单个进程来完成所有的事情，最好不要有网络开销。如果交易所允许，金融公司还会出钱将机器放在交易所的机房内。这样就能进一步缩短光的传输距离，节省宝贵的数据传输时间。

当然了，由于种种原因，目前国内的交易所和券商在信息技术这一块还没有毫秒级以下的需求。但是随着金融系统的逐步开放，外资金融机构会带着这些成熟的技术来和我们竞争。软件技术的成熟需要一定的时间积累，我们需要做到笨鸟先飞，提前做好技术布局。

场外交易

股票市场和其它的场内交易只是金融市场的冰山一角。绝大部分的金融交易都发生在交易所外，也叫场外交易。

金融产品

场外交易的金融产品类型非常广。如果你想私下交易股票，这个行为也属于场外交易。外汇交易虽然和股票市场类似，但是外汇交易没有交易所，也属于场外交易。你平时听过的花呗、借呗、白条、P2P、供应链金融等，一旦它们被资产证券化，也属于场外交易。那这些场外交易产品，它们对于信息系统又有什么要求呢？

合同定价与市场风险计算

前面提到过，交易所的一个功能是发现价格。人们通过在公开市场交易来消除价格信息的不对称。那场外交易没有交易所，又该怎么发现价格呢？

既然你在场外交易的时候没法被动地发现价格，那么就只能主动发现价格了。所以对于场外交易的金融产品来说，你需要独立计算出合同的价格。

前面学习资产证券化的定价过程时，我和你说过，定价系统需要处理复杂度和数据量的问题，这一点也是对所有场外交易定价系统的要求。但资产证券化只是一种场外交易的金融产品，金融行业还有很多其它的金融产品，不同的产品定价方式完全不一样，那时间长了怎么解决定价系统数量多的问题呢？

幸运的是，所有场外交易的金融产品都存在共性，比如说它们肯定都跟钱有关。这些共性可以进一步提炼出来，从而有希望搭建一个金融产品的统一定价平台。这个抽象总结的方法就是领域驱动设计，我会在第 4、5 节课里给你详细解读。

小结

这节课我给你讲了不同的金融业务都有哪些技术实现要点。

传统的银行主要处理的是放贷业务，在很长一段时间不需要信息技术的帮助。但是信息技术的崛起不仅提高了用户信用评级的准确度，成本还很低。这时候的信息系统需要具备批处理计算的能力。

2008 年次贷危机之后，人们学会了如何复制次贷这种金融模式，衍生出了很多信贷类的资产证券化产品。这些产品要求系统能解决复杂度和数据量的问题。

除了信贷类业务，还有一类业务是交易类业务。交易类业务可以分成场内和场外两种交易模式。

股票交易所是一种常见的场内交易模式。对于股票交易所来说，信息系统需要解决极低延时，极高吞吐量的交易问题。股票交易所的用户对速度和吞吐量也有同样的要求。但是他们彼此之间会互相竞争，需要用算法交易平台来发现彼此的赚钱机会。这对编程语言和架构的选择都带来了很大的影响。

场外交易的金融产品类型众多，因此需要用到领域驱动设计的方法论来降低定价系统的复杂度。

思考题

在上世纪中期的美国，银行的定义是吸纳存款并且发放贷款。一旦一家金融机构被定性成为了银行，就需要接受美联储监管。监管会限制金融机构的行为，因此金融机构并不一定愿意成为银行。那么，如果你是一家银行的 CEO，既不想被定性成银行，又想做一些银行的事情，你准备做怎样的业务调整呢？

欢迎留言和我分享你的想法。如果学完这节课你有所收获，也欢迎你分享给你的朋友，一起交流和讨论。

04 | 领域驱动设计（上）：如何设计金融软件顶层架构？

你好，我是任杰。这节课我想和你聊一聊如何设计顶层金融软件架构。

通过前面三节课的学习，你应该感觉到金融业务比较复杂，对应的金融软件也很复杂。那想让业务和架构设计良好配合，就是一个非常有挑战性的事情了。这也是软件工程要解决的问题。

软件工程的教材里一般不会写项目怎么成功，而是记录项目怎么失败。金融行业对信息化要求很高，那在实际解决问题时，金融行业自然也会经历很多失败的软件项目。

幸运的是，错的次数多了也渐渐总结出一些比较好的方法论，本世纪初出现的领域驱动设计就是其中一个。一些大型的金融公司已经用这个方法，从战略上重新设计了顶层框架和运作机制，也取得了不错的效果。

想更好地掌握这种方法，你就要搞清楚它的适用范围以及整体思路，这样才能在后续实践中更好地使用。话不多说，接下来我会从三个角度入手，带你理解领域驱动设计理论中的核心知识点。

领域驱动设计侧重点

顾名思义，领域驱动设计（Domain Driven Design，简称 DDD）指的是针对特定领域进行定制化的设计。

从空间上来讲，它看到的是整个行业或者整个领域。从时间上来讲，它看到的是软件从发生、发展到消亡的整个生命周期。从角色上来讲，它看到的是业务、产品、开发和运维等所有参与人员的合作。

因此领域驱动设计是战略上的思路，而不是战术上的实操。这里你要抓住一个重点，领域驱动设计解决的是复杂问题。

虽然领域驱动设计是从宏观角度解决问题，但是它非常务实，主要体现在以下两个方面，一方面是注重投资回报比（Return On Investment，ROI），另一方面是做长期优化。

注重投资回报比

我们想做好领域驱动设计，需要多角度思考问题，还需要对各方面因素做一些平衡，所以这个设计本身是一个非常耗时耗力的过程。

如果你投入了大量的人力物力和时间，却没有收到相应的成果，就有点得不偿失了。这种情况下就不推荐使用领域驱动设计了。

具体来说，适合使用领域驱动设计的系统需要具备这些特点：

1. 系统组件足够多。

2. 业务逻辑足够复杂。

3. 软件生命周期长。

总之一句话，简单问题不要用领域驱动设计。金融软件恰好满足了上面三个要求，因此它也是一个领域驱动设计的标志性应用场景。

长期优化

你也许听过一些国外的真实故事，讲的是金融系统完成开发之后，毫无问题地运行了几十年。当它突然出了问题时，还需要把早已经退休的开发人员反聘回来解决。

这些故事告诉我们，金融系统可能会存在很久，可能所有最初的相关人员都已经去做其他项目了，这个系统还需要有人继续维护。

这说明软件的生命周期可能超过个人的项目周期。如果维护的人换了，就有可能出现经验知识的传承问题。领域驱动设计尝试解决这些长期沟通问题，降低整个软件生命周期中的沟通成本，这样就能降低项目失败的可能性。

了解了这两个重点之后，我们还要搞清楚具体应该怎么做。

我们在解决问题的时候一般顺序是先搞定人，再做事。所以接下来我们先看看怎么设计人员组织架构，然后再考虑怎么设计系统组织架构。

人员组织架构

我们都知道，软件开发过程中涉及到三类人，第一类是业务方；第二类就是系统分析人员，包括产品经理和架构师；还有一类是系统开发人员。

相应地，软件开发流程有 4 个步骤，估计你也不陌生：

1. 业务方提出需求。

2. 产品经理分析需求。

3. 架构师根据产品经理的分析做出合理的架构设计。

4. 开发人员按照产品文档和架构设计文档来进行开发。

开发流程示意图如下：

这个流程看上去很好，但是在实际操作中会有一些问题。比如当开发人员发现文档细节不够全面，甚至内容有误怎么办呢？这时候，沟通只能原路返回，等产品或者业务方再次确定清楚后，再从上往下传递消息。

我们尝试从宏观角度分析这个沟通问题。软件开发过程中不同角色之间的沟通，其实是一个内容翻译的过程。由于每一步沟通都会造成信息损失，沟通的流程越长，信息损失越大。

那怎么解决沟通中的信息丢失问题呢？领域驱动模型提了一个根本的解决方案。既然沟通的流程长了会丢失信息，那么最优化的沟通方式不就是只有一个层级的沟通吗？

所以领域驱动设计取消了常见的垂直沟通方式，变成小组沟通方式。每次沟通都是所有相关人员参与。下图列出了这两种不同的沟通方式：

表面上这只是沟通方式上的一点小小的改变，但其实是一个文化的转变，时间长了会带来一些根本性的变化。

第一个变化是提高了决策的速度。在小组沟通的模式下，问题能直接从发现的人手上交给解决问题的人，不再需要层层分析，层层审批。

第二个变化是打破了部门壁垒。我们在开篇提到了领域驱动设计需要有一定的业务复杂度。复杂业务会有自己独有的专业术语。

金融行业充满了生僻的术语，比如说中文的有融资融券、内保外贷、永续债券等等，英文的有 DCF、long/short、cap/floor、Snowball、Butterfly 等等。不知道这些术语你都了解多少。开发人员想要正确地进行系统建模，前提就是先对这些术语有正确的了解。

那怎样才能了解呢？答案很简单，就是直接参与。开发人员需要直接和业务方沟通，甚至直接参与业务。同样，业务方也很难理解开发人员的术语，也需要通过直接沟通来了解一些基本的行业逻辑和假设。

这个沟通过程中，会沉淀下来对专业术语的共同理解，这就是领域语言（Domain Language）。有了领域语言之后，各方的沟通再也不是鸡同鸭讲，而是永远在一个频道上。

第三个变化是弱化了产品经理的角色。产品经理负责收集业务问题，分析后将需求交给开发人员。那问题来了。谁对业务最了解呢？在金融行业，开发人员才是最了解金融业务的人。这一点可能会超出你的想象，但是事实如此。

金融行业讲究细节，每一个犄角旮旯的情况都要考虑清楚。开发人员在写程序的过程中，需要对金融业务的正常和异常情况进行极为完整的分析，所以不得不深入了解金融业务。这也是为什么在一些大型的金融公司里很少有产品经理这个岗位。

总结一下：领域驱动设计建议软件的所有参与方之间能以小组的形式进行直接沟通。开发要懂业务，业务方和产品也要懂技术。沟通的结果是形成一个大家都能认同和理解的领域语言。

系统组织架构

人的问题解决了，我们再来看看怎么解决事情。当你接手了一个复杂问题，第一步不是想怎么去解决它，而是应该仔细分析它为什么复杂，复杂在什么地方。

经验告诉我们，一个复杂的软件系统也不是所有地方都复杂，各个组件的复杂度和重要程度都不一样。所以我们可以像庖丁解牛一样，对系统组件进行分解，然后把相同的归类，统一对待。

领域驱动设计提供了一个分解问题的思路。领域驱动设计将所有业务领域划分为三大类型：核心领域、通用领域以及支持型领域。不同领域有不同的处理态度。那我们来逐一讲解一下每个类型的划分标准。

核心领域

金融系统软件有一个特点是所有组件看起来都很重要，就连最基本的短信通知功能都不能出问题。这就是我们之前提过的，只要是和钱相关的都是大事情。

但是你明显能感觉到，组件之间的重要性还是有微妙的区别。比如说，同样是对外发消息的网关组件，券商对接交易所的网关明显比短信通知网关更重要。那你的这种“感觉”究竟是从哪里来的呢？

一个软件组件是否重要，这取决于它所属的业务是不是核心业务。而业务是不是核心又取决于竞争对手。从整个金融行业的角度来看，一家金融公司如果能存活下来，一定需要有比其它类似金融公司做得更好的地方，也就是它的竞争优势。

举个例子。上世纪 70 年代股票交易量大增。当时一台大型计算机的价格简直就是天价。一些有远见的金融公司花了巨额资金，买了这些计算机来处理股票交易记录。

很快，没这么做的人就因为无法处理大量交易而淡出市场。他们退出不是因为业务方向错误，而是因为在没有计算机辅助的情况下，服务质量下降。这时候的金融行业的核心竞争力就是电算化，股票交易处理就是核心业务领域，交易清结算组件就是核心组件。

到了 21 世纪初，衍生品定价模型逐渐成熟了，谁计算金融风险的速度越快，结果越准确，谁的盈利就会越高。

在 08 年经济危机的时候，有的金融公司计算风险的速度快，就能提前逃离市场。如果你计算得慢，就得被迫吞下有毒资产。这时候的金融行业的竞争优势就是风险的计算和对冲，风险计算就是核心业务领域，风险计算组件就是核心组件。

所以，你可以这样理解，一个业务是否是核心业务，取决于它是否能给公司带来行业内的竞争优势。

我结合自己的经验，给你梳理了核心领域的三个要点，分别是资源分配、审时度势以及宏观视角。

资源分配

核心竞争力是买不来的。所以核心领域所对应的软件组件一般建议自研。同时我建议你把公司最好的人手放在核心领域的开发项目组中，这就是好钢用在刀刃上。

另外，由于核心竞争力需要不断升级，你还要做好长期维护的准备。我们开头说过，领域驱动设计的一个侧重点是投资回报比。核心领域是重要的赢利点，所以它需要有足够的时间和资源投资来保证未来的竞争优势。

审时度势

金融行业的玩家都是聪明人，所以一家公司的核心竞争力是瞒不住的。稍微过段时间就会有人学会你的赚钱之道，这时候你们之间的竞争就变成了公平竞争，你就再也没有核心竞争力了。

还是举之前金融市场的例子。在上世纪 70 年代，计算机处理订单是一家金融公司的核心竞争力。但是很快人们发现只要用了电脑就可以有类似的服务质量。金融公司最不缺的就是钱，所以它们很快也都对业务做了更新换代，用电脑替代人工操作。

这时候金融行业的核心竞争力又变成了怎么用计算机的速度来赚钱。

过了 40 年，到了 08 年金融危机的时候，有的金融公司靠着超前的风险计算能力躲过了危机。很快怎么复制这种计算方法不再神秘，这时核心竞争力就变成了谁能最快地上线风控模型，以及谁的计算成本最低。

所以在金融行业，我们想判断核心领域，就需要合理判断此时此刻公司所处的行业位置，在可预见的未来对手会如何应对，以及自己应该如何针对性地升级业务和系统。

宏观视角

每家公司的核心竞争力都不相同，所以一家公司的核心领域可能对其它公司来说并不是核心。比如，尽管同属于金融行业，券商的会计系统不是最核心的领域，但是对于一家提供会计服务的公司来说，会计系统是它最核心的领域。

所以核心组件不能根据其内容来一刀切，而是要根据它属于哪一个更大的环境，再来做灵活判断。

那怎么宏观地判断一个核心领域呢？这就要看它能不能给你带来超额利润。超额利润是一个经济学的名词，表示超出一般行业水平的利润。比如对券商来说，能不能发短信通知给用户其实对盈利水平的影响不大。但是如果能提高用户下单的速度，那就完全不一样了。

通用领域

顾名思义，通用领域是可以在不同行业通用的领域。比如我们前面提到的短信平台，不只是金融公司可以使用，物流公司也可以使用。类似的还有安全、日志、存储等等。

既然是通用领域，那么市场上一定会有多个类似的产品，而且也会存在第三方服务提供商。领域驱动设计强调投资回报比，所以当市场上存在多个类似的产品的话，我们要尽量采购相关服务或者产品，而不是自己研发。

道理很简单。同一件事情如果做的次数越多，质量就会越好，成本也会更低。所以相比自己做，通用领域选择第三方服务提供商的性价比更高。

当然了，有些情况下我们会发现通用领域也不是完全通用的，在一些细小的地方不能完全满足要求。如果你找不到完全符合自己需求的产品，就争取只做二次开发。如果二次开发也不行，一定需要投入研发力量，那么尽量投入一些非核心开发力量，比如外包团队或者非资深开发人员。

同时你也不要对软件质量做过多的要求。这时候要本着能用就行的心态，研发系统的时候也按随时能替换的方式去设计。

支持型领域

支撑型领域是那些用来辅助核心领域正常运行的领域。支持型领域并非核心竞争力，但是缺了之后也无法正常开展业务。比如说会计系统、市场数据系统等等，一般属于支持型领域。

支持型领域和通用领域很容易搞混。支持型领域一般并不会跨行业通用，顶多只在金融行业内通用。

比如对一家做聊天论坛的公司来说，一个股票数据分析系统并没有多大帮助。因为支持型领域通用程度不大，它们的共性就会更小，因此更难找到可以替代的解决方案。

如果你确定了某个系统是支持型领域，那么你可以按照这 3 个步骤来考虑：

1. 和通用领域类似，首先考虑购买第三方软件。

2.如果市场上没有合适的产品，考虑能否人工处理。比如一些简单的会计科目处理或者市场数据处理，用办公软件就能达到很好的效果。金融公司不是互联网公司，只要能赚钱就行，不需要为了面子而投入稀缺的软件开发力量。

3. 如果我们一定需要自研，要和对待通用领域的态度一样，能用就行，保持和核心领域低耦合，随时准备替换。

领域分析举例

我们讲完了怎么做领域分析之后，你一定想知道怎么分析一个实际的例子。

假设你有一天成为了有钱人，需要有人帮你打点一下财产。这时候金融公司会给你提供资管服务，替你理财。理财产品有很多种，其中资产数量最大、收益最高、风险也最高的一类是衍生品。在这里我们看看衍生品管理系统应该怎么分析。

理财产品的管理分两步。先要购买合适的金融产品，也叫交易前，或者投前。买好之后就需要管理，在合适的时候买进卖出。这一步也叫交易后，或者投后。我们先来看看投前的过程涉及到的系统应该怎么分析。

首先，你需要在系统中记录有兴趣购买的衍生品。因为衍生品是一种有完整生命周期的金融合同，所以系统需要有一个金融合同的生命周期管理系统。

其次，你在挑选金融产品的时候需要知道这个产品的价格，如果价格低就买。这就是定价和报价环节。这个环节开始的时候，你需要对合同价格有自己的判断，这就要用到定价系统。

接着就到了合同签订和打印的步骤。金融合同的签订其实是一个交易过程，就需要有交易系统。

衍生品交易涉及到的金额特别巨大，因此很多人都不太信任电子合同。合同签订之后还需要打印存档，这就需要有打印系统。

投前的步骤在合同确认之后就结束了。接下来就需要你自己管理合同了。这时一般要注意这几个事项。

第一，金融合同签订以后，买卖双方需要履行合同义务，也就是按照合同声明的金额进行资金往来，因此需要有支付系统和会计系统。

第二，资金转账通常不能在节假日进行，所以需要日期系统来通知系统自动调整日期。

第三，你需要随时知道自己的资产有多少，风险有多大，这里需要有风险计算系统。

那么，一个简单的衍生品管理系统就成型了，它需要下面这些不同的领域系统：

到这里，我们完成了衍生品管理系统的初步设计。接下来我们来做个练习，看看如何将每个部分划分到正确的领域。

我们先来看看哪几个是核心领域。你做资产管理是为了获得合理的收益，所以核心业务应该和金融产品的收益相关。这里我们发现有两个系统组件比较相关，分别是定价和风险计算系统，所以这两个属于核心领域。

至于打印、支付、会计、日期变更等都是常见功能。除了金融行业，很多其他行业也有，所以这些都是通用功能，属于通用领域。

但是相对而言，交易、报价和生命周期管理没那么通用，但是也不属于核心竞争力，因此这些都属于支持型领域。下面这幅图展示了我们划分下来的结果：

小结

这节课我们介绍如何通过领域驱动设计来对金融系统做顶层设计。设计的两个核心原则是追求投资回报比，以及长期优化。

领域驱动设计首先解决了人的问题。软件系统研发是多个部门通力合作的结果。合作的过程需要有效的沟通，因此建议直接沟通，而不是采用传统的层级式沟通方式。沟通时不同部门的人需要尽量了解对方的术语。当大家有了共同的术语后还要沉淀下来，形成领域语言。

理顺了人之后，我们就可以解决事情了。领域驱动设计将复杂的系统组件分为三大领域。核心领域与金融公司的核心竞争力有直接关系。通用领域可以跨不同行业。支持型领域负责支持核心领域，跨行业的可能性较低。

最后我们结合前面所学的内容，通过分析衍生品管理这个业务，练习了如何分析必须的系统组件，以及如何划分这些组件的领域。

思考题

两个不同领域之间传输的数据类型也是有讲究的。一般来说有两种选项：

传输的是领域模型。

传输的是简单数据类型，比如 POJO（Plain Old Java Object），Json 等。

如果你是一家大金融公司的 CTO，需要进行公司级别的领域驱动设计。这时除了需要考虑软件本身的设计外，还需要结合公司的人员变更现状（10% 的平均年员工流动率）和公司未来 10 年的软件发展计划（金融业务变更非常频繁）。

现在你需要拿出一份指导意见，那么你会选择传输哪种数据类型呢？

欢迎留言和我分享你的想法。如果这节课让你有所收获，也欢迎分享给你的朋友，一起交流进步。

05 | 领域驱动设计（下）：如何设计统一的金融业务模型？

你好，我是任杰。这节课我想和你聊一聊怎么设计统一的金融模型。

上节课我带你了解了，如何通过领域驱动设计的方法来设计金融软件的顶层架构。我们知道了金融软件的核心原则之后，今天我们来学习实际的金融架构设计和建模，看看金融行业的一些通用的软件设计和分析方法。

这节课会涉及到一些英文专有名词。我用表格形式做了梳理，你可以在进入正文之前先了解一下：

金融业务背景假设

在上节课我们说过领域驱动设计的过程非常耗时耗力，所以从性价比角度考虑的话，尽量只用这个方法来解决复杂金融业务的问题。

那么这节课，我要先选择一个既容易让你理解，又足够复杂的金融业务例子。

也许你还记得，我们在第 3 节课里给你说到了很多金融业务。其中交易类业务和场内交易业务都相对简单。所以我们如果想要找复杂的例子，就需要定位在场外交易的产品类型。场外交易最简单的类型就是期权（Option），所以我们可以选择债券期权（Bond Option）这个金融业务的例子。

正式分析系统架构之前，我们先来看看需要解决的金融业务细节。

债券期权本身也是一个期权（Option）。你购买了期权之后就有了一个选择的权利。在期权到期日那天，你可以选择用期权规定的价格来购买债券。

如果这时候期权价格比市场价低，你可以用低的期权价格来购买债券，同时以更高的价格卖出去，这样一买一卖就能马上获利。但是如果期权价格比市场价格高，你可以选择什么都不做，就当没有买过这个期权一样。

当然了，你也可以选择买了债券之后不卖出去。这时候你就拥有了这个债券。债券的发行人会定期给你利息，比如半年一次或者一年一次。

你可能已经发现了，期权给你的选择权力可以让你永赚不赔。金融行业都是聪明人，这么好的事情显然不可能是免费的。你需要花钱来买债券期权，所以债券期权本身是有价格的。

由于不知道未来债券价格会怎么波动，所以我们只能根据自己对各种未来概率的假设来估算债券期权的价格。这个估算的过程就是债券期权的定价过程。如果你认为的债券期权的真正价格比券商卖的要高，就可以从券商那里买入这个债券期权。

定价过程有两个最重要的输入，一个是债券的所有未来现金流，另一个是债券价格的历史数据。还有一些比较重要的数据，比如无风险利率和债券发行方的信用数据等，我们都归类于市场数据。

你如果在金融行业有一定经验，就会发现这里对业务做了很大的简化。比如我们举例的债券期权其实是叫看涨期权（Call Option），类似的还有看跌期权（Put Option）。只能在到期日行权的期权类型叫作欧式期权（European Option），类似的还有美式期权（American Option）。

这些简化是为了方便你理解，并不会影响后面架构分析的正确性。你如果对金融业务有兴趣，可以自行研究各种不同的场外金融业务。

例子选完了，接下来我们从建模逻辑和生命周期两个方面进行分析。

建模逻辑

实体（Entity）

在进行领域驱动设计建模的时候，首先要确认的是有哪些是实体。

实体是具有唯一标识符的业务对象。你可以用唯一标识符来区分不同的业务对象。如果你见过债券的话，会发现它和人民币很像，都是在角落里印上一串数字。这个就是债券的唯一标识符。

债券期权虽然是一个纯电子合同，但是它也有唯一标识。对开发人员来说，唯一标识符就是一串字符串，比如 UUID。债券期权这个实体的示意图如下，图中“待补充”的内容后面我们会逐渐补全：

债券和债券期权这两个实体有一个共同点，它们都不会凭空产生和消失。它们都有一个日期叫作发行日期，这就是它们出现的一天。它们也有到期日期。

金融合同在发行日期和到期日期之间，还会规定一些其他的资金行为。我们把金融合同从产生到消失的整个过程叫作生命周期。另外，这个金融合同我们会通过唯一标识符进行管理。

说到这里我们总结一下，实体有这样三个特点：

1. 有唯一标识符。

2. 用唯一标识符来判断是否是同一个实体。

3. 有生命周期管理。

反过来说，如果一个金融业务对象具有上面这三个特点，那么它很有可能被建模成实体。在这个债券期权的例子里，期权和债券都是实体。

期权对应了一个未来可能的资金流（Optional Cashflow），这个资金流会不会发生取决于你是否行权。这个可能的资金流也是实体。债券的资金流（Cashflow）相对来说比较确定，也是实体。债券现金流是由债券利息（Interest）构成的，这个利息也是实体。

我们把所有的实体和它们之间的引用关系画出来就是下面这张图：

值对象（Value Object）

和实体对应的另一个概念是值对象（Value Object）。

顾名思义，值对象里的“对象”就是面向对象编程（OOP）里的对象（Object），“值”表示这个对象里只有值，没有其他东西。这么定义你可能感觉比较抽象，我们来看一个具体的值对象例子。

金融合同一定会规定合同涉及到的金额（Notional）。金额就是一个值对象。金额本身由币种（Currency）和数额（Amount）两个信息组成。

如果一个金额是 100 元人民币，而另一个金额也是 100 元人民币，我们会说这两个金额相等。

因为我们在判断金额是否相等的时候并没有用到任何唯一标识符，这说明了值对象并不是由唯一标识符决定的，而是由它内部的组成内容决定的。所以当我们判断两个值对象是否相等时，只能比较值对象内容。

前面讲的债券期权例子中也有几个值对象。前面提到的金额是一种。还有期权行权的日期和行权方式。我们再仔细分析的话，会发现行权日期里的日期也属于值对象。我们把值对象加上之后的示意图如下：

另外还有一些和债券期权没有直接关系的市场数据也是值对象，比如无风险市场利率。市场利率的示意图如下：

和实体相比，值对象还有一个不一样的地方，就是应该怎么正确修改值对象。比如说，债券利息原来的金额是 100 元人民币。现在想修改成 200 元人民币。那我们应该怎么实现呢？

我们在修改值对象之前要先找到值对象。你不能像对待实体一样，通过唯一标识符找到值对象。那应该如何访问它呢？

这就要说到值对象的另一个特点。值对象一定是作为实体的附属品存在的，值对象其实是实体的一个属性。你只能通过建模对象之间的引用关系顺藤摸瓜地找到值对象。我们通常会说一个对象具有状态（State），值对象是用来表示这些状态的。

找到值对象之后，接下来我们就可以来解决怎么修改值对象了。因为值对象没有唯一标识符，你一旦修改了值对象里的内容，这个值对象本身就会变得不一样，所以修改值对象的过程其实是替换值对象的过程。

领域驱动设计里，值对象的修改方法更极端。既然值对象的修改就是替换，那还不如将所有值对象的所有属性都设置成只读。这样既能满足值对象修改的要求，又能避免无意之中的修改。

这里我给你梳理一下，值对象有这些特点：

1. 没有唯一标识符。

2. 有内部属性。

3. 通过比较内部属性来判断是否相等。

4. 不可修改。修改会返回新的值对象。

5. 不能独立存在，是其他实体或者值对象的附属品。

要重点提醒你的是，业务对象属于实体还是值对象并不是一成不变的。比如我们在数钱的时候，每张人民币纸钞都是一样的，人民币的纸钞可以作为值对象存在。但是如果我们想判定人民币真伪的时候，就需要检查人民币的编号，这时候会用到人民币的唯一标识符，人民币纸钞也就变成了实体。

所以这和上一节课核心领域和通用领域的区分标准一样，一个业务对象是属于实体还是值对象要取决于具体的问题，需要我们合理判断。

领域服务（Domain Service）

领域服务其实就是这个领域相关的服务。我们今天提到一个词叫业务逻辑。如果业务逻辑跨了多个实体，或者任何一个实体都不适合包含所有业务逻辑，那么用来承载业务逻辑的主体就是领域服务。

通俗来说，领域服务就是我们常说的业务逻辑，负责处理各种业务实体，也随便通过实体来处理各种值对象。

金融行业的领域服务有一些独有的特性。首先，领域服务不附属于任何一个实体或者值对象。它自身独立存在。

其次，金融产品的领域服务有一个设计原则是无状态。无状态指的是它内部没有维护全局状态，整个计算过程也不能有任何随机性。请注意这是金融行业的设计原则，而不是所有行业都有这个要求。因为金融行业对正确性要求非常高，做任何计算都建议不要出现不确定性。这也是为什么金融系统架构普遍都是不可变架构（Immutable Architecture）。

总结一下，领域服务有这些特点：

代表业务逻辑，处理实体和值对象。

本身无状态。

不依附于任何实体或者值对象。

在债券期权的例子中，买卖合同时的定价（Pricing）过程是一个领域服务，合同的风险计算（Risk Calculation）也属于领域服务。我们把领域服务补全之后的示意图如下：

生命周期管理

聚合（Aggregate）

单个聚合

我们在前面说过实体是有生命周期的，我们需要对生命周期进行管理。

但是通常生命周期管理不是以单个实体为粒度进行管理的。你应该能从上面那些示意图里感受到实体之间存在着非常复杂的引用关系，彼此之间形成了一个引用关系图。如果你对引用关系图中的任何一个实体节点进行操作，势必会影响到周边的实体节点。

如果你不考虑这些被影响的周边实体节点，那么整个系统的状态会变得不一致。一个办法是对影响的范围做一个划分。这个划分就是聚合。从图论的角度来看，聚合就是一个有向图的子图。

聚合规定了一个影响范围的边界。它有一个唯一入口叫作聚合根（Aggregation Root）。聚合根一定是一个实体，通过它可以访问到所有跟它相关的其他实体或者值对象。

比如开头例子的债券期权（Bond Option），它就是一个聚合根。通过它可以访问到债券起源合同的其他内容。债券期权的聚合和聚合根示意图如下：

聚合除了定义影响力边界以外，还同时定义了存储边界。聚合里面所有内容需要在一个数据库事务内操作。

聚合的存取管理由我们这节课最后提到的仓库（Repository）来负责。读取的时候是先通过聚合根的唯一标识符来获取聚合根，然后再通过聚合根将聚合里剩下的内容从数据库拉出来。仓库在进行数据存取的时候需要合理选择数据解决方案，我会在第 10 节课里给你详细分析。

我们前面只提到了聚合的边界，但是没有说边界究竟有多大。其实大和小没有统一标准，需要具体情况具体分析。一般来说你要注意这 3 点：

1. 聚合里节点的关系尽量形成有向无环图。

2. 聚合的关系图尽量小一点。这样数据库事务小，存取速度快。

3. 聚合的关系图如果太大的话，需要做一些优化。一般采用延时访问（Lazy Loading）的方法。当你访问聚合根的时候，系统并没有将所有内容从数据库加载进来，等访问到具体内容的时候才会加载。这样就能摊销数据加载时间，也可以节省不必要的数据库访问。

多个聚合

前面提到过，聚合是引用关系图的一个子图。当你把所有聚合划分好之后，会发现聚合之间也是存在引用关系的。比如一个聚合内部的实体节点可能需要访问另一个聚合内的节点。那多个聚合之间的访问应该遵守什么规则呢？

我们学习单个聚合时提过，只能通过聚合根来访问聚合内的元素。这个规定依然需要遵守。但是我们没有规定谁可以访问聚合根，所以一个聚合的内部节点也能访问另一个聚合的聚合根，甚至可以将聚合根的唯一标识符作为值对象存储下来。

我们还是拿债券期权这个金融产品举例，债券是期权的底层资产，因此期权这个实体需要能访问债券这个实体。体现在架构图上，就是在债券期权这个聚合内部，有节点可以访问债券这个聚合根。示意图如下：

讲完了多个聚合的访问原则，我们再来看看多个聚合怎么处理存储。前面说过，单个聚合的存储需要在一个数据库事务内处理，这样多个事务的存储就会跨不同事务，情况会更加复杂。

我们可以选择用一个更大的事务来包含所有单个聚合的事务。但这样会导致事务范围过大，存储内容的范围不确定，存储的效率偏低。所以在实践中一般会放弃用数据库事务来存储多个聚合。

这时候的存储原则是最终一致性，只要最终所有聚合都能被正确存储就行，不需要在存储的中间过程中满足数据库事务的 ACID 要求。因为最终一致性会导致系统在存储的中途出现不一致的状态，所以在做架构设计时，我们要提前考虑这些情况应该如何处理，具体怎么处理我在后面第 13 节和 14 节课会讲到。

聚合的内容到这里就讲完了。下图展示了完整的聚合划分结果：

工厂（Factory）

领域驱动设计将业务对象的创建与使用分开。业务对象的使用由领域服务来负责，而创建由工厂来负责。

因为领域驱动设计里的工厂（Factory）和设计模式里的工厂作用完全一样，你可以查看设计模式相关内容，比如《设计模式：可复用面向对象软件的基础》，或者《深入浅出设计模式》。

仓库（Repository）

仓库负责所有业务对象的存储。领域驱动设计对存储也有一些独到的建议，我们下面就来看看。

仓库和现在基于服务的架构（SOA，Service Oriented Architecture）里的数据访问层（DAL，Data Access Layer）层很像，主要解决内存数据的序列化问题。但是从领域驱动设计的角度来看，仓库其实起到的是一个防腐化层（Anti-corruption Layer）的作用。

防腐化层也是领域驱动设计里的一个术语，它解决了两个系统在协议变更之后如何进行交互的问题。一般防腐化层会介于新老两个系统中间，它负责将老系统和老协议包装成一个使用新协议的系统，这样新的系统无论怎么变化，老系统都会安然无事。

所以防腐化指的是不让外部协议的变化来入侵内部协议。由于变化入侵通常会让系统架构变得更差，入侵的过程就是腐化的过程。

仓库也能防腐化，它分隔了数据的使用和存储，这样数据使用的变化就不会影响到数据存储。如下图所示，左边是防腐化层的示意图，右边是仓库的示意图：

由于仓库负责业务对象的存储，我们自然会想到用仓库来做数据分析。不过，我不建议你使用仓库来做数据分析。领域驱动设计里有专门的事件溯源和 CQRS 来解决数据分析问题。我会在后面几节课给你详细讲解。

小结

这节课我给你讲了怎么设计统一的金融模型，具体要从建模逻辑和生命周期两个方面来看。

建模逻辑分为三个大的方向：实体、值对象和领域服务。

实体是有唯一标识的业务实体，有生命周期，有状态，有业务逻辑。相对而言，值对象没有唯一标识符，是通过自己内部属性来判断是否和其他人一样。它没有生命周期，通常是作为实体的属性或者状态存在。领域服务只包含业务逻辑，不包含任何状态。它通过操作实体和值对象来实现最终的功能。

而生命周期管理也分为三个大的方向：聚合、工厂和仓库。

聚合将紧密相关的实体和值对象组合成一个原子单元。外界只能直接访问这个原子单元的根结点，不能直接访问内部节点。这个原子单元的存取需要通过数据库事务来保证数据的完整性和一致性。工厂负责生成聚合。仓库负责将聚合序列化和反序列化。

领域驱动设计的建模过程会指导你深入思考金融业务涉及的对象和它们的逻辑，这样有利于划分系统组件边界和区分组件的重要性。而领域驱动设计对生命周期管理的一些思考，能帮助你从时间维度思考合理的金融对象管理方式。

所以有了领域驱动设计的指导，你设计出来的系统不仅仅现在是合理的，在将来也可能是合理的，这样就能增加金融系统演进的正确性。正确性是金融系统最重要的要求。

思考题

债券期权有一个行权日。实际经常会出现金融公司忘记了行权的情况。假设你需要实现一个自动通知的功能，如果债券期权没有行权，相关用户需要在第一时间收到相关通知。那你会怎么实现这个功能呢？是作为实体的内部逻辑，还是领域服务，或者是我们这节课还没有提到的其他数据分析方法？

欢迎留言和我分享你的想法。如果这节课让你有所收获，也欢迎你分享给朋友、同事，一起交流进步。

答疑集锦（一） | 思考题解析与外汇架构知识拓展

你好，我是任杰。

在 2021 年的第一天，祝你元旦快乐。一元复始，万象更新，希望你在新的一年里工作顺利，学习进步。

到今天为止，我们学完了第一个模块金融业务与系统的内容。我专门为你准备了这篇加餐，把前 5 节课的思考题做了一个系统梳理。我建议你先仔细学习前面每一讲的内容，自己独立思考之后，再来看我这份参考答案。

思考题答案

第 1 节课

Q：第三方支付公司在做外汇交易的时候，不会只碰到单一方向的汇兑业务。既然有人民币到美元的转换，那么也会碰到美元到人民币的转换。汇兑提供商的收费只和交易数量有关，和交易币种无关。如果你是负责第三方支付公司的资金管理，你有什么方法能进一步降低汇兑成本吗？

A：按照从简单到复杂的顺序，外汇业务的发展可以分成两步。第一步是先尽快支持业务。公司在联系好外汇提供商后，将客户的每笔外汇交易都交给中间商处理，自己赚一点手续费。这一步的重点是收集用户的交易行为，为之后的外汇自营作准备。

当第一步成熟之后，公司再进行第二步，做财务优化。优化的主要方向是减少跟中间商的交易次数，方法是建立外汇资金池。公司在资金池内放一部分自有资金作为垫资（Floating）。对于每个不同币种都需要有资金池，这样就能把外汇交易分解为单币种交易。

建立好资金池之后，外汇交易就和平时的单币种交易一样了。公司可以利用自己的清结算能力来对业务进行轧差。

唯一会出现问题的是当单向交易过多时，资金池的资金会不够用。这时候第一步的预测能力就能帮上忙，公司财务部门要能提前预测流动性不足的风险，提前做好外汇购买来补充资金池的资金。如果来不及的话，还可以复用第一步的行为，将所有的外汇交易代理给交易商处理。

第 2 节课

Q：假设支持外汇业务的第三方支付公司自己拥有大量的人民币和美元储备，可以在外汇支付过程中充当买家和买家共同的对手方，即买家的人民币支付给电商公司，随即电商公司将自己拥有的美元支付给卖家。那么，为了支持外汇业务，支付公司的架构应该做如何调整呢？

A：一笔外汇其实是两笔同币种的交易，所以在处理的时候你可以当作没有外汇这件事情。这里我们需要生成两笔不同币种的支付订单，然后想办法分别将这两个支付订单关联起来就行了。关联的目的主要是把当时的外汇和手续费等信息记录下来，这样日后才能方便对账。

在实际实现的时候，一般都会把外汇单独拿出来做一个外汇系统。这个系统会提供外汇的基本功能，比如外汇的购买、与汇兑提供行对接，以及外汇资产管理系统。你可以把外汇资产管理系统当成另一种点券系统，只不过这时候的点券是不同国家的货币。

这时候系统的交互也会稍微发生一些变化。业务系统需要先向外汇系统查询合适的外汇报价，然后生成外汇支付订单。

第 3 节课

Q：在上世纪中期的美国，银行的定义是吸纳存款并且发放贷款。一旦一家金融机构被定性成为了银行，就需要接受美联储监管。监管会限制金融机构的行为，因此金融机构并不一定愿意成为银行。那么，如果你是一家银行的 CEO，既不想被美联储定性成银行，又想做一些银行的事情，你准备做怎样的业务调整呢？

A：我们来看一下数理逻辑。这里的“并且”是个并列关系，所以只要有一个条件不满足，金融机构就不是银行了。

一种选择是只吸纳存款，不发放贷款。这样没有多大意义，因为存款是有利息的，银行会一直往外出钱。

另一种选择是只发放贷款，不吸纳存款。这个选择看起来好像可行，因为至少银行可以通过放贷来赚利息。但是不吸纳存款的话，银行的自有资金总会枯竭的。

那该怎么办呢？这时候的解决办法是将用户的存款换一种形式。用户将钱交给银行后并不是拿回存折，而是会转成公司股份。这样银行就能合法地规避对银行的规定了。显然这种做法是钻了监管漏洞，所以很快也被堵住了。

第 4 节课

Q：两个不同领域之间传输的数据类型也是有讲究的。一般来说有两种选项：

传输的是领域模型

传输的是简单数据类型，比如 POJO（Plain Old Java Object），Json 等

假设你是一家金融公司的 CTO，需要进行公司级别的领域驱动设计。这时候需要考虑的除了软件本身的设计外，还需要结合公司的人员变更现状（10% 的平均年员工流动率）和公司未来 10 年的软件发展计划（金融业务变更非常频繁）。现在你需要给出一份指导意见，那么你会选择传输哪种数据类型呢？

A：这道题我认为要分情况讨论，小公司和大公司的考虑点会有所不同。

小公司的开发人员少，因此有可能做到大家互相都很了解对方的信息，加上小公司普遍要求上线速度快，所以一般会选择实现快的方式，放弃长期的可维护性。因此在小公司里，选择用简单数据类型的会比较多，不同系统之间通过数据库交流。这也是互联网公司在创业期的普遍思路。

而大公司的开发人员相对较多，因此需要先假设他们不能完全了解对方。这时候部门或组件之间的低效沟通是效率低下的一个主要原因。那选择用 POJO 的话，效率就没有之前那么高了。

POJO 的问题在于它是二进制表现形式，缺乏了对象（Object）的行为。在缺乏的行为中，最重要的就是逻辑校验的能力。所以 POJO 的使用方需要重构所有的业务验证逻辑，这相当于从二进制数据中反编译出原来的业务行为。

我们知道，大公司会有很多不同类型的业务数据，这就会导致很容易出现反编译的错误。

如果从 POJO 切换到有领域内容的对象模型也会有问题。这时候公司会面临一个过渡期。简单的数据类型和带业务逻辑的领域模型会在一段时间内同时存在，这时候公司需要同时维护两种不同的数据，会在短时间内加重系统的维护成本。

这时候就是考验公司领导层的时候了。我们在讲投资回报比的时候讲的是长期回报。领导层需要根据公司对未来的计划来合理选择究竟应该走哪条路。

对于金融公司来说，对正确性要求非常高，因此会选择在初期就使用领域模型。当然了，随着互联网行业对金融行业的渗透，互联网的一些做法也带入到了金融行业，这些做法也取得了一些不俗的效果。究竟是劣币驱逐良币，还是模式上的创新，我们拭目以待。

第 5 节课

Q：债券期权有一个行权日。一个经常出现问题是金融公司忘记了行权。假设你需要实现一个自动通知的功能，如果债券期权没有行权，相关用户需要在第一时间收到相关通知。那么你会怎么实现这个功能呢？是作为 Entity 的内部逻辑，还是 Domain Service，或者是作为我们这一节还没有介绍到的数据分析？

A：行权自动通知由于涉及到多个实体，所以是一个领域服务。它的操作对象是实体的行权日期，也就是值对象。它在通知用户的时候需要使用到消息系统，这是一个通用领域。

好了，第一模块的思考题答案解析就说到这里。我还想给你补充一下外汇系统的架构知识，如果你学有余力，可以作为拓展阅读学习，如果跳过这段内容，也不会影响你对整个专栏的把握。

外汇系统

这里我们着重看一下和第三方支付相关的外汇系统。做跨境电商的机构很可能需要对接具有外汇支付能力的第三方支付公司。

还是老规矩，我们在讲系统架构前先看一下业务。外汇支付和外汇交易都需要外汇系统，也都有购换汇过程。但是涉及到的主体和钱不同。在外汇交易的过程中，两个主体互相交换不同币种的货币，其金额比率由汇率决定。外汇支付涉及 4 个主体。

我们还是拿电商举例：一位中国用户用 70 元人民币购买 10 美元物品。中国用户先将这 70 元转给第三方支付公司的人民币账户。第三方支付公司再从自己的美元账户内转 10 美金给卖家。第三方支付公司不能凭空转出美元。它需要通过外汇交易的方式获得美元。

具体过程是第三方支付公司从外汇交易商那里提前用 70 元人民币购买 10 美元，并将美元存放至美元资金池内。示意图如下：

那我们来看一下系统架构。由于外汇系统是利用自有资金池做交易，属于内部资产管理，需要内部资产管理系统。因此外汇系统和第三方支付公司的资金池处理非常类似，多数组件也能复用。

对于外汇系统来说，其购换汇过程要通过外部资产管理系统来完成。金融网关此时对接的是外汇交易商，而不是第三方支付公司。

如下图所示，外汇系统多出来的一个组件是汇率查询系统。这个系统负责提供当前可交易汇率给跨境电商的支付页面。

在外汇系统中有一个不可或缺的组件是清算中心。用到清算中心主要是为了节省外汇交易成本。

我还是拿跨境电商举例。中国买家可以买美国产品，美国买家也可以买中国产品。这两个相反的过程对于支付公司来说可以彼此对冲，从而能减少资金流的交易笔数，进而节省交易成本。请你注意，这里需要清算中心来做相应的优化。

当然了，为了方便你理解，上面的架构示意图是简化版本。在实现过程中有很多可以优化的地方。和数学的合并同类项一样，外汇系统和支付系统有很多可以合并的组件，比如账务系统、金融网关、资产管理系统、核算系统等等。你需要根据实际情况来灵活选择。

其实外汇交易是一个很大的命题，比如有时候第三方支付公司会将外汇支付代理给另一家具有外汇支付能力的第三方支付公司，这时候架构需要对应调整。

在实际购换汇过程中，司库（Treasury）部门需要根据未来几天的外汇支付行为来计算需要购换汇的金额，所以外汇系统还需要具有一定机器学习的能力。有兴趣的同学，还可以自行查阅相关专业领域的内容。

思考题的出题思路

最后，我还想和你聊一聊，我是怎么设置思考题的？

金融业务的发展具有规模优势。如果一家金融公司规模越大，涉及到的领域越多，那么它就越能给用户提供更全面的服务，利润也越高。

所以，金融公司的发展有一个特点，那就是公司会不断追逐高利润点的新业务。对于金融系统来说，我们开发人员面对的挑战是不断会有新的业务需求。

罗马不是一天建成的，金融系统也不是一次就能搭好的。我们不可能在搭建系统的时候就能完美解决未来所有的金融问题，因此我们还要学会如何不断升级现有的系统。这就是我们思考题的出发点，希望能带你思考业务会有哪些发展，系统应该有哪些对应的改进。

以上就是今天的全部内容了，希望能够对你有所帮助。下节课，我们将要进入到第二个模块了，预祝你学习愉快，希望我们后面有更多的交流，一同学习进步。

06 | 计算输入的正确性：怎么选择正确时间的数据？

你好，我是任杰。今天起我们进入了第二个模块：系统正确性保障。

在前面第一个模块“金融业务及系统”里，我带你了解了常见的金融业务、盈利模式和对系统工具的要求。在第一个模块的最后，我们讲了领域驱动设计，它是一个在金融行业行之有效的方法论。

但是领域驱动设计只是从顶层设计来分析应该怎么做金融系统，并没有说在具体实践的时候怎样才能把系统做好。所以我们在这个模块会重点解决怎么做才能达到金融系统的最重要的要求：正确性。

所谓巧妇难为无米之炊，如果在处理金融业务的时候没有用到正确的金融数据，那计算出的结果是万万不能相信的。所以正确的数据是所有正确性的基础。那让我们来一起看看怎么解决正确性的第一个问题：怎么选择正确时间的数据。

业务举例

和前面一样，我们在分析技术之前先看一个金融业务的例子。

在国外有一种金融机构叫作养老基金，大家平时交的养老保险有时候就是养老基金在负责处理。由于养老基金的收益是在你退休之后才能获得，所以养老基金一个最重要的衡量指标就是，这个基金未来能不能给你足够的生活费用。

你应该能感觉到，现在生活费用越来越贵，同样的东西第二年就会涨价。那一个可能的衡量指标就是，养老基金每年的收益率能不能超过每年生活费用的涨幅。

生活费的涨幅一般用通货膨胀率（CPI，Consumer Price Index）来表示。通货膨胀率每个月都会公布，它就是一个数字而已，这是一个比较简单的金融市场数据。但是它的特点在于数据公布时间特别晚。当前只能公布一个现在通货膨胀率的预期值。真正的值可能要几个月之后才能公布，而且之后还有可能会修改。

比如下面这张图的例子里，我们公布了两份数据，分别是 2018 年 3 月和 6 月的通货膨胀率。2018 年 3 月的数据是一个月之后公布的，在 3 个月之后和 1 年之后又公布了两次修改。6 月份的数据也类似。

那问题来了。上面这个例子里公布了 5 次共 2 个数据，每次公布的数据都不一样。你怎么确保每次数据的更新都不会影响前面已经完成的金融业务呢？

这个问题看起来非常简单，但是如果我给你介绍一下大的背景，你就不会觉得简单了。金融公司会面临成千上万的金融数据，通货膨胀率只是其中的一种。而且，像保险这种金融业务可能一旦签署就需要执行好几十年，这几十年里公司的信息系统会发生翻天覆地的变化。

如果你成为了金融公司的 CTO。有一个人拿了 30 年前的养老保险，投诉你说通货膨胀率用错了。这时候监管人员来到你的办公室，要求你一步一步证明合同数据是正确的。合同的签订需要好几个月，也会涉及到很多部门。你怎么能保证这么长的时间内，所有部门都没有因为之后的数据更新而用错数据呢？

再假设你是这家金融公司的 CFO。你想分析一下 2018 年 3 月的通货膨胀率，看看每次调整对保险合同价格的影响是多少，这样你才能知道通货膨胀对整个公司的影响有多大，所以你想用更新后的数据来算老合同的价格。

那么问题来了，你怎样才能保证你只是用了更新后的 2018 年 3 月的数据，而不是用了更新后的其他月份的通货膨胀率数据呢？如果你想对公司层面所有合同做类似的计算，怎样才能保证所有人把所有数据都更新正确了呢？

所以，金融公司的数据正确性是一个正确性的管理问题。你需要让业务、运营、财务、合规等所有部门的信息系统都用统一的数据访问方式。这种数据访问方式还需要可重现。不管是过了多少年，系统更新换代了多少次，开发人员换了多少批，你都能正确地知道过去发生了什么。这也是我们这节课叫作“正确时间”的原因。

对一家小金融公司来说这些都不是问题。但是如果是一家大的金融机构，立志于流芳百世，那么这些就会是非常困难的架构挑战和管理挑战。

既然我们要找到一个能满足金融公司所有部门的长期的数据使用方案，那么这个方案一定要和金融数据的核心原理相关。在金融系统里这个解决方案叫作双时序数据库。

我需要提醒你一下，双时序数据库和领域驱动设计一样，只适合解决复杂的问题。所以它一般用来解决机构金融业务，而很少用来解决普惠金融业务。

那话不多说，让我们来看看它是怎么从原理上解决数据时间的问题吧。

如何理解双时序数据库？

双时序坐标轴

从前面的通货膨胀率的例子你可以看到，一个金融数据会有两个时间，一个是数据对应的业务发生时间，一个是数据的修改时间。在双时序数据库里这两个时间分别叫作发生时间和记录时间。发生时间也叫作 valid time，一般缩写成 VT。记录时间叫作 transaction time，一般缩写成 TT。

既然存在双时序数据库，那么一定还有单时序数据库。你平常见到的时序数据库其实就是单时序数据库。这两者的区别在于，单时序数据库解决的是数据增加问题，双时序数据库解决的是数据修改问题。

时序数据库作为双时序数据库的简化版本，在复杂度要求不高的场景有广泛的应用。时序数据库在金融行业和互联网行业都有很多相关介绍，你如果有兴趣可以查看相关文档。

为了方便你理解核心概念，我把双时序数据库的时间逻辑画了出来。由于双时序数据库有两个时间，我们需要二维平面才能表示，所以我们需要画一个坐标系。

按照行业惯例，横轴是记录时间，纵轴是发生时间。理论上每个轴都指向正负无穷远，但是在实际展示中通常将坐标原点表示为负无穷远，主要是为了画起来好看。示意图如下：

数据可见范围

那根据前面的例子，我们在 2018 年 4 月收到了一个月前的通货膨胀率的数据。这个数据对应了坐标系的一个点，坐标为（2018 年 4 月，2018 年 3 月）。画出来就是下面这个样子：

你会发现，上面图中这个坐标系里有一个粉红色的方块。这个方块表示了数据的在系统内的可见范围。那什么叫可见范围呢？这就涉及到如何查询双时序数据库的数据了。

既然数据的存储有两个时间，那么数据的查询也需要同时提供两个时间，也是发生时间和记录时间。先看记录时间的逻辑。通货膨胀率的发生时间是 2018 年 3 月，这意味着这个时间点数据没有任何意义。再看看记录时间。通货膨胀的记录时间是 2018 年 4 月，这意味着这个时间点前数据还不存在。所以可见范围指的是数据既存在而且有意义的时间范围。

下面这幅图表示了 4 种查询范围。其中在粉红色方块的查询能查到数据，其余 3 个都查不到数据。你可以感受一下具体的查询过程：

可见范围的覆盖

根据假设，在 3 个月之后的 2018 年 7 月，收到了一个月前的通货膨胀数据。这时候坐标系在（2018 年 7 月，2018 年 6 月）多了第二个点。我们将这两个点的可见范围画出来就是下面这幅图的样子：

可以看出来，第二个点加入之后新增了一块蓝色的矩形区域，覆盖了原来矩形的右上角。还是按照之前对可见范围的定义，不同颜色的区域表示了你能看到的具体是哪个数据。

所以如果你查询用的两个时间点的坐标刚好在蓝色区域时，看到的就是 2018 年 7 月新增的通货膨胀率数据，如果你的点坐标在粉红色区域时，看到的就是 2018 年 4 月增加的数据，就像下面这幅图表示的一样：

可见范围的正确定义

假设又过去了 2 个月。在 2018 年 9 月的时候，机构更正了 2018 年 3 月的通货膨胀率，也就是更正了我们录入的第一个数据。这是我们坐标系的第三个点。你会发现这第三个数据和第一个数据的发生时间都是一样的，但是记录时间差了半年。下图展示了第三个数据加上去之后，各个数据的可见范围：

你会发现这第三个点的可见范围有些奇怪。第三个点的纵坐标碰到第二个点的时候就停下来了，而其它点的纵坐标都向上到无穷远。这个奇怪的现象要怎么理解呢？这就需要我们先理解一下可见范围的正确定义了。

可见范围是和数据查询息息相关的。我们是用查询的结果来定义这个结果的可见范围。当我们在做数据查询的时候，我们关心的是离当前查询时间点最近的合理数据。

这里需要一点解释。定义里的“合理”指的是数据既存在，且有意义，也就是说查询的记录时间和发生时间不能比数据的时间要早。定义里的“最近”指的是当有多个数据都是合理的时候，选择发生时间最晚的数据。

你要注意的是，可见范围定义里的“最近”和金融业务里的“最近”的定义是基本一致的。

比如在金融业务中我们经常会问到现在的股价是多少，或者现在的利率是多少。由于金融数据的变化永远是离散的，而不是连续的，所以并不存在一个时间叫“现在”。当你问现在是多少，其实从逻辑上来讲，你问的是离现在最近的数据是多少。

所以当你在双时序数据库查询的时候，你表达的意思是当你坐上“时间机器“返回到查询所对应的记录时间，然后查询在发生时间点以前就已经生效的所有数据之中，哪个数据离你最近。

所以，正确的数据可见范围定义是能查询到这个数据的查询时间点的范围。数据的可见范围和查询是互相定义的，你需要仔细思考。

当我们解释完最终版的可见范围之后，你就能理解为什么下面这幅图查询到的是第 2 个数据，而不是第 3 个数据，而且为什么第 3 个数据的可见范围只有一小部分。

优缺点分析

说到这里，你应该已经了解双时序数据库的基本原理和使用方法了。在我们实际应用之前，还需要知道它的优缺点，这样你才能设计之初就会有个合理判断。

优点

双时序数据库最大的优点是数据的不变性。没有特殊要求的情况下，金融行业要求数据不可被覆盖和篡改，这种业务需求决定了系统数据一定要具有不变性。

另一个优点是数据的唯一性。所有数据都有唯一标识符，也就是数据对应的记录时间和发生时间。所有数据的可见范围也可以由这个数据的唯一标识符来唯一决定。

如果数据有唯一标识符，而且数据永远不变，那么数据的使用就有了正确性保证。这里逻辑环环相扣，你一定要跟上。数据的使用由数据查询开始。数据查询对应的坐标点属于某一个可见范围之内，而这个可见范围有对应数据的唯一标识符。所以我们就可以从一个确定的查询时间定位到确定的数据时间。

那回到我们开头提的第一个问题，你怎么才能知道 30 年前养老保险涉及到的所有数据？当你用合同定制时间作为记录时间和发生时间，就能查询到 30 年前这个合同用到的所有数据。之后的修改一定不会影响你查询的结果。

最开始提到的第二个问题也有了答案。如果你想知道每次对 2018 年 3 月的通货膨胀率的修改究竟会带来什么影响，就需要保持 2018 年 3 月这个发生时间不变，然后依次调整记录时间。这样就能保证你只更新了 2018 年 3 月的数据，而没有意外地用到其他月份的数据。

需要指出的是，通过调整记录时间来选择性地引入数据变化的方法在金融行业有很广泛的应用。金融行业在进行风险分析的时候会采用情景计算（Scenario Analysis）的方式进行。监管机构会提出一些假设性的事件（What if），比如银行挤兑、地震、贸易战等等。为了完成计算，需要对金融合同数据进行修改。

在引入双时序数据库之前，我们需要花费很大的人力物力来保证情景计算的修改不会影响到真实数据的使用。在引入双时序数据库之后，由于每次修改只会影响到记录时间，我们只需要使用合同中记录的原始的业务时间，就能保证所有的业务数据不会受到情景计算的影响。

数据的时间正确性是所有金融计算正确性的开始。我们会在下一节课学习事件溯源的架构设计，这个架构能保证计算过程的正确性。一旦这个架构的数据输入是正确的，那么整个架构就能真正达到金融级别的正确性。

缺点

优点说完了，我们再看看缺点，缺点有两个。

双时序数据库的第一个缺点是学习成本高。以往处理数据的时候都只有一个时间，现在变成了两个时间。所有开发人员都需要了解二维情况下的数据可见范围。有时候我们跟业务方和产品经理沟通，也会发现他们也需要用双时序数据来定义自己的数据使用规则。这些都是很高的教育成本。

双时序数据库的另一个缺点是执行速度慢。和时序数据库相比，双时序数据库多了一个维度的时间，所以需要多加一个索引。这个额外的索引在数据插入和查询时候都会消耗额外的时间，因此不太适合于延时要求非常高的使用场景。

你还记得第 4 节课里，我们说过金融讲的是投资回报比，而不是只单纯考虑成本。虽然双时序数据库的学习成本和使用成本都不低，但是作为整个公司层面的数据正确性框架来说，它能让所有人深入理解数据的时间本质，从框架层面排除了不正确的使用方式，从而降低出错的可能性。从长期来看，有十年磨一剑的功效。

理论与实际的区别

我们在最开始介绍双时序数据库的可见范围时，没有说过发生时间的可见范围有多大。所以可见范围默认是一直可见的。但是理论上并没有这个假设。理论上数据的可见范围可以是有限的。

拿房贷举个例子。房贷最长时间是 30 年，所以 30 年以后房贷合同就无效了，也就是房贷合同的可见范围只有 30 年。这意味着在双时序数据库里，你的房贷合同的可见范围是一个高度为 30 年的矩形，看起来应该是下图这个样子：

虽然有发生时间限制的房贷合同看起来非常合理，但是在实际处理过程中却碰到了操作复杂度上的问题。

比如你几年后和贷款公司商量要延长房贷合同的期限，将期限延长到 100 年。但是又过了几年，贷款公司觉得 100 年太长，又调整成 50 年。从理论上来讲，这时候会有 3 个高度有限的可见范围互相覆盖。如果用带有可见范围约束的双时序数据库来表示，结果就是下面这张图：

虽然逻辑上是正确的，但是在实际使用时人们发现可见范围的定义会变得过于复杂，同时在数据库实现上也会碰到很多查询优化问题，所以实际一般不推荐对发生时间做可见范围的约束。如果数据真的失效了，你可以通过保存一个新的无效版本来覆盖原来的可见范围。

小结

我们这节课学习了如何用双时序数据库来正确存储和查询金融数据。

因为金融数据大多都与时间相关，用时序数据库可以很好地解决一些金融数据的使用场景，但是无法很好地处理数据的修改问题。这样一来，我们就需要新的解决方案，也就是双时序数据库。

双时序数据库除了存储数据的发生时间外，还保存了系统的记录时间，所以对于每个数据都有两个相关时间，组成了一个坐标系。双时序数据库的数据插入和查询操作都可以理解为坐标系节点之间的可见范围的处理。

由于多了一个维度的时间，双时序数据库有了额外的优点和缺点。优点是数据的唯一性和不变性得到了保证。缺点是系统的学习成本和使用成本偏高。

理论上双时序数据库里的数据发生时间范围并不一定是无限的，而是可以有一定区间范围。但是在实践过程中会导致额外的使用复杂度，所以并不建议采用。

下一节课我们会学习事件溯源这个保证计算正确性的框架。双时序数据作为框架的数据输入，是整个流程正确性的保证。

双时序数据库尽管看起来有些复杂，但是它是一个金融级的数据正确性解决方案，金融公司的规模越大，历史越悠久，就越能显示出这种方法的威力。这就是高盛和摩根士丹利这些华尔街的大型投资银行的核心竞争力。

思考题

双时序数据库里的一种存储方式是将坐标空间切割成尽量多的矩形，然后将这些矩形存储在数据系统内。数据库的索引建立在矩形的左下角和右上角这两个坐标点。

具体的切割做法是当坐标系内新增一个数据节点时，以这个点为中心，将整个坐标系进行水平和垂直切分。下图展示了系统中有 3 个数据点时的一个切割方式，3 个数据点将坐标系切割成了 16 个矩形：

每个插入操作都会对已有的矩形进行切割。每次查询都会遍历相关的矩形。那么你能算一算这个方案的存储空间复杂度和查询时间复杂度吗？

欢迎你在留言区分享你的感悟和疑问。如果有所收获，也欢迎你把这篇文章转发给自己的朋友、同事，一起交流学习。

07 | 计算过程的正确性：如何设计正确的数据处理架构？

你好，我是任杰。这节课我和你聊一聊怎么设计一个能正确处理数据的架构。

只把一件事情做正确很容易，难的是把所有的事情都做正确。当然了，绝对的完美是很难达到的，那退而求其次，我们有没有可能设计出一种架构来减少犯错误的可能性呢？或者再退一步，如果出现了错误，我们能不能准确地知道错误出在什么地方呢？

金融行业是有强监管要求的。金融系统不仅仅要求你正确地实现系统，而且还要求你能解释系统为什么是正确的。

所以这节课我会带你掌握事件溯源（Event Sourcing）的核心设计。这个架构是金融行业多年来沉淀下来的行之有效的正确性解决方案。你掌握了这个架构，金融行业正确性的问题也就基本解决了。

基本概念

游戏举例

不知道你有没有玩过联网的 5v5 即时对战手游。10 个人通过手机玩游戏，每个人都能看到其他人在游戏里的情况。虽然手机信号不太稳定，可能还会临时断网，但不管网络条件怎么恶劣，所有人手机里的游戏情况都是一样的。这就是多人游戏的正确性。

金融系统和游戏一样，对正确性都有很高的要求，这两个行业的架构也有类似之处。所以接下来我在介绍事件溯源设计的时候也会举一些游戏的例子，方便你理解。

关键术语

我们在第 4 节课和第 5 节课介绍了领域驱动设计。事件溯源是领域驱动设计理论关于正确性的重要内容。在事件溯源里有三个重要的术语：

1. 命令（command）

2. 事件（event）

3. 状态（state）

命令指的是系统收到的外部指令。比如你在玩游戏时，键盘和方向键的输入就是命令。

系统在收到外部的命令后，并不会马上执行，而是会先做一些检查，如果合理才会执行，不合理就不执行。比如说游戏里的地图都有边界，如果你控制的角色已经走到了墙角，再往前走就会碰到墙。这时如果游戏收到向前走的命令，游戏的边界碰撞检查算法就会判断这个命令是非法的。

命令检查的结果就是事件。事件是合理的、一定要执行的事情。由于事件是正确性检查后的结果，事件的执行一定不会出问题。所以从逻辑上来说，只要生成了事件就一定要执行。一般我们会用英语的过去式来表示事件。

比如在游戏里，让角色向右走的命令叫作"move right"，而对应的事件是"moved right"。这个小小的区别很重要，你要仔细体会。

事件执行的结果是改变状态。还是沿用游戏的例子，你在游戏里看到的画面就是游戏的状态，比如你游戏角色的位置、装备、属性等等。

当系统的状态改变之后，外界会根据最新状态再产生新的命令，周而复始地执行。这就是用事件溯源设计的术语来描述你玩游戏的过程。

命令、事件和状态这三者之间的关系可以用下图来表示。

上面这幅图展现的是三者之间的静态关系。另一个角度是从时间的维度看这三者之间的动态关系。如下图所示：

账务系统举例

前面游戏的例子主要是为了方便让你理解命令、事件和状态这 3 个术语。掌握了这三个核心术语之后，我们再来看一个账务系统的例子。

账务系统负责记账，所以它管理着所有用户的账户金额信息，比如说你的现金余额、贷款等等。这些账户金额信息就属于状态。假设你现在账户余额有 100 元，你朋友的账户余额有 200 元，你们俩的金额状态示意图如下：

假设你想通过手机转账的方式，转给你的朋友一块钱。这个转账请求是命令，会发送到账务系统。

账务系统在收到这个命令后会进行检查，判断这个命令是否合理。现在需要转账一块钱，而你的账户金额大于一块钱，所以转账是合理的。

既然合理，那么账务系统就会从命令生成事件，一共有两个。一个是从你的账户扣款一块钱，另一个事件是给你朋友账户入账一块钱。从这个账务系统的例子中你可以发现，一个命令可以生成多个事件。

在我们这个转账例子里，一个转账命令会生成两个事件。示意图如下：

接下来是执行这两个事件。执行后会改变系统状态，也就是改变你们俩的余额情况。你的余额会变为 99 元，而你朋友的余额则变为 201 元，示意图如下：

这时候你发现自己账户上还有一些余额，于是想尝试给你朋友转 100 元钱。但是当账务系统收到你的新转账命令后，会发现余额不足，无法完成转账。这时候系统应该怎么处理呢？

当命令的检查不通过时，系统可以选择不生成事件，或者选择生成一个空事件（NOP）。生成空事件的好处是能在系统中记录某个命令在历史上曾经存在过。空事件的执行结果是不改变任何状态。这里你这两次转钱的流程示意图如下：

如何处理命令和事件队列？

掌握了事件溯源设计的三个核心术语后，我们再来看看相应的系统应该如何实现。

事件溯源设计的一个核心设计是所有的命令或者事件的处理都要有确定的顺序。同样的两个命令，如果它们俩到达的顺序不一样，生成的事件可能就会不一样。

比如说你现在的余额有 100 元。接下来有两个命令，一个命令是给你转账 100 元，一个命令是你打 200 元钱给你朋友。

如果你先收到 100 元钱，再付出去 200 元钱，那么你付钱的时候账户里刚好有 200 元，因此这两个命令的检查都能通过。但是如果你先转出去 200 元，再收到 100 元的话，系统会发现你在转 200 元钱出去的时候余额不足，所以这个命令会失败。

保证顺序的方法也不难，就是将所有的命令和事件都分别放到两个先入先出的队列（First In，First Out，FIFO）。一般这些队列会被保存到文件中。系统会从命令队列中逐一读取下一个命令，判断这个命令是否合理，然后将生成的所有事件放到事件队列末尾。示意图如下：

在实现时还可以做一个小的优化。命令队列和事件队列虽然是两个不同的队列，但是由于它们的先后顺序是完全一致的，我们可以将这两个队列合并为一个队列。这时候的处理逻辑需要做一些小的调整。命令收到了之后，我们并不会马上存储下来。而是先处理这个命令，得到了对应的事件之后，再将命令和事件打包到一起，存到队列中。

下图列出了这个优化后的存储情况。你可以结合图片体会一下具体的区别：

怎样实现队列存储？

事件溯源设计对于存储设备非常友好。无论是基于碟片的传统硬盘，还是新一代的 SSD 存储，事件溯源设计都能非常有效地利用存储设备提供的吞吐能力。

这是因为命令和事件这两个队列只会在末尾增加新的内容，而不会修改中间的内容。我们一般把这种访问方式叫作顺序写。与之对应的是随机读写。

你在挑选硬盘的时候，一般能看到硬盘生产商会公布两个硬盘速度，一个是顺序读写速度，另一个是随机读写速度。你会发现顺序读写的速度会快很多。所以事件溯源设计一般都能达到很高的读写效率。

请注意，当你将每个队列存储到文件时，需要存储的是两个文件，而不是一个。其中一个文件显然是队列的内容。另一个文件则是这个队列的索引文件，它记录了每个内容在队列中的位置。

在一些场景下，我们需要能定位到指定位置的内容，比如第 3 个命令是什么，或者第 10 个事件是什么。由于每个命令或者事件的内容大小会不一样，我们需要额外的索引文件来帮助我们定位。

由于位置信息和偏移量这两个数据的长度都是固定的，索引文件的每个内容都有固定大小，所以我们可以根据我们要的位置直接计算出索引文件的偏移量，然后根据索引文件找到队列文件的位置。计算的示意图如下：

怎样执行事件和改变状态？

解决了如何处理命令和事件之后，我们就剩下最后一件事情，那就是怎么执行事件和改变状态。

自动机执行

事件的执行用到了计算机里最经典的计算模型，叫作自动机。你可以将事件队列当作一个有始无终的磁带。你会从头开始依次读取每个事件。读取之后按事件内的指示来改变内存状态。然后挪到下一个位置，继续处理下一个事件。是不是很简单？自动机的示意图如下：

这里有一个非常重要的限制你要牢记：自动机在执行事件的过程中不能有任何随机行为。这是为了保证整个系统能准确复现每一步计算，因为这样才能满足金融行业对每一步计算过程都能审计的要求。

对于没有随机性，我们要注意两点。

第一点是不要使用随机数。这里的随机数指的是真实的随机数，而不是伪随机数。真实的随机数一般会采用硬件的随机数发生器，每次读取都会读到不同内容。

伪随机数是一个算法和对应的初始值（也叫随机数的种子）。初始值一旦确定，伪随机数发生器所有接下来的随机数也就确定了，所以伪随机数其实并不是一个随机的事情。你需要将随机数的算法和初始的种子也记录到事件中，这样虽然看起来有随机数这几个字眼，但运行起来还是完全确定的。

另一点是不要有 I/O（输入 / 输出）。准确地说是不要有来自外部的输入。外部输入有很多不确定性，比如输入到达的时间不确定，或者到达的内容每次都会变化，或者消息超时，什么都收不到。由于外部输入有太多的不确定性，一般要求不能有外部输入。

但是我们不能完全取消所有外部输入。这时候有一个折衷处理方式。你可以提前从外部获得输入，然后存储在事件队列中。这样在执行事件的时候就不会受到外部输入不确定性的影响了。

时光机

我们还是拿游戏举例，给你说明什么是时光机功能。一般来说游戏都可以存档。如果你游戏玩不下去了还可以读档，恢复之前的游戏状态。这个存档读档的过程就是坐时光机回到过去的过程。

事件溯源提供了更完美的时光机（time machine）功能。它能恢复到过去任何一个时间点的状态。你需要做的事情也很简单，只需要重置自动机状态，然后把事件一个一个执行，直到运行到你指定的时间点。如果你按照我前面指出的要求，保证自动机在执行过程中的每一步都是完全确定的，那么最终一定能准确地回到过去的状态。

时光机给了金融系统审计的能力。由于过去所有的命令都得到了保留，你能解释状态是怎样一步一步从最开始的情况变到现在的样子。在互联网架构里我们更关注的是当前事实，所以架构设计时会倾向于记录状态，而不是原因。但是在金融系统里，我们更关注的是为什么，而非是什么，所以架构设计会倾向于记录原因。

系统快照

时光机还给系统架构带来了一个副产品，那就是容灾能力。如果机器出了问题，状态全都丢失了。只要事件都在，事件溯源设计能保证一定能恢复到出问题前的状态。

但是这种容灾有一个问题。系统恢复的时间长短和事件的个数有关。事件多了可能恢复的事件会变得太长。所以我们需要针对性地优化恢复速度。

优化的方法很简单，只要将当前的系统状态全都保存到文件就可以了。我们一般称呼这个过程为打快照（Snapshot）。过了一段时间之后，如果想要恢复到系统的最新状态，你只需要先将快照文件加载到自动机里，然后从打快照的时间点开始执行后面的事件。

为了能让自动机找到下一个需要执行的事件，你需要将快照对应的事件位置也记录到快照里。打快照的示意图如下：

有了打快照这个优化之后，系统恢复时间只和那些不在快照里的事件个数有关，跟事件的历史长度无关。所以打快照的频率决定了恢复时间，而不是事件的总个数。

打快照频率有多种选择。你可以选择频繁地打快照，这样会减短系统恢复时间。但是考虑到系统打快照也需要时间，系统的运行时间会增加。或者你可以选择偶尔打快照，这样恢复时间变长，但是系统运行时间会变短。

幸运的是金融系统不需要过多思考打快照频率的问题。因为金融系统里有一个日切的概念。日切指的是在每天晚上 12 点的时候，你需要对当天的所有业务进行清点，确认无误后再开始下一天的工作，所以系统需要在每晚 12 点打一个快照。

除了每晚 12 点以外，金融行业一般还需要按月、季度和年度来进行业务清点工作。通常这些特殊的时间点也需要晚上 12 点整的状态，因此可以复用每天晚上日切的快照内容。但是也有可能碰到特殊时间点的要求，这时候需要单独打快照。

怎样查询数据？

到目前为止，我给你解释了事件溯源设计如何进行存储和计算，但是还没有说怎么查询数据。事件溯源设计对于查询有专门的术语，叫做 CQRS（Command Query Responsibility Segregation），就是我们通常说的读写分离。这里的 Command 就是事件溯源里的 Command。

读写分离指的是写入的组件只负责写，查询的组件只负责读。这样做的优势是，写部分的存储和读部分的存储可以根据访问的特点来分别做优化。

读写分离不仅仅是事件溯源需要，在其他架构中也经常能看见。比如有些 K/V 存储在写入的时候，会选择一些写入速度较快的数据结构，像 LSM 树。在读取数据的时候则会选另一些读取速度快的数据结构，比如 B+ 树。

事件溯源和其他设计不一样的地方在于，事件溯源既能查到当前内容，也能查到任何过去内容。我们先来看看怎么查询最近的内容。

思路很简单。如果我们将事件队列实时地复制出来，然后在另一台机器上用自动机执行这些事件，那么我们不就有最新的状态了吗？这就是状态机的读模式（Read Mode）。在读模式下，状态机只负责执行事件，不负责处理命令。示意图如下：

读模式自动机在游戏行业也经常能碰到。5v5 即时对战手游在进行比赛的时候会有现场直播，讲解员会在电脑上实时讲解当前所有选手的对战情况。电脑就是用读模式复制了手机上所有的实时状态。

我们再来看看怎么查询历史状态。最直接的方案显然是利用时光机的功能。我们先找到距离查询时间最近的快照，然后从这个快照开始执行事件，直到碰到查询时间点。这时候的状态就是我们需要的状态。一般我们把这个重新计算历史状态的过程叫作回滚。

在进行架构设计时你可以选择将实时查询和历史查询的优势结合起来。你需要做的是搭建多个读模式自动机。其中一个永远保持在最新状态，剩下的根据历史查询的频率来选择固定在过去某个时间点，比如日切的时候。多个读模式自动机的示意图如下：

事件溯源正确性的数学本质是什么？

我们在开篇词里提到会带你透过现象看本质。所以在给你讲完怎么实现事件溯源之后，最后我来带你了解一下事件溯源正确性的本质。

事件溯源的框架隶属于一个更大的系列，叫做不可变架构（Immutable Architecture）。在不可变架构里，所有数据都不能发生变化。所有这些不能变化的数据分为两大类，分别是事件（Event）和状态（State），分别用 e 和 S 来表示。

我们把前面讲到的自动机在数学上用函数 f 来表示。这个函数接受一个状态和事件，返回一个新的状态。如果我们把事件、状态和自动机结合在一起看，整个事件溯源的运行逻辑其实就是下面这个数学公式：

Sn​=f(Sn−1​,en​)

如果你把公式里的所有 S 都展开，那么数学公式就会变成下面这个样子：

Sn​=f(f(…f(f(f(S0​,e1​),e2​),e3​)…),en−1​)

上面这个数学公式可能看不出来什么熟悉的东西。但是如果换个表现方式你可能就熟悉了。我们可以把 f 换成 + ，这样事件溯源的公式就会变成将当前状态和事件的求和，从而生成新的状态，所以数学公式也可以变成下面这个样子：

Sn​=Sn−1​+en​

我们把简化后的数学公式展开之后可以发现，在事件溯源的设计里，任何一个时间点的状态等于之前所有事件效果的累积，就像下面这个公式表现的一样：

Sn​​=S0​+e1​+e2​+…+en−2​+en−1​=i=0∑n−1​ei​​

说到这里，我就可以给你解释，为什么在事件溯源里的我们会有那些假设了。

我们要求自动机是没有随机性的，原因是在数学里所有的数学函数都没有随机性，这样才能保证数学计算的结果是可以一步一步推演出来的。

另外，我们在记录事件的时候要求事件之间有顺序，这是因为自动机对应的函数一般是不可交换的（Non-commutative）。

也就是说函数的参数交换顺序后会导致结果不一样，这也导致数据之间是线性序列（Linear Order）的关系。这个线性序列关系导致我们在存储的时候选择用 FIFO 队列的存储格式。

由于我们可以通过逻辑推导来验证数学计算的正确性，当事件溯源和数学公式之间有严格一一对应关系之后，我们就可以像验证数学公式一样来验证事件溯源结果的正确性。这就是事件溯源能保证金融系统正确性的本质原理。

上面这些公式是用求和的方式来表示最终的状态是怎么得到的。在极限情况下，我们还可以有积分和微分表现形式。用积分的概念去理解的话，任何一个时间点的状态等于过去所有事件的积分，表示出来就是下面这个公式：

S(T)=∫t=0T​e(t)dt

微分的形式可能更有意义一些。每个事件是状态关于时间的导数，也就是下面的这个公式：

e(t)=dtdS(t)​

微分和积分的形式更多的是让你从时间的角度来理解事件和状态之间的关系。你可以仔细体会一下。

小结

这节课我给你讲解了事件溯源设计这个架构设计思路。在事件溯源设计里，你重点要关注命令、事件和状态这三个术语。命令指的是我想要做什么，事件是我合理的行为会做出什么改变，状态就是改变的对象和结果。

命令和事件都需要按照事件的先后顺序来处理。它们的存储也需要遵循同样的先后顺序。为了能定位到指定位置的内容，我们需要在存储数据的时候还同时存储一个位置的索引文件。

命令和事件都存储好之后，事件溯源设计里的状态机就可以从零开始，按顺序一一执行所有事件。我们要求所有执行的操作都具有可重复性，也就是不允许有随机性。这样就能确保我们多次从头执行，最终都能得到一样的结果。

这样要求有很多好处：既可以审计所有的状态变化过程，也可以有一定的容灾能力，同时还可以通过时光机和快照来让系统回滚到历史中任意一个时间点的状态。

事件溯源设计的查询需要遵循 CQRS，也就是读写分离的架构。系统会有一个自动机负责处理所有的命令和事件，另外还有很多读模式的自动机负责提供查询服务。这些读模式自动机会将系统回滚到打快照的时间点，然后从这个时间点出发，计算查询时刻的历史状态。

思考题

我们在存储事件队列的时候需要存储两个文件。一个存储事件，另一个存储事件的索引。在现实中会出现各种异常的情况，比如机器可能会中途死机，这样有可能文件只写了一部分。

1. 这时你应该如何检测文件是否完整？

2. 这两个需要存储的文件，应该按照怎样的先后顺序存储呢？

欢迎你在留言区留下你的思考和疑问。如果这节课让你有所收获，也欢迎分享给你的同事、朋友，和他一起交流进步。

08 | 计算结果的正确性：怎么保证计算结果是正确的？

你好，我是任杰。这一讲我想和你聊一聊怎么保证计算结果的正确性。

在前面几节课里我们学习了如何保证数据输入的正确性，如何用事件溯源的架构来保证数据计算的正确性。但这只能保证一个组件是正确的。系统里还有很多其他组件，我们也需要保证它们的交互也是正确的，这就需要一个系统性的指导方案。所以，这节课我们一起来看看如何保证最终计算结果正确性。

从抽象的角度来讲，任何一个计算过程都分为三个步骤：收到请求、处理请求和输出结果，也就是分为事前、事中和事后三个步骤。接下来，我们就从这三个方面逐一分析，看看如何系统性地保证最终结果的正确性。

事前

如果计算的输入错了，计算的结果就很难正确。输入不正确有两种可能性。一种是单个数据内容不正确，另一种是多个数据之间的顺序关系不正确。接下来我们就看看怎么解决这两方面的问题。

内容正确性

在这里我们可以假设，系统组件之间的信息交流方式是上游系统负责将数据传输给下游系统。所以上游系统需要保证数据内容的正确性。

我们在第 6 节课提到过，在数据可以被修改的情况下，我们很难保证所有人都能使用正确的数据。所以金融公司需要用双时序数据库来保证我们能查询到正确时间的数据。

我还想提醒你，尽管你找到了正确时间的数据，但数据本身还是有可能会出问题，比如在读取的时侯出现部分数据丢失，或者在传输的过程中出现了数据损坏。这时候我们需要给数据增加完整性校验的功能，比如在存储的时侯增加 HMAC 验证，这些也都是常规操作。

最后，也是最容易忽略的一点，就是我们需要给数据增加版本号。版本号代表了当前的数据格式，下游可以用来做校验。这么做在进行系统升级后的向下兼容处理时有奇效，所以一般建议你加上。

顺序正确性

顺序的正确性是指上游发给下游的多个消息之间需要保证正确的顺序。这不再只是上游一个人的独角戏，下游也要参与，上下游两方面通力合作。

顺序问题简单来说就是上游按顺序发送了 1、2、3 这三个消息，下游需要按顺序收到 1、2、3 这三个消息。这就表示接收消息需要满足这三点：顺序不能乱，个数不能少，也不能多。那我们来看下应该怎么保证。

不乱序

请你注意，这里的不乱序指的是接收端不乱序，对发送端没有什么要求。其实由于网络通讯协议是异步的，就算发送端按顺序发送，接收端也可能会乱序。

TCP 解决乱序问题的方案是在发送方在每个消息里包含了一个自增 ID。接收方将所有收到的乱序消息先放到一个消息缓冲区。如果自己等待的 ID 出现在了缓冲区，再从缓冲区里将这个消息捞出来。

在一般情况下，TCP 自带的方案就能很好地解决乱序问题。但是在云计算的处理框架下，消息的发送方可能会通过多个 TCP 链接来给接收方发消息。这样的话虽然单个 TCP 内消息是不乱序的，但是多个 TCP 链接之间还是有可能乱序。

举个例子。发送端和接收端中间有两个路由，分别是路由 A 和路由 B。发送端一前一后分别给这两个路由发了一个消息。这两个路由将消息传递给接收端的速度不一样，最终导致消息的接收顺序错位。示意图如下：

解决乱序的方法很简单。发送方和接收方之间可以实现和 TCP 完全一样的乱序解决方案，也就是通过自增 ID 和消息缓冲区来解决乱序。

从理论上来讲，你需要的不乱序其实是要求系统具有线性一致性（linearizability）。这个内容，我们会在第 13 节课“多机无容灾有哪几种不同的一致性实现”里给你详细讲解。

消息投放至少一次

上游系统架构

通俗来说，至少一次的意思就是消息不要丢。在理想情况下，发送方把消息发送出去之后就可以不管了，消息系统或者网络会将保证接收方一定会收到这个消息。

不过事实本不完美，我们面对的是一个随时会丢失消息的不稳定网络。在没法完全相信网络的情况下，发送方只有在收到接收方的回执了，这时才能肯定接收方确实已经收到了消息。

可是这里有个悖论。回执消息是接收方传递给发送方的消息，也会碰到消息丢失问题。这就是非确定性网络所带来的消息丢失问题。

这个问题的解决方法也很简单，那就是发送方需要一直不断地重发消息，直到收到了至少一次接收方的回执。从逻辑上来讲，从一次回执消息的接收可以推算出至少一次的消息接收。

由于消息的发送和金融系统的主营业务无关，消息处理部分一般会单独作为一个消息中间件来处理。按照我们在第 4 节课的分类，消息系统属于通用组件。

这个消息中间件有自己的数据库，负责存放所有需要投放的消息。每个消息还有一个回执标识位，负责记录对应的消息回执是否已经收到。如果这个回执标识位一直为空，消息中间件会不断地往下游发送消息。整个处理过程分为 6 个步骤：

1. 业务系统将消息发给进程内的消息中间件。

2. 消息中间件将该消息保存在数据库中，并将回执位设置为空。

3. 消息保存好后，消息中间件通知业务系统发送成功。

4. 消息中间件将消息发送给下游系统。这时候消息中间件会分情况做处理：如果没有收到下游系统的回执，消息中间件要持续发送消息；如果上游系统重启，消息中间件会从数据库中找到所有还没有收到回执的消息，重发给下游系统。

5. 下游系统收到消息后返回回执消息。

6. 消息中间件收到回执消息后，改变数据库中的回执消息位。

架构的简单示意图如下：

虽然你很可能一眼就看出来，上面这个架构能保证至少一次的要求。不过，我还是要给你说说里面蕴含的一些道理。

至少一次指的是下游至少收到了一次消息，这是下游系统的客观状态。这个状态和上游系统无关，因此我们需要保证就算上游系统出了问题，系统也能正确记录下游状态。这种出了问题还能正确记录状态的能力，我们叫作数据库事务。这就是为什么我们需要用具有事务能力的数据库来存储回执状态。

另外，回执消息还有一个数学特性叫作幂等性（idempotency）。幂等性指的是同一个操作执行多次的结果和执行一次一样。我们将回执位设置为成功多次和一次的结果是完全相同的，所以回执位的多次更新不会改变逻辑的正确性。正因为有了事务和幂等性这两个保证，上面的架构才能保证消息投放至少一次。

下游系统架构

讲完了上游系统作为发送方的架构，我们还需要弄清楚作为接收方的下游系统应该如何处理。

常见的下游系统错误是先返回消息回执，然后再处理消息。如果下游系统重启了，就会导致下游系统当前消息丢失。而这时上游系统已经收到了回执，也不会重发消息。这样这个消息就从系统中整个丢失了。

解决方法和上游系统类似，下游系统也会通过独立的消息中间件，实现和上游系统的正确交互。消息中间件在收到上游发过来的消息后先记录到数据库，然后再通知下游的业务系统处理。整个过程也同样分为 6 步：

1. 上游系统给下游系统的消息中间件发送消息。

2. 消息中间件收到消息后记录到数据库。此时消息是未执行状态。

3. 消息中间件发送回执给上游系统。

4. 消息中间件将消息传给下游的业务系统进行处理。此时消息中间件要处理以下几种情况：如果消息中间件没有收到下游业务系统已执行完的通知，需要持续发送消息给下游业务系统；如果系统重启，中间件要从数据库中找到所有未执行的消息，然后按正确的顺序发给下游业务系统。

5. 下游的业务系统处理完成后，通知消息中间件。

6. 消息中间件将消息变为已执行状态。

架构的简单示意图如下：

和上游系统的逻辑一样，下游系统也是通过事务和幂等性来保证消息反馈的正确性。

上下游结合

前面我们分别学习了上游系统和下游系统应该分别如何处理消息。如果我们将两者的架构图结合起来看的话，就是下面这个样子：

这个架构有一个隐含的假设：上下游分别由不同的人来设计。所以上下游系统需要在假设对方是正确的情况下，各自维护自己的逻辑。那我们可以再想想，如果有人同时设计上下游两个系统，有没有可能将架构设计得更加简洁呢？

你可能已经想到了。只需要将上下游的消息中间件剥离出来，然后合并在一起，这样系统就分成了 3 个组件：上下游系统和消息系统。

这时候上下游之间也需要 6 步来完成所有的交互，比之前两者分开的情况节省了 4 步。当中也有一些消息检查和重发的机制，你可以仔细想想都在哪些地方。合并之后的架构图如下：

上面这个架构图有很多名字，比如消息系统、企业总线等等。我们在开篇词提到过要透过现象看本质。你需要重点关注它究竟解决了什么问题，为什么可以解决这些问题，以及在不同环境下的不同使用方法。

消息投放至多一次

刚才我们在保证消息投放至少一次的时侯，也制造了一个问题，那就是下游系统有可能收到多条同样的消息。

比如你本来给人转了一笔钱，可在系统内却转了多笔一样金额的钱，这样的系统显然是不可接受的。要想解决很简单，我们可以将消息的处理变为具有幂等性的操作。

实现幂等性的方法是去掉重复的消息，只保留第一个消息，这个行为简称去重。去重要求你能够判断不同的消息是否重复，这就要求消息有唯一标识符。

唯一标识符有两种方法可以生成，一种是消息自带，另一种是由上游系统生成。

消息如果想要自带唯一标识符的话，就需要用到自己内部的属性，也分为两种情况。一种是利用和业务有关的属性，比如支付订单号。另一种是用和业务无关的属性。这时候一般会将消息当作是数据库的一行记录，利用数据库对应的主键或者具有完整性校验功能的字段作为唯一标识符，比如 MD5 或者 SHA1。

上游系统有时候也能帮助生成消息的唯一标识符。前面我们说到保证消息不乱序的解决办法时，就用到了自增 ID。这个自增 ID 就可以用来作为消息的唯一标识符。当然了，这里有个要求，就是消息系统需要能控制消息的格式。

总结一下，如果上游系统有自增 ID，就可以用这个 ID 来去重。否则就需要用到业务或者数据库的某些唯一性来去重。

事中

对于消息处理正确性而言，事前准备工作是最重要的，这也是为什么前面花了大量篇幅去讲它。而消息的处理则跟架构关系不大，更多和软件工程相关，我们可以从函数式编程和计算精度两个角度理解。

函数式编程

上节课我们提到过，事件溯源和数学计算很类似，所以才能一步一步证明正确性。其实有一类编程语言也和数学计算很类似，叫作函数式编程语言。

在函数式编程语言里，所有的数据都不可以被修改，所有函数也不允许有随机性。这样我们就可以将函数随意地组合，然后生成下一个确定性的新函数。这种可以将函数像乐高一样随意组合也能保持正确性的特性（Composibility），保证了我们在编写程序的时候，程序不会因为代码的增加而导致正确性变化。

这就是函数式编程语言在软件工程上相对于其他编程语言的优势。我们在开篇词提到过金融行业注重投资回报比。函数式编程语言有很高的学习成本，但是长期来看它的正确性维护成本很低。

所以函数式编程语言虽然小众，在金融行业也有很大规模的应用。比如高盛公司发明的函数式编程语言 Slang 和用它实现的数据系统 SecDB，摩根士丹利发明了 A+ 和修改了 Scala，Jane Street 的 Ocaml，以及渣打银行的 Haskell。我们在第 10 节课“金融业务应该如何选择数据存储类型”里会给你介绍 KDB/Q，这也是一个函数式编程语言和基于它的数据库。

计算精度

现在编程语言众多，数据传输格式多种多样，数据中心的硬件系统也多，很容易出现精度问题。你可能对这个问题没有什么感觉，因为只有在金额特别大的时侯才有可能出现精度的问题。

我给你举个例子。在 2015 年的时侯，175 千万亿津巴布韦元可以换 5 美元。在 2018 年，日本的 GDP 约为 500 万亿日元。如果不小心设计，这么大的金额很可能会出现存储方面的精度问题。我们在设计金融系统的时侯需要知道可能的业务边界。业务对接的机构越大，资金的金额越高，越容易出现计算精度的问题。

所以如果你有志成为一个伟大的架构师，致力于解决大型金融机构的系统架构问题，那么对于计算精度你一定要提前做好应对。

事后

计算完毕不代表正确性相关的工作就结束了，我们还需要在事后对计算的结果进行验证。

举一个实际发生过的例子：我们在一次计算中偶然发现算出来的金融合同市场风险的值非常高。因为市场风险的各个数值之间有一定的数学逻辑关系，我们通过数学计算判断确实是某一个数值偏高。

由于我们的系统采用了双时序数据库和事件溯源，可以在云计算环境一直重复这个有问题的计算。最后终于发现是某台机器 CPU 的一个核的浮点数寄存器出了问题，在计算的时候会出现随机数值。CPU 的厂商解释说，可能是宇宙射线的问题损坏了 CPU。

这个例子说明错误的计算结果并不一定是人为的，周边的软硬环境也有可能导致错误。因此我们在对计算结果进行验证的时候，一定要选择不同的计算环境，这样才能降低和之前计算结果的相关性。

降低计算结果和验证结果的相关性有一些常见思路。首先我们可以选择不同的编程语言。其次可以选择由不同的人实现。最后还可以选择不同的架构设计和云服务提供商。

那么，验证多少次你才会放心呢？在极其重要的场景，比如飞机或者航天器材上面的软件系统，一般会验证 3 次。因为这些系统和人命相关。这种场景下同时会有 4 个系统在一起工作。这 4 个系统彼此验证，只有在至少 3 个结果完全一样的情况下才会向外输出结果。而金融软件的要求一般没有这么高，所以验证 1 次基本就够了。

验证结果还有一个时效性的问题。你可以选择实时验证，在验证通过之后再往外公布计算结果，或者可以选择异步验证，在公布结果之后再择机进行验证。

实时验证的好处是可以防范于未然，在造成不好的影响前解决问题，但是代价是增加系统的延时。异步验证刚好相反，可能会对外造成不好的影响，因此需要业务有事后补偿的能力。在金融系统中常见的日切或者对账其实就是异步验证的解决方案。

小结

这节课我们从事前、事中和事后三个部分学习了如何保证整体计算结果的正确性。

事前我们可以做好准备工作。首先我们要保证数据内容是正确的。这要求我们使用正确的查询，比如双时序数据库。同时我们还不能假设结果的完整性，也要进行验证。

保证了数据内容正确性之后，接下来还要保证数据的接收顺序也是正确的。这要求数据的接收不能乱序，而且保证数据只处理一次。

事中的正确性要通过软件工程来解决，而不是通过架构设计。金融系统推荐使用函数式编程语言。在实现过程中还需要注意计算精度的问题。

事后需要验证结果的正确性。验证的时候尽量不要和之前的计算有任何关系。一般验证 1 次就可以，多的可以验证 3 次。验证的时间可以选择实时验证，或者异步验证。如果采用异步验证，需要业务方对应的业务补偿能力。

总之，单个组件的正确性并不能保证整个系统的正确性。我们需要在架构设计上将组件之间不确定的交互行为变得确定，同时在软件工程实现上要选择一些不容易出错的解决方案。

思考题

我们在讲如何保证消息至多投放一次的时候，说过可以用数据库来做去重工作。不过数据库的容量一般是有限的。

假如你设计的系统预期会运行 10 年以上。数据库由于存储不了这么久的数据，一定会将过期不用的数据进行归档后删掉。这会造成你用来去重的数据有一部分会不见了。这样如果来了一个请求，这个请求恰好用了被删掉的 ID，系统就会重复处理。那么你应该如何做呢？

欢迎你在留言区分享你的思考或者疑问。如果这篇文章让你有所收获，也欢迎转发给你的朋友，一起学习进步。

09 | 数据传输的质量：金融业务对数据传输有什么要求？

你好，我是任杰。这节课我想和你聊一聊怎么做好金融数据的传输。

我们在开篇词提到过，如果你对系统的要求高，通常都会说要按照金融级的标准来设计。所以当我们提到金融数据传输的时候，你可能也会觉得数据传输应该也是要求非常高的，既要速度快，又要流量大，而且还有极强的容灾能力。

没错，在金融行业很多地方会有这样的要求，但是也有一些地方要求并不高。所以这节课我会带你分析一下金融业务在不同场景下对数据传输的要求是什么，以及解决方案都有哪些。

案例分析

按照惯例，我们还是从一个具体的例子开始。和我们之前讲扫码支付一样，这里的例子既要典型，又要简单。所以思考再三，我选择了简化版的券商算法交易平台对接交易所的例子，原因有这些：

1. 涉及场景多。既有事务数据，也有市场数据。

2. 模型简单。只涉及到 2 个主体。

3. 复杂度可选。连接交易所的要求可以很高，也可以很低，具体取决于你愿意出多少钱，有多久的研发时间。

那我们来看看简化的模型是怎样的。

在券商对接交易所的例子里，一共包括两个主体：券商和交易所。券商的任务是将券商提供的市场交易信息发送到自己的算法交易平台，平台分析这些信息之后发送买卖订单给交易所。交易所负责处理收到的订单，在处理完之后将交易信息传递给券商。整个过程都是用软件实现，而不是用硬件。

下图画出了这个简单的交互流程：

交易数据

券商发给交易所的订单数据属于事务数据。这里的事务指的是数据库事务（Transaction）。所以交易数据的传输需要满足我们在前一节课提到的顺序正确性要求，也就是既要保证顺序的正确性，也要保证消息处理的一次性。不了解的同学可以回到上一节课复习一下相关内容，这里就不做复述了。

既然我们这节课讲的是数据传输质量问题，那么我们还是要分析一下可能的异常情况。数据传输已经具有事务性了，还能出什么问题吗？

不知道你有没有在电商促销的时候抢过东西。如果你网页刷新太快，可能会收到远端服务器拒绝访问的消息。这意味着电商服务器觉得你的访问频率过高，临时降低了提供给你的服务质量。

金融行业里的服务器容量也有上限，所以也普遍采取了限流这种保护措施。那我们接下来聊聊限流该怎么做？

限流

常用的限流方法有两种，分别是漏桶算法和令牌桶算法。

漏桶算法的原理是消息的生产者将所有请求放到一个容量固定的桶里。消费者会匀速地从桶里消费消息。因为桶的消息满了就丢掉，所以这个桶叫作漏桶。

令牌桶算法和漏桶算法一样，也有一个容量固定的桶，只不过这个桶里装的不是消息，而是令牌。系统会按固定的速度往令牌桶里放令牌，满了之后就会溢出。消费者每处理一个消息都需要消耗一个令牌，所以桶里面的令牌总数决定了处理消息的最快速度，而放令牌的速度决定了处理消息的平均速度。

漏桶算法和令牌桶算法是互联网常见的限流算法，你有兴趣的话可以上网查询相关的实现细节。我们在这里主要看一下这两种算法应该如何选择。

先看看券商应该如何选择。当两个不同组织之间的金融系统进行对接的时候，接收方一般要假设发送方是恶意的。因此交易所需要限制券商的消息发送速度，比如一秒钟内最多只能发多少消息。这时候券商可以使用漏桶算法来限制自己对外的消息发送速度。

再来看看交易所应该怎么选择。尽管交易所明文规定了每家券商的速度上限，但是交易所不会相信券商会遵守规则，因此从系统安全的角度考虑，交易所依然会对券商的消息进行限流。这些经过限流的券商流量最终会汇集到一起，再集中处理。

现在又有一个新的问题产生了。虽然对每家的流量都做了限制，但是他们的总流量还是有可能会超出系统承载上限。所以交易所还需要对总流量做一个限流。

这时候你就有一个选择，如果你想对总流量做一些微调，就可以选择令牌桶算法，这样就可以通过调整生成令牌的速度来调整处理速度。还有另外一个好处是令牌桶里的总令牌数目代表了系统的峰值处理流量，这样系统还具有一定峰值处理能力。当然你也可以选择安全一点的漏桶算法。

一旦上下游之间做了限流，那么整个系统就需要假设数据会丢失。因此你需要处理好订单发送不出去，或者发送出去后无法被执行这两种情况。

市场数据

交易数据的处理一般具有事务性，所以选择灵活度比较小。市场数据就不一样了，选择面要宽泛很多。

我们先来看看什么是市场数据。这里的市场指的是金融交易市场，所以市场数据指的是金融市场成交信息。我们平时关心的股价就是股票买卖双方的成交价格。

交易数据是事务型数据，那市场数据也是事务型数据吗？这个问题是市场数据处理的最核心问题。

如果你关心的是自己订单的成交信息，那么这个成交信息是事务类数据。但是在我们的例子里，券商的算法交易平台关心的不是自己的交易信息，而是当前所有人的交易信息，所以算法交易平台并不需要数据有事务的保证，这也意味着在我们的例子里是允许掉数据的。

正因为放松了这个假设，我们对市场数据的处理才有了多种不同的选择。因为不同能力的算法交易平台对数据的实效性要求不一样，所以我们先来看看非实时的情况是怎样的。

非实时市场数据

非实时在这里主要指的是那些对延时要求不是特别高的使用场景。这时候消息的传输本着尽量快的原则，稍微慢了几百毫秒或者几秒钟问题不大。绝大多数情况下，你碰到的都是这种非实时的市场数据场景。

我们先看看非实时市场数据的分类，然后再结合前面的例子分析怎么选择数据处理方式。

订阅发布与消息

数据的传输方式分为订阅发布（Pub/Sub）和消息（Messaging）两大类。在订阅发布的情况下，每个消息的消费者是互相独立的，每个人都需要处理所有消息，并且每个人处理消息的顺序必须是一样的。消息则刚好相反。所有人之间共享所有消息。这意味着每个人处理的只是一部分消息，从他的角度来看消息是断断续续、不连续的。

我们常见的一些数据系统，比如 Apache ActiveMQ、Amazon SQS、IBM WebSphere MQ、 RabbitMQ、RocketMQ，它们在最开始的时候都是按照消息的方式设计的。而 Apache Kafka 和 Google Cloud Pub/Sub 则是按照订阅发布的方式设计的。

请注意这些都只是这些数据系统最开始的设计目标。系统架构在演进过程中可能会同时具有订阅发布和消息的一些能力，比如 Apache Kafka 就是一个典型。

那我们来看看券商需要哪种数据传输方式。如果算法平台只有部分数据的话，可能就会缺失一些重要的历史信号，所以算法交易平台需要所有历史数据。

所以很显然券商需要订阅发布的数据传输方式，通过这个方式从交易所接收数据。这也是为什么现在 Apache Kafka 在金融系统中使用得越来越多的原因。

优化及原理

接下来，我们看看前面例子里的数据传输系统如果想优化，应该怎么做呢？

这里我们需要利用金融数据的一个属性——数据的时效性。数据的时效性指的是不同时间的数据对你的价值。

我们先要明确一点的是，对于金融市场数据来说，你永远得不到当前的数据。不管延时有多低，你收到市场数据的时候已经是历史数据了，所以我们在这里谈论的都是历史数据的时效性。

如果所有历史数据对你的价值都是一样高，那么一般来说数据需要尽量完整。相反，如果越接近现在的数据对你的价值越高，那么数据则有可能允许丢失。因为就算丢失了，你只要稍微等一段时间，丢失的数据重要性就会变得很低，这样丢失对你的影响就会很小。

那我们再来看看算法交易平台需要的数据属于哪种类型。算法交易平台属于高频交易类型，它需要根据最近的趋势预测未来的盈利机会。如果数据的时间太久，可能市场上的其他参与者早就利用这些信息赚过了钱，这种时间太久的数据就没剩多少价值了。所以算法交易平台的数据属于具有时效性的数据，允许部分缺失。

我在这节课最开始提到过，金融系统不是所有地方的数据要求都很高，这里就是一个例子。所以你一定要结合业务特点来选择合适的系统架构。那知道了这个特点，我们就可以针对性地调整数据传输系统的容灾能力了。

比如说 Apache Kafka 默认带有一定容灾功能。一般要求部署 3 个节点，其中一个是主节点，另外两个是备份节点。

每当 Kafka 收到数据后，会将数据先同步给备份节点，这个备份的过程需要一定时间。备份的节点个数叫作同步数（ISR，In Sync Replica）。我们可以将 Apache 的同步数设为 0，这样我们就能牺牲掉部分不重要的容灾能力，换来更快的处理速度。

当然了，就算数据可以丢失，也要避免这种情况频繁出现。所以，你要建立合理的监控机制，当数据频繁丢失的时候能及时反应。

实时市场数据

金融行业的非实时市场数据的处理和互联网行业的方式比较接近，用的系统架构也比较类似，所以你会有种似曾相识的感觉。接下来我们看看实时市场数据的处理，这和互联网的处理区别比较大。

特殊部署

实时市场数据指的是对延时要求非常高的数据场景。低延时的系统维护对交易所本身是一笔很大的开销，而且因为能支持的用户数有限，所以一般都需要收费，也就是常说的席位费。

席位费也分不同的等级。当你交完席位费后，一般就能连到交易所，获取一些低延时的数据，同时还有可能得到更详细的数据内容。

一般来说你的服务器和交易所有一定的物理距离。我们都知道光的传播有速度上限，所以这个物理距离会导致一定的延时。比如跨省传播数据的话，延时可能在几毫秒到几十毫秒左右。

如果你觉得这几十毫秒很重要，你可以再用更多的钱来解决这个问题。交易所一般会提供同机房主机服务，你可以将自己的系统部署在交易所的机房内。这样你的系统和交易所系统的物理距离只有几米左右，光速带来的影响基本可以忽略不计。

不过，因为交易所机房的物理机器有限，你需要和其他有钱的金融公司竞争这为数不多的位置，可能需要付出天价的运营费用。

数据分发

从前面讲的特殊部署你可以发现，实时市场数据的消费者之间并不是平等关系。谁的钱多，谁的延时就低。这表示实时数据分发的系统架构是一个层级结构，越接近上层的人收到的消息越快。

我们先从交易所出发。当交易所生成完数据之后，会将数据传送给顶层的数据节点。这一层会部署多个节点以防单点故障。

假如是按照 Apache Kafka 的模式，交易所数据应该发给一个主节点，主节点负责和备份节点之间通讯。但是这样会有一个网络延时，所以交易所采用的是局域网内广播的方式，所有第一层节点都会同时收到所有数据信息，如下图所示：

顶层数据节点收到数据之后会做两件事情，分别是推送给优先级最高的 VIP 客户，以及推送给下一层的数据分发节点。

这里需要注意的是，低延时架构优化的是系统延时，而不是系统的吞吐量。互联网常见的消息系统普遍采用的是消费者定时拉取数据的模式。这样做的优点是能支持大量的数据消费者，但也会带来问题，就是两次拉取之间有一定的时间间隔。比如 Apache Kafka 的默认客户端就是这种行为。

数据的实时推送会消耗很多推送端的硬件资源，但是由于交易所的 VIP 客户数目很少，实时推送对系统的影响可控，所以数据可以通过顶层数据节点直接推送给用户。

其实我们还可以从经济学的角度来考虑这个问题。VIP 席位费的总盈利是总 VIP 客户数乘以席位费，VIP 客户数减少之后，VIP 之间的价格竞争会更激烈，所以席位费会增加。因为总盈利是这两者的乘积，很有可能总盈利会因为席位费的增加而大幅增加。

所以从利润的角度来考虑，系统也不一定需要支持很多的 VIP 用户。这也说明了我们在选择系统架构的时候，一定要结合业务来一起考虑。当然了，为了让这一点能成立，VIP 席位需要具备一定的价格弹性（Price Elasticity），你有兴趣的话可以了解一下经济学的相关内容，比如诺贝尔经济学奖获得者 Paul Samuelson 写的《经济学》。

数据除了顶层的 VIP 用户之外，还有非 VIP 的付费实时数据用户。但是由于数据已经有了延时，这时候不一定需要用到数据广播，所以我们可以选择让顶层节点推送数据给下层节点。这时候的架构示意图如下：

以此类推，每一层都能往下继续分发数据。这时候数据分发的对象有交易所的非 VIP 付费客户，也有其他的数据节点。

当交易所解决完所有付费用户的实时数据推送问题之后，接下来要解决怎么把实时数据变为非实时数据，也就是怎么让非付费用户也能访问到数据。

其实这个过程很简单，只要将某一层的实时数据节点对接到非实时数据系统就可以了。这时候数据由实时的推送方式变为非实时的拉取方式。更新后的系统架构如下：

数据压缩

实时数据系统大部分的时间都花费在数据的解码和编码上。所以要想速度快，首先要数据量小，这也是金融系统架构和互联网架构很大的一个区别。

所以，你平时经常能见到的 Json 或者 XML 格式的数据一定不能用在实时数据系统里。因为这些传输格式是为人准备的，里面有多余的信息，所以你需要换成只有机器看得懂的二进制表达方式。

如果要求不高的话，一般来说 Google Prototol Buffer 协议就足够了。这个协议会按照你定义好的二进制表现形式来进行编码，能对数据进行很大幅度的压缩。

在要求更高的金融场景下，普遍会使用金融行业专用的 FIX 通讯协议。这个协议定义了通讯规则，同时也定义了数据传输方式。

市场数据有一个显著特点是，连续两个数据之间大部分内容都是一样的。比如说你去比较连续两个股票价格信息数据，就会发现它们很有可能只有价格这一个指标会有变化，其他的信息完全一样。

FIX 协议就利用了这个特性，在很多情况下只需要传输数据变动的部分，这样就能减少很多数据传输量。其实这个设计思想和视频压缩算法非常类似，视频压缩的时候是以关键帧为基准，其他帧只存储相对于关键帧的变化。

小结

这节课里我们学习了怎么做好金融数据的传输。

金融数据分为交易数据和市场数据两种。金融交易数据的处理和互联网处理方法非常类似，在处理的时候需要做好数据限流的架构选型。

市场数据的处理分为非实时和实时两种。非实时市场数据的处理也和互联网处理方法类似，在处理的时候，对订阅发布和消息这两种不同架构选择，我们要做好区分。因为市场数据具有实效性，我们可以容忍偶然的数据丢失，这也给了数据系统一个很大的优化空间。

实时市场数据的消费者分为不同的级别。实时数据系统的架构和用户一样，也是分为不同级别。数据会层层分发下去，不同层级有不同的延时情况和部署方案。实时系统的优化主要体现在数据压缩上，金融行业有自己特有的 FIX 二进制通讯协议。

思考题

实时数据系统的数据节点通常都是价格昂贵的机器。这些机器的处理速度极快。交易所机器运行太快了之后，会导致推送给实时用户的数据量过大，用户来不及处理。你这时候应该怎么处理这两者速度不一致导致的问题呢？

欢迎你在留言区和我交流互动。如果这节课对你有帮助的话，也欢迎你分享给朋友、同事，一起学习和讨论。

10 | 数据存储的合理性：金融业务可以不用关系型数据库吗？

你好，我是任杰。这节课我和你聊一聊金融业务应该如何选择数据存储类型。

提到金融行业的数据存储，我们的第一反应肯定是要用关系型数据库。但是如果我追问一句，为什么一定要用关系型数据库？估计很少有人能答上来。最常见的理由是别人在用，所以我也得用，但是这个并不是理由，而是借口。

其实金融行业的数据存储有很多种选择，今天我们就一起看看都有哪些。

数据分类

我们都知道，不同的数据对存储和使用有不同的要求，所以我们选择数据存储类型前先要分析数据有哪些特点，然后才能根据这些特点来针对性地选择适合的存储方案。

通常我们会按照数据与数据之间关系的复杂度来对数据进行分类。最简单的显然是数据之间没有什么关系，比如常见的市场数据就属于这一类。复杂一点的是数据之间有单向的关系，这些关系形成一个树状结构。最复杂的是网状结构的数据，也叫图数据类型。

虽然这些数据在金融系统里都有，但是它们的重要性和出现的频率都不一样，所以在做存储选型的时候也有不同的考量标准。

按照数据出现的频率，数据大体可以分成这样三类：图数据类型、没有关系的数据类型和树状数据类型，它们分别对应了图数据库、时序数据库和关系型数据库。接下来我们就分别看看。

图数据库

顾名思义，图数据库存的是图。图数据库除了提供数据的存储以外，还支持图的查询，比如常见的相邻关系查询，或者连通关系查询。

但是金融行业里很少有图这种类型的数据结构。主要是因为图是一种非结构化数据，而金融业务里处理的数据都要有非常清晰的结构，所以金融数据从本质上就不是非结构化数据类型。

虽然金融行业里图用得比较少，也不是完全没有，一般都出现那些跟数据分析相关的部门。比如在和客户进行业务往来之前先要对客户进行背景调查（KYC，Know Your Customer），或者查看用户是否存在洗钱行为（AML，Anti-Money Laundering）。这就需要分析客户的社会关系和财务状况，这时候用图来表示这些彼此关联的信息就比较合适。

时序数据库

不知道你有没有注意到，金融市场数据一般都带有时间？你可以回想一下，我们平时在新闻里听到的和金融市场相关的数据，比如大盘、汇率、指数等等，它们都是指某个特定时间点的数据。这些带有时间的数据有特殊的存储方式，叫作时序数据库。

关系型数据库也可以用来存储时间序列数据，但是会慢一些。为什么时序数据库会更快呢？这就要提到数据库存储数据的方式了。

行存储和列存储

时序数据库会更快的原因是它们普遍采用了列存储的方式来存储数据，而你熟悉的关系型数据库一般都用行存储的方式来存储。

举个例子。假设你要存储外汇信息，那至少需要处理这些数据：时间、汇率币种、买价格、卖价格和平均价格。下面这幅图展示了连续 3 个时间点的外汇信息：

上面这幅图从数学上说是个矩阵，有两个维度。但是存储设备只有一维的地址，不是二维的，所以我们需要把这个矩阵从二维变为一维，这样才能存储到磁盘上。

关系型数据库采用的降维方法是将矩阵横向切割。这样每行会作为一个整体存储下来，行与行之间挨在一起。

就像下面这幅图展示的存储方式一样，外汇信息被分为 3 个单位存储，每一行是一个单位：

这样存储似乎看起来也可以。问题在于在进行数据查询的时候，需要将每行作为一个整体从文件上加载到内存，这样会拖慢速度。

比如说，如果你想算一下这 3 个时间点对应的买入价格的平均值。虽然你只用到了整个数据的一小部分，但是你要将这 3 个时间点所有数据都加载到内存之后，才能完成计算。

列数据库选择了另一种存储方式。它降维的方式是将矩阵纵向切割。这样存储的单位就不再是一行，而是一列。还是同样的外汇信息，现在被分为 5 个单位存储，每一列是一个单位：

这时候你再想计算这 3 个时间点对应的买入价格的平均值，只需要加载上面这幅图粉红色的部分就可以了。由于大部分数据都不需加载到内存，这样就能节省大量的读取时间。很显然，对于金融市场数据来说，时序数据库是一种更加有效的存取方式。

那为什么会出现这种情况呢？这就要提到数据的业务性了。

金融市场数据和金融业务数据不一样。市场数据一般是业务处理的结果。比如你看到的股票价格信息是股票交易所进行了买卖撮合之后的结果，外汇信息是外汇交易之后的结果，利率、指数等等也都是这样生成的。既然市场数据是业务处理的结果，那它就不是业务问题了。

关系型数据库在最开始研发出来的时候是为了解决业务问题。业务有个共同的特点是需要对单个业务数据进行完整的读写。在关系型数据库里，一个业务一般用一行来表示，因此数据库在进行存储优化的时候，选择优化了行的整体读取能力。

而金融市场数据不是业务数据，并不太适合用关系型数据库处理，所以我们在选择存储金融市场数据的时候，会优先选择基于列存储的时序数据库。

KDB 简介

金融行业很早就知道关系型数据库不太适合市场数据的处理，所以有自己的行业解决方案。这些方案中最出类拔萃的数据库叫作 KDB，我在这里也做一个简单说明。

KDB 不仅仅是一个数据库，它还有自己的编程语言 Q 和 K。其中 K 源自于一个编程语言叫作 A+。A+ 是 KDB 作者在摩根士丹利的时候发明的一种编程语言。A+ 又来源于一门数学编程语言叫作 A。

所有这些语言和 Lisp 一样，都属于函数式编程语言，所以你在使用 KDB 的时候会看到很多 Lisp 的身影。目前 A+ 已经开源，你可以在这里找到它。

先介绍一下 Q 这门编程语言。这个语言有几个设计特别精妙的地方。因为 Q 是函数式编程语言，所以它里面的数据都不允许修改，修改会返回新的结果。

另外，它也假设函数没有随机性。在数据不允许修改和函数没有随机性的情况下，每个函数就可以当作是一个 Map。这个 Map 的键是函数的参数，Map 的值是函数的返回值。这样就让函数和 Map 得到了统一。

Q 的另一个设计是统一了 Map 和关系型表。表的列名是 Map 的键，表每一列的值是 Map 的值。表和 Map 之间的转化是通过 flip 操作来进行的。

讲到这里，你可能意识到 KDB/Q 也是个列存储的数据库。KDB 确实也是按照列数据库设计的，所以它的磁盘操作非常快。

KDB 不仅数据存储快，它的数据操作也快。

比如在前面讲到的例子中，3 个时间点价格平均值的计算。如果是你用编程语言实现，可能会用一个循环来求和，然后求平均值。

由于 KDB 知道每一列的数据类型都是完全一样的，它在计算的时候会用到 CPU 的向量指令，用一个指令来完成多个数据的同时处理。这一点使得 KDB 在处理金融数据时有极高的处理速度，而这种效果正是 KDB 通过实时编译 Q 语言来实现的。

为了处理的速度更快，KDB 采用了单线程的运行模式。这样就避免了线程切换和同步锁带来的开销。由于 KDB 在 IO 和 CPU 的速度都很快，在金融行业里对计算速度要求高的领域有广泛的应用。

那我们应该在什么时候选择 KDB 呢？主要还是数据量的问题。KDB 适用的数据量范围是 GB～TB 之间。比如你的金融市场数据在几十 G 左右的话是完全没有问题的。同时，KDB 会大量使用内存，因此内存尽量大一点好。

当然，KDB 也不是没有缺点的。最主要的缺点是学习门槛高。KDB 的 Q 和 Lisp 一样是函数式编程语言，市面上会的人不多，教材和文档也比较缺乏。因此需要使用者有很强的抽象能力和学习能力，很多人学着学着就半途而废了。

KDB另一个缺点就是太贵。它的价格非常高，一般只有顶级的金融公司才能承担得起。而且需要整个团队进行周边工具的开发，这就是一笔很高的运营成本。

不过，我们一直强调在金融行业要讲究投资回报比，而不只是价格。虽然 KDB 成本这么高，但是一旦学会了就能有很快的开发速度和运行速度，在每秒几千万上下的金融市场往往能有奇效。

KDB 一直以来都在很专业的领域内发展，比如金融和医药等。这些年来互联网行业的列数据库也越来越成熟，比如现在风头正盛的 ClickHouse，里面的技术和 KDB 大同小异。

行业技术的出圈和彼此融合值得我们高兴，在这里我也希望当不同行业的解决方案在进行碰撞的时候，你能够独立思考特殊的方案是如何解决行业的特殊问题，这样你才能形成自己的架构思想，而不是人云亦云。

双时序数据库

我们在前面第 6 节课里讲了双时序数据库。虽然双时序数据库的名字里也有“时序数据库”这几个字眼，但是它的实现和时序数据库完全不同，因此适用的场景也不同。

简单说一下双时序数据库的实现有哪些不同。双时序数据库由于多了一个时间维度，就不能按照列存储的方式进行存储。

其实我在第 6 节课的思考题里，已经给你提示了双时序数据库的存储空间复杂度和时间复杂度，这些复杂度并不低。而且，当你把内容加载到内存之后，会发现无法使用 CPU 的向量指令来加速运算。

这些都导致双时序数据库不适合吞吐量特别高的业务，比如股票和外汇业务这些高频交易类业务。但是它比较适合交易量稍小一些的场外交易类业务，像债券、期货、资产证券化等等。

我再说一下双时序数据库的实现。虽然这个理论提出来很早，但是市面上的通用产品不多，一般都是金融公司自己研发。

你还记得，我们在第 4 节课领域驱动设计中把系统组件分成了 3 个部分么？其中最重要的就是核心组件。核心组件代表了公司的核心竞争力，需要自己研发。双时序数据库对于大型金融公司来说就是核心竞争力，所以外界很少知道这个产品。

实现双时序数据库的挑战主要在时间索引的生成和查询，你可以参考第 6 节课思考题的方法，或者使用空间树的数据结构来实现。

关系型数据库

讲完了时序数据库以后，我们最后来看看关系型数据库这个最有争议的地方。

对象关系阻抗不匹配

关系型数据库的争议点主要集中在和面向对象编程之间的冲突。学术界甚至有个专业名词来形容这种冲突：对象关系阻抗不匹配（Object relational impedance mismatch）。

面向对象编程里的所有对象之间的关系形成了一个图，因此研究方法需要用到数学上的图论。而关系型数据库的模式（schema）是基于关系代数（Relational Algebra），是一系列同构（Homomorphic）的列表组成的集合（Set），因此用到的是数学上的集合论。

其实你将对象存储到关系型数据库的过程，就是一个将图论翻译到集合论的过程。因为这是两个关系不大的数学理论，所以你在翻译的时候会觉得很不自然。因此，这两者不匹配的原因是图论和集合论的区别。

除了原理不匹配之外，它们在数据封装上也有区别。你在学习面向对象编程的时候，老师一定教过你要隐藏类的实现细节，只向外界暴露行为或者接口，类与类之间通过接口来进行交互。但是关系型数据库会暴露所有的内部细节，你在数据库里看到的是所有数据最原始的表现形式。数据库的表与表之间交互是原始数据的直接交互，没有任何抽象出来的行为或者接口。

所以面向对象编程里有对象和行为，而关系型数据库里只有数据，这两者有本质的区别，这点你需要仔细去体会。

虽然面向对象编程和关系型数据库里都有数据，但是它们的数据并不一样。面向对象编程里的对象本身也是数据，这是一个更高级和复杂的数据。而数据库里存储的是基本数据格式。这两者的数据抽象程度不一样。

仔细想想，你会发现它们俩还有很多原理上就不一致的地方，比如说面向对象编程有公有和私有属性，有访问权限，还有一致性校验和继承。所有这些都不能直接反映到关系型数据库里。

所以在日常开发中我们不得不使用一些奇技淫巧来强行将业务对象存储到关系型数据库里。时间久了大家也会试着解决这个对象关系阻抗不匹配的问题，所以就有了 NewSql（以前叫 NoSql）这个新的概念。

树状数据存储

大多数情况下业务数据之间不是图的关系，而是树状结构。这颗树的根节点是业务交易，交易的对象和细节作为子节点一步一步向下展开，所以也叫这种结构为雪花（snowflake）。

NewSql 在诞生的时候解决了两个问题。第一个解决的问题是高并发和高流量，我们会在第 13 和 14 节课详细给你讲解。第二个解决的问题就是树状数据的存储问题。最开始学术界主推的是 XML 的存储格式，但是没有流行起来，它被后来工业界推行的 JSON 格式取代了。

对于你来说，树状数据存储格式并不重要。重要的是在 NewSql 里，面向对象编程里的对象可以作为一个原子单元来存储，这样就解决了大多数在前面提到的对象关系阻抗不匹配问题。

虽然 NewSql 解决了对象的存储问题，但是它没有完美解决对象的查询问题。NewSql 普遍采用了分布式架构设计，我们会在第 14 节课给你讲解，最终一致性甚至分布式事务在解决二级索引一致性上有非常大的时间开销，因此二级索引一般会采用最终一致性的实现方式，这样会导致查询不准。这也是金融行业对于 NewSql 一直采取观望态度的原因。

那查询不准有问题吗？如果你继续沿用现在关系型数据库的同步处理思路，肯定是有问题的。但是如果你是按照异步架构的思路来解决业务问题，在一些特定领域也存在应对的办法。

异步处理会增加架构难度，而关系型数据库之所以成为金融行业万金油，主要是因为事务的支持极大简化了架构难度。所以从投资回报比的角度来考虑，只有在业务量大到逼迫金融公司使用分布式数据存储方案的时候，才会升级到异步处理架构上。

小结

在这节课我们学习了金融业务应该如何选择数据存储类型。

在选择存储类型前先要对数据类型分类。按照数据之间关系的复杂度，我们可以把金融数据分为图数据类型、没有关系的数据类型和树状数据类型，它们分别对应了图数据库、时序数据库和关系型数据库。

因为金融业务需要准确地定义数据，所以很少用到图的数据结构。一般会在风控和反洗钱领域用到图相关的工具。

金融市场数据一般使用时序数据库。相比关系型数据库常用的行存储方式，时序数据库用了列存储的方式，这个方式在存储、读取和计算上都有很大的速度优势。KDB 是金融行业的专用列存储数据库，它具有更高的执行效率。双时序数据库适合交易量稍小的场外市场业务，一般是金融公司自研。

关系型数据库和面向对象编程之间有天然的矛盾。现在的 NewSql 在解决对象存储方面有更多优势，但是由于 NewSql 普遍采用了分布式架构，在使用的时候我们需要小心处理异步处理和最终一致性等关系型数据库不存在的问题。

思考题

NewSql 出现之后确实解决了很多问题，所以传统的关系型数据库也在大力向这方面靠拢。比如 PostgreSql 近期也支持了 JSON 作为基本数据类型。

从理论上来看，JSON 一旦也作为了基本数据类型，就相当于承认基本数据类型的内部也可以有结构。过去很长一段时间内都不是这个假设。

有意思的是，在 50 年前 Codd 发表关系型数据的奠基论文——"A Relational Model of Data for Large Shared Data Banks"的时候，就提到过关系型数据库的基本类型可以有复杂的结构。Codd 认为，表的值也可以是表。这样的话，关系型数据库就可以保存完整的树状结构了。你可以看看这篇论文第 380 页的右下角：

So far, we have discussed examples of relations which are defined on simple domains - domains whose elements are atomic (nondecomposable) values. Nonatomic values can be discussed within the relational framework. Thus, some domains may have relations as elements. These relations may, in turn, be defined on nonsimple domains, and so on.

到目前为止，我们已经讨论了定义在简单域上的关系的例子 -- 其元素是原子（不可分解）值的域。非原子值可以在关系框架中讨论。因此，一些域可能有关系作为元素。这些关系又可以定义在非简单域上，以此类推。

那问题来了，现在表里可以存 JSON 格式的数据。你觉得从整个公司层面推广这个特性的话，有哪些需要注意的问题呢？

欢迎你在留言区和我交流。如果这节课对你有帮助，也欢迎转发给你的朋友，同事，一起学习进步。

答疑集锦（二） | 思考题解析与账务系统优化

你好，我是任杰。

到今天为止，第二模块的系统正确性保障的内容就告一段落了。在专栏更新的过程中，也很开心看到同学们的留言，我要为你们认真学习、主动思考的精神点赞。

今天我为你准备了这篇加餐，把第二模块的思考题做一个系统梳理。我还是建议你先看完前面每一讲的内容，自己独立思考之后，再来看我这份参考答案。

思考题答案

第 6 节课

Q：双时序数据库里的一种存储方式是将坐标空间切割成尽量多的矩形，然后将这些矩形存储在数据系统内。数据库的索引建立在矩形的左下角和右上角这两个坐标点。

具体的切割做法是当坐标系内新增一个数据节点时，以这个点为中心，将整个坐标系进行水平和垂直切分。下图展示了系统中有 3 个数据点时的一个切割方式，3 个数据点将坐标系切割成了 16 个矩形：

每个插入操作都会对已有的矩形进行切割。每次查询都会遍历相关的矩形。那么你能算一算这个方案的存储空间复杂度和查询时间复杂度吗？

A：存储的空间复杂度是 O(n^2)，查询的时间复杂度是 O(log n) 或者 O(1)。你可以沿着这个思路，继续想想，这样的复杂度对双时序数据库的使用有怎样的影响，换句话说，就是双时序数据库适用的业务有什么特点？这个问题，你可以在第 10 节课找到答案。

第 7 节课

Q：我们在存储事件队列的时候需要存储两个文件。一个存储事件，另一个存储事件的索引。在现实中会出现各种异常的情况，比如机器可能会中途死机，这样有可能文件只写了一部分。

这时你应该如何检测文件是否完整？

这两个需要存储的文件，应该按照怎样的先后顺序存储呢？

A：我们先看看如何判断文件是否完整。一般来说，判断文件是否完整是针对于整个文件来说的。如果文件完整就继续使用，如果文件有一点点不完整就整个丢弃。

但是，对于事件溯源架构来说，一下子就丢弃到整个文件是比较可惜的，很有可能会造成容灾时的数据恢复成本过高。所以基于事件溯源的架构会尽量找出来哪些文件内容是可以用的，哪些是需要丢弃的。

事件文件和索引文件的完整性检测可以合起来做。我们计算出事件文件每一个内容的校验码，比如 MD5，SHA1，HMAC。这个校验码长度是确定的，因此可以放在对应的索引文件内，这样我们就可以用索引文件来检测事件文件的完整性了。

接下来就是如何判断索引文件的完整性。对于线性写的文件，出问题只会出在文件末尾。因此我们可以先把索引文件的大小裁剪到单个索引的整数倍，然后再检验最后一个索引是否完整。如果不完整就删掉最后一个索引，然后再检查新的最后一个索引是否完整，以此类推。

那就剩下最后一个问题，如何检查单个索引内容是否正确？方法也很简单，每个索引内容的最后面是前面所有内容的校验码即可。

由于我们这种方法是用索引文件来校验事件文件，所以需要先存储事件文件，再存储索引文件。

第 8 节课

Q：我们在讲如何保证消息至多投放一次的时候，说过可以用数据库来做去重工作。不过数据库的容量一般是有限的。

假如你设计的系统预期会运行 10 年以上。数据库由于存储不了这么久的数据，一定会将过期不用的数据进行归档后删掉。这会造成你用来去重的数据有一部分会不见了。这样如果来了一个请求，这个请求恰好用了被删掉的 ID，系统就会重复处理。那么你应该如何做呢？

用户“小动物”的留言回复：

感觉做不到很完美。

1. 数据删除时能否留下去重用的字段，因为是有限的个别字段，数据量有限，空间会小一些。但这种只增不减的数据还是会判断空间有限的问题。

2. 唯一 ID 是否可控，若可控，可带上一些规则，如时间、自增 ID 等。通过规则判断是否已经超过合理期限。但这个的可能性很低，因为 ID 是别人的，基本没法介入。

3. 消息中增加时间，业务发生时间。超过合理时间范围的数据不做处理。

A：我们来看看这个问题的本质是什么。问题要求我们检查一个 ID 是否属于已经被删掉的 ID，其实这是一个如何检测元素是否存在于一个集合的问题。由于集合数目偏大，所以才造成了检测困难。

用户“小动物”的留言就是一个优化的思路。如果所有 ID 之间有线性关系，而且删掉的内容恰巧是一个连续区间的话，我们只需要简单判断一下，看看新来的 ID 是否在删掉的区间的最大和最小值以内就行。

所以接下来就是怎么让 ID 之间实现线性关系的问题。线性关系意味着，任何两个 ID 之间都可以比大小，而且大小关系具有传递性。

你再分析一下就会发现，我们需要让 ID 之间的大小关系遵循它们产生时的物理时间的关系，也就是说后生成的 ID 需要更大。我们一般把这时候的 ID 叫作逻辑时间。逻辑时间反映了事件之间的顺序关系。

所以就像用户“小动物”指出来的一样，我们可以在 ID 中增加时间或者自增 ID，而且需要业务系统自己来保证逻辑时间的正确性。

第 9 节课

Q：实时数据系统的数据节点通常都是价格昂贵的机器。这些机器的处理速度极快。交易所机器运行太快了之后，会导致推送给实时用户的数据量过大，用户来不及处理。你这时候应该怎么处理这两者速度不一致导致的问题呢？

思考题 09 tt 的回答：

问题中说的速度的差异还会带来数据量的积压，所以还需要有“削峰填谷”的能力，这个正好是消息系统最主要的职责之一。

但是，等一下，客户来不及处理的数据真的需要放入数据的“水库”中等待用户后续处理么？这可是实时数据，也许等到客户可以处理的时候，数据的价值已经消失了，所以此时的数据已经不值得用户再去花那么多钱了。

所以，用户在和交易所买席位费的时候先评估自己的需求：要么花费和交易所等量的钱去对接，要不就降低自己的层级。

对交易所来说，提供的就是实时数据，第一是不能被下游系统阻塞；第二是不必缓存没有被消费的数据，因为缓存完再提供，那提供的就不是实时数据服务了。

推导来推导去，得出的结论不过是：实时数据就是传输过程中发生丢失就不需要找回的数据。

A：我们先来看看发送端太快会发生什么问题。发送端发送太快，接收方就来不及处理，因此就会像用户“tt”指出来的一样，接收方会发生数据积压。

一种做法是接收方主动丢弃掉积压的数据。但这样会造成一些资源的浪费，我们具体分析一下。

比较容易想到的一个浪费是接收方的 CPU。CPU 需要处理网络数据包，放到内存后丢弃数据。但是 CPU 浪费的影响还不大，影响最大的是时间的浪费。网络处理需要时间，如果网络数据处理后就直接丢弃，那么这个处理的延时就白费了。因此这个方法最大的问题是增加了有效数据的接收延时。

所以，如果发送方和接收方都是内部系统的话，我们可以做一个处理速度的协调。接收方如果处理不过来，需要丢弃数据，那么丢弃之后需要返回给发送方一个丢弃的消息。发送方这时候会降低自己的发送速度，直到接收方在一段时间内稳定住，不再丢数据。这个做法和 TCP 最大带宽发现的算法有些类似，你可以仔细体会。

在正式部署机器之间，公司一般都会对自己的机器做性能评估，在机器的处理能力上限和自己席位费的上限之间选择一个最小值。

第 10 节课

Q：NewSql 出现之后确实解决了很多问题，所以传统的关系型数据库也在大力向这方面靠拢。比如 PostgreSql 近期也支持了 JSON 作为基本数据类型。

从理论上来看，JSON 一旦也作为了基本数据类型，就相当于承认基本数据类型的内部也可以有结构。过去很长一段时间内都不是这个假设。

有意思的是，在 50 年前 Codd 发表关系型数据的奠基论文——"A Relational Model of Data for Large Shared Data Banks"的时候，就提到过关系型数据库的基本类型可以有复杂的结构。Codd 认为，表的值也可以是表。这样的话，关系型数据库就可以保存完整的树状结构了。

那问题来了，现在表里可以存 JSON 格式的数据。你觉得从整个公司层面推广这个特性的话，有哪些需要注意的问题呢？

A：关系型数据库的表有模式（schema），限定了表的内容，相当于对数据结构做了规定，这样就能减少错误。JSON 没有模式，大家可以随意定义自己的格式。而且 Json 的值也可以是 Json，这意味着树状数据结构只有顶层有模式保护，顶层以下没有任何保护。

从公司的角度来说，需要肯定的是没有 schema 会加快系统开发速度，但是没有 schema 会增加系统的维护成本。所以是否选择这个特性就相当于，要我们在短期上限速度和长期可维护性中做一个选择。

既然是一个选择，那么你就需要根据公司的具体情况来判断。如果公司需要功能的上线速度快，而且出错了影响也不大，那就可以选择有复杂结构的基本数据类型。

如果功能的正确性要求非常严格，那么就尽量少用。如果需要使用，就需要将测试级别提高，至少要超出一般关系型数据库情况下的测试力度，这样才能保证测试之后的软件 bug 率没有增加。

第 11 节课

Q：支付系统会有一些超级大账户。这些账户的交易极其活跃，不在秒杀的情况下也会有很高的流量，那秒杀的时候系统压力就更大了。比如说一些卖低价体育类用品的网店，或者收水电煤气费用的公司，都有这些行为特征。那对于这些超大流量的账户，你应该怎么应对呢？

A：我在文稿里选取了两条回复，这两位同学的答案刚好给了我们两个可以参考的思路。

思考题 11 tt 回答节选：

这样的账户往往是入账或贷记操作比较多，即要让它可以很快地增加余额而不出错。这样可以把它分成多个子账号，每个账号分别做入账，然后日终的时候再汇总。

或者把金额记录到一个科目里，由于是入账，可以没有余额的概念，这样也不会出错，这样连累加的过程也可以省掉了。而且记录的过程都是新增，可以顺序写，也可以提高性能。

用户 luke 回答：

NUMA 架构，线程绑定，CPU 缓存，内核旁路，低延迟网卡……

如果出现了超级大账号，最直接的方案是单独给他们分一个库，这样可以直接复用现在分库分表的架构和配置文件。由于他们交易量大，对应的营收也高，所以如果对比时间成本、机器成本和收益，单独拿一个库出来也许是性价比较高的方案。

问题在于如果单个库的处理速度也不够了怎么办。这时候就可以考虑用户“tt”指出来的方法，可以将用户账户拆分，一个拆成多个。虽然用户用起来不方便，但是至少能支持业务。

用户“luke”也指出了另一个思路。如果单个库处理速度不够，那么我们也可以纵向扩容，增加单机的处理能力。

好了，第二模块的思考题解析到这里就结束了。技术的世界总是日新月异，我这里稍微闲谈一条新闻，不知道你有没有关注到，最近历时 4 年的分布式文件系统 JuiceFS 正式开源了，你有没有思考过，这个系统对于金融行业来说，有没有应用的可能性呢？

我们在第二个模块已经介绍过了，金融系统并不是所有部分的要求都非常高，因此我认为它一定能找到用武之地。

不过，对于金融业务最核心的交易及账务数据来说，它们的数据存储方案需要有过往的长期大规模正确性验证证据，而且对应的数据提供商需要有能力解决对应规模的问题。这就是一个留给你的问题了。

如果你是一家金融公司的 CTO，系统管理了 2 万亿的人民币资产，每天有 1 亿日活，你会不会将最核心组件搭建在这个开源系统上呢？

好了，既然是加餐模块，我还想给学有余力的同学额外补充一个知识点，那就是账务系统的特殊优化。我认为，完整的软件系统并不是一蹴而就，而是逐步迭代和升级的。感兴趣的同学可以仔细体会后面的优化思路，希望可以给你带来更多启发。

账务系统的特殊优化

举例

你还记得，前面第 7 节课我们讲了一个账务系统的例子么？通过那个例子，我给你讲了命令和事件存储，打快照，以及读写分离的查询。不过，这些都是一般性的解决方案。因为账务系统特别简单，所以它还有特殊的优化方案。

在这里我给你简单梳理一下思路。这些优化方法并不只局限于账务系统。如果你发现自动机的状态是 K/V 结构，那么很有可能这种优化都适用。

我们先看看账务系统的初始状态。在最开始所有人的余额显然都是 0，之后随着转账的发生，各个账号余额会发生变动。

假设系统最开始有 2 笔转账，分别是从用户 A 转给用户 B 共 100 元。用户 C 也转给用户 B 共 100 元。同时我们假设允许用户欠款（贷记账号）。这两笔转账分别对应了两个命令，他们的执行情况如下图：

合并存储

优化的第一个目标是优化事件的存储内容。在账务系统中，如何进行用户操作非常简单，所以我们有能力在生成事件的同时，假装执行这个事件，这样就能得到用户的最终余额情况。

这样我们在生成转账事件的同时，就能知道这笔转账执行前和执行后的用户余额情况。我们可以把事件和前后两个状态都保存在一起，像下图展示的这样：

上面这幅图可能看不出什么特点。但是如果我们稍微做一些展示上的简单修改，给每个用户一条单独的时间线，你就会发现大不一样了，就像下面这幅图展示的一样：

那现在的历史查询就变得非常简单。由于现在每个事件都有对应的前后状态，我们只需要寻找离查询时间最近的事件就可以了。找到了对应的事件，我们就可以找到对应的状态，就像下图展示的一样：

因此，采用将事件和状态变化存储放在一起的方式，可以大幅简化查询的复杂度。常用的时序数据库都支持相应的查询语句。如果你再仔细思考一下，会发现我们其实也不用打快照了，这样就能进一步节省系统运行的时间。

对账优化

将事件和状态变化存储放在一起存储，这样做的另一个好处是可以简化内部对账。账务系统有一个硬性要求是需要对账户余额和余额变动细节进行一一比对。常见的方法是用另一套系统计算所有的余额变动总和，然后和日切余额做对比。我们来看看优化后的版本应该如何处理。

我先给你交代一下，后面的优化需要用到账务系统的两个等式：

前一个事件的最终余额等于下一个事件的初始余额。

每个事件的最终余额等于这个事件的初始余额加上事件变动金额。

优化的思路是将每个用户所有相邻的余额变动都链接起来，同时将一个事件的前后余额也链接起来。链接之后，我们之前举的两个转账的例子，就会变成下面这幅图描述的样子：

这样对于任何一个用户的任何一个状态，我们都可以顺着链条找到所有金额变动的过程，并对这个过程进行校验。

这种链接的方式和区块链的思路很像，其实本质是完全一样的。区块链只是以非中心化共识的方式构建了这个链接关系。因为我们的主题不是区块链，所以你如果有兴趣的话可以查看相关资料。

好了，第二模块的思考题我就说到这里，希望能够给你一些启发。也欢迎你继续积极思考，畅所欲言。下节课，我们将要进入到第三个模块了，希望你再接再厉，跟上我的脚步，一起深入学习金融系统的分布式正确性及高可用内容。

11 | 系统优化：如何让金融系统运行得更快？

你好，我是任杰。

这节课是我们第二个模块“系统正确性保障”的最后一节课。在第二个模块里，我们一起学习了如何正确地处理数据和计算，以及如何做好数据的传输和存储。

不过系统设计得再好，如果不能及时地完成业务处理也不行。所以，在最后一节课里我给你讲讲如何让金融系统运行得更快。

我们重点来看为什么不同业务有不同优化需求，以及常见的优化方式和问题有哪些。吃透了这些优化思路，不但能让你对金融系统的优化有一个系统性的认识，也方便你后续根据自己的需要有针对性地学习提高。

背景分析

“快”在不同的环境下有不同的定义。对于互联网业务来说，快一般意味着吞吐量大。对于金融业务来说，快意味着延时低。

那为什么会有这两种定义的区别呢？我们先来分析一下互联网业务。互联网业务在经济学上有一个特点是边际成本（Marginal Cost）基本为零。

边际成本决定了业务扩张的成本，所以既然扩张成本很低，那么互联网业务倾向于扩张，而且是大规模扩张。扩张的结果就是互联网业务会有大量的用户，这也决定了互联网业务需要解决的是大流量问题。

那流量大为什么和速度快扯上关系了呢？我说个实际例子你就清楚了。

不知道你有没有在网上秒杀过商品。秒杀的时候你会发现网页变得非常卡，半天显示不了内容。这时候你肯定会抱怨网站速度慢，这是因为在解决秒杀这种大流量问题的时候，互联网通常采用解决方案是用延时来换吞吐量，也就是通过降低你的网页加载速度来支持更多的人秒杀。

这时虽然吞吐量上去了，但延时也增加了。虽然所有人的体验都因此变得更差，但是至少大家还能买到东西，没有出现网站宕机这种更差的结果。所以互联网里的“快”，指的是服务器集群的处理能力快，能同时处理很多东西。

让我们回到金融业务，金融业务的边际成本一般都很高。金融机构在和机构用户进行对接前，双方都要做详细的客户身份识别（KYC，Know Your Customer）。对接之后就是业务、财务、合规、风控等一系列的流程。因为这些流程都有一定的时间和人力物力成本，所以机构类的金融业务很难像互联网一样大规模扩张。

既然机构类的金融业务无法大规模扩张，那么金融机构就无法靠规模优势来彼此竞争，只能靠质量优势来吸引客户。在金融行业里，时间就是金钱，所以金融机构就会想办法把速度提高，这样才能帮客户省钱。这也决定了金融行业的“快”指的就是延时低。

互联网业务和金融业务有一个重合点，那就是普惠金融，比如说第三方支付、小额信贷等等。普惠金融的业务特点更接近于互联网业务，所以对于快的要求也和互联网业务一样。

尽管金融业务多种多样，但我们把握住相应背景中“快”的本质定义，就能更合理地选择优化方向了。那么接下来，我们就从吞吐量和延时这两个方面，分别来看看金融系统的优化要点。

吞吐量优化

首先我们看看吞吐量优化的两种常见方法，分别是分库分表和使用消息队列。

分库分表

吞吐量最常见的解决方式就是分库分表。

分库分表指的是将数据库表做横向切割和纵向切割。单个数据库表容易受到单机硬件处理速度的限制，但是在拆封成为了多个部分之后，每个部分都可以放在不同的机器上处理，这样就能使用更多的硬件资源。所以分库分表之后，我们可以用大量的硬件来应对大流量的问题。

我们在切分的时候需要注意一个原则，那就是切分完的子表需要互相独立，但是也要完全穷尽（MECE，Mutually Exclusive Collectively Exhaustive）。互相独立指的是子表之间不要有任何内容的重复。完全穷尽指的是把原始的表切割完之后不要有任何数据丢失。简单来说，在切分表的时候，你要保证切分后的结果不多不少。

按哈希值和主键切分

了解了切分的原则之后，具体应该怎么切分呢？我们来看看最常见的水平切分，水平切分的时候会按照数据库的主键来切分。切分的方法一般有两种，一种是按主键的哈希（Hash）值来分，另一种是按主键的范围（Range）来分。

如果按哈希值来分，你需要注意哈希函数的值域大小。一般来说我们会把每个哈希值对应的数据都放在一台机器上。因为机器数量有限，所以哈希函数的值域一般不大，比如 10 或者 100。

按哈希值切分有一个很大的优点是有一定的随机性。用户的访问并不一定很随机，有可能出现某些主键范围的访问量特别集中的情况。由于哈希值会将原来的值打散，所以有可能将流量分散在不同的机器上，这样就会避免单台机器过载。

不过哈希值的随机性也带来了一个缺点，那就是连续访问性能差，不过互联网应用很少看到需要主键连续访问的情况。

但是在金融行业有一类数据叫市场数据，比如说股票和期货的实时交易价格信息。这类数据的特点是，在使用的时候一般会访问一个时间段内所有数据，因此在时间上需要连续访问。那么对于这类数据，按照时间的哈希值来切分就不太适合使用场景。

按范围切分

这里我还要简单介绍一下按范围划分的方法，它比较简单。它的优点是主键的范围连续，对于市场数据的访问很友好。

但是，范围连续的优点也会成为缺点。范围连续可能会导致访问过于集中，这样有可能造成单机过载。比如说在进行量化分析的时候，可能会大量访问最近几分钟的市场数据，按照范围划分会导致存有最近数据的服务器被大量访问。

分库分表带来的问题

分库分表将原来在一台机器上处理的事情，变成了在多台机器上处理。无论你是按照哪种切分方法，都会带来多机环境下的一些问题。

第一个问题是正确性。分库分表之后，大家可以同时更改多个表的内容。由于这些表在不同的机器上，网络通讯需要一定的时间，你很难确定别的机器上的内容是正确的。就算你验证了是正确的，在网络消息传回来的那段时间也可能会发生变化。因此分库分表之后，你需要一定的分布式正确性保障，也就是需要分布式事务。

第二个问题是延时。分布式事务需要至少两次网络沟通，这也决定了分库分表方案的最低延时。对于个人用户来说，网络延时带来的问题不大，但是对于高频交易相关的机构来说会延时过高。

第三个问题是容灾。机器不可能一直在线，一定会出问题，只是时间早晚的问题。如果你学过概率论就会更理解这点，机器数目越多，出问题的概率越高，所以分库分表的情况下一定需要考虑集群的容灾。

第四个问题是容量限制。分库分表的过程一旦完成就很难再调整分库的数量。因此有经验的架构师在最开始会做一些看起来“超前”的准备。比如说如果分 10 个库就够了的情况下，可能你会分为 20 个或者 50 个。但是互联网应用的增长速度谁都说不准，很有可能会突然爆发增长，这时候依然会出现集群整体容量不足的情况。

在课程的第三模块，我还会给你详细讲解分布式正确性、容灾，以及动态分库分表的方法，这里你先掌握分库分表会带来哪些问题就可以了。

消息队列

吞吐量的另一种优化方式是从消息队列下手，消息队列的核心思想是将流量先写入到消息队列中，然后服务器按照固定的速度处理消息队列内的消息。这样就算是峰值流量进来了，也不会造成服务器过载。

消息系统是很常见的一种处理峰值流量的架构，在这里我也给你举个具体例子。

电商公司在重大节日会举行秒杀抢购的活动。秒杀会生成大量的支付订单，因此会对支付系统产生极大的压力。我们来看看这种压力有什么规律。

秒杀一般分为三个阶段。第一个阶段是准备期，这时候支付系统的流量和平常一样，不需要有特殊的准备。

第二阶段是秒杀开始之后的几分钟。这时候从系统监控上可以看到一个很高的流量峰值。这个峰值是电商系统丢给支付系统的流量，代表了理论上最高的并发量。

支付系统的流量并不会直接处理，而是会写入到消息队列中。消息队列也有一个消息写入速度的上限，但是这个上限非常高，通常不会成为瓶颈。

支付系统会尽最大能力从消息系统中拉取要处理的消息。这个处理的速度是有上限的，一般是分库分表后所有机器的处理能力。和消息系统不同的是，支付系统的处理速度会低于电商系统丢过来的峰值流量。所以你能看到的是，电商系统丢过来的流量很高，但是时间很短。支付系统处理的速度慢，时间也长。

第三阶段是秒杀结束之后。这时候系统流量会慢慢恢复到秒杀开始之前的情况，一切回归正常。

下面这幅图给你展示了简化版的秒杀三个阶段。中间第二阶段，我们一般形象地比喻成削峰填谷：

那除了削峰填谷，还可不可以继续优化呢？当然是可以的，不过要结合业务。虽然你是在零点秒杀，但是货品需要过一段时间才能送到你手上。等你确认收货之后，商家才能收到你的钱。所以从你付钱到商家收钱中间有很长一段时间，这就给了我们进一步优化的空间。

在秒杀的时候，钱不是从买家账号直接打到卖家账号，而是先打到中间账号，也叫作担保账号。所以我们在处理秒杀的时候，只需要处理买家账号到中间账号的流量问题。

买家账号到中间账号还可以进一步切分。我们在学关系型数据库事务的时候，常常提到转账的例子，转账要求两个账号需要同时都变，或者都不变，这就是事务性要求。

可是我们从支付系统角度来看，只要买家账号能正确扣款就行，中间账号稍微延迟一点打款完全没问题。其实再进一步分析，中间账号的打款就算丢了也问题不大。支付系统在每天晚上日切的时候进行所有账户的对账。如果中间账号的打款丢了的话，会通过补账的方式把钱再补回来。

这就意味着中间账号可以异步处理，所以一种可能的优化方法是这样的。

首先，在秒杀开始的时候，也就是在秒杀的第二阶段，支付系统只处理买家账号，将对中间账号的处理暂时搁置下来，这样就能减少一半的账号操作。到了第三阶段，也就是促销结束后，再慢慢恢复对中间账号的处理。

这时候系统多了第四阶段，也就是快递送货之后打款给卖家的阶段。示意图如下：

延时优化

吞吐量优化是系统能力的横向扩展，是宏观资源的调配。而延时是系统能力的纵向扩展，是微观资源的调配，因此需要不同的解决方案。

因为不同的编程语言、操作系统和硬件情况都会有独特的优化手段，介绍起来可能是挂一漏万，所以为了让你理清思路，我选择了相对更常见的单机优化和网络优化。

单机优化

提高单机性能有一个反直觉的解决方案是单线程处理。我们在第 7 节课讲事件溯源架构的时候提到过单线程的自动机。在第 10 节课也讲过单线程的列数据库。那你有没有想过，为什么单线程可以有这样高的处理速度呢？

我们谈到多线程优势的时候，常常会提到可以用到计算机的多个 CPU 或者多个核，因此有更多的计算资源，因此可以处理更多的事情。这话听起来很有道理，但是这里有个假设是计算之间不会抢占资源。

事实上，在多线程处理的时候，计算机的操作系统会进行线程调度。线程调度需要更新操作系统内的核心数据结构，以及更新 CPU 上的各种缓存，这个过程也需要消耗时间。所以虽然多线程能用到更多的资源，但是准备资源本身就会消耗资源。

这就是为什么单线程可以比多线程更快的原因。当然了，这只是一种可能性，为了能真正超过多线程，你还是需要做一些处理的。

首先你可以把你的线程绑定到某块 CPU 上。比如在 Linux 操作系统有一个 C 函数叫 sched_setaffinity，它可以把你的程序绑定到指定的 CPU 上。

需要注意的是，默认情况下绑定到 CPU 指的是你的程序只会在这块 CPU 上运行，不会跑到其他的 CPU。尽管其他程序还是可能会过来抢你的这块 CPU，但是你的程序绑定到 CPU 之后，还是会运行得更快。

绑定 CPU 还有一些优化空间。Linux 内核启动时有个 isolcpus 的启动选项。这个选项可以将一块 CPU 独立出来，这样任何程序都不可以使用这块 CPU 了。唯一可以使用这块 CPU 的方式是你将进程绑定到这块 CPU，这样你就能真正独占这块 CPU 了。

CPU 处理好之后，我们就要说到内存了。当你访问内存的时候需要注意的是，CPU 并不是直接访问内存，而是通过 CPU 缓存来访问的。

机器在加载缓存时会一次性加载一小段内存，这也决定了内存的顺序访问会比乱序访问的速度更快。在进行金融风险计算的时候会用到多维数组，这时候需要根据算法访问的顺序来合理组织数组的位置。

内存另一个需要注意的是 C 语言分配内存需要一定时间，而且这个时间长度还是随机的。所以如果你的程序需要频繁分配内存，或者对延时非常敏感，最好自己实现内存池。

最后就是文件系统了。我们在第 7 节课里提到过，事件溯源由于是顺序写文件，可以达到非常高的写入速度，所以如果你的程序也能顺序写文件的话，尽量按照顺序写。

如果一定需要随机写，Linux 也有一个能帮助你的函数叫 mmap 。mmap 会将文件映射到进程的内存页表上。这样在 C 程序里就能像访问内存一样访问文件。这就减少了用户进程和操作系统之间来回拷贝数据的开销，节省一部分时间。

网络优化

一台机器的各个组成部分相对来说还是比较稳定的，所以单机优化都有一些可以复用的优化手段。而网络则是非常不确定的一个环境，优化的手段需要结合实际情况来看。在这里我们重点看看 Linux 上比较有用的几个函数，它们可以解决网络消息处理的问题。

在本世纪初有个问题叫 C10K，指的是有没有可能让一台机器支撑一万个并发。用进程或者线程的方法是万万达不到的，所以就需要 epoll 上场了。

epoll 是 Linux 独有的一个函数，它可以同时监听大量的网络链接。当网络链接变得可以读写的时候，它会通知你的程序。这样你就不需要同时等待所有的网络链接，只需要等待这个函数的通知就行。另外，epoll还做了内核数据结构上的优化，就算网络链接特别多的时候也能高效地工作。

但 epoll 还是有一个问题。它只能告诉你网络是否可以读写，你还是需要自己写代码来读写网络。由于每次读写网络都会调用内核的函数，这样会造成大量的用户态和内核态切换，浪费很多计算资源。那有没有办法解决这个问题呢？

在 2018 年 Linux 内核新增了一个功能叫作 io_uring，它就解决了用户态切换过多的问题。

它解决问题的思路很简单。你在写程序的时候准备一个队列，里面记录了所有你想要做的读写操作，同时也包含了你预先分配的读写内存。

接着你将这个队列一股脑交给内核。内核会先做 epoll 的事情，检查哪些网络链接可以开始读写。然后内核会多做一步，帮你处理网络数据。

如果你的操作是写网络的话，会把你内存的数据写出去。如果你的操作是读操作的话，会把数据读到你预先分配的内存。内核操作完之后会把这些操作的状态记录在另一个列表里，返回给你的用户态进程。示意图如下：

你会发现，从架构上来说，io_uring 替你把 epoll 和之后的读写操作在内核态批量处理，同时用户进程和内核共享数据页表，这样既节省了状态切换开销，也节省了数据拷贝开销。

目前有一些网络操作频繁的应用正在实验这种新技术。不过相对于已经存在近 10 年的 epoll 来说，io_uring 从文档和工具上来说都还不太成熟，所以你要做好挑战的准备。

小结

在这节课里我们学习了如何优化金融系统。

首先我们分析了为什么金融系统会有吞吐量和延时这两个优化的方向。普惠金融和互联网业务类似，面向大众，对系统吞吐量要求非常高。机构金融专业性特别强，对延时要求非常高。

接下来我们了解了吞吐量优化的两种常见方法，分别是分库分表和使用消息队列。分库分表有按哈希值和范围这两种不同的划分方式。这两种划分方式都有各自的优缺点，但是它们都有正确性、延时、容灾和容量限制这四个问题。我会在第三个模块讲解应该如何解决这些问题。

消息队列的作用是对流量做削峰填谷。我们用秒杀场景下的支付系统为例，讲解了在碰到问题时应该如何分析业务规律，应该如何利用业务规律的特点来优化系统架构。

最后我们讲了延时优化的一些常见方法。延时优化要从单机优化开始，优化对 CPU、内存和文件这些资源使用。网络优化主要是通过 epoll 来减少在高并发情况下的线程开销，同时用 io_uring 来进一步减少网络操作的用户态和内核态的切换开销。

通过这节课，我希望你还能了解金融业务的多样性所带来的金融系统架构的多样性。

普惠金融的架构是从宏观层面解决架构的横向扩张问题，是互联网云计算的标准使用场景。机构金融是从微观层面解决架构的纵向扩张问题，需要对用户进程、操作系统和硬件做特别的优化和控制，因此非常不适合云计算的解决方案。知道这些区别之后，你还要根据具体业务进行相应的优化和选择。

思考题

支付系统会有一些超级大账户。这些账户的交易极其活跃，不在秒杀的情况下也会有很高的流量，那秒杀的时候系统压力就更大了。比如说一些卖低价体育类用品的网店，或者收水电煤气费用的公司，都有这些行为特征。那对于这些超大流量的账户，你应该怎么应对呢？

欢迎你在留言区分享你的思考和疑问，如果这节课对你有帮助的话，也欢迎转发给你的同事、朋友，一起讨论金融系统优化的问题。

12 | 正确性分级（上）：单机无备份有哪几种不同的一致性？

你好，我是任杰。从今天开始，我们进入到最后一个模块：分布式正确性及高可用。

在前面两个模块里，我们一起学习了金融业务，以及如何实现正确的金融系统架构。不过我们前面所讲的正确性，主要侧重点是金融业务实现的正确性。但是这些正确性还远远不够，你原来正确的代码运行在多线程或者分布式环境下，依然有可能出错。

所以，这一个模块我们主要探讨的内容是如何保证与业务无关的正确性。按照从简单到复杂的顺序，我们先来看看单机情况下会出现哪些不正确的情况。

冲突

如果一个东西永远都不会变，那么我们在使用它的时候一定不会出错，这就是我们在第 8 节课说过的函数式编程优势。

可惜现实并不完美，程序的状态还是会被修改。如果多个人在没有良好沟通的情况下独自修改状态，这样就可能会出错，而这种错误就叫冲突。

当然了，这么定义太过于模糊，我们还需要对冲突做准确的定义。为了方便后面做分析，我们先对要研究的问题做一些假设。

我们假设机器上存在着一些共享资源，用 x 、 y 、 z 表示，对这些资源有读和写两种操作，用 R 和 W 来表示。对资源的读操作不会改变资源的状态，但是写操作会改变。

有很多人会对这些资源做读或者写的操作。每个人的操作叫作事务，我们用 T来表示。所以一个事务T里会有一系列读写操作。下面是一个时序图，展示了两个事务随着时间推移的情况：

需要注意的是，事务在运行到一半或者结束后，可以选择保留所有操作的影响，或者取消所有操作的影响，对应的术语是事务的提交和回滚。由于提交和回滚不会影响我们接下来的分析，这节课里我们会省略这一步。

定义好了资源、操作和事务之后，我们再来看看前面说的冲突。

如果两个操作对应的是不同的对象，那么这两个操作不会有任何冲突。所以两个操作如果要有冲突，一定是它们操作了同一个对象。另外，如果两个操作都是读操作，也不会出现任何冲突。

所以我们可以这样理解冲突，两个操作如果冲突，一定有一个操作是写操作；发生冲突的时候，一定是一个操作先发生，另一个后发生（我们称后面的操作依赖于前面的操作）。

我们可以用排列组合算一算。有两个操作，每个操作都有读和写两种情况，一共就有 4 种情况。现在排除两个操作都是读的情况，就只剩下了 3 个情况，分别是读写、写读和写写这三种。

下面这幅图给你展示了这 3 种冲突类型和操作之间的操作关系：

隔离级别分类

现在很多数据系统在加上了一定的正确性保障之后，会宣称自己支持了事务。你也许会觉得这个事务，就是平时在使用关系型数据库时最高的正确性保障。其实不是这样，事务也分很多种不同的正确性级别，就算是同一个名字，也有可能意味着不同的东西，你一定要多加小心。

我们接下来就一起看看都有哪些正确性的级别。

当我们定义好冲突之后就可以看看都有哪些不正确了。在数据库理论里对不正确的分级叫作隔离级别（Isolation Level）。

有关隔离级别的经典论文是 1995 年发表的"A Critique of ANSI SQL Isolation Levels"。这篇论文抨击了当时 SQL 标准的事务，指出了这个标准不够完备，又提出了隐含的假设。论文对隔离级别做了详细的定义和梳理，你有兴趣可以仔细去研究。

这里我们主要了解一些常用的隔离级别，重点是最后的可串行化和 MVCC。

最低的隔离级别 Read Uncommitted 解决了脏写（Dirty Write）的问题。脏写指的是两个事务写了同一份资源，这样后写的事务会覆盖先写的内容。其实脏写就是上面提到的写写冲突，示意图如下：

稍微高一级的隔离级别叫 Read Committed。这个隔离级别除了解决脏写问题以外，还解决了脏读（Dirty Read）问题。

脏读指的是当一个未结束的事务写了一个值之后，另一个事务读取了这个值。一旦前面的事务通过回滚取消了自己的所有操作，那么后面的事务就会读取到一个不应该存在的值，也就是读了一份脏数据。

其实脏读就是我们在上面提到的写读冲突。示意图如下：

再高一级的隔离级别是 Repeatable Read。它相对于前一个级别也多解决了一个模糊读（Fuzzy Read）的问题，其实就是前面提到的读写冲突。

读写冲突和写读冲突刚好相反。读写冲突发生的时候需要负责写的事务提交，而写读冲突需要写的事务回滚。那为什么要叫这个名字呢？

原因是读的事务如果再读一次的话，会将另一个事务写入的值读回来，因此前后两次读到的结果会不一致。示意图如下：

隔离级别最高的是可串行化（Serializability）。它解决了两个事务之间的所有冲突。我会在后面详细讲解可串行化。

在这里我把所有介绍过的隔离级别的层级关系画出来，结果就是下面这幅图：

可串行化（Serializability）

事务可串行化基本上能解决所有的冲突。因此在多个事务在同时操作数据库的时候，我们都会要求事务具有可串行化的属性，这样就能避免出现错误的结果。那到底要怎么理解可串行化呢？

如何理解可串行化？

你如果用过关系型数据库的话，应该知道多个事务是可以并发执行的，这样数据库就能有效利用 CPU 和存储设备。

可串行化规定了这些同时在运行的事务的结果，它要求这些并发执行的事务的最终结果永远等同于它们某个顺序执行的结果。

这个定义比较拗口，让我们来逐一分解。首先，我们看看什么叫“顺序执行”。比如下面的图上有两个事务，这两个事务交互地读写 x 和 y ：

这时候我可以调整这两个事务的读写操作，把第一个事务里所有的操作都放到第二个事务的前面，就像下面这幅图展示的一样：

当我们调整了这两个事务的操作之后，第一个事务所有的操作会在第二个事务开始前全部结束，这两个事务在时间上没有任何的重合。这时候这两个事务就是顺序执行。

那什么叫“永远等于”呢？你可以再看看上面这幅图，就会发现我们调整了事务的执行顺序之后，最后的读写结果和调整前完全一样。这就是“等于”的定义，我们的调整不改变结果。“永远”意味着对于任何 x 和 y 的初始值，调整之后的结果都相等。

注意，我们在可串行化中还有一个关键的定语是“某个”。这意味着我们只要找到一个等价的顺序执行结果就可以，这个结果不一定唯一。这也说明可串行化也具有一定的随机性，我在第 14 节课里会说到严格可串行化（Strict Serializability）的隔离级别，它可以消除这种不确定性。

那为什么可串行化这么重要呢？这是因为一旦多个事务可以被串行化，我们就可以当作这些事务是一个一个分开执行，每个事务会成为一个原子的单元。

在没有其他事务的干扰下，我们很容易就能知道每个事务执行的结果是不是正确的。所以可串行化相当于把一个大的正确性问题，分解成了以事务为单位的小正确性问题，通过分而治之的办法来降低正确性成本。

如何理解冲突可串行化？

除了前面说的可串行化，我们通常使用的关系型数据库用的是另一种叫作冲突可串行化（Conflict Serializability）的调度方案。

这里的“冲突”就是我们开始提到的读写、写读和写写这 3 种冲突。冲突可串行化依然要求等价于某个事务串行化的结果。

但是它和可串行化不一样，可串行化只需要你找到一个等价的串行结果就行，而冲突可串行化要求你通过一系列无冲突的互换过程将原来的执行序列变为等价的串行执行。

如果两个操作之间没有冲突，你可以互换他们的顺序，也叫无冲突互换过程。所以一共有两种情况。一种是两个操作的对象不一样，这样不管是读写都不会有冲突，你可以随便调整。另一种情况是两个操作都是读操作。

下面这幅图展示了我们怎么通过调整顺序来证明冲突可串行化。还是之前的例子，我们看看这两个事务的中间两个操作。这两个操作的对象不一样，所以没有冲突，我们可以调整它们俩的顺序。一共调整 4 步，我们就可以从最开始的情况调整为最后的可串行化结果。

冲突可串行化的局限

那我们怎么来理解可串行化和冲突可串行化的关系呢？准确来说，冲突可串行化是可串行化的充分条件：如果一个事务是冲突可串行化，那么它一定是可串行化。反过来，如果一个事务是可串行化，那它可能不是冲突可串行化。

所以冲突可串行化的集合是可串行化的子集，就像下面这幅图展示的一样：

我们还是来举个例子。系统中有 3 个事务，它们的操作之间的时间关系如下图：

你如果仔细分析一下就会发现，上面这 3 个事务是可以被串行化的，就像下面这幅图展示的一样：

但是你无法通过无冲突互换的过程，将这 3 个事务的执行顺序变为串行化的结果。原因是中间两个对 x 的写操作导致 T1 和 T2 无法调整为串行执行，就像下面这幅图解释的一样：

如何通过 2PL 解决冲突可串行化？

虽然我们有两种不同的串行化的定义，其实我们真正需要的是可串行化，而不是冲突可串行化。那为什么还要提冲突可串行化呢？

这是因为在数据库的实现过程中，一般会通过锁的方式调整事务执行顺序，而用锁的方式一般能实现冲突可串行化。

锁（Lock）在这里是排它锁，表示一旦你锁住了某个共享资源，其他人都无法访问，直到你释放这个锁。

通过锁来实现可序列化的方式叫作 2PL（Two Phase Lock）。请注意 2PL 和 2PC（Two Phase Commit）不一样，什么是 2PC 我们下节课再讲。

2PL 的过程很简单，它要求对于任何一个事务，这个事务会先对所有访问的资源加锁，然后再访问所有资源，最后再释放所有的锁，加锁和解锁的过程不能有交替。

我们用 L 来表示加锁， U 来表示解锁。下面这张图展示了一个事务在 2PL 的情况下的加锁解锁过程：

为什么 2PL 可以实现冲突可串行化呢？接下来我们一起证明一下。证明的过程其实就是冲突可串行化的无冲突操作互换过程。

假设系统中有很多事务都遵循着 2PL 在运行。我们选择所有这些事务中最早释放第一个锁的事务。下面我们来证明，通过无冲突操作的互换过程，可以将这个事务的所有操作放在最早执行。

假设所有事务中第一个释放锁的是第一个事务 T1 ，释放的锁是 z 。和它冲突的是第二个事务，冲突在 x 。那有没有可能出现下面这幅图的情况呢？

答案是不可能。因为按照 2PL 的要求，每个事务在访问 x 之前，都需要对 x 上锁。由于锁之间是互斥的，后面的事务想要成功对x 加锁，需要前面的事务先释放锁。

所以，按照上面的时序图， T1 需要先释放对 x 的锁，然后 T2 对 x 加锁，像下面这幅图展示的一样：

这样我们就推断出 T1 需要先释放对 x 的锁 ，然后释放对 z 的锁，这与我们最开始假设所有事务最早释放的是 z 的锁矛盾。

所以，在第一个事务释放第一个锁之前，它和其他所有的事务的所有操作都没有冲突，因此可以通过无冲突操作互换的过程，将第一个事务的所有操作提前到其他事务之前。这样，我们就可以把第一个事务和剩下的事务独立开来。

接下来，我们可以用同样的操作把剩下的事务一个一个向前调整，最终把所有事务分解为串行化的执行过程。

在证明过程中我们利用了操作之间的冲突，以及用锁来解决冲突，因此最后的串行化结果其实是冲突可串行化。

2PL 局限性

2PL 是一个理论上很美好的结论，但是在实践过程中用得不多。

这是因为 2PL 要求我们提前知道所有访问的数据都有哪些，这样才能在解锁前锁住所有内容。我们一旦释放了任何一个锁，就不能再新增其他锁住的资源，这就是 2PL 的局限性。

幸运的是，我们还有另一种隔离级别。它比冲突可串行化弱一些，但是实现起来特别方便，运行速度也比较快，它就是快照隔离（Snapshot Isolation）。

如何理解快照隔离（Snapshot Isolation）？

快照隔离的核心思路是在一个事务开始的时候给当前所有正确的数据打一个快照（Snapshot）。

这个快照一旦生成就不会改变，所以事务在运行的时候不会被其他事务干扰，也不会出现因加锁导致的等待，就会运行得很快。所以快照隔离的优点是，不同事务之间的读写互不干扰。

快照隔离的优势在于，它放弃了一些可序列化的能力来换取事务执行的速度，同时不同事务之间的读写无冲突，比较适合需要运行时间特别长的事务。

因为快照隔离有很多优势，所以它是现在很多数据系统默认支持的隔离级别。当你听到一个新的数据系统支持了事务，首先要想到它支持的是不是快照隔离这个隔离级别。

这里需要提醒你的是对正确数据的定义。快照不能包含当前所有数据，因为有可能有进行到一半的事务已经修改了一部分数据。所以，我们需要把所有还没有完成的事务所对应的数据都排除在快照之外。

说完核心思路之后，我们还是要讲一下实现。因为理解了实现。你才能更深入地理解快照隔离究竟做了什么。

另外，在一些情况下，你也可能需要自己给一些数据系统加上事务能力，这时候快照隔离就是默认选项。

MVCC

快照隔离一般用 MVCC（Multi-Version Concurrency Control）来实现，实现的方法也多半参考 PostgreSQL 的经典实现。

我们先看看 MVCC 的存储。存储有一个特点是数据只会增加，不会修改。所以它和我们第 7 节课讲的事件溯源存储很类似。

因为数据库的数据会被删除，这些要被删除的数据并不会马上从数据库删掉，而是会打一个删除的标签。当确认没有任何人使用之后再择机删掉，这一点和 Java 里的垃圾回收器很像。

其实，在实现的时候，真的会有一个垃圾回收器，它会定期回收这些不可能再被访问的数据。

如果你要修改数据的话，修改的操作会被分解为删除和新增这两个操作，因此你对数据的修改也不会改变原来的数据，只会增加数据的版本，这也是 MVCC 里 Multi-Version 的由来。

接着我们再看看事务开始的时候应该做什么。

前面讲快照隔离时说过，快照需要包含正确的数据，所以事务在开始的时候，我们需要找到哪些事务是合理的，这些合理的事务就包含了正确的数据。

按照时间来划分，所有事务一共分为 3 大类：

1. 已经全部完成的事务，比如已经提交或者回滚。

2. 正在进行中的事务。

3. 还没有开始的事务。

所以我们需要选择已经完成的事务，同时忽略正在进行中或者还没有开始的事务。

你可能有个疑问，为什么我们需要忽略还没有开始的事务呢？这是因为当事务开始运行之后，还会有新的事务会陆陆续续进来，这些新来的事务对应的数据不应该包含在快照之内，所以也需要排除。

下面这幅图解释了，如何根据事务开始时间来选择快照应该包含哪些数据：

最后，我们再来看看怎么生成快照？其实快照只是个逻辑的概念，我们并没有真正把数据拷贝出来作为快照。快照的生成实际是通过修改查询语句实现的。

数据库的每个数据都会增加一个隐藏列，里面记录了是哪个事务对数据做了修改。

当你查询数据的时候，数据库会在你的查询语句里自动增加与正确性相关的查询条件，要求返回数据的隐藏列里只能包含正确的事务。

总结一下 MVCC 的架构设计思路。它通过多版本将数据变为了只读状态，从而在查询数据的时候，可以通过事务的开始时间来判断应该使用哪些数据的版本。

这个架构属于一个叫做只读架构（Immutable Architecture）的设计思想，第 7 节课提到的事件溯源也属于这个大的架构类型。

到这里，常见的一致性分类我们就讲完了。下面这幅图是对前面重点内容的一个总结：

小结

这节课我给你介绍了在单机情况下，事务都有哪些隔离级别。

首先，我们定义了什么是冲突。冲突有读写、写读和写写这三种类型。

然后，我们学习了事务的隔离级别。最低级的是 Read Uncommitted，它解决了写写冲突。

高一级的是 Read Commit，它解决了写写和写读冲突这两种。再高一级是 Repeatable Read，它解决了写写、写读和读写这三种冲突。级别最高的是可串行化，它解决了两个事务之间的所有冲突。

我们日常使用中还有一个级别是快照隔离。快照隔离比 Read Committed 级别要高，但是比可串行化要低。

接下来，我们一起分析了可串行化的定义，以及它和冲突可串行化的区别。我们日常使用的可串行化是冲突可串行化，一般用 2PL 来实现。冲突可串行化要求事务提前知道自己要用到哪些数据，因此对使用场景有一些限制。

快照隔离基本上是最常用的隔离级别。它的核心思路是在事务开始前给所有数据打一个快照，这样事务之间就不会干扰，所以执行速度快。

我们一般用 MVCC 的方法来实现快照隔离。在 MVCC 里，数据的修改会创建新的版本，因此数据都是在只读状态。事务在刚开始的时候需要查询到有哪些事务已经成功结束，然后根据这些已经结束的事务来选择数据的正确版本。

思考题

快照隔离虽然比可串行化的级别要低一些，但我们稍做调整就可以达到可串行化的能力，这个做法叫作串行化快照隔离（SSI，Serializable Snapshot Isolation）。

SSI 主要需要解决的是事务的回滚。2PL 是一种用悲观的态度实现的可串行化，它假设事情会出问题，因此提前用锁的方式避免问题发生。

SSI 则是一种乐观的态度。它假设事情不会出问题，大家都很开心地运行下去。当事务要提交的时候才检查是不是可以提交，如果不可以就回滚。

如果事务之间的冲突特别小，那么 SSI 能显著增加系统性能。但是当事务之间冲突很频繁的时候，SSI 会导致事务在运行很久之后才会被回滚，这样会浪费资源，效果不好。所以 SSI 是一种在系统压力不大情况下的良好选择。

SSI 主要需要检查的是读写和写读冲突，像下面这幅图展示的一样。那你怎么才能正确地找到这些冲突呢？

欢迎你在留言区记录你的思考或者疑问。如果这节课对你有帮助，也欢迎你分享给同事、朋友，和他共同进步。

13 | 正确性分级（中）：多机无容灾有哪几种不同的一致性实现？

你好，我是任杰。这一讲我想和你聊一聊怎么在多机无容灾的情况下保证一致性。

我在前一节课里给你介绍了，在单机情况下的 5 种不同的一致性级别。在分布式环境下，由于网络存在很大的不确定性，金融系统首要关心的不是如何在这些一致性中做选择，而是理论上有没有可能达到最高的正确性。那么这节课我们就来学习一下最常用的两个方法。

背景

在分布式环境下，每个节点上的数据库都会保证这台机器的数据操作具有可串行化或者快照隔离的事务隔离级别，但是这只是本地机器局部的事务保证，是分散的信息。

如果想要具有分布式事务（Distributed Transaction）的能力，就需要有个方法把局部的信息收集起来做集中决策。这个收集的过程和做集中决策的过程也需要有事务的保证。通过单机事务来达到多机之间的事务协调，通过单机事务的正确性来保证全局事务的正确性，你在后面的学习中一定要注意这个核心思路。

分布式事务的实现也分为两种不同的级别。一种是偏底层的实现，由数据库自己来实现分布式事务，比较著名的有两阶段提交（2PC）和三阶段提交（3PC）。另一种是偏上层实现，业务系统自己来实现分布式事务，在国内比较常见的是 TCC。接下来我们先看看两阶段提交。

两阶段提交（2PC）

假设

前面提到过，分布式事务需要有人能收集信息后做集中决策，这个人就是两阶段提交的协调者（Coordinator）。

想理解 2PC，你要先知道分布式环境的两个假设：

第一，每台机器是独立的，它有自己独立的事务控制机制。两台机器间没有直接交流。

第二，协调者是唯一和所有其他机器交流的角色。它负责给所有机器发指令。机器收到指令后一定需要执行，但是执行不一定会成功。另外，不稳定的网络可能会造成指令丢失，或者指令的返回状态丢失。

协调者只是一个角色，它既可以是一台单独的机器，也可以是集群里的某一台机器。下面这幅图列举了这两种不同的协调者选择方式：

协议

顾名思义，两阶段提交一共分为两个阶段。

在第一阶段，协调者向所有参与的数据库发送准备提交的消息。每个数据库在收到协调者的消息之后，对自己本地的数据库进行预处理，比如给数据加锁、修改数据等等。

如果预处理成功，本地数据库返回准备成功的消息给协调者。如果预处理失败，则返回准备失败的消息。请注意，这时候本地的数据库事务还没有完成，也就是既没有提交事务，也没有回滚事务。

在第二阶段，协调者会收集所有参与者的准备状态。如果所有人都返回了准备成功的消息，那么协调者发消息让所有参与者提交本地事务，这时候整个分布式事务属于提交状态。如果出现了任何问题，协调者就会发消息让所有参与者回滚本地事务，这时候整个分布式事务处于回滚状态。

导致分布式事务回滚的问题有很多种，比如至少有一台机器返回了准备失败，或者一段时间之后没有收到一台机器的准备情况，我在后面还会提到。

举例：两个账号跨机器转账

我们还是举个例子来加深理解。分布式事务最常用的例子，就是账务系统中两个账号之间的跨机器转账。

我们假设用户 x 给用户 y 转 100 元钱。最开始用户 x 刚好有 100 元，存在一台数据库 A 上。用户 y 最开始没钱，信息存在另一台数据库 B 中 。

转账后 x 的余额为 0，而 y 的余额为 100。转账的代码用 SQL 代码写出来，就是下面这个样子：

begin transaction
  update A set balance=  0 where accountID='x'
  update B set balance=100 where accountID='y'
end transaction

下面这幅图给你展示了我们在分布式环境下转账想要达到的结果：

由于数据库 x 和 y 在两个不同机器，所以我们要用分布式事务来保证整个转账不出问题。

首先，协调者要在自己的本地数据库记下来全局事务状态，里面记录了分布式事务到达了第一阶段的准备提交状态。

接着，协调者分别给数据库 A 和 B 发送准备提交的细节，A 需要将 x 变为 0，而 B 需要将 y 变为 100。这一步的示意图如下：

数据库 A 在收到协调者消息后，会对自己本地的数据库进行操作，将 x 变为 0，返回给协调者准备成功的消息。

同样，数据库 B 将 y 变为 100 后也会返回给协调者准备成功的消息。然后协调者将这两个数据库的返回状态记录到自己的全局事务状态表里。这一步示意图如下：

协调者收到所有数据库的成功消息后，两阶段提交的第一阶段就顺利结束了。协调者在本地记录这个事实，然后开始第二阶段的提交过程。这时候协调者给每个数据库发出提交事务的消息。这个过程的示意图如下：

数据库 A 和 B 提交了本地事务之后，会将提交成功的消息返回给协调者。协调者在本地记录分布式事务第二阶段执行状态，整个分布式事务结束。这一步的示意图如下：

我们来看看两阶段提交的成本。首先，通过上面的例子我们可以看到，分布式事务至少需要两次网络沟通，这个是无法再减少的时间成本。

另外，在分布式事务第一阶段，每个数据库都没有提交事务，事务会在第二阶段才提交。因此第一阶段和第二阶段之间的时间，所有数据库都需要对访问过的数据加锁。这个锁的时间可能会很长，这是另一个时间成本。

到这里分布式事务基本就讲完了。不过我们只解决了正常的情况，两阶段提交还需要考虑好，一旦出了问题之后要怎么应对。

如果有任何一台机器在第一阶段出了问题，协调者会在第二阶段通知所有数据库回滚在第一阶段的操作。

你有没有发现一个悖论？对于在第一阶段操作成功了的数据库来说，这些操作已经提交了，那已经提交了的事务怎么可能在第二阶段回滚呢？事务不是要求已经提交的事务不能回滚吗？

这就涉及到两阶段提交的实现细节了。我们前面说过，两阶段提交是偏底层的实现，数据库需要修改自己的逻辑后才能支持这个功能。单机版的数据库事务有开始和完成两个状态，两阶段提交需要增加一个新的状态叫作“准备成功”。

至于数据库究竟做了哪些改变，我会在第三模块的加餐里给你详细介绍。

TCC 协议

讲完了偏底层的两阶段提交，我们再看看偏上层的分布式事务实现方法 TCC。

TCC 全名是 Try-Confirm-Cancel，和两阶段提交一样，它也分为两个阶段，也有一个协调者负责协调整个分布式事务的流程。和两阶段提交不同的是，业务系统需要负责整个分布式事务的执行，而不能全权交给底层的数据库。

在 TCC 的第一个阶段，协调者要求所有数据库尝试（Try）进行所有本地事务。本地尝试之后将尝试的结果返回给协调者。在两阶段提交的第一阶段，事务并没有提交，而是到达了“准备成功”的状态，而在 TCC 的情况下，事务会真正提交。

TCC 第一阶段结束之后，协调者知道了所有节点的状态。如果所有节点的本地事务提交都成功，那么协调者会给所有节点发送确认（Confirm）消息。节点在收到 确认 消息之后进行确认操作。

另外，如果有任何一个节点在第一阶段出了问题，协调者就会给所有节点发送取消（Cancel）的消息。节点在收到 取消 消息之后，会对第一阶段的事务做逆向操作，取消掉第一阶段的影响。

请你注意，TCC 的取消操作不是事务的回滚，而是业务的回滚。因为第一阶段已经提交了事务，所以不能对已经提交的事务进行回滚操作。

这时候用到的是事务补偿，也就是说用一个反向业务来对冲正向业务的效果。因此你如果想要实现 TCC 的话，需要把每个业务实现两遍。一遍是正向的业务，另一遍是反向的业务。

举例：TCC 情况下的跨机器转账

我们还是举同样的转账例子，看看它在 TCC 的情况下会有什么不同，同时也让你感受一下什么是反向业务。

假设和前面一样，一个用户 x 的账户开始有 100 元钱，账户信息存储在数据库 A 中。另一个用户 y 的账户里最开始没钱，账户信息存储在数据库 B 中。然后系统发起了一笔从 x 到 y 的转账，金额为 100 元。所以转账后 x 的余额为 0，而 y 的余额为 100。

我们先看看第一阶段对用户 x 的操作。这一步和两阶段提交基本相同，都是将用户 x 的余额变为 0。

和两阶段提交不一样的地方在于对用户 y 的操作。在两阶段提交的情况下，用户 y 会在第一阶段就增加 100 元钱。但是在 TCC 的情况下，用户 y 在第一阶段的金额不变。下面这幅图给你展示了第一阶段的情况：

TCC 第一阶段结束后，就需要进行第二阶段了。由于第一阶段两个数据库的事务提交都成功了，所以协调者在第二阶段给所有人发确认的消息。

因为数据库 A 在第一阶段已经完成了对账户 x 的修改，所以数据库 A 收到 确认 之后什么都不用做。

相反，数据库 B 在第一阶段什么都没有做，所以在第二阶段收到 确认 之后需要对账户 y 进行入账操作。这时候数据库 B 通过一个正常的数据库事务来完成对账户 y 的 100 元入账操作。下面这幅图展示了第二阶段的流程：

下面这幅图展示了从时间的维度来看 TCC 成功时的两个阶段流程：

细心的你也许已经发现了，在 TCC 第一阶段结束后， x 和 y 账号的钱都为 0，因此在这一瞬间整个系统掉了 100 元钱。不过不用担心，因为在协调者的全局事务数据库里记录了当前 TCC 的状态，之后会在第二阶段把缺失的 100 元钱再补回来。

我们在初学数据库事务的时候，老师都会说转账需要是个原子操作，钱不能丢失，但这是一个宏观的结果。从这个例子我们可以看到，从微观上来讲转账并不是一个原子操作，而是由多个原子操作组成。而且，转账也不是一瞬间完成，而是有中间阶段。钱在这个中间阶段也会部分丢失，但是最终是正确的。

在单机版和两阶段提交的情况下，数据库隐藏了所有上面这些中间细节，因此你会感觉事务有原子性。但是在 TCC 的情况下，由于业务系统控制了分布式事务的进程，这些中间状态会暴露给业务系统，因此你才能感受到一些临时的不一致状态。

其实我们还可以从更高的角度看待金融业务的分布式正确性问题。一个完整的金融业务会涉及到非常多内部和外部的系统组件，每个组件提供一定的事务能力。

在进行顶层架构设计的时候，金融业务需要先通知所有系统做自己应该做的事情，然后通过第二阶段对各个组件的结果进行调整。这就是宏观的 TCC 过程，我们在第 2 节课提到的对账系统就是协调者在第二阶段的代表。

第二阶段的取消处理

我们在前面介绍的是 TCC 的正常流程，也就是所有节点在第一阶段都能成功提交。但是也会出现异常情况，比如第一阶段的提交失败，这时候协调者会在第二阶段给所有节点发送取消事务的消息，如下图所示：

还是看一下我们之前的例子。如果最开始数据库 A 里用户 x 的余额只有 50 元，那么在第一阶段，用户 x 进行 100 元出账就会失败。这时候用户 x 的账务金额没有变动，之后在第二阶段进行的事务回滚也很简单，什么都不用做。

但是如果用户 x 余额足够，用户 y 由于账户锁定无法入账，那么第一阶段结束后，用户 y 提交失败，这时候需要取消用户 x 的结果。数据库 A 收到取消消息之后，会通过一个新的事务将用户 x 的余额再加回来。

对于用户 y 来说比较简单。由于用户 y 是入账的账户，在尝试提交阶段和取消阶段，它什么都不用做，只有在确认阶段需要通过一个事务给账户增加 100 元钱。

这两个账户的确认和取消流程示意图如下：

异常处理

协调者在收到一个出问题的反馈后，就会进入第二阶段的错误处理流程。但是这时候其他节点并不一定出现了错误。更特殊的一种情况是，有的节点根本就没有收到第一阶段的消息，这是异常处理最复杂的情况。

因为分布式环境下网络不稳定，第一阶段的消息可能很久以后才会发到一个节点，但是这时候这个节点可能早就收到了协调者第二阶段的消息。就像下面这幅图展示的一样，一个节点可能会先收到第二阶段取消的消息，然后才收到第一阶段尝试提交的消息：

为了解决尝试提交和取消这两个消息的乱序问题，业务系统在进行 TCC 的时候需要做 3 处加强：

1. 允许取消一个不存在的事务，也叫空回滚。

2. 空回滚需要在系统里留下记录。

3. 第一阶段的尝试提交如果发现有空回滚标识的话，尝试提交需要失败。这个过程也叫作防悬挂。

需要说明的是，两阶段提交也要解决类似的问题，但一般是数据库底层解决，而不是把问题暴露给业务系统。

正确性反思

在讲完分布式事务的两个不同实现后，你应该发现了协调者的重要作用。协调者负责跟每个节点沟通，并将每个节点的局部信息汇集到一起之后做全局判断，所以协调者的全局事务数据库里保存了所有分布式事务的信息。

有了集中信息之后，协调者就能做出正确的全局判断，所以我们可以说协调者的本地数据库的事务能力保证了整个分布式事务的事务能力。

协调者的信息收集和事务处理是先后两个过程，过程的中间会出现状态不一致的情况，协调者通过最终一致性来解决集群最终的状态正确性。

那这里又出现了一个新的问题。在分布式环境下机器节点可能会出问题的，万一协调者的全局事务数据库出了问题怎么办呢？这就是分布式事务的单点问题，我们后面会讲到该怎么解决，这里你先有个印象就行。

小结

这一讲我们学习了怎么在多机无容灾的情况下保证一致性，也就是实现分布式一致性，即分布式的可序列化。

我们先了解了两阶段提交。两阶段提交是由数据库实现的分布式事务，整个过程分为两个阶段。

第一阶段协调者通知所有节点准备提交，所有节点将自己的准备情况反馈给协调者。第二阶段协调者根据第一阶段的结果来判断要提交所有事务，还是回滚所有事务，并将结论发给所有节点。节点收到第二阶段命令后加以执行。

接着我们学习了 TCC。TCC 是国内互联网用得最多的分布式事务实现方式。它和两阶段提交不一样的地方在于，上层的业务系统需要自己管理分布式事务的进度。上层业务系统需要实现 3 个方法：尝试提交、确认提交和取消。

TCC 的整个过程也分为两个阶段。第一个阶段由协调者和所有节点之间进行尝试提交。之后在第二阶段，协调者根据第一阶段的结果来判断是确认提交还是取消。

TCC 和两阶段提交的不同在于，TCC 的每个阶段都是完整的本地数据库事务，而两阶段提交只有在第二阶段完成后，本地事务才真正结束。因此 TCC 的好处是事务的加锁时间短，对应的代价是业务系统复杂，需要感知分布式事务的存在，还需要通过空回滚和防悬挂来解决乱序问题。

思考题

在两阶段提交的情况下，协调者的全局事务数据库可能会出现两种问题。一种是数据库重启。这样数据还没有丢失，协调者可以根据恢复好后的数据情况判断接下来应该怎么做。另一种是数据库整个消失不见了，这时候需要用到后面的课程知识来完美地解决。

但是，大部分情况下就算全局事务数据库的数据丢失，协调者也是可以根据所有节点的情况来反推出自己应该做什么。你知道协调者应该怎么做吗？

欢迎你在留言区提出疑问或分享思考。如果这节课对你有帮助，也欢迎转发给同事朋友，和他一起交流讨论。

14 | 正确性分级（下）：多机有容灾有哪几种不同的一致性？

你好，我是任杰。这一讲我想和你聊一聊，在多机有容灾的情况下怎么保证一致性。

在前面两节课里，我们已经学习了在没有容灾的情况下，如何在单机和多机的情况下保证数据一致性。由于没有容灾，每一份数据只会出现在一个地方，因此我们可以集中对所有数据访问进行控制。

但是，我们在搭建架构的时候一定会对数据进行容灾，会将数据复制到多个地方，这时候就会出现数据访问不一致的问题。

这种情况下前面两节课的内容就不适用了，我们需要用新的理论来分析所有会出现的问题。首先让我们来看一下我们“看起来熟悉”的 CAP 的理论。

为什么不要用 CAP 来描述一致性的选择？

CAP 由三个性质组成：一致性（Consistency）、可用性（Availability）、分区容错性（Partition tolerance）。其中，分区容错性指的是网络出现了问题，把原本通过网络连接在一起的机器分成了几个独立的部分，也叫作脑裂。

首先我来说说 CAP 第一个容易让人误解的问题。我们会觉得 CAP 这三个性质不能同时拥有，最多只能有两个，那么三选二之后，排列组合一共有三种选择，分别是 CA、CP 和 AP，其实这个理解是错误的。

CAP 真正的假设是当出现了脑裂后，你只能在一致性和可用性当中选择一个，从而放弃另一个。也就是说你只能选择 CP 或者 AP。在一些早期的文章中你还能看到这个常见的错误。所以 CAP 并不是像下面这幅图左边展示的三选二，而是像右边展示的那样二选一：

对于 CAP，我们还有另一个常见的误解。通常我在介绍名词的时候都会先给个定义，但是对于 CAP 却没有。那你知道一致性的具体定义是什么吗？

我们一般对一致性都有一个模糊的认识，知道一致性描述了一个正确的数据读写场景，但是很少有人能说清楚，具体怎样才算是正确呢？我们会在这节课的后面告诉你，其实 CAP 的一致性指的是可线性化（Linearizability）。这就是 CAP 的第二个问题，定义不明确，容易产生误解。

CAP 理论在出现之后，很快就变成了分布式系统一个脍炙人口的术语。但是因为 CAP 会带来一些误解，慢慢地在学术界已经不太建议用这个术语了。

在反对的声音当中，对于 CAP 三选二的误解争论不大，原因是这个误解比较容易澄清。现在反对比较强烈的是 CAP 对一致性的定义太过于简单。

因为分布式环境不但有一致和不一致，中间还有很大的选择空间。CAP 将多种不一致选择变成了单一的选择，非常不利于我们清晰描述分布式环境下会出现的问题。所以你可以用 CAP 来给第一次学习分布式理论的人启蒙，但是在真正做架构设计的时候要尽量少用。

接下来，我们来看看分布式环境下的一致性分类。已经总结出来的一致性有好几十种分类，在这里，我们重点学习一些常见和重要的一致性。

最终一致性

我们在第 2 节课讲支付系统的时候提到过，支付系统会碰到信息流和资金流不一致的情况，因此需要用到异步系统对接的方式，最终信息流会和资金流一致。这里有一个关键的术语是最终一致性。

最终一致性是分布式系统中一个常见的一致性级别。基于消息系统的架构在宣传自己正确性的时候，一般会声明自己是最终一致的。

顾名思义，最终一致性指的是最终会一致。那问题又来了，什么叫最终，什么又叫一致呢？

为了说明一致性，我们要先弄明白什么叫作可见性（Visible）。假设有两台机器 A 和 B，这两台机器之间互相做备份。

如果你在机器 A 上对数据的修改，经过一段时间之后反映在了机器 B 上，这时候你的修改在机器 B 上就是可见的。一旦在机器 B 上是可见的之后，你就可以在机器 B 上使用在机器 A 上的修改结果。下面这幅图展示了可见性的意义：

我们再回到对最终一致性的定义。这里的一致性指的是你的修改在所有机器上都是可见的。如果你的修改在一台机器上被看到了，那么这台机器就和原始的机器是一致的。

“最终”则定义了一致性的时间范围。它用到了数学上的极限（∞）概念。在有容灾的情况下，你对一台机器的数据修改会被慢慢复制到其他的机器。随着时间的推移，没有复制到数据的机器数目会越来越少。当这个时间是无穷大的时候，没有复制到数据的机器数目会降为零。

跟 CAP 没有对一致性做出准确的分类一样，最终一致性对于最终的定义也没有提出准确的、工程可用的定义，所以它的实际指导意义也不大。

从会话角度看一致性分类

接下来的 4 个一致性都和会话（Session）有关。会话是个使用者的概念，而不是服务器端的概念。会话是用户的唯一标识符，通过会话可以判断是不是同一个用户。

在单机或者没有容灾的情况下，能不能判断出是同一个用户的作用不大。但是在有容灾的情况下，多台功能一样的机器会作为彼此的备份节点。这时候同一个用户的不同请求可能会被发送到不同的机器上处理。虽然这时候是多台机器在处理你的请求，但是从用户的角度来看，你需要保证最后的处理结果，和在一台机器上处理的结果是一样的。

这里的一样并不要求完全一样，因此也会有一些选择的余地，这就是为什么我们会有 4 个不同的和会话相关的一致性。为了你理解起来更方便，在正式讲解之前，我们先来看看简化版的容灾模型。

在简化版的容灾模型里，用户会往集群的主节点写入数据。主节点负责将数据复制到备份节点。在这里对于复制的同步和异步没有任何要求，对于复制节点的个数也没有要求，只要多于一个备份节点就行。

用户的读取请求比较复杂。用户既可以从主节点上读取数据，也可以选择从备份节点读取数据，也可以有时候从主节点读，有时候从备份节点读。读取哪个节点取决于用户和服务器之间的协议，也可能有一定的偶然因素。

下面这幅图展示了一个同步备份的例子。用户把数据写到主节点后，并不会直接返回，主节点会将数据同步写入两个容灾节点。只有这两个容灾节点都写入成功之后，主节点才会通知用户说数据已经写入成功。

在定义了会话和容灾之后，让我们来看看都有哪四种不同的会话一致性分类吧。

单调写一致

单调写一致的英文名是 Monotonic Write。如果你往有容灾的集群里写了多次数据，单调写一致要求所有的节点的写入顺序和你的写入顺序完全一致。这样我们就能保证对于任何一个节点，它看到的别人的写操作和自己的写操作是完全一致的。

我给你举个例子，下面这幅图展示了一个不是单调写一致的情况。用户有连续三个写操作。主节点的写入顺序和用户发起的写操作顺序一致。但是主节点在复制数据到容灾节点 1 的时候，前面两个写操作顺序发生了错位，因此整个集群不满足单调写一致。

单调读一致

和单调写一致对应的是单调读一致，英文名是 Monotonic Read。你要注意的是，单调读一致并不表示所有机器上的读顺序都是一致的。单调读一致依然和写的顺序有关。它要求新的读操作不能读到老的结果。比如说，你如果从集群里读到了一个值，那么如果你再读一次的话，一定不能读到之前的值。

单调读不一致的情况一般发生在读取的节点发生变化的时候。如果你的两次读发生在两个不同的备份节点，那么由于备份的速度不同，很有可能你的后一次读取会读到更早一些的结果。

下面这幅图展示了单调读不一致的例子。用户写入两个值之后读取了两次结果。第一次读的时候是从主节点上读的，因此读的是最新的写入结果。

第二次读发生在容灾节点 1 上面。由于主节点将数据备份到容灾节点 1 需要很长的时间，第二次读的操作发生的时候，容灾节点还没有最新的数据，因此第二次读返回了第一次写入的数据，这样就违反了单调读一致的要求。

自读自写

自读自写的英文名是 Read Your Write，也就是说你能把自己写入的值读回来。它不仅仅要求能把写入的值读回来，还要求能把所有过去写入的值读回来。

为了能保证自读自写的一致性要求，服务器的节点在处理你读请求的时候，需要确保自己节点上有这个会话过去所有的写入记录。这样你才能确定所有写入的结果都没有丢失，而且在读的时候前面的写入都已经完成了。

注意，所有写入都已经完成并不表示写入的顺序是正确的，因此如果你想要得到正确的写入结果，还需要单调写一致来保证。

下面这张图里展示了一个不符合自读自写一致性的例子。用户在连续写入两个值后进行了读取操作。读操作发生在容灾节点 1 上。由于主节点到容灾节点 1 的备份速度过慢，容灾节点 1 在处理读请求的时候，还没有收到第二个写请求，因为读取结果漏掉了一个写入的结果，所以不是自读自写。

先读后写

先读后写的英文名是 Write follow Reads。前面三个一致性规定了一个会话的行为应该是怎样的。先读后写不同，它规定了多个会话之间互动应该满足怎样的一致性要求。

先读后写要求比较严格。假如你曾经读到了另一个人写入的结果，那么你想再写数据的话，你的写入一定要在另一个人的写入之后发生。也就是说，你们俩之间的写入有个先后顺序。

你如果看到了另一个人的结果，就表示另一个人的写入是过去发生的事情，这时候如果你想再写点新东西进去，那么整个集群需要保证你们俩写入的先后顺序。

下面这个例子展示了一种不是先读后写的情况。这个例子里有两个用户和两个节点。

用户 1 向主节点写入了第一个数据，接下来用户 2 马上从主节点读到了这个写入的数据，然后又立刻写了一个新的数据。由于主节点复制第一个数据到容灾节点 1 的速度太慢，导致容灾节点 1 先保存了后一个写入的数据，然后才保存前一个写入的数据，因此不是先读后写。

线性一致性

线性一致性的英文名是 Linearizability。线性一致性是分布式系统里最重要的一致性。你可以理解为线性一致性是分布式环境下的可串行化（Serializability）。

线性一致性所定义的环境里有一些程序，这些程序会执行一系列的操作，每个操作都有开始和结束的时间。

对于单个程序来说，它所有的操作之间没有时间上的重叠，也就是说属于同一个程序的两个操作不会并发执行。但是属于不同程序的操作可以在执行时间上有所重叠，比如说下面这幅图展示了 3 个程序一共 6 个操作的时序图：

线性一致性要求我们可以调整这些程序的操作开始和结束时间，调整的结果是所有程序的所有操作之间没有任何时间上的重叠。

和我们在第 12 节课讲的冲突可串行化一样，线性一致性对时间的调整也有一个要求，那就是如果两个操作之间没有时间上的重叠，那么这两个操作之间的时间先后顺序不能发生改变。

下面这幅图展示了对前面例子的分析。前面的例子一共有 3 个地方有时间重叠，因此这些彼此重叠的操作可以随意调整先后顺序。例子里还有两个地方有操作的先后关系，因此在调整顺序的时候，我们不能把这几个有先后关系的操作顺序搞反。

下面这幅图展示了一个可能的线性一致性调整结果：

那调整之后就是线性一致性了吗？其实还不是。你还需要对调整之后的结果进行正确性验证。这里的正确性指的是业务逻辑的正确性。

当你把所有操作按照线性一致性的要求进行调整之后，所有操作可以看作是先后进行的，没有任何并发。所以，你可以按照业务逻辑来分析所有程序的所有操作是否合理，比如说加减钱是否正确，或者消息入栈出栈的顺序。

如果你发现逻辑不正确，就需要尝试另一种线性一致性调整的顺序。要是你尝试了所有调整的排列组合后，还是找不到一个正确的结果，那么整个过程就不是线性一致性了。

线性一致性是分布式环境下最重要的一致性。它在分布式环境下对所有操作进行了排序，因此能帮助我们分析最后结果的合理性。线性一致性的实现还需要用到接下来两节课的内容，你先有个印象就行。

严格可串行化

在第 12 节课我给你介绍了单机情况下最强的一致性是可串行化。而这节课我们又学了分布式情况下最重要的一致性是可线性化。那么把这两者结合起来，就得到了分布式情况下最强的一致性，叫作严格可串行化（Strict Serializability）。

我们再来重温一下可串行化的定义。可串行化表示两个事务里所有操作的执行结果等价于这两个事务的某一个顺序执行结果。这里对“某一个”并没有做任何限定。

而严格可串行化则对这个“某一个”做出了规定，它要求两个事务的运行结果等价于唯一一个顺序执行结果。在这个结果里，原来谁的事务先结束，那么在顺序执行的情况下谁的所有操作先结束。严格可串行化虽然有着极强的正确性保障，但是它的运行效率特别低，所以一般很少用到。

小结

这节课我们学习了在有多机容灾的情况下如何保证一致性。

首先我给你简单介绍了分布式系统中最常见的 CAP 理论。CAP 能方便初学者理解，但是由于它容易被误解，而且对于分布式问题的复杂度有一个过于笼统的结论，建议你在真正讨论问题的时候尽量少用。

然后我们讲了最终一致性。异步处理架构一般具有最终一致性，但是最终一致性和 CAP 理论一样，没能对分布式环境下的复杂问题做更为准确的分析。

接着我们从会话的角度来看一致性，一共有单调写一致、单调读一致、自读自写和先读后写这 4 种一致性分类。这 4 类之间都是并列关系，没有高低强弱之分。

接下来我们了解了线性一致性。线性一致性是分布式系统中最重要的一致性级别。它对所有操作开始和结束时间进行合理的调整，最后的结果是所有操作能按顺序执行，这也是为什么叫作线性一致性的原因。

最后我们学习了严格可串行化。严格可串行化要求在分布式环境下的事务需要遵守线性一致性。由于它的执行效率低，一般很少使用。

为了让你建立起更加系统、清晰的认知，这里我整体总结一下所有一致性的情况。

在第三个模块的前面三节课里，我给你介绍了单机、多机无备份和多机有备份这三种情况下对一致性的分类。很多情况下，我们并不是追究极端的一致性，而是根据我们的业务和经济情况来选择合适的一致性级别，这一点在你设计金融系统的时候尤为重要。

现在分布式数据解决方案众多，我们对这些技术宣传的内容一定不能盲从，而是要根据它们的实现做选择。当然了，一致性选择也不是完全没有规律可循。下面这幅图列出了我们前三节课大部分内容之间的层级关系和我们通常的选择。

分析的思路是这样的。首先我们要看是单机问题还是多机问题。如果是单机问题，那么首选快照隔离，一般不需要用到可串行化。

如果是多机问题，那么先解决的是多机容灾。这时候有多台机器需要提供同一份数据，你可以根据容灾后的正确性要求具体判断。

一种情况是你对容灾后的正确性要求不高，这时就要看看从客户端角度发起的会话是否需要有正确性。

这里还可以细分成两种更详细的情况，如果你只需要保证一个会话的正确性，那么一致性要求就是保证单调读一致、单调写一致和自读自写。如果需要保证多个会话之间的正确性，就要保证先读后写。

另一种情况是对容灾之后的数据访问正确性要求高，那么就要保证线性一致性。

最后，如果你要解决的是在有容灾的情况下的分库分表问题，就需要解决分布式事务。这时候，每个分完的库和它的容灾机器组成的集群需要先满足线性一致性，这样容灾集群对外才能表现得像单个节点一样。然后我们再用 TCC 或者 2PL 来实现分布式事务。

思考题

我们这节课的思考题很简单。如果让你来实现分布式环境下的严格可串行化，你能想到什么办法呢？

欢迎你在留言区提出你的思考或疑问。如果你身边的朋友、同事也对一致性的话题感兴趣，也欢迎你转发给他们，一起学习进步。

15 | 分布式正确性的存在性（上）：什么情况下不存在分布式共识算法？

你好，我是任杰。这一讲我们聊一聊，什么情况下不存在正确的分布式共识算法。

对于金融行业来说，系统的正确性要远高于系统的执行效率。打个比方，当你在网上和朋友聊天的时候，漏掉了一两条消息其实无所谓。但是如果你给朋友网上转钱，钱转丢了就是件大事了。

金融行业的信息系统和互联网企业一样，也是由很多台机器组成的集群提供服务。机器一旦多了就会出现分布式系统常见的各种问题，比如宕机、网络中断。

那这种情况下，我们怎么才能保证金融系统的正确性呢？套用一句知乎上经常看到的评论，我们在回答为什么之前，先要问问是不是。你有没有想过，万一正确的分布式系统并不存在呢？

这些问题其实是对分布式系统的深入思考，也是金融级软件对架构师的要求。只有知其然并且知其所以然了，客户才能对你做出来的金融系统有信心。

核心思路 & 小结

共识是指多台机器之间达成统一的结论。这节课我们会证明可能是分布式系统里最重要的一个结论，那就是不存在共识算法。准确来说，在一个完全异步的分布式系统里，如果至少有一台机器可能会出问题，那么就不存在非随机的共识算法。

从结论可以看出，想要共识算法不存在，需要同时存在两个现象：一个是机器出问题，另一个是完全异步。只要我们能让任何一个条件失效，就存在共识算法。

很显然我们无法保证机器不出问题，所以重点要放在怎么让系统不是完全异步。幸运的是完全异步这个条件非常容易去除，只需要给每台机器增加一个时钟来判断发送的消息是否丢失。

这也是为什么常见的共识算法，比如 Paxos 和 Raft，里面一定会判断消息是否超时。所以虽然理论上分布式系统不存在共识算法，但是现实中很容易绕开这些理论约束，实现正确的共识算法。

证明的过程分为三步。第一步证明，分布式系统一定存在某个特殊的开始状态，共识算法的最终结果与这个特殊状态无关，只取决于某台机器是否出问题。

第二步证明我们能构造出一些特殊场景，使得分布式系统从这个特殊状态开始运行后，还会进入下一个特殊状态。

第三步结合了前面两步的证明，我们不断构造特殊场景，系统会周而复始地进入特殊状态，从而永远无法做出共识结论。由于对于任何一个号称具有共识能力的算法，我们都可以构造出这些特殊的情境，也就不存在真正的共识算法了。

如果你想更深入地了解这个结论，可以看这几篇论文：

1985 年三位科学家 Fisher，Lynch 和 Patterson 的论文"Impossibility of Distributed Consensus with One Faulty Process"。这节课主要讲解的是这篇论文。

1996 年的论文"Unreliable failure detectors for reliable distributed systems"。这篇论文证明了如果分布式系统存在一个能让你最终做出准确判断的不准确时钟，那么系统存在共识算法。

1996 年的论文"The weakest failure detector for solving consensus"。这篇论文证明了这个不准确时钟是共识算法存在的充要条件。

好，现在我已经交代了这节课的核心内容，不过你要是有好奇心，可以仔细看看后面的证明过程。

算法课一般会讲如何实现一个算法，很少会谈到如何证明算法不存在。证明算法存在只需要举个例子就可以，但是证明算法不存在，就需要证明所有可能的算法都不行，难度非常高。你可以通过下面的证明，提高自己对分布式系统复杂度的理解。

背景及定义

背景

"Impossibility of Distributed Consensus with One Faulty Process"这篇划时代的论文在 2001 年被评为 Dijkstra 分布式系统最具影响力论文。因为这个结论太重要，所以一般称为“FLP 结论”。

这篇论文证明了分布式系统不存在共识算法。我们前面说过共识的定义，但不够准确。所以在证明开始之前，让我们重新定义一些专有名词。

定义

分布式系统和机器

分布式系统由至少 3 台机器组成。每台机器都有自己的初始状态。为了方便证明，我们假设状态是二元的，即只能是 0 或者 1。不是二元状态的情况和二元状态一样，只是证明过程会稍微繁琐一点。

分布式系统是一个封闭的系统，没有外界输入。系统里的所有机器节点在最开始都有自己的状态，随后这些节点之间，会按照算法定好的逻辑给彼此发送消息。每台机器在收到消息之后可以做三件事。

第一，改变自己的状态。这时机器从初始状态变为中间状态。

第二是向其他机器发送消息。发送的机器数量不做限制，可以一个机器都不发，也可以发给很多机器。

第三是输出一个结果。这个结果只能从 0 或者 1 中选择，而且机器只能输出一次结果。

通俗地说，每台机器里面最开始含有一个数字，0 或者 1。机器最后会输出一个数字，也是 0 或者 1。机器之间可以互相发送消息，通过消息来改变彼此内部的状态。输入和输出示意图如下：

和现实世界类似，分布式系统内的机器并不稳定。它们会死机，会重启，也会变得很慢。但是我们这里假设机器逻辑都是正确的，一旦我们写了一个算法，机器会老老实实地按算法执行，不会偷偷利用算法的漏洞来攻击算法。

用现在的区块链理论来说，我们这里假设碰到的是非拜占庭问题，而不是区块链或者比特币解决的拜占庭问题。如果你对拜占庭问题有兴趣可以自行查找相关定义，或者参考这篇文章。

最后说说论文对共识算法的一个隐含假设。这里假设共识算法不是随机的，主要是考虑结果可复现性。

共识

有了前面对机器的准确定义，共识就更好定义了。共识其实就是要求这组机器的输出都相同，但是这还不够准确，更加精准一点的定义是这样的，共识需要满足以下 3 个条件：

1. 终止性（termination）

2. 一致性（agreement）

3. 有效性（validity）

其中，终止性指每个还能正常运行的机器最终都需要确定唯一一个结果。这里有两个重点。一是所有还能正常运行的机器都需要生成结果，一个不落。不能正常运转的机器没有任何要求。二是结论只能生成一次，一旦做出就无法更改。

一致性相对好理解一点。一致指所有结果都需要完全相同。终止性和一致性有一个更通俗的解释：所有机器的状态初始可以在 0 和 1 中任意选择，最后当共识算法结束的时候，所有还活着的机器需要输出一样的结果，要么都是 0，要么都是 1。示意图如下：

最后一个条件是有效性。有效性指的是所有结果都有可能是共识结果。举个例子，你其实也可以参与分布式系统的决策。你可以写一个共识算法，不管机器初始状态是什么，最后都输出 0。这样也算是一个共识，但是这个共识算法毫无意义，所以我们需要把这种极端简化的情况排除。

消息

我们前面提到了机器之间是可以互相发消息的。消息需要仔细定义，因为分布式系统的复杂度是和消息传输方式的复杂度一一对应的。

消息的第一个假设是消息的发送是异步的。异步是指你给对面的机器发送了一条消息后，你不一定能收到反馈。

收不到反馈可能有很多种情况，这时候你不知道究竟是消息还没发送到那台机器，还是那台机器已经收到了，但是给你反馈前死机了。

收到消息的时间间隔也没有任何假设。你的消息可能几分钟后就能收到，或者很久才收到，甚至永远收不到。时间间隔是一个重点，我们在最后会再次提到。

消息的另一个假设是消息系统本身的运行是完美的。所有消息会被先存储在消息队列里。这个队列不会丢失任何消息。

另外，所有消息只会被处理一次，也就是说不会碰到消息重发的问题。一旦机器从消息队列里读取了一条消息，这条消息就会永远从消息队列里消失。这时候如果机器刚好又重启，那么这条消息就会永远在系统中消失。

虽然现实中不会存在这么完美的消息系统，但是由于我们解决的不是消息系统的问题，在这里对消息系统做完美假设，这有利于让你关注分布式系统本身。

消息的最后一个假设是消息的接收是异步的。消息的接收顺序是完全随机的，并不是先到先得。这意味着你给一台机器发送两条消息，先发的消息可能后到。

正在运行的机器会不断地从队列里拉取消息。当队列里没有和它相关的消息时，系统会返回一个空消息。机器也可以根据空消息的情况来改变自己的状态。

举例

到这里为止我们明确了论文对分布式系统里机器、共识和消息这 3 个术语的定义。为了方便你理解，我在这里举个例子。

假设分布式系统由 A、B、C 三台机器组成，初始值分别为 1、0、0。系统在运行了一定时间之后，A 给 B 和 C 发了两个不同消息，同时 A 输出结果为 0。

B 过了一定的时间后，收到了 A 给自己的消息。B 这时候决定输出结果为 0，现在 A 和 B 达成了共识，都输出了 0。

C 收到了 A 给自己的消息后，没有给外面发任何消息，也没有输出任何结果。C 虽然一直在运行，但是没有输出任何结果，因此不满足共识算法的终止性。所以 A、B 和 C 这三个节点组成的分布式系统没能达成共识。

但是我们稍微改改对 C 的描述，结果就会大不一样。如果这时候 C 死机了，那么 C 就处于非运行状态。由于共识的定义对于非运行的机器没有任何要求，此时 A、B 和 C 组成的分布式系统就达成了共识。你可以再仔细体会一下两者的区别，下面是这个例子的示意图：

问题定义和解题思路

完成上面的定义后，我们终于可以对要证明的问题做一个准确概括了：在一个完全异步的分布式系统里，如果至少有一台机器可能会出问题，那么不存在非随机的共识算法。证明的过程分为三步。

第一步证明分布式集群存在一个特殊初始状态。我们无法通过这个初始状态预先知道共识结果，具体结果取决于哪些机器会在什么时候出问题。我们把集群的这个特殊初始状态称为非确定状态。

第二步，证明一旦存在一个非确定状态，系统在运行了一段时间后，一定还会进入下一个非确定状态。

第三步是将第一步、第二步合起来。从一个非确定性的初始状态开始，系统会运行到第二个非确定状态，然后会运行到下一个非确定状态，最后一直无限运行下去。这样就违反了共识算法的终止性，也就证明了不存在共识算法。

证明过程用了反证法，比较精妙。接下来，你需要紧跟我的思路，仔细体会论证过程。

第一步证明

当分布式系统处于某种状态时，如果我们能提前计算出最后的共识结果，那么这个状态叫确定性状态。反之，如果最后的共识结果取决于机器是否在线，这个状态就叫不确定性状态。

我们在这里需要证明的是，任意一个集群都存在一个非确定性的初始状态，即我们无法通过这个初始状态，判断最后的共识结果。

现在反证法开始了。反证法需要将需要证明的结论反过来描述，所以我们假设从所有初始状态开始，不管机器是不是出问题，我们都能提前计算共识结果。

我们先假设有一个初始状态的集合 C（configuration），C 包含了集群内所有机器的初始状态。比如下图画了 3 台机器和它们的初始状态集合 C：

根据反证法的假设，我们能提前计算出最后的共识结果，所以上图的初始状态也会有一个共识结果，比如说为 0：

接下来我们需要用到共识算法的第 3 个特性：有效性。有效性表示当我们遍历所有初始状态时，一定有的初始状态最终会产生共识结果 0，也一定会产生共识结果 1。简单起见，假设初始状态集合为 C0 的时候，共识结果为 0。当初始状态为 C1 的时候，共识结果为 1。

下面就是反证法最精妙的地方了。前面提到的 C0 和 C1 是关于集群的初始状态。这两个初始状态虽然不一样，但我们可以一步步将它们变成一样的，过程很简单。

先选出 C0 里的一台机器，它在 C0 和 C1 的状态不一样。然后，将 C0 的所有状态复制一份到新建的初始状态 C2，并将 C2 里这台机器的状态变为它在 C1 里的状态。

接着，在 C2 里找一台和 C0 状态不一样的机器，建立一个新的初始状态 C3，并将这台新机器的状态改变。以此类推，由于机器数目是有限的，最后一定会构建出一系列初始状态，它们之中只有一台机器的状态不一样。

举个例子，如下图所示，假设 C0 里的 3 台机器初始状态都是 0，C0 的共识结果为 0。C1 的所有初始状态都为 1，共识结果为 1。C2 将 C0 里的第 1 台机器的初始状态从 0 变为 1。C3 将 C2 里的第 2 台机器的状态变为 1。这样通过 3 次变化，我们最终可以将初始状态 C0 变为 C1：

那么问题来了。在上面这个例子里，C2 和 C3 所对应的共识结果应该是什么呢？其实不管它们对应的结果是什么，你会发现对于上面的 4 个初始状态，一定有相邻的 2 个初始状态，它们分别对应了 0 和 1 这两种不同的共识结果。你可以试试枚举所有共识结果的排列组合来验证。

我们假设 C2 和 C3 对应的共识结果都为 0，这样 C3 和 C1 这两个相邻的初始状态集合，就会有不同的共识结果：

现在就到考验你的时候了。C3 和 C1 只有第 3 台机器的初始状态不一样。如果这第 3 台机器从一开始就死机了，C3 和 C1 的初始状态就会完全一样，这时它们俩都会产生怎样的共识结果呢？

下图展示了这个疑惑。反证法里假设过，集群的共识结果只和初始状态有关。那么如果第 3 台机器一直有问题，C1 和 C3 的初始状态其实是一样的，那么它们俩会产生一样的共识结果，要么是 0，要么是 1。

如果最后结果都是 0，那么 C1 在第 3 台机器不出问题时产生共识结果 1，但是当这台机器出问题后，会产生不同的共识结果，这和我们反证法假设矛盾。如果最后结果为 1，这样 C3 也会产生同样的矛盾。示意图如下：

按照同样的道理，我们可以证明任意数目的集群都会产生类似的矛盾。所以对于任意一个机器集群，一定存在一个特殊初始状态，它的共识结果取决于一台特殊机器是否正常运行。第一步证明结束。

第二步证明

第一步证明只用到了分布式系统的初始状态和最终结果，而第二步证明则需要用到分布式系统的中间状态。和这一节课最开始类似，在证明之前我们再做一个定义。

分布式系统里消息的接收是有顺序的，尽管接收消息的时间差可能会很短，但是依然有顺序差别。所以，我们可以给分布式系统状态的变化定个顺序，任何两个相邻的状态变化之间是新接收到的消息，这个状态变化的顺序叫作路径。

和第一步证明一样，分布式系统的状态也分为非确定性状态和确定性状态两种。系统可能从非确定性状态运行至确定性状态，但是反过来不行。路径和两种状态的示意图如下：

这里还要说到一个新的操作。由于消息系统是异步的，消息的接收可以任意延迟。下图展示了对于某一条路径，将第一个消息 e 一步一步往后挪时，系统的不同运行状态：

好，我们终于可以开始证明了。这里需要用到第一步的非确定性初始状态。

从这个状态开始，我们先随便选择一个消息，比如集群里可能会出现的任意一个消息 e。接下来，我们从集群所有可能的中间状态中，选择两大类出来。

一类是从来没接收过消息 e 的状态，我们称之为 C，另一类是刚刚接收过消息 e 的中间状态集合，称之为 D。剩下的中间状态跟证明无关，可以忽略。

那么，我们接下来证明，在集合 D 里一定存在另一个非确定性的中间状态。示意图如下：

这里还是用反证法，我们假设集合 D 里所有的状态都是确定性状态。

非确定性的初始状态一定会有两条不同的路径，分别产生 0 和 1 这两个共识结果。如果这个初始状态所有路径的最终共识结果都一样，那么就没有非确定性了。对于产生共识结果 0 的路径，如果这个路径没有穿过集合 D，那么表示路径在集合 C 里就结束了。

那么我们可以将消息 e 添加到这个路径的末尾。这样，构造出的新路径会穿过集合 D。由于路径在添加消息 e 之前共识结果为 0，那么添加消息 e 之后的共识结果也为 0。这里用到异步消息的一个属性：既然消息 e 出现过，那么消息 e 的接收时间可以任意调整。

所以，一定有一条穿过集合 D 的路径会产生共识结果 0。同理，也有一条会产生共识结果 1。所以，集合 D 里所有状态不仅仅是确定性状态，它们一定能产生 0 和 1 两个共识结果。

接下来选取两条路径。一条是第一个消息是 e 的路径，假设对应的共识结果为 0。由于初始状态是非确定性的，所以剩下的路径中，一定有一条产生不一样的共识结果。

如下图所示，我们选取另一条产生共识结果为 1 的路径。如果这个路径不穿过集合 D，那么就可以按照上面的步骤，添加消息 e 到路径最后面，这样这条路径一定可以穿过集合 D。

然后，我们调整第一个消息 e 的接收时间，一步一步往后挪，这时候会产生一些新的穿过集合 D 的路径。那么对于中间的几个可能的路径，它们的共识结果是什么呢？

不管这些路径的共识结果是多少，和第一步证明类似，我们一定可以在集合 D 里找到两个相邻的路径，而它们的共识结果刚好相反。

如下图所示，我们假设状态 C0 在收到消息 e 后会进入状态 D0，D0 最终输出共识为 0。状态 C0 收到消息 f 后，会进入状态 C1，C1 收到消息 e 后，会进入状态 D1，D1 会输出共识结果为 1。

到这里为止，最关键的 4 个状态我们已经找到了。反证法的下一步是对上图消息 f 和 e 进行分析，看看这两个消息的接收方是否一样。

我们接下来会证明，不管这两个消息的接收方是否一样，如果集合 D 的所有状态都是确定性状态，最终都会有逻辑矛盾。

下面进入分情况讨论的环节。首先我们先看看消息的接收方不一样是什么情况。

这时候，我们将上图 D0 和 D1 之间增加一个消息 f，也就是把消息 f 的接收时间调整到消息 e 之后。这表示，我们有两条从 C0 状态到 D1 状态的路径。第一条是先接收消息 f，然后接收消息 e。第二条是先接收消息 e，然后接收消息 f。如下图所示：

这时候出现了一个悖论。消息 e 和 f 对应了两台不同的机器，它们互不影响，所以 e 和 f 的接收顺序并不影响最后的共识结论。那么，上图从 C0 到 D1 的两条不同路径，最终应该导致同一个共识结果。这样看来，这个菱形的关系是正确的。

但是请你注意，我们在反证法里假设了状态 D0 是个确定性状态，它不能既产生共识结果 0，又在接收消息 f 后产生共识结果 1，所以这个菱形关系和我们之前的反证法假设矛盾。

那我们再来看看如果 e 和 f 的接收方一样，又会出现什么情况。我们假设从状态 C0 开始，接收这两个消息的机器就出了故障，无法运行。系统在经过了一条路径 g 后，到达最终状态 A，并且产生共识结果。因为无限路径和共识算法的终止性相矛盾，所以路径 g 的步数是有限的，。

如下图所示，状态 A 究竟会产生什么样的共识结果呢？

先看上图的左边，我们构造两个场景。第一个场景是这样的。假设分布式系统从状态 C0 通过路径 g 到达状态 A 之后，原来一直出问题的机器突然恢复了。碰巧这时候消息 e 也刚刚到达，分布式系统会到达一个新的状态 E0。

另一个场景从状态 D0 开始。由于消息是可以任意延时的，我们可以将路径 g 贴在状态 D 之后，这样状态 D0 在经过路径 g 后，也会到达一个状态。那么问题来了，如下图所示，这两个场景最终会达到同一个状态 E0 吗？

答案是会的。接收消息 e 的机器在路径 g 中一直无响应，所以消息 e 和路径 g 没有共同的机器，两者之间互换顺序不会影响最终结果。因此上图左边的菱形是合理的。两条不同的路径都会到达同一个状态 E0。

按照反证法的假设，D0 是一个确定性的状态，最终生成的共识为 0。所以 E0 最终会达到共识 0，这也意味着状态 A 也应该达到共识 0。

同理，我们也可以将右边用两条新的路径补全。一条是从状态 A 开始，添加一条 f+e 的路径。另一条是从状态 D1 开始，增加一条路径 g。这样右边也会达到同一个状态 E1。由于状态 D1 会达到共识结果 1，状态 E1 也会到达共识 1。下图画出了补全之后的情况：

所以 A 这个状态既可以达到共识结果 0，也可以达到共识结果 1。这意味着 A 是一个非确定性状态，这和我们前面假设 A 是一个确定状态相矛盾。

好了，到目前为止我们证明了，不管 e 和 f 这两个消息的接收方是否是一样的，如果集合 D 的所有状态都是确定性状态，最终都会有逻辑矛盾。所以反证法的假设不成立，也就是说，集合 D 里一定存在非确定性的状态。

第三步构造

证明的第三步是构造出一个不会终止的共识过程，构造过程很简单。按照第一步证明，存在一个非确定性的初始状态，和它对应的第一个接收消息 e。如下图所示：

按照第二步的证明，我们能够找到下一个非确定性状态，这个状态刚刚接收了消息 e。如下图所示：

这样每当到达了一个不确定性状态，我们可以将消息 e 往后挪，从而制造出下一个不确定性状态。由于这个过程可以永远重复下去，系统会永远处于非确定性状态，这就违反了共识算法的第一个特性终止性。示意图如下：

思考题

1996 年的论文"Unreliable failure detectors for reliable distributed systems"证明了，如果在分布式系统里，存在一个能让你最终做出准确判断的不准确时钟，那么系统存在共识算法。这个时钟起到的作用是在分布式环境下，检测机器是否出问题。

失败检测分为两种属性：完整性和准确性。按照排列组合，一共有 4 种可能的情况：

1. 强完整性。所有正确的节点都会最终怀疑每个出错的节点。

2. 弱完整性。一些正确的节点都会最终怀疑每个出错的节点。

3. 强准确性。所有正确的节点都不会被怀疑出了问题。

4. 弱准确性。一些正确的节点不会被怀疑出了问题。

论文指出，就算只有很弱的失败检测，也能实现共识算法。你觉得这里的“弱”是指哪几种情况呢？

欢迎你在留言区跟我交流互动。如果学完这节课让你有收获的话，也欢迎你转发给同事、朋友，一起学习、探讨共识算法不存在的证明过程。

16 | 分布式一致性（下）：怎么理解最简单的分布式一致性算法？

你好，我是任杰。这一讲我想和你聊一聊，怎么理解最简单的分布式一致性算法 Raft。

在第 14 节课里，我们学习了在有容灾的分布式环境下的各种不同一致性情况，其中最重要的是线性一致性。线性一致性有非常多的好处，但是它的实现却非常困难。20 多年前，计算机科学家终于找到了一个算法，但是非常晦涩难懂。

直到 2014 年，才出现了一个通俗易懂的算法，它就是 Raft。从此以后，各种具有分布式一致性能力的数据系统便层出不穷，普通人也有能力和大型互联网公司一样，设计出一个在分布式环境下正确的系统。

既然 Raft 算法通俗易懂，实现细节我就不展开了，这节课我们重点来看看，Raft 算法究竟能做什么，以及它应该怎么使用。

分布式一致性能解决的问题

文件同步

能实现分布式一致性的算法也叫作共识（Consensus）算法。我们在前一节课说过，共识需要满足以下 3 个条件：

1. 终止性（termination）

2. 一致性（agreement）

3. 有效性（validity）

我们不是理论科学家，不需要去理解这些深奥的定义，而且就算我们理解了，也不一定能指导实际的开发工作。所以，我们需要的是一个更简单的结论。通俗一点来说，共识表示在多台机器之间，能同步一个内容不断增加的文件。

同步一个文件乍看起来非常简单，很多人都有远程拷贝文件的经历。但是，如果装有文件的机器出了问题怎么办呢？你可能会尝试找下一台有文件的机器重新拷贝。

不过这还没完，我们再深入想一想，你怎么确保你找的机器刚好就有完整的文件呢？而且文件内容还在不断增加，你又怎么能确定增加的内容也是正确的呢？

多亏有了共识算法，它帮我们解决了分布式环境下的这些不确定性问题，最后留给了我们一个确切的结果：那就是只要大部分机器都能正常工作，那么这些机器上的文件就是完全一样的。

这时候，机器上的文件可以分为两部分。一部分是确认已经同步好了的内容，这部分的内容你可以放心使用。另一部分是正在同步中的内容，这些都是临时内容，暂时不能使用。下面这幅图展示了这两部分内容的区别：

光说理论太抽象了，我们来看看共识算法最简单的一个应用场景，那就是跨机器的文件同步。比如你可以用共识算法来同步图片，但是这对于共识算法来说就有点小儿科了。共识算法更多是被用来同步非常重要但是数据量不大的文件。

那么，哪些数据是重要但不大的文件呢？

比如我们在进行云计算的时候，就需要知道机器都是哪些。这些描述信息都很重要，但是数据量不大，它们就可以存储在共识后的文件里。顺着这个思路，常见的配置文件信息、路由信息等等也都符合刚才说的特性，通常都会存储在有共识能力的数据系统里。

全序广播和线性化存储

不过，这么复杂的共识算法如果只是来解决文件的存储，依然还是有点大材小用。它真正的威力体现在能实现全序广播和线性化存储。

全序广播

全序广播的学名叫 Total Order Broadcast。从名字就可以看出来，全序广播和广播的顺序有关系，事实也确实如此。在分布式环境下的全序广播需要满足这两个条件：

1. 所有机器的消息都不能丢失。如果一台机器上出现了某个消息，那么这个消息一定能在所有其他机器上找到。

2. 所有机器记录的消息的顺序完全一致。

如果你把记录消息当作往文件末尾增加一行内容的话，全序广播的要求是不仅仅要同步文件，而且要求文件内所有内容的顺序也完全一致。

所以，全序广播可以用来解决对文件内容顺序要求极其严格的场景。比如在分布式环境下，数据库需要做数据的容灾备份。一种可行的做法是将数据库的日志文件通过全序广播的方式，广播到所有容灾节点。

在金融系统中，一个常见的对顺序要求很严格的场景是会计系统的账本。会计账本要求记账的顺序不能错，所以在对会计账本做数据容灾的时候，也可以采用全序广播的方式，这样可以保证账本能正确地备份到容灾节点内。

下面这幅图给你展示了全序广播。你要注意看里面的内容顺序：

顺便给你说一下，区块链的技术其实就是用共识算法生成了一个分布式的会计账本。

不同点在于，区块链的共识算法假设有的节点是恶意的，因此解决的是拜占庭问题。我们这个系列的假设是节点不是恶意的，因此解决的是非拜占庭问题。由于非拜占庭问题里有额外的正确性假设，算法可以用更少的资源来达成共识，速度也会更快。

线性化存储

说完了全序广播，我们再来看看线性化存储。线性化存储的学名叫 Linearizable Storage。

你也许发现了，线性化存储中的“线性化”，和我们在第 14 节课讲的“可线性化”好像一样。没错，其实它们俩就是一个东西，只不过之前我们学的“可线性化”是理论，线性化存储是满足这个理论的实现。

线性化存储是一个分布式的数据存储集群。这个集群给你提供一些数据操作，比如你可以修改数据，或者读取数据。线性化在这里保证了你的操作是可线性化的。

那什么叫“操作是可线性化”呢？我们来重温一下第 14 节课的内容。如果一系列操作是可线性化的，那么你就可以把这些操作重新排序。排序之后，这些操作能先后顺序执行，并且最后生成合理的结果。

既然我们花了这么大精力讲解这么深奥的定义，那它一定能解决很复杂的问题。没错，线性化存储能解决的一个标志性问题是分布式锁。

你如果学过数据库，那么一定知道单机版的锁。锁操作的顺序非常重要。谁先加锁，谁就能访问资源。后加锁的人必须要等前面的人释放了锁之后，才能加锁成功。

在单机版的情况下，锁的实现非常简单，一般会利用特殊 CPU 指令，甚至用纯软件也可以实现。

但是在分布式情况下，锁的实现会变得非常复杂。这是因为如果只用一台机器来保存锁的状态，那么这台机器可能会出问题。

但是，如果复制锁的状态到其他机器，那么就会出现我们在第 14 节课讲到的各种不一致情况。

尽管线性化存储是由多台机器组成的，但你在所有这些机器上，操作的顺序都是完全一样的。因此，如果你在一台机器上获得了锁，那么你在其他所有机器上也获得了这个锁，这样就真正实现了分布式锁。

有了分布式锁之后，数据系统就有了很多高级的处理能力。比如你可以在云计算环境提供一个有容灾能力的锁服务，然后用这个锁服务来实现分布式事务。

全序广播等价于线性化存储

分布式理论有一个很有用的结论是，共识能力等价于全序广播，也等价于线性化存储。因此全序广播和线性化存储之间也是等价的。

既然这两者是等价的，那么我们就可以放心地去使用了。不过，这里我还是给你简单做一下证明，在证明的过程中会用到分布式状态机这个概念。

分布式状态机是我们第 7 节课事件溯源设计的分布式版本，我会在下节课给你详细讲解。这里我们先看看它能做什么。

首先，我们说说怎么用线性化存储实现全序广播，实现方法很简单。线性化存储可以实现分布式锁，你在广播任何一条消息之前，会先获取一个分布式锁。

当你拿到锁之后，给所有机器发送这条消息。等你确认了所有机器都收到了这个消息之后，再释放锁。

接下来，我们再看看怎么用全序广播实现线性化存储，这里就需要分布式状态机了。你将想加锁的命令通过全序广播发给所有机器。每台机器上都有自动机。你的加锁操作是事件溯源架构里的命令，而所有已经获得了的锁是事件溯源架构里的状态。

如果命令是合理的，也就是说现在还没有其他人有这个数据的锁，那么自动机会生成的事件溯源架构里的事件，也就是你已经获得了锁，并接着更新状态，同时将结果通知给你。这样就实现了分布式锁。

怎么使用 Raft 算法

前面我们已经了解了分布式一致性能解决的问题，但实际使用时，想要用好 Raft 算法还是有不少注意点，我们分别从客户端和服务端两个角度来看看。

客户端

由于 Raft 算法是一种共识算法，所以我们可以通过 Raft 来实现文件同步、全序广播和线性化存储。一般来说，我们会很少自己实现 Raft 算法，而是通过云服务来访问集群提供的功能。

这时候作为客户端，你需要了解应该怎么使用 Raft 才是正确的。否则尽管集群提供了共识能力，你的使用方法不对，还是会出错。

Raft 算法有一个主从（Leader and Follower）的概念。在任何一个时间点，整个集群最多只有一个主节点，其他的都是从节点。

主节点是唯一会处理你请求的节点，所以你所有的请求都需要发送给这个主节点，主节点负责将你的请求正确地同步给剩下的从节点。

共识算法对同步的正确性有一个定义。当这个定义满足了之后，主节点会通知你请求已经处理成功。你需要注意的规律是每台机器的主从角色是会一直变化的，但是共识算法会保证不管怎么变化，集群里最多只有一台机器是主节点。

讲完主从的概念之后，我就要说说想正确使用 Raft 算法，我们第一个需要注意的事情，那就是你只能访问主节点。

分布式环境下机器会宕机，因此主节点也会出问题。这时候你需要不断尝试集群剩下所有的节点，找到谁是主节点。

如果 Raft 实现过于简单，你很难分出谁是主节点，这时候你需要一台一台机器去问，如果问不出来就一直轮询。

Raft 实现得好的话，如果你问的机器不是主节点，它会告诉你，它心中的主节点是谁。你按照这个提示去找的话，有很大概率能找到真正的主节点。如果它恰巧不再是主节点了，那么你会拿到下一个提示，最终你会找到当前真正的主节点。

我们再来看看第二个注意点，那就是怎么判断自己的消息已经被处理了。我们在第 2 节课讲异步处理架构的时候提到过，异步系统的请求有三种，分别是成功、失败和不确定。Raft 算法也有这三种状态。

如果 Raft 告诉你消息成功处理，那么消息一定是通过全序广播保存到了所有正常的机器上，你可以放心地处理下一个消息。

如果 Raft 告诉你失败，那么绝大多数情况是因为你访问的机器不再是主节点。这时候你需要再次寻找主节点在哪里。

如果 Raft 什么都没有告诉你，那有可能服务器端已经成功处理，或者处理失败。这时候你需要假设处理失败，然后重发请求。重发请求可能会造成同一个请求重复多次，因此服务器端一定需要有去重的能力。

服务器端

从用户的角度来看，服务器端主要是要考虑容灾能力。Raft 协议的正常工作不需要所有机器全都在线，只要多于一半的机器在线就可以了。

因此。我们通过 Raft 实现的全序广播或者线性化存储都具有一定的容灾能力。我会在后面的第 20 节课里，详细讲解应该如何选择容灾的力度。

Raft 算法核心概念

说到这里，你应该能正确地使用 Raft 算法了。

不过，我还是建议你再稍微了解一下 Raft 算法的核心概念，这样你在使用 Raft 算法的时候，就能弄清楚为什么你的使用是正确的，这就是我们在开篇词里提到的，知其然而知其所以然。

Raft 里每个节点都有三个状态：主节点、从节点和候选节点（Candidiate）。

主从节点我们在前面已经讲过了。候选节点是未来可能的主节点。如果某台机器发现集群里好像没有主节点了，那么它会把自己变成候选节点，然后尝试通过一个选主过程将自己变成主节点。因此，系统中可能会有多个候选节点存在。

下面这幅图展示了节点的这几个状态变化：

Raft 算法里有一个重要的概念是任期（Term）。任期是一个不断递增的正整数。每个成功当选的主节点都有自己的任期数。随着时间的推移，历任主节点的任期数一定不断在增加，绝不会不变或者倒退。

在正常的情况下，主节点会通过心跳机制将自己的任期数定时发给所有其他节点。节点在收到主节点的心跳消息之后会保持在从节点状态。我们一般把这个心跳过程叫作主节点的压制效果。

一旦分布式系统出了问题，比如断网或者主节点消失，主节点就无法再压制其他节点。这时候节点会纷纷将自己的状态变为候选节点，参与选主过程。

选主过程很复杂，不过你跟着我的思路来理解，就能把它弄明白了。

简单来说，每个节点将自己收到的最后一个任期数加一，然后问其他节点自己的任期数是不是最高的。

如果有一半及以上的节点同意你的任期数是最高的，那么你就变成了主节点，同时通过心跳机制压制其他所有节点，阻止集群中再出现新的主节点。

最后，我们再说一下 Raft 的存储。Raft 实现了线性化存储，因此在本地会维护一个自增不减的日志文件，里面记录了所有的用户请求。这些请求的前面一部分是已经同步过了的内容，而后面一部分是正在同步的内容。已经同步过的内容是安全的，你可以放心访问。

Raft 这个算法，我们就说到这里。Raft 虽然是最简单的共识算法，但是它依然比较复杂，主要体现在如何实现选主过程和主节点的压制过程上。如果你有兴趣，可以去Raft 的官方网站查看算法和论文的细节。

小结

这节课我们学习了怎么理解最简单的分布式一致性算法 Raft。

首先，我们分析了分布式一致性能解决的问题。分布式一致性在分布式环境下能正确地同步文件，同时也能做全序广播和线性化存储。

接下来，我们又讨论了如何使用 Raft 算法。Raft 的客户端需要永远只访问主节点。如果主节点没有反馈消息处理成功，那么你就需要一直重试。Raft 的服务器端可以根据情况选择容灾能力的大小。

Raft 的算法有两个核心概念。一个是节点的状态，分为主节点、从节点和候选节点三种。另一个概念是任期。算法的运行阶段被分为一个个的任期，任期由一个不断增加的任期数来表示。Raft 算法能在分布式环境下计算出正确的任期数和节点状态。

金融系统的核心组件在分布式环境下一般要求具有线性一致性，因此无论是核心组件自己，还是周边的数据系统，都需要通过共识算法来实现线性一致性。

偏底层的基础架构需要用共识算法来实现线性化存储，以及用线性化存储实现的分布式锁和分布式事务。而偏上层的应用需要用共识算法来实现分布式状态机，保证业务在多机情况下的一致性。

思考题

我们在第 14 节课讲了分布式事务。分布式事务的原理是：通过协调者的本地事务来协调各个节点的事务执行状态。

因此，分布式事务能不能正确运行，这取决于协调者的本地数据库。这个本地数据库就是系统的单点，一旦出了问题，整个分布式事务就不能顺利进行。

所以，为了提高分布式事务的容灾能力，我们需要解决协调者的单点问题。那么问题来了：

1. 你能分析一下，这个问题的本质是我们这节课讲的哪个问题吗？

2. 如果你要解决单点问题的话，可以怎么解决呢？

欢迎你在留言区记录你的疑问和收获。如果这节课对你有启发，也欢迎转发给你的同事、朋友，和他一起探讨 Raft 算法的应用。

17 | 正确性案例（上）：如何实现分布式的事件溯源架构？

你好，我是任杰。这一讲我想和你聊一聊怎么实现分布式的事件溯源架构。

在第 7 节课，我们讲了单机版的事件溯源架构。尽管这个架构处理能力快，但是单台机器的处理能力毕竟有限，而且也不能保证系统有容灾能力。

所以，这节课我们一起来看看，如何一步一步解决系统扩容和容灾的问题。这里我先做个提示，因为这节课会用到很多前面讲过的内容，必要的地方我会给你说明关联到前面哪一节课。我建议你先把握整体思路，有弄不懂的，可以再温习一下前面的内容。

这节课要讲的解决问题的思路，不仅仅适用于事件溯源架构，很多和计算及数据相关的系统也会碰到同样的挑战。所以，你在学习这节课时，重点要放在理解为什么会有这些问题，以及为什么有这些解决方案，而不是放在解决方案的细节上。

多机容灾

我们先来看看分布式环境下我们能解决的第一个问题，那就是容灾。

容灾的思路是花钱来换取服务质量。如果单台机器出问题之后无法对外提供服务，那么只要我们能把同一个功能部署在多台机器上就行，这些机器作为一个整体对外提供服务。如果一台机器坏掉了的话，只要集群里还有其他的机器，那么就能再找一台机器，替换掉前面那台坏掉的。

刚才的分析看似正确，但隐含着三个重要的假设。这几个假设会直接影响到我们的架构能达到的正确性级别。

我们先来分析一下这个思路里的假设。第一个问题是我们对正确性的描述很模糊。如果同一个功能可以由多台机器提供的话，那么就会出现在第 14 节课提到的单调读一致、单调写一致等各种弱一致性问题。

金融系统在分布式环境下，很多时候的要求会比较高，因此需要达到线性一致性的一致性级别。这时候常见的实现通常是整个集群的功能由一台机器来提供。这个特殊的机器就是主节点。

然后，我们来看看第二个假设。主节点是单台机器，因此会出现单点故障。当单点故障出现了之后，我们需要在剩下的机器里再找出一台机器来替换之前的主节点，所以这就是一个选主的过程。

再来看看第三个假设。我们不是简简单单地随便找一台机器来替换主节点。我们在这里有一个隐含的假设，那就是这个替换的机器是正确的，而且在主节点出问题后，它能够挺身而出，接手主节点之前所有的工作。更专业的说法是，我们希望替换节点和主节点完全一致。这个特点就是我们在第 16 节课提到的全序广播。

所以综合这三点假设，我们要求，在有线性一致性的情况下，容灾需要集群使用分布式一致性算法，这样就拥有了主节点，有换主过程，以及通过全序广播达到的所有节点状态一致。这就是为什么在对有状态机能力的系统进行容灾时，我们一定会选用一致性算法。

我们分析完了容灾的所有假设之后，再来看看为了支持容灾，我们需要对事件溯源架构做哪些修改？

一个很直接的修改方法是将事件溯源架构部署在多台机器上，通过一致性算法来复制命令队列，就像下面这幅图展示的一样。我们把这种部署方式叫作复制状态机（Replicated State Machine）。

这样做确实能对命令队列做多节点的容灾。但是又会出现一个问题。我们在讲事件溯源的时候提到过，命令变为事件的过程可以具有随机性。如果主节点出了问题，那么就算命令完全一样，新的主节点依然有可能生成不同的事件。所以上面这个方法，只适合从命令到事件的转变没有任何随机性的情况。

因此，为了保证事件溯源架构有全序广播的能力，我们需要用一致性算法来同步事件队列，而不是命令队列。下面这幅图的右边部分是正确的复制位置，你可以左右对比着看：

那架构升级到这里就结束了吗？其实还没有。

我们在第 7 节课学习单机版的事件溯源时，没有说过用户如何知道自己的消息已经被正确处理了。这是因为单机版的情况下，消息的处理和结果的返回都是由一台机器完成的，没有什么不确定的情况。

但是，等我们用到了事件溯源的复制状态机版本后，同一时间可能会有多台机器在处理同一个命令。那怎样才能保证用户能收到正确的处理结果呢？

这时候就需要对状态机的能力做一些限制了。我们要求只有在当前机器是主节点的情况下，这台机器才能对外进行通讯。如果机器是从节点，那么它只能复制事件队列和更新内部状态，而不能返回执行结果给用户。

因此，在复制状态机版本的事件溯源框架里，只有主节点可以往集群外写消息执行状态，比如通知处理结果给用户。那问题都解决了吗？其实还没有。

集群的主节点并不是一直是一台机器，有可能计算到一半的时候，主节点换到了另一台机器。这时候有一个很实际的问题是，用户和原来的主节点之间的网络链接，它在换主之后就断掉了。新的主节点怎样才能通知到原来的用户呢？

这时候就需要有一个中间层，用来隐藏事件溯源是由一组机器组成的这个细节。这个中间层的学名是反向代理。

反向代理是服务器集群的一部分。对于用户来说，反向代理就是集群服务器的代表。反向代理负责和用户之间维持 TCP 长链接，这样用户和反向代理之间可以一直互通消息。反向代理负责将用户请求转给合适的节点，并将节点的计算结果转还给用户。

下面这幅图给你展示了加了反向代理之后，分布式事件溯源架构是什么样子的：

数据查询

分布式环境下，我们要考虑的第二个问题就是数据查询。我会从常规查询和一致性读两个方面带你分析。

常规查询

我在第 7 节课给你讲过，如何实现事件溯源架构的查询，当时说过我们需要用到读写分离的架构。读写分离的架构要求我们实现一个读模式的状态机。那我们来看看在分布式情况下，读模式状态机应该如何实现。

读模式状态机的核心原理是复制写模式状态机内的事件队列，通过复制事件队列来达到复制状态的结果。因此我们需要做的是，从复制状态机版本的事件溯源框架里复制事件。那问题来了，这么多台机器都有事件队列，从哪台机器上复制会比较好呢？

答案很简单，从任何一台机器上复制都可以。这里就需要用到 Raft 一致性算法的一个特性，它能保证所有节点上的已同步数据都是正确的。每台机器可能同步的数据不一样多，但是只要数据被标记为已同步，那么数据就一定是正确的。

这样我们就很轻松地解决了第一个问题，那就是从哪里复制事件队列。这时候你有两种选择。一种是从主节点上复制数据。如果这样选择，你很有可能可以获取到最新的数据，但是问题在于这会加重主节点的压力。

为了避免主节点压力过高，我们就要说到另一种选择了，也就是到从节点上获取数据。因为从节点不需要处理命令，也不需要复制数据给其他节点，所以从节点压力比较小，多加一个数据访问不会有太大影响。但是从节点有一个问题是它可能会有很大的数据延时。如果从节点一直处在和主节点断开的状态，你就无法访问到更新的数据。

因此，你需要结合自己的情况来选择是从主节点还是从节点复制事件队列。下面这幅图给你展示了这两种不同的选择：

一致性读

刚才说的常规查询其实还不完善，因为通过读模式的方法实现的查询，可能会出现一些常见的分布式环境问题。我们在前面第 14 节课讲会话一致性的时候提到过，单个会话有单调读一致和自读自写这两个一致性级别。这两个级别在读模式的情况下都不能满足。那怎样才能保证读的正确性呢？

答案是要一致性读（Consistent Read）。一致性读解决问题的思路是在分布式环境下将读和写之间的操作进行排序，从而达到线性一致性。由于线性一致性比单调读一致和自读自写的一致性要高，所以也就解决了分布式环境下会话会出现的问题。

那具体应该怎么实现呢？过程分为两步。第一步是将查询发送给事件溯源的写节点。注意，这时候一定不要发给读模式的节点。

事件溯源需要命令和事件。对于查询请求来说，它的命令和事件什么事情都不做，因此是个空操作。我们需要的是，通过共识算法的线性一致性对查询请求进行正确的排序。

写节点处理完之后，我们就到了第二步。这时候写节点通过反向代理将查询结果返回给用户。你会发现一致性读和一般性的写过程完全一样，唯一变化是需要有特殊的命令和事件。

分库

业务处理

最后我们来看一下分布式事件溯源框架的分库处理。我会在第 19 节课给你讲解，如何做动态的分库，这里我们假设已经分库完毕，我们看看分库之后需要怎么处理。

为了方便你理解，我对架构和业务做了一些简化。接下来，我会用一个节点代表通过一致性算法实现容灾能力的多个节点。同时我们假设把一个节点分为了两个节点，各处理一半的情况。业务也选择了账务系统。

系统中一共有两个账号，分别是 x 和 y 。俗话说一生二，二生三，三生万物。如果我们能将节点一拆二，那么更多分库的情况我们也能够解决。下面这幅图展示了简化版的系统和业务情况：

那我们来看看，分库之后的事件溯源架构应该处理哪些问题。事件溯源要求读写分离，所以我们也按照这个思路，先看看写的情况下有哪些需要考虑的地方。

先看看反向代理。在没有分库之前，反向代理的作用是作为用户和系统之间的一个桥梁。系统内的节点虽然有多个，但是这些节点之间的状态是完全一致的。反向代理只需要找到正确的主节点就行。

在分库之后，系统会出现多个不同状态的节点。这时候每个节点只能处理一部分的业务，不能处理所有的业务。因此反向代理这时候需要有路由的能力，它能够根据业务逻辑来选择哪些消息应该送往哪些节点。

所以这时候反向代理需要变成路由，它的内部要维护全局的路由信息。更新后的架构图如下：

分布式事务

在分库之后，我们可能会遇到一个事务跨多个节点的问题。比如上面这个例子，如果发生了一笔从 x 到 y 的转账应该如何处理呢？

我在第 13 节课给你说过，这时候我们需要用分布式事务来解决跨节点的事务问题。常用的分布式事务实现方法 2PC 和 TCC 都需要用一个协调者来维护分布式事务的状态。这个协调者的作用非常重要，但是它是个单点，一台机器如果出问题，会导致所有分布式事务都无法进行下去。

在第 13 节课的时候，我们还没有足够多的理论基础来解决这个问题。不过，现在我们已经准备好了所有的工具。那让我们来看看，应该怎么解决协调者的容灾问题吧。

协调者的容灾和事件溯源架构的容灾一样，都需要用多台机器来解决单点问题，因此需要用到复制状态机，将单个节点的内容正确的复制到多台机器上。数据库没有命令或者和事件队列，但是一般会有操作日志。

所以，一种办法是将操作日志通过共识算法同步到多机，其他机器通过日志来恢复最新的状态。这就是一种常用的分布式数据库的实现方式。

小结

这节课我们学习了如何将单机版的事件溯源架构扩展到多机。

多机情况下，首先要解决的是单节点的容灾问题。我们需要用复制状态机来解决多节点的状态一致性问题，因此需要用共识算法来复制事件队列，用共识算法的全序广播能力实现复制状态机。

事件溯源的读写分离架构在分布式情况下改变不大。任何一个

节点都可以作为读模式节点的数据源。但是这个方案不能满足单调读一致和自读自写的要求。因此，我们可以选择对读操作进行跟写操作一样，通过共识算法同步到所有节点。这样，我们就能用共识算法的线性一致性，来达到会话中的读写顺序正确性了。

解决完单个节点的容灾问题之后，我们再通过分库分表解决扩容问题。分库分表要将反向代理升级为路由节点。分库之后的协调者本身也有单点问题，所以我们可以通过共识算法，把数据库的操作日志同步到多台机器上，这样就可以实现分布式数据库的容灾了。

思考题

不知道你还记不记得一首关于“推敲”的古诗：

题李凝幽居

贾岛

闲居少邻并，草径入荒园。

鸟宿池边树，僧敲月下门。

过桥分野色，移石动云根。

暂去还来此，幽期不负言。

传说作者贾岛在作诗的时候，对于是用“推”还是“敲”犹豫不决。我们在设计金融系统的时候，也会有类似的选择困难症。

比如说，读模式的状态机需要复制事件队列。在复制的时候一定有两个选择，一个是主动将事件队列拉过来，另一个是将事件队列推过来。那你会选择推，还是拉呢？你觉得不同选择都有哪些优缺点呢？

欢迎你在留言区分享你的思考和疑问。如果这节课对你有帮助，也欢迎转发给你的同事、朋友，跟他一起学习进步。

18 | 正确性案例（中）：常见分布式数据方案的设计原理是什么？

你好，我是任杰。这一讲我想和你聊一聊常见的分布式数据系统的设计原理。

所有的业务系统归根到底都需要处理数据，因此从本质上来讲都是数据系统。业务系统和一般数据系统只是在处理数据的逻辑上有所不同，它们对于数据的存储、读取、容灾等都有极大的相似之处。

因此，我希望在学完这节课之后，你既能了解常用数据系统的运作原理，更好地使用它们，同时也能举一反三，在今后设计金融系统架构的时候能借鉴一些思路。

大部分的原理我在前面的课已经讲过了，关键之处我还会再次提示，如果有不清楚的内容，你可以回到对应的文章去复习一下。

Redis

我们先从最简单的 K/V 存储开始。Redis 出现之后，一举取代了 Memcached，成为首选的基于内存的 K/V 解决方案。Redis 的核心竞争力是速度快，那我们就来分析一下，为什么 Redis 会拥有速度上的优势呢？

首先，我们看看 Redis 处理数据的方式。Redis 默认用单线程处理所有数据。

单线程是一种能优化延时的解决方案，不过单线程虽然适合处理数据，但是不一定适合 I/O。同一时刻可能会有多个客户端在访问 Redis，如果用多线程处理的话，就会出现多线程造成的加锁冲突。

这时候，Redis 用了我们在第 11 节课讲网络优化时提到的 epoll 方法，用较少的计算资源来支持大量的 I/O 并发。

我们再来看看 Redis 的容灾和高可用。Redis 默认的容灾采用了主从备份的方法。主节点将内容异步复制给从节点。从节点默认是只读，所有从节点的写操作会失败，之所以这么做，主要是为了简化一些复杂的 Redis 使用场景，比如处理数据过期的问题。

有了 Redis 的异步主从备份，主节点就能更快地返回消息给客户端。同时，如果你想让主节点运行得更快，还可以取消主节点的本地备份功能。这样主节点不需要写本地文件，处理的速度会更快。

Redis 这种异步主从备份的方式极大减少了处理的延时。但是按照我们在第 14 节课讲的会话一致性分析，异步备份和只读从节点的方式无法满足单调读一致和自读自写的一致性要求。

如果为了更高的一致性要求，需要读操作也在主节点发生，这时候就能满足线性一致性。但是要注意，这个线性一致性假设主节点不能出问题。如果主节点出了问题的话，异步备份可能会丢失数据，所以整个集群依然不是线性一致性的。

接下来，我们再看看本地数据备份的两个优化选择。主节点可以选择将一部分数据保存在本地，之后可以用这些数据来恢复单机状态。一个选择是 RDB，Redis 会定期将内部状态保存到本地硬盘；另一个选择是 AOF，Redis 会将操作日志实时保存到本地硬盘。

我们比较一下 RDB 和 AOF 在速度上的优缺点。AOF 需要一直写文件，而 RDB 只需要偶尔写文件，所以 RDB 写入数据量小，频率低，因此速度会更快一些。但是 RDB 牺牲了数据完整性，两次 RDB 之间的数据无法恢复。

最后，我们来总结一下 Redis 关于速度上的一些优化思路：

1. 用单线程和 epoll 来处理数据。

2. 异步容灾，通过牺牲单调读一致和自读自写，换取消息返回速度。

3. 用 RDB 来实现定期的状态备份，通过牺牲数据完整性来换取处理速度。

Redis 的一个简单架构图如下：

RocksDB

数据系统一般有三件事情要处理，分别是数据查询，数据保存和数据处理，也就是数据的读写和计算。Redis 优化的是数据的读和计算，接下来我们来看看，RocksDB 怎么来优化数据的写。

RocksDB 和我后面要讲的 Spanner、TiDB 一样，都属于同一类 K/V 的实现。这些实现都基于 SSTable 和 LSM 树。

SSTable 的全称是 Sorted String Table。其实 SSTable 就是一个存储在文件的 Map，这个 Map 的键的类型是字符串，在存储的时候这些键是按从小到大的顺序排列的。下面这幅图是 SSTable 的一个简单例子：

你也许觉得单个 SSTable 没有什么特别之处，但是多个 SSTable 放在一起之后会，就出现一个特殊的数据结构，它就是 LSM 树。

LSM 树用多个 SSTable 实现了一个 Map。LSM 树将所有 SSTable 从上到下排列，查询的时候先查最上面的 SSTable，如果查不到就查下一层，以此类推。

我还是结合一个例子给你讲解。LSM 树一共有两层。这两层有 3 个键是重复的，分别是 a、b 和 c。当我们想查询 a 的时候，在第一层就发现了数据，因此不需要访问第二层的 SSTable。

但是如果我们换一下查询的条件。这时候查询的是 h，而不是 a。第一层不包含键为 h 的数据，因此查询会继续访问第二层。下面这幅图展示了这个查询过程：

那 LSM 树的这个架构有什么优势呢？前面提到过，这种架构的写入速度非常快。其实写入速度无论多快，肯定也比不上 Redis，因为 Redis 可以选择不写文件。所以 LSM 树的真正优势是当 K/V 数据量超过内存大小时，基于文件系统的 LSM 树结构提供了基本的 K/V 查询能力，同时它还具有很高的数据写入速度。

传统的基于文件的 K/V 结构是 B+ 树。B+ 树在更新数据的时候需要对文件做修改。LSM 树在更新数据的时候，并不会对已有的 SSTable 进行修改，而是在所有的 SSTable 之上再建一个新的 SSTable。

这种方式和 MVCC 的方法类似（MVCC 的内容详见第 12 节课），都是将一个修改操作变成了新增操作，这样对文件的所有操作都是在末尾添加新的内容。

第 7 节课讲事件溯源架构的时候，我们说过这种在文件末尾添加内容的操作方式，它能最大程度地使用硬盘提供的写入能力，所以写入速度会很快。

但是 LSM 树也有一个缺点，就是它的查询速度慢，这是因为随着时间的推移，系统可能有很多层 SSTable。当你查询一些更新频率不高的数据时，很有可能需要读很多层 SSTable 之后才能知道自己需要的值。

尽管我们有一些优化的方式，比如使用 bloomfilter 来做缓存，但是依然无法完全解决。因此，从监控上可以看到，LSM 树的查询延时像一个锯齿，很多时候延时都很低，但是偶尔会有几秒甚至更高的延时。B+ 树就没有这个问题，延时都很平稳。

所以，如果你对读写的速度要求不高，但是希望延时可控，那么你需要选择 B+。如果你对查询的延时要求不高，但是对写入速度要求很高，那么你需要选择 LSM 树。

那 RocksDB 这些基于 LSM 树的 K/V 存储对于分布式系统有什么帮助呢？

我们在这节课的最开始，提到了 Redis 的 K/V 存储和它默认的异步同步机制。

如果将 Redis 的 RDS 快照实现方式换成 LSM 树，那么这个快照就可以实时生成，并且时间开销小。这样我们就有了一个具有一定异步容灾能力的 K/V 集群，这个集群能绕开内存大小的限制，用更大的硬盘来提供存储能力。

这就是基于内存和基于文件的两种不同的 K/V 集群实现方式。这些方式依然有一些问题，那就是没法实现会话一致性。按照我们前面介绍的思路，如果想要有所得就需要有所失。

如果我们想要有更高的正确性，那么就需要牺牲一些速度。Google 的 Spanner 数据库基本上是这方面的开山鼻祖。

Spanner

Spanner 是 Google 在 2012 年公布出来的一个全球性分布式关系型数据库，它的横空出世惊艳了所有人，里面有很多神奇的数据解决方案。我们在平时可能就接触过这些架构和思路，但是 Spanner 是第一个将这些内容整合在一起的系统，并且它证明了这个方案是可行的。

有了 Spanner 开路，接下来开源领域就纷纷放手一搏，将 Spanner 的论文吸收引进，消化以后再创新，出现了很多一致性分布式数据方案，这些方案普通人也能使用。

Spanner 将传统意义上的单机版关系型数据库按照组件拆分了出来。传统数据库用顺序增长的日志文件来存储数据库的操作，这个日志文件可以用来恢复数据库的状态。既然日志文件可以用来恢复本地数据库，那么它也可以用来恢复其他机器上的数据库，而且如果日志文件同步得足够快，那么其他数据库有可能实现实时同步。

但是，跨机器日志文件同步会碰到分布式系统的一致性问题。我在第 16 节课说过，如果在分布式环境下将文件正确地同步到多台机器上，这时候我们需要用共识算法来达到全序广播的能力。

在 2012 年的时候，Raft 共识算法还没有设计出来，所以 Google 在当时用了另一种叫 Paxos 的共识算法。

算法虽然不一样，但它们都有一样的共识能力。Spanner 会先通过 Paxos 算法将日志文件同步到多台机器，然后每台机器通过日志文件实时同步彼此的状态（这个方式第17 节课最后提到过）。

对于数据库来说，它的状态其实就是数据库表。我们学习 KDB 列数据库（内容详见第 10 节课）的时候说过，关系型数据库其实就是个 Map，也就是 K/V 结构。它的键是列的名字，它的值是每一列的数据。这是一种将数据库表垂直划分的方式。

还有一种是水平划分的方式，划分的结果也是 K/V 结构。数据库表一般都有主键，这些主键就是 Map 的键，每一行内容就是 Map 的值。因此无论哪种划分方式，数据库表最后的存储都是 K/V 结构。

既然数据库状态是一个 K/V，那么只要我们在日志文件中记录了 K/V 的插入和修改操作，就可以用共识算法来实现分布式状态机，这样就能实现多个数据库的实时状态同步了。

数据库除了要生成关系型表之外，还需要支持很多重要的操作，比如数据库事务。

数据库事务的实现需要给数据加锁，这就意味着分布式环境下数据库事务的实现需要分布式锁。到目前为止，我们只说过一种实现分布式锁的方式，那就是用共识算法实现线性化存储，然后用线性化存储实现分布式锁。这里的内容如果你记不清了，可以回顾第 16 节课的内容。

Paxos 算法的节点也有主从之分。共识算法的主节点会承担起实现分布式锁的责任，同时它也负责维护集群的事务管理。如果事务需要横跨多个不同的 Spanner 组，那么每个组的主节点会互相沟通，选一个主节点作为分布式事务的协调者，用两阶段提交的方式来实现分布式事务。

通过前面的学习，我们知道了两阶段提交需要有一个协调者，这个协调者会成为分布式事务的单点。Spanner 在设计的时候，分布式事务的协调者其实是一个 Paxos 算法的主节点，因此它本身就有一定的容灾能力，不再是单点。

其实我们在上节课学习分布式的事件溯源架构的时候，采用的也是同一个思路。共识算法的主节点承担所有的写入操作和对外沟通工作，其他节点负责同步主节点的修改行为。

下图展示了简化版的 Spanner 架构图：

Spanner 还有另一个著名的优化技巧，那就是用原子钟实现的 TrueTime API，它用物理的方式优化了事务的实现。这个方法的普适性不高，所以在这里不再详细介绍，如果你有兴趣可以查看 Spanner 的论文。

在这里我要特别提到的一点是，虽然现在看起来 Spanner 的设计比较平常，用的都是我们常见的数据系统解决方案，但是不要忽略先后顺序。现在的数据系统用到的成熟解决方案，它们普遍来自于 Spanner，其实我们只是站在了巨人的肩上而已，饮水不忘打井人。

TiDB

江山代有才人出，了解了 Spanner，我们再看看国内的代表方案。TiDB 是国产分布式数据库的领军人物之一，它在 Spanner 的基础上有一些功能的加强。那接下来，我们来看看都是哪些架构优化的思路。

在设计 Spanner 的时候，只有 Paxos 这一个共识算法。但当 TiDB 出现的时候，已经有了 Raft 算法，所以可以用新的算法实现日志文件的全序广播。Raft 算法相对于 Paxos 来说更清晰易懂，从工程的角度讲能节省不少的研发时间。

讲 Spanner 的时候我们已经说了，数据库表其实就是一个 K/V。TiDB 将这个概念直接具现化，抽象出了一个 TiKV 的组件，这个组件通过 Raft 共识算法来实现分布式 K/V。你也可以直接使用 TiKV。对于 TiDB 来说，TiKV 存储了数据库的每一行的信息。

关系型数据库在设计之初是为了解决业务的事务问题，也就是解决每一行应该怎么操作。这样的优化结果却不方便我们分析数据，所以才有了用列存储方案的列数据库。

TiDB 在设计的时候，将自己定位为既能解决事务问题，又能解决分析问题的数据库。那么按照我们之前的介绍，TiDB 需要有一个列数据库，这样才能优化数据的分析。那这个列数据库应该怎么实现呢？

这里我来给你讲讲这个架构的分析思路。我们在第 7 节课介绍事件溯源架构的时候，提到过一个概念叫做 CQRS，也就是读写分离。读写分离的一个好处是可以将读和写拆分出来分别优化。

对于 TiDB 的设计目标来说，事务处理是写的部分，可以用基于行存储的 TiDB 来实现。TiDB 的另一个设计目标是数据分析，而分析显然是读操作，因此可以用基于列存储的解决方案来实现，这就是 TiDB 的另一个组件 TiFlash。

所以在 TiDB 中，TiKV 负责用共识算法实现数据库表的事务功能，TiFlash 负责用列存储的方式实现数据查询功能。那么 TiFlash 需要用到共识算法吗？

为了回答这个问题，你可以回忆一下第 17 节课讲分布式事件溯源架构时，我们提到的两种数据查询方式，分布式环境下的查询包括常规查询和一致性读两种。

数据分析不需要有一致性读的一致性能力，因此有常规查询的支持就足够了。常规查询需要用到读模式的状态机，因此 TiFlash 可以从 TiKV 的节点上复制日志文件。TiFlash 选择只从主节点复制，主要是为了节省延时。

TiKV 的存储方式和 RocksDB 类似，也用的是 LSM 树，因此写入速度快，但是查询速度慢。读写分离的优势是可以分开优化，既然写模式的 LSM 树查询慢，那么读模式就需要改变文件的组织结构，于是 TiFlash 用了一些 B+ 树来优化列文件的查询。

下图是简化版的 TiDB 架构图。

小结

这节课我们一起学习了分布式数据系统的设计原理。所有业务系统从本质上来讲都是数据系统，因此我们可以从分析分布式数据系统的架构，来学习怎么设计业务系统架构。

首先来看 Redis。Redis 是一个基于内存的 K/V 存储，用单线程和 epoll 来提高数据处理速度，用异步主从备份的方式来牺牲一致性，从而降低容灾延迟。Redis 还可以通过 RDB 实现定期的状态备份，牺牲数据完整性来换取处理速度。

RocksDB 是基于文件的 K/V 存储。它用 LSM 树来提高数据的存储速度，但代价是增加了查询的延时。

Spanner 用基于文件的 K/V 存储实现了分布式数据库。它用 Paxos 共识算法实现了数据的线性一致性，并用共识算法的主节点实现了分布式锁和分布式事务。

TiDB 进一步优化了 Spanner 的设计。它将关系型数据库的存储抽象成了独立的分布式 K/V 存储，负责解决数据库的写入操作，同时用基于列存储方式的 TiFlash 来实现数据库的查询操作。TiFlash 通过读模式状态机的方式，从 TiKV 的共识算法主节点同步数据。

从这 4 个例子我们可以看出一些常用的架构取舍思路。在分布式环境下，系统稳定性的提高会伴随着处理速度下降，而处理速度的提升会伴随着正确性的下降，因此稳定性、正确性和速度不能三者同时兼顾。

另外，写入的速度快，那么读取的速度就会慢，如果想读写都很快，那么需要用到多份存储，所以读、写和存储空间也不能三者同时优化。因此，在分布式环境下一般不会有正确的架构，只有满足业务需求的合适的架构，这就是分布式环境下架构的艺术性所在。

思考题

在大数据之前的时代里，关系型数据库倾向于把所有功能都实现在一起，这样就能充分优化各个组件之间的交互，从而达到很高的单机吞吐量。

但是到了现在这个大数据的时代，单机再也无法承载这么大的数据量，因此需要把数据分散在多台机器上处理。分布式环境下一定存在网络延时，所以单机版的优化并没有之前那么好的效果。

这时候的数据库实现就有了另一个思路。有没有可能把原来的数据库组件都拆分出来，再做一个分布式版本，把每个组件进行独立的分布式横向扩容呢？

这样一来，从整体上看还是一个完整的数据库，但是从实现上来讲是分布式数据库。

关系型数据库有这几个关键的功能：

1. 表存储

2. 主索引和二级索引

3. 缓存

4. 表的复制和容灾

5. 表查询

6. 单机事务

7. 分布式锁

8. 分布式事务

那么问题来了，如果要求你用现在市面上常用的数据解决方案，来拼凑一个分布式关系型数据库，你应该如何选择，如何搭配呢？

欢迎你在留言区记录你的疑问或思考。如果这节课对你有启发的话，也欢迎你转发给同事、朋友，和他一起交流讨论。

19 | 正确性案例（下）：如何在运行时进行数据系统的动态分库？

你好，我是任杰。这一讲我想和你聊一聊如何在运行时进行数据系统分库。

如果你需要进行数据系统分库，那么恭喜你，你的业务量又上了一个新的台阶。但是随之而来也有一个坏消息，那就是分库的过程想做得好的话会很困难。如果做得不好，可能你在每次集群的扩容前，都需要暂停业务，这样会带来一定的经济损失。

所以，为了解决这个问题，我们今天就来学习一下怎么做动态扩容。

支持分库功能的架构目标分析

在我们学习如何实现动态分库之前，先来看看我们的设计目标是什么。

最原始的分库方法是先将业务暂停，这样就不会有人修改数据系统。接着要完成数据系统备份，以防出错后回滚所有操作。然后按照预定的逻辑将数据切分到不同的机器上。如果测试结果没问题，那么就重启业务。

虽然这个方法清晰简单，但是带来的问题是业务需要暂停很久的时间。这个过程的示意图如下：

所以，分库并不是一个不能解决的问题，只是我们希望分库的时间越短越好。那前面说的这几个步骤在时间上还能不能再优化一下呢？其实也是可以的。

数据系统一般都会提供一个异步备份的容灾配置，你可以多备份一台机器。虽然这台备份机并没有所有数据，但是大部分数据都在，所以当你把业务系统停掉之后，只需要把备份机和主机之间的差别补全就可以了。

用备份机来进行分库的办法带给我们一个思路，那就是我们可以在不停止业务的情况下，解决存量数据的复制问题，停止业务之后解决增量数据的完整性问题。

但是，这个方法依然存在两个问题。第一个问题是人工干预过多，如果有可能的话应该自动化这个过程。

另一个问题是这个方法和业务强绑定，你需要能找出来主系统和备份系统之间的数据差别，而这一定程度上取决于你保存业务数据的方式。不同的业务可能需要不同的处理方式，所以这个方法通用性很低。

数据系统分完库并不表示整个过程就结束了。我们还要从整个系统来全面考虑分库带来的影响。

数据系统包括数据的生产者和消费者。在分完库之后，数据生产者需要能正确发现并写入新的库，同时，数据消费者需要从正确的库里读取数据。理想的状况是，这个发现并使用新库的过程尽量不需要停机，而且要尽可能自动化。

所以我们总结一下支持分库的架构设计目标。我们需要尽量减少因为分库带来的相关系统的停机时间，整个分库过程尽量与业务无关，同时尽量自动化。

分库方法

架构假设

因为数据系统的架构多种多样，我们并没有一个放之四海而皆准的方法，所以我们在这里要对能解决的数据系统架构做一些限制。

下面讲的分库方案适用于用事件溯源架构实现的数据系统。当然了，我们在上节课提到过，所有的业务系统从本质上来说都是数据系统，因此，我们的方案也适用于所有用事件溯源架构实现的业务系统。

我们来简单温习一下第 7 节课讲过的事件溯源架构。事件溯源架构的入口是一个按照时间排序的命令队列，以及从命令队列生成的事件队列。

在这里我们不具体区分命令和事件，假设我们在存储的时候选择将命令和事件放在一起存储。因此，我们可以假设事件溯源架构的入口是一个命令队列。

事件溯源架构里的状态机会处理这个命令队列，生成状态。这个处理的过程是完全可重现的，也就是说只要命令队列一样，最后生成的状态必定也一样。

事件溯源架构有一个优化是打快照。快照就是保存在硬盘的过去某个时间点的所有状态。我们可以从快照记录的事件点开始恢复最新的状态，而不需要从头开始执行所有命令。

好了，事件溯源架构的内容就说到这里。我先简单概括一下，接下来要讲的事件溯源架构的分库过程，它的核心思路就是利用快照来解决存量数据的复制问题，用命令队列来解决增量数据的完整性问题，用状态机的计算能力来达到实时状态一致。

架构假设我们就说到这里。接下来，我们看看如何将事件溯源节点一分为二。如果能实时地将一个节点一分为二，那么我们就可以通过重复这个过程，把一个节点分成任意多个。

分库前

在分库前，我们需要做一些准备工作。首先需要在系统里增加一个新的节点，用来处理分出来的那部分数据。其实这里需要增加的是一个集群，也就是我们在第 17 节课讲过的，具有共识能力的一组分布式事件溯源节点。

假设原来的集群是 A，我们新增的集群是 B。我们在分库前的目标是将集群 A 的内容尽量同步到集群 B。

由于我们的目标是将整个过程自动化，所以我们在系统中需要再新增一个协调者。这个协调者负责记录当前的分库阶段，并且向各个节点发送分库指令。因此，在分库前系统内一共有 3 组节点，如下图所示：

好了，下面我们来看看分库前的数据准备工作要怎么做。

首先，我们要发送分库细节给协调者。协调者收到分库命令之后，会向集群 B 发送同步数据的指令。

集群 B 收到同步数据的指令后，它会先从集群 A 里复制一份最新的快照文件，并且按照这个快照文件恢复到过去某个时间点的状态。

我们在第 7 节课讲过，快照文件里会记录这个快照对应的是哪个位置的事件，所以集群 B 恢复状态之后，就能知道自己恢复到了过去哪个时间点的状态。

接下来，集群 B 会尝试从集群 A 中获取最新的信息。你还记得，事件溯源架构有一个叫做读模式的部署（详见第 7 节课的内容）吗？这时候集群 B 会将自己变为集群 A 的读模式节点，通过事件溯源的架构，从集群 A 中同步最新的命令队列消息。这些命令消息通过状态机处理后，就可以更新集群 B 里的状态。

那什么时候集群 B 才算同步成功呢？实际上，完全同步是不可能的，因为集群 A 还在源源不断地处理新的业务，集群 B 永远都会有一个延时。

所以，我们要稍微放宽一下要求，只要集群 B 足够新就可以了。一般来说，如果两个集群之间的数据差只有几秒钟的时候，我们就可以判定两者已经同步好了。

判断的方法有很多种。一种是集群 B 可以将日志文件的时间戳和本地时间做对比，另一种是通过共识算法里心跳机制的时间戳来判断。

当集群 B 已经同步好后，集群 B 需要通知已经同步的消息给协调者。当然了，协调者也可以一直不断问集群 B 是否已经同步好。不管怎样，重点是只有集群 B 才能知道自己是否已经足够同步。我们可以用推送消息或者拉取消息的方式来获得同步状态。

这个过程的示意图如下：

分库中

集群 A 的分库过程

当集群 B 同步好后，协调者开始正式的分库过程。这时候，协调者会给集群 A 发送开始分库的消息。

集群 A 收到开始分库的消息之后，在自己的命令队列中记录一个特殊的命令，叫作分库命令。这个分库命令记录的是详细的分库细节，比如分完库后，集群 A 能处理哪些事情，集群 B 又能处理哪些事情。

其实，集群 A 并不只是记录了分库命令而已。当集群 A 将这个分库命令写入到日志文件之后，集群 A 的共识算法会将这个命令复制到其他容灾节点，这样就能保证主节点出问题之后，当前的分库步骤还能继续下去。

当集群 A 的节点通过共识算法同步了分库命令之后，集群 A 的主节点内的自动机就会执行这个命令。命令执行后会产生两个结果。第一个结果是改变集群 A 能处理的消息类别，也就是改变集群 A 的内部配置。从此以后集群 A 将不能再处理今后属于集群 B 处理的事情。

集群 A 收到新命令之后，还是会继续做处理，不需要停机。但是有个例外，如果这个命令分库之后分到了集群 B，这时候 A 就会反馈给用户说消息发错对象了。我们会在后面介绍用户应该如何处理这种情况。

集群 A 执行分库命令的另一个结果是会返回分库完成的消息给协调者。协调者会将这个消息记录到本地数据库中。

下面这幅图展示了集群 A 的分库过程：

集群 B 的分库过程

在集群 A 正在分库的时候，集群 B 也没有闲着。它还是处于集群 A 的读模式节点，源源不断地从集群 A 中复制最新的命令。

由于分库前集群 A 和集群 B 已经处于非常接近的状态，所以很快集群 B 就能读到这个特殊命令，也就是集群 A 记录下的分库命令。

这时候，集群 B 内的自动机也会执行这个分库命令。跟集群 A 一样，集群 B 在执行后也会有两个结果。一个结果是改变集群 B 的内部配置，从此以后集群 B 就能处理归他负责的消息了。当然了，如果集群 B 收到了不属于自己应该处理的消息，依然需要和集群 A 一样，通知用户说消息发错对象了。

在集群 B 的自动机执行后，另一个结果是通知协调者说集群 B 已经分库完成。协调者收到消息后也会更新自己本地的分库状态。

下面这幅图展示了集群 B 的分库过程：

分库效果分析

好了，前面我们分别看了集群 A 和 B 内部的分库过程，现在我们再来分析一下整体的分库过程。

整个分库的过程由协调者发起，发送方是集群 A，也就是我们想要拆分的数据节点。这个分库过程中协调者不会主动与集群 B 沟通。

集群 A 在整个分库过程中一直处于在线状态，因此集群 A 可以实时处理业务逻辑，整个过程没有停机时间。这就是我们希望达到的实时分库效果。

集群 A 在事件溯源的命令队列里记录的特殊命令，它也有一定的特点。这个分库命令记录了今后哪些业务只归集群 A 处理，哪些业务只归集群 B 处理。

不过，这个命令并没有涉及到业务应该如何处理，比如说数据的存储格式应该是怎样的，是新增数据还是修改数据等等。因此这个分库命令几乎与业务逻辑无关。

在分库的过程中，协调者只是向集群 A 发送了一个分库消息，然后等待各个组件的返回消息。这个过程非常简单，很容易实现自动化。

所以，我们的分库过程达到了最开始提到的架构设计目标，那就是我们需要尽量减少因为分库带来的相关系统的停机时间，整个分库过程尽量与业务无关，同时尽量自动化。

这个分库过程对业务的唯一影响在于集群 B 什么时候才能同步好分库命令。因为集群 B 在同步到分库命令之前，这两个集群处在一个分库的中间状态。

在这个分库的中间状态，集群 A 不能处理集群 B 的消息，集群 B 也不能处理自己应该处理的消息。所以分库会影响的是属于集群 B 应该处理的消息，而这个影响的时间长度取决于集群 B 多久能同步好分库命令。这就是我们这个分库过程对业务的影响。

不过我们在分库前的准备阶段，已经将集群 A 和集群 B 之间的状态差缩短到了几秒钟以内，所以对业务的影响也限制在几秒以内。这个延时几乎等于一个网络重发的时间，基本在我们可以接受的范围。

分库后

事件溯源节点分完库之后，我们需要做的后继工作是，让上游和下游节点都能准确找到新的库。我们依然需要分情况来考虑。

比较简单的是上游系统。上游系统负责发送数据给数据溯源节点。

我们在分库中提到过，当集群 A 或者集群 B 处理完分库命令之后，这两个集群就知道自己可以处理哪些内容，不可以处理哪些内容。对于自己不能处理的内容，集群会返回错误状态给上游系统，并给予一些重定向提示。这样上游系统下一次就知道应该访问哪个集群了。

这样就要求每个上游系统都要具备动态路由的能力，显然这是给用户的一个额外的成本。因此我们可以参考在第 17 节课提到的架构，在上游系统和事件溯源架构之间增加一层路由。这个路由会根据集群的错误提示，动态更新内部的路由信息。

下游系统就没有这么简单了，这是因为下游系统无法实时知道事件溯源节点进行了分库。另外，上下游系统之间还有一个区别。上游系统需要做的是找到一个正确的集群，重点在于它只需要一个。下游通常需要处理所有消息，所以它需要找到所有的集群。

因此，下游面临的问题是如何找到所有集群的信息。在这里有主动和被动这两个不同的处理思路。

一个思路是从分库的协调者那里获取。协调者是分库过程的发起者，它的本地数据库维护了所有分库的描述信息。

因此，当分库结束之后，协调者可以将分库信息实时推送给下游系统。下游系统这时候被动地知道了事件溯源系统刚进行过分库，因此可以从新的库里读取数据。这种思路就是被动的思路。

另一个思路是下游系统从事件溯源节点直接获取分库信息。我们在分库的时候往命令列表里存了一条特殊的分库命令。下游系统在处理任何一个事件溯源集群的时候，一定也会处理到这个分库命令。由于分库命令里包含了所有分库信息，因此下游系统可以通过分库命令来主动发现新的库在哪里。

选择主动和被动取决于你预期下游系统会有多少种不同的类型。在被动的情况下，只有协调者一个人需要理解分库的情况，下游系统按照协调者的指示做就好了。

在主动的情况下，对每个对接分布式事件溯源系统的下游系统，它们都需要实现一遍分库命令的解析，所以在下游系统类型很多的情况下，这会有一定的开发工作量。

小结

这节课我们学习了如何实现实时的事件溯源架构的分库。

一般的分库方法是先暂停业务，然后做数据的切分。这样会造成一定的业务损失。

所以，我们希望能对分库过程进行一定的架构优化，尽量减少因为分库带来的相关系统的停机时间，整个分库过程尽量与业务无关，同时尽量自动化。这就是我们分库过程的目标。

我们这节课介绍的分库方法适用于基于事件溯源的架构，因此大部分重要的金融系统和数据系统都能适用。这个分库方法，我们可以按照分库前、分库中跟分库后三个阶段分别做理解。

分库前，我们需要做到新老库之间的数据同步。这时候新集群通过事件溯源的读模式来尽量同步老集群的数据。

当数据基本同步之后，协调者发起分库过程。协调者会向老集群发送一条分库命令。老集群在收到分库命令后会更新自己的内部配置。同时新集群也会实时复制这条分库命令。这条分库命令被新老集群都处理完之后，整个集群完成分库。

集群分完库之后，上下游也需要及时更新分库信息。上游系统可以通过错误重定向来发现正确的集群位置。

下游则无法通过错误重定向来找到正确的集群。这时候你可以选择用协调者通知下游来处理分库，或者下游通过分析分库命令来主动发现分库信息。

思考题

在分布式环境下所有的机器都有可能出问题，协调者也不例外。协调者的本地数据库可以通过我们在前面介绍过的共识算法来解决单点的问题。但是这里还是有一些异常情况。

比如说，协调者在发送给集群 A 开始分库的消息之后就出现了问题，共识算法的主节点停机了。

这时候，共识算法会选出一个新的主节点来代表协调者。主节点只能知道自己已经给集群 A 发送过一个分库命令，但是它并不知道集群 A 有没有收到。因此保险的做法是，协调者集群内新的主节点再给集群 A 发送同一个分库命令消息。

那么问题来了，如果集群 A 陆续收到了多个分库命令，整个分库过程正确性会有影响吗？

欢迎你在留言区跟我交流，讨论。如果这节课对你有帮助的话，也欢迎你转发给同事、朋友，跟他一起探讨动态分库的问题。

20 | 容灾（上）如何实现正确的跨机房实时容灾？

你好，我是任杰。这一讲我和你聊一聊如何实现正确的跨机房实时容灾。

这一讲我们主要从这三个方面的内容给你讲解容灾问题，它们分别是正确容灾、跨机房容灾和实时容灾。

因为“跨机房”和“实时”是两个标准的技术问题，所以有非常明确的方式方法和衡量标准，我们会在后面详细讲解。

相反，“正确”这两个字可能会因人而异，不同环境下可能会有不同的理解。因此我们在讲解技术问题之前，先看看怎么理解“正确”这两个字。

正确的定义

金融行业覆盖的面非常广，不同子行业对容灾的要求会不一样。一种分类方式是按照用户专业性来分类，这种分法会将用户分为一般性用户和专业用户两大类。

一般性用户指的是非从事专业金融类工作的用户。专业用户指的是以金融类工作谋生的用户。注意这里的用户并不限定为个人用户，其中还包括企业等机构用户。通俗来讲一般性用户指的是 C 端用户，专业用户是 B 端用户。

对于一般用户来说，日常使用最多的金融服务是手机支付。如果手机支付系统出现了问题，支付公司进行了容灾处理之后，我们能接受的“正确”的容灾结果一般有两个。

第一个是尽量在规定时间内恢复服务，比如在 10 秒内恢复。第二个是如果有限时间内不能恢复服务，那么要避免出现金额对不上的问题。比如说，我这边已经显示扣款了，商家那边却迟迟收不到钱的情况，就一定要避免。

专业用户也有一般用户的需求，但是相比一般用户又多了一项。专业用户在和金融机构对接时会签署服务合同。在合同内会规定金融机构在无法履约时的赔偿条例。

比如说，对冲基金想通过证券公司的渠道抛售大量股票。如果此时证券公司系统崩溃，就会导致股票无法卖出。这个情况下，市场动荡带来的一部分经济损失可能需要由证券公司承担。

所以，我们综合两类用户需求的共性，可以这样来理解。从用户的角度来讲，正确容灾的第一个要求是服务质量协议（SLA，Service Level Agreement）的要求，规定一定时限内需要恢复服务。

另一个要求是事务正确性的要求，特别是对事务 ACID 四个属性中 A 的要求，即 Atomicity，原子性。因为金融系统一旦出现金额问题后要给客户赔偿。

那这两个要求之间是什么关系呢？如果金融机构无法满足服务质量协议，那么赔偿金额就会按照合同规定的金额来计算，风险可控。但是如果事务正确性出了问题，比如百亿债券过户到一半系统崩溃，或者员工工资重复打款，金融机构面临的是不可控的风险。

这两个正确容灾要求的解决是需要成本的。如果实现容灾的成本大于赔偿金额，那么可以用日常营业利润的一部分作为容灾赔付基金。所以这里的正确并不是绝对的正确，而是相对的正确。你需要在服务质量、事务正确性和成本这三者之间做权衡。

发现问题

人工监控

在解决容灾问题前，我们先要发现系统已经出了问题。

虽然在互联网领域，现在的监控已经走向了自动化、甚至智能化，金融系统依然存在人工监控的情况。一个原因是一些特定金融业务的 SLA 要求很低，比如几天或者几个月，这时实现系统容灾成本大于收益。

比如我们去实现资产证券化的业务，可能整个计算过程就在业务人员办公电脑的 Excel 里。如果电脑出问题了，重启一下就行，如果重启不了就换一位同事的电脑，而且这个过程可能一个月才做一次。一般来说，适合人工监控的一般是业务量小、客户少、金额高的金融业务。

系统监控

让我们回到一般情况下的跨机房容灾监控问题。因为监控系统本身是一个很大的命题，在很多地方都有详细的介绍，所以在这里不做过多阐述。

我重点给你讲讲，在解决跨机房容灾监控问题的时候，监控系统方面你需要注意的地方，主要有三点。

第一点是监控系统本身需要有跨机房容灾的能力。一种方式是用业务系统的跨机房容灾的架构来实现监控系统。虽然也可以解决问题，但是成本较高。

这是因为监控数据是可以部分丢失的。我们在第 9 节课提到过数据具有时效性。监控数据和市场数据一样，时间越久，数据的价值越低。所以如果丢失了几秒钟的监控数据，你只要稍微等一下，刷新一下页面可能就会拿到最新数据。

这样看来，我们就不需要为了数据中心级灾难这种小概率事件，采用高成本的容灾方案。

因此，一个性价比较高的方案是监控数据本地异步备份，不同数据中心的监控平台彼此之间互相监控。如下图所示：

第二点是监控的延时。监控系统有推和拉两种获取监控数据的方式。推指的是业务系统主动将监控指标数据推送给监控系统，比如常见的心跳数据（heartbeat）。这么做实时性很强，但是对监控系统的峰值计算和存储能力要求很高。

因此一般采用另一种折中的方式，让监控系统定期从业务系统里拉取监控指标。这样对硬件资源的要求基本固定，但是监控延时会稍微长一些，此时你需要和业务方仔细沟通金融业务需要的 SLA。

第三点是监控会伪报。当服务变多、网络变复杂之后，所有可能出现的异常都会出现。

比如在防火墙配置出错的情况下，可能明明监控系统已经发现某服务不可用，但是该服务却在依然正确运行，想退出该服务的时候，退出指令也无法发送成功。因此，容灾处理需要考虑到在伪报情况下的正确性。

容灾过程

在基于服务的架构下（SOA，Service Oriented Architecture），系统一般分为 3 层：用户应用层，无状态服务层和数据层，如下图所示。

因此在出现数据中心级别灾难的情况下，我们需要处理服务的容灾和数据的容灾。接下来，我们先看看服务容灾。

服务容灾

无状态服务容灾

无状态服务的容灾相对简单，基本上什么都不做就可以了。一般来说，服务调度算法会将服务请求随机调度到不同的数据中心，因此当一个数据中心出现问题的情况下，用户服务在多次尝试之后，就会被调度到没有出现问题的数据中心。

更进一步的优化方法是节省掉重复尝试的时间。如果数据中心内的服务均不可用，可以更新服务路由信息。这样新的请求不会被发送至出问题的数据中心节点，等到该数据中心恢复之后再加回到路由信息内。

消息重发问题

在容灾的过程中，无状态服务需要处理消息重发问题。如果表面上消失掉的无状态服务，其实依然还在处理服务，那我们该怎么办呢？一个常见的情况是服务成功处理了请求，但是由于防火墙问题无法将成功状态返回给用户。

我们在第 8 节课一起研究过，怎么解决请求重发的问题。解决重发问题要求我们实现业务操作的幂等性（Idempotency），比如利用消息内的唯一标识符，或者使用 K/V 这种具有天然幂等性能力的数据结构。

有状态服务容灾

金融行业的低延时场景通常会采用有状态的服务，比如外汇交易所或者股票交易所。这时候可以将有状态服务节点当作数据节点来处理。

数据容灾

数据容灾一般需要数据系统自己来处理，容灾作为数据系统的一种自有的能力。这里我们重点看看金融系统常见的两个方案，分别是两地三中心和三地五中心的实时容灾方案。

单节点容灾

为了满足金融级的容灾要求，我们需要先保证单机节点具有一定的容灾能力。

单机节点容灾指的是，数据节点重启后能恢复之前所有数据。在这里有一些细节需要你注意。通常在写入数据文件时，数据并没有被写入到硬盘中，此时数据会保存在操作系统内的缓存。

当这个缓存满了，或者等待了一定时间之后（比如 30 秒钟），操作系统会将这个缓存写入到硬件控制器的缓存。用户也可以通过 fsync 的系统调用来主动完成这个过程，但是需要小心的是此时只写入到了硬件控制器缓存，并没有写到了硬盘。如果这时候机器断电，依然有可能出现数据丢失。

那么怎么处理这种小概率事件呢？也有两种方式。一种方式是用企业级硬件。企业级硬盘可能会携带一小块电池。当机器断电之后，这块电池能保证硬件控制器内的数据都能写入到硬盘中，这是用钱来换正确性。

另一种方式是不管不问。如果我们已经确定了，一定会用一组集群来实现多节点容灾，这种情况下，因为断电而产生的数据丢失可以看作是单节点故障来处理。这也是用钱来换正确性的思路，Kafka 就是用了这种假设，甚至 Kafka 连 fsync 都节省掉了。

所以单节点容灾到什么程度取决于你有多少钱。

多节点跨机房容灾

跨机房部署

两地三中心或者三地五中心的容灾利用了共识（consensus）算法的能力。我们在第 16 节课详细讲过 Raft 共识算法，如果你记不清了，可以做个回顾。

Raft 的部署一般有 3 个备份或者 5 个备份两种选项。偶尔也会出现 9 个备份的选择，比如 Google。

我们先来看看有 3 个备份的情况。这时候有两种选择。一种选择是一个数据中心有 2 个节点，另一个数据中心只有 1 个节点。这样只能保证单节点级别的容灾，不能保证数据中心级的容灾。

比如下面这幅图展示了一个典型的部署。Raft 算法要求有一大半的机器在线才能正常工作，因此 3 个备份的情况下至少需要 2 台节点在线。如果下图的数据中心 1 整个出了故障，那么只剩下一个节点在数据中心 2，算法无法正常工作。

3 个备份的另一种选择是将 3 个节点放在 3 个数据中心内。这时可以将两个数据中心放在一个城市，另一个数据中心放在另一个城市，这个做法也叫两地三中心。这样能满足单个数据中心级的容灾，但是不能满足城市级容灾。

比如下图所示。如果城市 1 因为道路施工挖断了光纤，城市 2 就不能保证 Raft 协议正常工作。

5 备份的情况和 3 备份的情况类似，但是选择更多。其中有一些选择能达到城市级别的容灾。

我们举个例子来理解。如下图所示，这时候 5 个节点基本平分到了 3 个城市的数据中心。你可以算一算，这时候任何一个城市网络出现了问题，Raft 协议都能正常运行。这就是常见的三地五中心部署，也是普遍使用的较高容灾级别的部署方式。

总结一下。在使用多个节点容灾的情况下，两地三中心只能达到数据中心级别的容灾。如果需要达到城市级别的容灾，需要三地五中心部署。

客户端处理

我们在进行容灾分析的时候，一般会侧重处理服务器端的各种特殊情况，这时候很容易忽略客户端。

从原理上来说，如果服务器端容灾做得正确，就不会出现数据正确性的问题。但是我们在开头提到过，容灾除了正确性之外，还有一个服务质量协议的问题，我们还需要尽量减少无法提供服务的时间长度。

因此，我还是要强调一下正确的客户端行为（我们在第 16 节课说过）。当服务器出现问题的时候，Raft 协议自动换主之后，客户端一定要用正确的方式来找到新的主节点，这样会大大减少容灾的延时。

小结

这节课我们学习了如何实现正确的跨机房实时容灾。

首先，我们了解了正确性的定义。正确性分为服务质量协议（SLA）和事务正确性这两个方面。这两者的解决需要付出成本。我们很难达到绝对的正确，因此需要在成本和正确性之间做一个权衡。

接下来，我们讲了怎么发现问题。如果金融业务的业务量小、客户少，而且金额高，那么可以选择人工监控。一般情况下应该选择用监控系统来监控集群状态。这些监控系统之间需要彼此监控，尽量采用拉取的方式来获取监控数据。

最后，我们分析了发现问题之后如何容灾。无状态服务容灾比较简单，只需要解决下游数据节点的重发问题。有状态服务的容灾和数据节点容灾一样，是最复杂的情况。

数据节点容灾先要提高单节点容灾的能力，可以通过使用更好的硬件和正确的方法来提高。多节点跨宿主中心容灾需要考虑对容灾的需求。数据中心级别的容灾可以采用两地三中心，城市级别的容灾可以考虑三地五中心。

思考题

除了三地五中心之外，还有一种容灾能力更高的部署方式，那就是三地九中心，Google 曾经采用过这种部署方式。

三地九中心并不是直接部署 9 个 Raft 节点，而是将 Raft 节点分为了两层。下面一层按照 3 个一组分为了 3 组，分别放在 3 个数据中心。每个数据中心的 3 个节点刚好组成一个 Raft 集群，通过 Raft 选主的方式选出来一个主节点。

这样 3 个数据中心就一共有 3 个主节点。这 3 个主节点之间刚好也可以形成一个 Raft 集群，再选出一个级别更高的 Raft 主节点。这个唯一的主节点负责代表集群对外提供服务。下面这幅图展示了三地九中心的部署方式。

那么，你觉得这个三地九中心部署方案有哪些优点呢？

欢迎留言和我分享你的想法。如果学了这一讲你有所收获，也欢迎你把这篇文章分享给你的朋友，一起交流和讨论跨机房实时容灾的问题。

21 | 容灾（下）：如何通过混沌工程提高系统稳定性？

你好，我是任杰。今天我们来聊聊混沌工程。

这一讲是第三个模块“分布式正确性及高可用”的最后一讲。我们前面学习了实现金融系统正确性、可用性的许多方法。

不过，尽管你掌握了各种金融系统的屠龙之技，平时工作也兢兢业业，但是业务方并不一定懂技术。他们怎么才能放心让几百亿上千亿的业务跑在你的系统上呢？

所以，这节课我们就来看看，如何通过混沌工程来证明系统是正确的。这一块内容涉及面非常广，所以我还是会照例给你重点讲解混沌工程背后的原理，你了解了背后的原理之后，很容易就能在实践中加以应用了。

分布式系统正确性理论

我们在学习算法的时候，老师和教材都会证明为什么算法是正确的。分布式系统也是一样，有很多对于分布式算法的正确性证明。但是不知道你发现了没有，我们从来没有说 Raft 共识算法是正确的？

Raft 算法已经在很多环境运行了很长的时间，从来没有人怀疑过它的正确性。如果你看过 Raft 算法的论文，也会觉得它应该是正确的。那为什么我们不会说 Raft 算法是正确的呢？

原因是现在都是通过形式语言的方式来证明分布式算法的正确性。形式语言证明有一个局限，它只能证明有限状态的情况。由于 Raft 算法涉及到无限种可能的情况，因此无法通过形式语言来证明正确性。

既然我们无法从理论上证明 Raft 共识算法的证明性，那么也无法通过实践来证明，原因是逻辑推理过程比主观推理过程的正确性要高，算法的实现不可能比理论的正确性要高。

所以，从 Raft 共识算法的例子可以看出来，我们需要换一种思路来证明金融系统的正确性。

用不正确来证明正确

我们可以选择“用不正确来证明正确”的思路吗？这不是绕口令，它是一种很巧妙的思维方式。

逻辑证明有一个原则是证有不证无。比如说，如果让你证明这世界上存在一只黑天鹅，你只要找到一只就可以了。但是如果让你证明这世界上没有黑天鹅，那么你需要跑遍全世界才可以，而且就算你跑遍了全世界，我也可以说你其实漏掉了一些地方。

系统的正确性证明也是一样。我们只能证明系统中可能会出问题，而不能证明系统一定不会出问题。

从数学的角度看，你能证明的场景是可数集，但是自然界的很多集合都是不可数的。再简单的软件，它运行时的周边环境也极其复杂。因此不管你再怎么努力，也无法穷举所有的可能性来证明系统没有问题。

这就说明，我们对于正确性的证明需要换一种理解方式。我们需要放弃证明一个系统不会出问题，也就是没有 bug，而是要证明我们可以找到 bug。

由于寻找 bug 需要一定的人力物力和时间成本，当你解决了一些显然易见的问题之后，剩下的问题就会越来越难发现。

你还记得，我们在开篇词提到过金融系统需要考虑性价比么？如果发现 bug 的成本过大，但是带来的问题过小，那么我们就会放弃寻找 bug。这时候我们就会说系统出问题的可能性非常小，达到了我们对于正确性的要求。

因此，系统的正确性是一个概率问题，套用一个统计学的术语，正确性其实是系统不会出大问题这个结论的置信区间。通俗一点来说，正确性其实是我们对系统的信心。

混沌工程原理

系统定义

混沌工程，就是顺着我前面说的概率思路来回答正确性问题。它通过改变环境来提高出问题的概率。如果这时候依然没有发现问题，那么平常的情况下的正确性可能会更高，我们也就对系统更有信心。

首先，我们来看看什么系统需要用到混沌工程。和我们在第 5 节课领域驱动设计的条件一样，只有当系统复杂的情况下才需要使用。在分析为什么之前，让我们先来看看什么是系统。

虽然我们一直在提系统，比如软件系统、金融系统，但是我们从来没有对系统进行过定义。系统是由相互作用、相互依赖的若干组成部分结合而成，是具有特定功能的有机整体。系统本身也是另一个更大系统的组成部分。

那我们应该关注系统定义里的哪些内容呢？这里就要提到系统复杂度了。当系统的组成部分超过 3 个之后，系统就很有可能会进入混沌（Chaos）的状态。处于混沌状态的系统，它的行为是非线性的。我们可能不容易对非线性这个词产生直观的感受。通俗一点来说，非线性指的是无法预测和描述。

所以，混沌工程的对象是混沌的系统。

通常我们对软件系统的理解局限在软件上。一个系统要想生存，除了软件之外，还需要有周边环境的配合。因此，我们在这里的系统指的不仅仅是软件，还包括硬件、运维系统、监控系统、开发人员、运维人员、值班人员等等。这是一个容易忽略的地方，你一定要注意。

实验定义

我们在前面分析正确性证明的时候，提到过正确性证明其实是一个概率置信区间的问题。既然是一个概率统计题，那么我们就可以用统计的方法来解决问题。

这里我们要用到一些统计学的术语。我们在混沌工程开始前要定义一些我们关心的实验。

首先要定义的是实验的假设（Hypothesis）。这个假设里涉及到两大类主体，一类是自变量，一类是因变量。

自变量是你能控制的东西，比如你可以决定软件运行的方式和它周围的环境。

而因变量是被自变量影响的变量。比如当你关掉数据中心硬盘之后，如果系统给你发了一条短信，那么关掉硬盘是自变量，发短信就是因变量。而假设就是我们在实验前提出的，自变量和因变量之间的影响。

在这里还要解释一个常见的误解，那就是混沌工程实验和测试的区别。测试是在我们已经知道正确答案是什么了的情况下进行的实验，测试的目标是为了肯定正确性。而混沌工程实验是在我们不知道会发生什么的情况下进行的实验，混沌工程的目标是为了更好地理解系统。

正因为混沌工程不知道会发生什么，实验会采用假设，而不是断言（Assert）的方式。当然了，如果混沌工程暴露了不好的结果，我们也应该和测试 bug 一样做修复。

自变量定义

如何找到统计学的自变量没有一般规律，但是软件行业有自己的行业特殊性，我们可以总结出一些软件系统的自变量规律。这里就涉及到你对整个软件运行架构的理解深度了。

我们先看看最常见的情况，也就是软件系统运行环境。现代的软件工程都会把系统分成了很多层。上下层之间有比较清晰的功能访问区分。

处于最下层的是硬件。通常来讲硬件和输入输出有关。常见的硬件有 CPU，硬盘，内存，网卡，显卡，鼠标键盘等等。

再上一层是操作系统。操作系统负责管理所有硬件资源，并将这些硬件资源抽象成软件可以理解的概念，然后分配给多个用户。因此，操作系统需要处理所有前面提到的硬件资源。

同时，操作系统还在硬件之上抽象出了更高一级的软件资源，比如虚拟内存、文件系统、网络套接字（Socket）。这些软件资源也有一些更高一级的管理方法，比如进程和线程。

随着云计算的崛起，操作系统之上不再是软件，而是虚拟机了。虚拟机提供了独立于操作系统的虚拟环境。有的虚拟机选择虚拟出一个不一样的运行环境，比如 VMWare，有的虚拟机选择虚拟出一个几乎类似的运行环境，比如 Docker。

当然了，这里对于虚拟机的分类并不是特别完整和准确，主要目的是为了让你知道虚拟机是对操作系统更高一级的抽象。

在虚拟机之上是系统 API。系统 API 提供了能访问操作系统的编程接口。

比系统 API 更高一级的是用户的应用程序。如果用户的编程语言选择了 Java 或者 Python 等解释型编程语言，那么还有一个中间层是这些编程语言的运行时虚拟机。

下图展示了软件系统运行环境的各个层级：

那我们为什么要介绍软硬件的层次关系呢？这里有两个原因。第一个原因是这些都是你可以控制和调整的自变量。也就是说，你在实现混沌工程的时候，可以考虑对这些提到的对象进行调整。

第二个原因更重要，刚才的这个层次划分关系能给你提供分析问题的思路。计算机软件是一个从复杂到简单，从底层到高层的抽象过程。每次抽象都是靠新增一个层次关系，因此你可以顺着这个思路来解决一些我们没有提到过的场景。

比如说，在现在云计算环境下，一个程序不再局限在一台机器上。这种情况下，你应该怎么分析自变量呢？

我们也可以从划分层次的角度来思考这个问题。比如说从硬件的视角，你可以从底层到高层的划分机器为刀片、机柜、单个数据中心、城市容灾集群，然后再依次处理。

自变量的划分决定了混沌工程可以进行哪些实验。每一个自变量都定义了自己支持的操作类型。比如硬盘可以慢，也可以出错，网卡可以断网，也可以掉帧。自变量定义得越准确，实验就能设计得更精细。

因变量定义和假设定义

因变量就是你通过实验想检验的目标。这里就涉及到 4 个名词：SRE、SLA、SLO 和 SLI。

SRE 的全称是 Site Reliability Engineer，也叫作站点可靠性工程。这个工种的传统说法就是运维。但是现在随着云计算规模的扩大，这个工种也有了更大的责权范围和专业性提高。简单来说，他们负责帮助定义和实现 SLA，SLI 和 SLO。

SLA、SLI 和 SLO 是三个非常容易搞混的名词。SLA 是 Service Level Agreement，也叫服务质量协议。SLO 是 Service Level Objective，也叫服务质量目标。SLI 是 Service Level Indicator，也叫服务质量对象。

SLA 是整个服务质量的协议，包含了 SLO 和 SLI。SLI 是监控的对象，比如吞吐量、延时、性能等等。

SLO 是我们认为这些监控的指标需要达到的目标。比如我们在提到网站高可用的时候都说要到达 5 个 9，也就是 99.999%。这里的高可用就是监控对象，而 99.999% 就是需要达到的目标。

对于混沌工程这个实验来说，SLI 就是因变量。我们在混沌工程这个实验中通过改变自变量来影响监控对象。而 SLO 就是实验假设。

比如说，我想通过关掉一块硬盘来看看是否影响服务器的高可用。在这个实验里，硬盘就是自变量，高可用是因变量，高可用是否会因此发生变化是实验假设。

实验过程

当我们定义好自变量、因变量和实验假设之后，还不能马上开始实验。在实验前还要有一些准备工作。

首先需要解决的是因变量的监控。不知道你有没有想过一个问题。同样都是工程，我们为什么能比较准确地估计修一个楼房需要的时间，但是往往不能估计一个软件的开发时间？

答案在于建筑工程是肉眼可见的工程。我们既然能看到进度，就比较容易理解各个组件之间的关系，也就容易控制。

相反，软件是看不见摸不着的，和空气一样，我们只能间接了解软件的情况。软件的不可见性让我们很难理解软件究竟做了些什么。

因此，为了能更好第了解混沌工程，我们需要在软件中加上尽量多的监控指标，覆盖所有相关的 SLI（因变量）。

另一个需要准备的是实验过程。混沌工程的目的不是为了故意让生产环境出问题，而是通过制造问题，来帮我们更好地理解生产环境的能力范围。因此我们只要能收集到会出现哪些变化就行，尽量不要太影响生产环境的正常运行。

因此一个好的步骤是将实验慢慢升级。最开始的时候从测试环境开始。当情况可控之后再逐步升级到回归环境、预生产环境。

最后在生产环境运行时，我们也最好先在灰度测试配置下进行小规模的实验。我们把这个过程叫作伤害半径（Blast Radius）。我们要将这个伤害半径控制得尽量小。

Jepsen 测试

前面提到过，混沌工程实验会监控 SLO 的变化。SLO 通常和吞吐量、高容灾和延时等有关。对于金融系统来说，还有一个非常重要的 SLI 指标是正确性。金融系统正确性的目标通常是 100%。

有一类专门用来证明分布式系统正确性的工具，它叫 Jepsen 测试。Jepsen 是这个测试的发明人。Jepson 测试和混沌工程一样，也会定义一些自变量和实验。但是当实验完成之后，它会从各个节点收集数据，验证最后的结果是否正确。

Jepsen 在自己网站上列举了他发现过的各种分布式系统问题，你有兴趣可以了解一下。你会惊讶地发现，很多熟悉的数据系统其实并没有它们宣传的那么正确。

小结

这节课我们学习了怎么通过混沌工程来提高系统的正确性。

分布式系统并不一定能证明是正确的，因此我们需要换一种思路，用不正确性来证明正确性。如果不正确出现的情况足够少，那么我们就有足够多的信心认为系统是正确的，因此正确性是一个概率上的置信区间问题。

混沌工程就是用概率的思路来解决问题。它通过改变系统运行环境来提高出混沌系统出问题的概率，因此混沌工程是个统计实验。为了进行这个实验，我们需要定义好实验的自变量、因变量和实验假设。

在实验的过程中，我们需要精心控制事故的伤害范围，实验以了解混沌系统的行为为目的，而不是刻意地损坏生产环境。

金融系统最关心的是正确性，在分布式环境下有一类正确性测试叫 Jespsen 测试。这个测试和混沌工程几乎完全一样，只是在 SLO 的定义上有些许不同。

金融系统的正确性和金融业务的风控一样，都是投入巨大，但是产出不明显的工作。只有当潮水退去的时候才能知道谁在裸泳，同样的，只有在出了资金问题之后，人们才知道金融系统的正确性有多重要。

我们自己在做基于 Raft 共识算法的支付系统的时候，整个研发只用了 3 个月，但是混沌测试了一整年，其中最后一个 bug 是在混沌测试半年之后才发现的。在这个期间，已经有几家加密货币交易所因为代码问题造成了资损。

所以正确性是金融系统开发人员的道德底线，一定不能先做大再做强，而要先做强再做大。

思考题

我们的最后一道思考题也很简单。我在最开始说过 Raft 算法有无限种状态，因此无法通过形式语言来证明这个分布式算法的正确性。那么 Raft 的无限种状态指的是什么状态呢？

欢迎你在留言区晒出你的心得或者疑问。如果这节课对你有启发，也欢迎转发给同事、朋友，一起交流混沌工程这个话题。

答疑集锦（三） | 思考题解析与数据库底层实现

你好，我是任杰。

到今天为止，我们最后一个模块，分布式正确性及高可用的内容就结束了。恒者行远，思者常新。积极思考是学习精进的重要一环，所以这里我要特别表扬积极留言、主动学习的同学，相信你一定会更有收获。

今天我为你准备了这篇加餐，把第三模块的思考题做一个系统梳理。我还是建议你先看完前面每一讲的内容，自己做了思考之后再看我这份参考答案。

思考题答案

第 12 节课

Q：快照隔离虽然比可串行化的级别要低一些，我们也是可以稍作调整就达到可串行化的能力，这个做法叫作串行化快照隔离（SSI，Serializable Snapshot Isolation）。

SSI 主要需要检查的是读写和写读冲突，像下面这幅图展示的一样。那你知道怎么才能正确地找到这些冲突吗？

A：数据库需要记录下来你都读了哪些数据。这样在其他事务写的时候，就能判断是否会对你的读造成了影响。在这里我们要分两种情况考虑。

第一种情况是上图左边的情况。这时候数据库需要知道 T1 的写入操作会影响哪些还没有结束的事务。数据库需要保留读的信息。

第二种情况是上图右边的情况。这时候数据库需要记录 T1 的读操作忽略了哪些相关的、还没有结束的事务。这时候如果 T1 还有任何写的操作，那么就需要考虑将 T1 回滚。需要你注意的是，如果 T1 一直只是读取数据，没有任何写的操作，那么 T1 是不需要回滚的。

第 13 节课

Q：在两阶段提交的情况下，协调者的全局事务数据库可能会出现数据丢失，这时候协调者也是可以根据所有节点的情况来反推出自己应该做什么。你知道协调者应该怎么做吗？

A：协调者这时候需要对所有节点的情况进行一次普查。

如果至少有一个节点进入了第二个阶段，那么所有节点都进入了第二个阶段。这时候如果有一个节点进入提交状态，所有节点都需要提交。如果有一个节点进入了取消状态，那么所有节点都需要取消。

如果所有节点都停留在第一阶段，那么就没有这么容易判断了。

如果至少有一个没有记录“准备成功”的标志，那么事务的第一个阶段还没有结束，因此可以回滚分布式事务。

最复杂的情况在于所有在线的节点都记录了“准备成功”。这时候可能有的节点并不在线，你不知道它们都处在什么阶段，因此需要灵活处理。

第 14 节课

Q：我们这节课的思考题很简单。如果让你来实现分布式环境下的严格可串行化，你能想到什么办法呢？

A：如果不考虑执行效率的话，最简单的实现方式是，利用分布式锁来锁定对所有分布式数据库的访问。全局只有一个锁。只有拿到了锁，分布式事务才能开始。

第 15 节课

Q：我们在最开始提到过，1996 年的论文“Unreliable failure detectors for reliable distributed systems”证明了，如果分布式系统存在一个能让你最终做出准确判断的不准确时钟，那么系统存在共识算法。这个时钟其实起到的作用是在分布式环境下检测机器是否出问题。

失败检测分为两种属性：完整性和准确性。按照排列组合，这两种属性一共有 4 种可能的情况：

强完整性。所有正确的节点都会最终怀疑每个出错的节点。

弱完整性。一些正确的节点都会最终怀疑每个出错的节点。

强准确性。所有正确的节点都不会被怀疑说出了问题。

弱准确性。一些正确的节点不会被怀疑说出了问题。

论文指出来，就算是只有很弱的失败检测，也能实现共识算法。那么你觉得这里的“弱”指的是哪几种情况呢？

A：需要弱完整性和弱准确性。

第 16 节课

Q：为了提高分布式事务的容灾能力，我们需要解决协调者的单点问题。那么问题来了：

你能分析一下，这个问题的本质是我们这节课讲的哪个问题吗？

如果你要解决单点问题的话，可以怎么解决呢？

A：这个问题的本质是数据库日志在分布式环境下的线性化存储。因此需要用共识算法来复制日志文件。

第 17 节课

Q：读模式的状态机需要复制事件队列。在复制的时候一定有两个选择，一个是主动将事件队列拉过来，另一个是将事件队列推过来。那你是会选择推还是拉呢？不同选择都有哪些优缺点呢？

A：选择有两个点。一个是延时，一个是谁付出成本。

对于延时来说，推一般是实时推送数据，延时低。而拉是批量拉，吞吐量大，但是延时高。

成本有两个方面，一个是计算成本，一个是系统复杂度。从计算成本考虑，推需要写模式节点需要付出成本，而拉则是读节点。

从系统复杂度考虑，在推送的反思下，写模式节点需要维护数据消费者当前的数据消费偏移量，然后根据这个偏移量来选择是否重发。当消费者个数比较多的情况下，写模式节点的压力会比较大。在拉取的模式下，读模式节点只需要维护自己当前的消费情况。

第 18 节课

关系型数据库有这几个关键的功能：

表存储

主索引和二级索引

缓存

表的复制和容灾

表查询

单机事务

分布式锁

分布式事务

如果要求你用现在市面上常用的数据解决方案来拼凑一个分布式关系型数据库，你应该如何选择，如何搭配呢？

A：表的存储和主索引用 rocksdb。二级索引也可以用 rocksdb。缓存可以用 redis。表的复制用 kafka。表的查询用 clickhouse。分布式锁用 zookeeper 或者 etcd。简单的单机事务可以用 rocksdb 实现。

两阶段提交的分布式事务没有开源方案，但是 TCC 可以通过业务系统来实现。

第 19 节课

Q：在分布式环境下所有的机器都有可能出问题，协调者也不例外。比如说，协调者可能因为网络原因给集群重复发送了多个分库命令。这样会影响整个分库过程的正确性吗？

A：答案是不会影响。分库命令唯一的副作用是修改集群内的配置信息。配置信息的修改具有幂等性，因此多次操作和一次操作结果一样。

第 20 节课

除了三地五中心之外，还有一种容灾能力更高的部署方式，那就是三地九中心，Google 曾经采用过这种部署方式。

三地九中心并不是直接部署 9 个 Raft 节点，而是将 Raft 节点分为了两层。下面一层按照 3 个一组分为了 3 组，分别放在 3 个数据中心。每个数据中心的 3 个节点刚好组成一个 Raft 集群，通过 Raft 选主的方式选出来一个主节点。

这样 3 个数据中心就一共有 3 个主节点。这 3 个主节点之间刚好也可以形成一个 Raft 集群，再选出一个级别更高的 Raft 主节点。这个唯一的主节点负责代表集群对外提供服务。下面这幅图展示了三地九中心的部署方式。

那么，你觉得这个三地九中心部署方案有哪些优点呢？

A：最主要的优点是减少了跨城带宽使用量。三地九中心和三地五中心具有一样的城市级容灾能力。但是在三地五中心的情况下，主节点需要负责将数据同步到 3 个其他城市的节点，而在三地九中心的情况下，主节点只需要同步 2 个其他城市的节点。

第 21 节课

Q：我们的最后一道思考题也很简单。我在最开始介绍过 Raft 算法有无限种状态，因此无法通过形式语言来证明这个分布式算法的正确性。那么 Raft 的无限种状态指的是什么状态呢？

A：Raft 的日志文件是 Raft 的状态之一。日志文件可以不断增长，因此长度是无限的。准确来说日志的长度是可数的（Countable），但是由于每个日志的内容可以有多种类型，整个日志的状态是不可数的（Uncountable）。

为什么分布式数据库需要数据库的特殊支持？

还记得我在第 13 节课给你留了一个彩蛋么？表面上看，我们可以在任何一种数据库上实现两阶段提交。其实并不是这样的，两阶段提交的实现需要数据库底层的特殊支持。

在这个加餐里，我来补充一下数据库的一些底层实现，以及如何加强这些底层实现来实现两阶段提交。如果你学有余力，可以拓展学习。

事务实现细节

现在数据库事务基本是靠重做日志（Redo Log）来实现。重做日志和我们第 7 节课讲的事件溯源的日志文件完全一样。数据库把所有操作记录到日志文件上，当机器出问题之后，机器的自动机逻辑会依次读取日志文件的内容，选择性地提交事务或者回滚事务，这样就能将机器恢复到最后的正确状态。

每个事务开始的时候，数据库会在重做日志上记录一行 开始事务 。接下来就是对数据库内容的修改，数据库并不会马上修改数据，而是先在重做日志上记录修改的操作，然后才真正在数据库内修改数据。最后，事务提交的时候会记录一行 事务提交 ，或者当事务回滚的时候留下一行 事务回滚 的记录。

这里有一个细节，在重做日志上，数据库的修改并不会简单记录修改后的值，而是会记录修改前和修改后两个值。修改前的值用来做事务的回滚操作，而修改后的值用来做事务的提交操作。用事件溯源的观点看，可以这样理解：重做日志上会记录当前值的信息和对值的操作。

这里给你举个例子。假设我们通过一个事务将数据库 A 里 x 的值从 100 变为 0 。伪代码如下：

begin transaction
  update A set balance=  0 where accountID='x'
end transaction

数据库收到这个 SQL 之后，会先在重做日志文件里记录一行 开始事务 ，然后再记录一行 x 的前后变化，最后记录一行 事务提交 后返回。整个重做日志的内容如下图所示：

这是一个最简单的常规情况，这时候重做日志似乎没发挥什么作用。但是当数据库出问题了之后，重做日志就能发挥巨大的作用。

还是这个例子，一共可能在 3 个地方出现问题，一个还是记录了 开始事务 之后，一个是在记录了 x 的前后变化之后，最后一个地方是在记录了 事务提交 之后。下面这幅图给你展示了这 3 个可能会出问题的时间：

接下来，我们分别来看这三种情况。如果问题是出在记录了 开始事务 之后，这时数据库还没有对数据做任何操作，所以数据库在恢复的时候可以什么都不做。

如果问题出在记录了 x 的变化之后 ，数据库就需要回滚所有对 x 操作。这时候数据库需要用重做日志里 x 的初始值 100 来覆盖现在数据库里的 x 的值。这时候不管 x 是什么值，覆盖的操作都是正确的。

如果问题是出在记录了提交事务之后，那么数据库需要重新执行所有操作。这时数据库需要用重做日志里 x 变化后的值 0 来覆盖现在数据库里 x 的值。

这就是重做日志里“重做”的真正含义，我们通过从头到尾重新执行一遍日志的操作，就能将数据库恢复到正确的状态。

两阶段提交实现细节

在在第 13 节课讲 2PC 的时候我提出过一个问题，这里我们快速回顾一下。在第一阶段完成之后，第二阶段开始之前，单个节点的数据库可能会出问题，这时候会出现本地数据库回滚，这样会导致第二阶段执行的时候数据丢失。那该怎么办呢？

当时我预告过，这个内容我会在第三模块的加餐里给你详细讲解，现在就是揭秘的时候了。

我曾经说过两阶段提交是偏底层的实现，原因是两阶段提交需要扩展重做日志的内容。原始的重做日志分别使用开始事务 和 事务提交 这两个特殊记号，表示事务执行的状态。两阶段提交需要增加一个特殊标记，叫作 准备成功 。

准备成功 这个标记意味着数据库到达了一个中间状态。当数据库出问题准备恢复的时候，如果出现了 准备成功 的标记，那么这个事务不会在恢复的时候回滚，这一点和事务提交这个标记的作用类似。

但 准备成功 并不代表事务处于可以提交的状态，所以它的出现并不会让事务释放自己的锁。想要真正提交事务，还要等协调者发过来的第二阶段的提交消息。

下面这幅图展示了前面转账例子里，第一阶段在数据库 A 的细节，重点在第 5 步。

当数据库 A 将对 x 的操作记录到重做日志之后，会在重做日志里记录一个 准备成功 的标记，然后返回给协调者自己已经准备成功的消息。数据库 B 的情况完全一样，所以这里我没有画出来。

当第一阶段都成功完成之后，协调者开始第二阶段。下面这幅图展示了第二阶段在数据库 A 的细节，重点在第 2 步。当数据库 A 收到了协调者给的提交事务的指示后，数据库 A 在重做日志内记录一个事务提交的记号，这表示本地事务真正提交了。数据库 B 的情况也完全一样，所以没有画出来。

好了，第三模块的思考题我们就说到这里，希望能给你带来新思路。也欢迎你继续积极思考，通过留言区跟我交流互动。

春节策划第1期 | 分布式金融系统知识，你掌握了多少？

你好，我是任杰。

今天是大年初一，首先祝你春节快乐，身体健康。专栏的正文部分已经结束，相信这几个月的时间，你已经学到了很多。为了让你过节期间能够轻松一些，同时也能巩固之前所学，这个春节假期，我一共为你安排了 3 期特别策划的内容。

今天是策划的第 1 期，我从之前学的课程里精选了一些知识点，给你出了这一套测试题，帮助你检验学习成果。客观题的答案和解析，你在测试后就能直接看到。主观题我暂时不公布答案，给你留下一定的思考时间。

第 2 期我会为你整理一份我的推荐书单。

第 3 期，我会公布主观题的参考答案。有必要的地方，我也会说明对应前面课程的哪一节课，方便你查漏补缺，根据需要去复习巩固相关内容。

好了，那今天我们就先牛刀小试，通过测试题来练练手吧！

首先我给你出了 10 道客观题，5 道多选，5 道单选，你可以点击文稿中的答题按钮进入测试。

完成客观题之后，这里还有两道主观题在等着你。金融系统的特点是要求高，所以当你学会了如何解决金融行业的问题之后，其他行业的问题也就是手到擒来的事了。所谓它山之石可以攻玉，我们来看一看下面这两道其他行业的经典问题。

春运卖票

除了支付以外，技术圈还有一个广为人知的高难度系统是卖火车票。你可以从 2020 年初的这个新闻片段感受以下技术挑战的难度：

今天起，全国铁路开始在 12306 网站发售 2020 年 1 月 23 日，也就是农历腊月 29 的车票，节前售票高峰即将度过。自本月 12 日春运售票启动以来，全国铁路已累计发售车票 1.75 亿张，每天的发售量都在 1000 万张以上。

下面是 2020 年初另外一篇新闻的片段：

2020 年春运期间，12306 在高峰日网络点击量高达 1495 亿次……也就是说，12306 在高峰日平均 1 秒就要承受 170 多万次点击……作为对比，2019 年淘宝的订单创建峰值，是 54.4 万笔 / 秒。

这篇新闻也提到了售票业务的复杂度：

而 12306 的特殊性在于，火车票是一种动态的 SKU，计算起来的数据量可能是普通电商产品的数百倍。

以北京西到深圳福田的 G71 次高铁为例……从北京西站始发的车票，后面有 16 个车站，即 16 种不同的车票；涿州东站是第二站，有 15 种不同的车票，以此类推，单以上下车的站来计算，G71 次高铁就会有 16+15……+2+1=136 个 SKU，而每种票对应 3 种座位，一共是 408 个商品。

以上只是 SKU 的减值。若旅客购买的是短途票，如北京西站到涿州东站，则在 SKU 减去 16 的同时，还要增加涿州东站到之后各站、之后各站相互间的 SKU，即增加 120 个 SKU。若再叠加当前的选座功能（A、B、C、D、F），计算数量可能还要再翻倍。

12306 有雄厚的资金，因此可以选择一些特殊的软硬件方案来解决卖票的问题。作为一个金融系统背景的人来说，你应该如何分析这个春运卖票的问题呢？

王者荣耀

《王者荣耀》是由腾讯游戏天美工作室开发并运行的一款 5V5 手游。一个完整的游戏有很多功能部分，比如聊天室、支付系统、电商等等，在这里我们主要研究一下最核心的玩游戏的功能。

常见的游戏设置有 10 个客户端。每个客户端会控制自己在游戏里的角色。所有 10 个角色都在同一个虚拟竞技场内交互，因此每个人都能实时看到其余 9 个角色实时的情况，比如位置、血量、技能等等。

手机玩游戏有一个不好的地方是信号不稳定。当手机信号不好的时候会出现掉线的情况。如果你掉了线，在别的玩家眼里，你一直站着不动，在你自己的眼里，所有其他人都站着不动。但是一旦你手机连上了线，系统马上会恢复到其他 9 个人当前的情况，你还可以继续参与这场还未结束的比赛。

在极端情况下，如果游戏崩溃重启了，你会发现自己依然能进入到原来的游戏，只是加载时间稍微长了一点而已。

那如果按照我们介绍的架构设计思路，你应该怎么设计游戏的前端和后端呢？

欢迎你在评论区分享你的思考和分析，主观题答案我会在大年初四的第 3 期春节策划里公布，敬请期待！

春节策划第2期 | 读书如抽丝，为你推荐一些我读过的好书

你好，我是任杰。

今天是春节策划的第 2 期。这期内容是分布式金融架构课的福利加餐。在专栏更新的过程中，有一些同学留言想要我推荐一些金融系统相关的图书，所以我为你精心梳理了一份书单，希望对你有所帮助。

选书标准

我们这个专栏讲的是分布式金融系统，因此在选书上会涉及到计算机系统、分布式系统、金融和经济。

国外在这些基础类科学的研究上有多年的沉淀，内容经历过很多版的验证，因此比较适合学习。另外从文学理论来说，翻译的过程是一个再创作的过程，会受到翻译者自身能力的限制。如果你觉得，一本中文翻译版的技术书籍没有把问题讲清楚，很难知道原作者的问题还是翻译者的问题。因此按照溯本求源的原则，这里选择的主要还是英文版的教材。

俗话说，书到用时方恨少。对于系统开发人员来说，如果是某个具体的问题，比如软件应该如何配置和使用，可以查看相关的文档，不需要看书。如果你突然发现需要看书，那么书大多情况下都不能解决当下的问题。

因此，读书要放下急功近利的想法。要把读书作为质变的手段，而不是量变的方法。质变是一个缓慢且无法自我感知的过程，要有耐心。

后面的书单我会分成技术、经济和金融三个大类给你介绍，也会附上我的简单评价和推荐理由供你参考。

推荐书目的分类以及推荐理由

技术类

领域驱动设计

领域驱动设计是 Eric Evans 在 2000 年左右提出的。《Domain-Driven Design: Tackling Complexity in the Heart of Software》这本书总结了他的一些经验。虽然内容很重要，但是他的行文思维比较跳跃，前后顺序不对，有很大的阅读难度，其实就是写得不好。

虽然 Eric Evans 的书没有写太好，但是领域驱动设计这个方法论实在是太过于重要了，因此后面也有不少人尝试复述内容，增加一些自己的合理理解。

Scott Millett 写了一本《Patterns, Principles, and Practices of Domain-Driven Design》，算是一本近十年来写得不错的书。但是行文比较啰嗦，你需要有一些耐心，知道哪些内容可以跳过。

数据系统

数据系统的教材首推 Martin Kleppmann 的《Designing Data-Intensive Applications: The Big Ideas Behind Reliable, Scalable, and Maintainable Systems》。作者有很强的理论和实践知识，在这本书里总结了几乎所有我们应该了解的知识点。

由于这本书的厚度有限，很多重要的知识点只能点一下名，你需要根据每个章节后面的论文列表来进一步学习。这本书有中文翻译版，但是有中文翻译书籍的常见问题，如果你想真正了解知识，我还是建议看英文版。

数据系统，肯定少不了数据库的内容。数据库有很多介绍怎么使用的教材，但是很少有介绍系统架构的教材。Hector Garcia-Molina 的《Database Systems: The Complete Book》是为数不多的一本介绍数据库系统架构的书。

书里对事务和锁的实现介绍得极其详尽，浅显易懂。这本书的第二作者 Jeffrey Ullman 是计算机领域的传奇人物，也是书质量的一个保证。这本书的问题在于书里介绍了大量的 XML 处理。XML 是上一个风口，现在已经被 JSON 取代，因此书的重点不一定是现在的重点。需要提醒一下，这本书特别难买。

有关数据库的实现，现在有了一本新的书，Alex Petrov 写的《Database Internals: A Deep Dive into How Distributed Data Systems Work》。

有意思的是，这本书的名字虽然写的是分布式系统，但是书里写的单机版内容比分布式内容要好，分布式内容介绍得比较凌乱，重点分散。你可以当作是《Designing Data-Intensive Applications》的一个补充。

优化

系统架构优化得好不好，取决于你对整个系统了解得是否全面。我们按照从宏观到微观的顺序介绍一下经典教材。

这里介绍一本唯一的中文书，李智慧的《大型网站技术架构 核心原理与案例分析》。这本书对互联网架构的各个方面进行了点到为止的介绍。

内容不深，但是很全面，能让你清楚地知道都会出现哪些问题，会有哪些解法。知道了问题和解法，之后就可以按图索骥，按照关键字查询细节了。

接下来，再让我们看看介绍单个计算机的教材。

Randal Bryant 的《Computer Systems: A Programmer's Perspective》是一本经典教材。这本书按照从硬件到软件的顺序介绍了整个计算机系统。从这本书里，你会学到如何设计一块有流水线的 CPU，怎么实现编译器和操作系统，以及怎么让计算机动起来。内容很浅，但是很全。

介绍完了计算机，接下来我们就要仔细了解所有软件打交道最多的东西了，它就是操作系统。操作系统有很多教材，不过按照流行度和全面度来说，我比较推荐 Andrew Tanenbaum 的《Modern Operating Systems》。这本书不用过多介绍。

最后介绍一本关于性能监控和调优的书，Brendan Gregg 的《Systems Performance》。作者发明了系统调优的火焰图，还是 Solaris 操作系统早期监控工具的发明人。

这本书列举了几乎所有的监控工具，能够极其全面地让你看到计算机 CPU、内存、硬盘、文件系统、网络和云计算环境当前处于什么情况，以及应该如何对系统性能进行调整。重点的要说三遍，极其全面，极其全面，极其全面。

经济类

经济类教材入门教材首推曼昆的《经济学原理》。曼昆年少成名，担任过美国经济顾问委员会主席。他写书浅显易懂，但是比较啰嗦。在书中他强调自己是中立态度，不偏向某个学派，但是行文之间依然有所倾向。最新版是 2020 年的第 9 版。

相对少见一些的是保罗·萨缪尔森的《经济学》。萨缪尔森是凯恩斯主义的代表人物之一，获得过诺贝尔经济学奖。他于 2009 年辞世，因此教材停留在第 19 版。

另外，微观经济学有一本书我也想推荐给你。Hal Varian 写的《Intermediate Microeconomics: A Modern Approach》。Varian 是 Google 的首席经济学家，主导了公司的拍卖流程，是经济学家弃笔从戎的一个成功案例。

最后我再推荐一本科普读物，哈耶克的《通往奴役之路》。哈耶克是奥地利学派的代表人物之一，也获得过诺贝尔经济学奖。他的《通往奴役之路》有很多争论，请你酌情选择。

金融类

金融类有用的教材其实不多，原因是比较实用的不是教材，而是考试材料。对于大多数人来说，他们的金融生涯需要掌握的是如何完成金融过程，而不是设计顶层的金融体系，所以理论帮助有限。

考试内容比较全面的是 CFA。CFA 以股票为主，分析股票以基本面为主，因此可能和一些实际情况有所出入。CFA 也介绍了一些其他金融资产的知识，但是和现状相差较远，不能学以致用。虽然有各种不足，但 CFA 对于立志于跨行业的人来说是一个不错的敲门砖。

金融学有一本教材写得还不错，是 Mishkin 的《Economics of Money, Banking and Financial Markets》。作者在 2008 年金融危机中被人诟病，但是他丝毫没有受到影响。这本教材从稍微学术一点的角度介绍和分析了各种金融市场，数据翔实，是一本不可多得的好书。

好了，以上就是书单的全部内容了。关于金融系统或者金融方面的内容，你有什么正在阅读或者计划阅读的书呢？欢迎你在留言区分享出来，我们一起交流、进步。

春节策划第3期 | 如何运用架构知识解读春运买票和手游案例？

你好，我是任杰。

今天是大年初六，估计你还在假期中，所以首先祝你假期愉快。不知道我在春节策划第 1 期留的两道主观题，你想到了怎样的方案呢？接下来，我就来公布参考答案啦，希望对你有所启发。

春运卖票案例分析

Q：除了支付以外，技术圈还有一个广为人知的高难度系统，那就是卖火车票（第 1 期的完整题干点击这里回顾）。12306 有雄厚的资金，因此可以选择一些特殊的软硬件方案来解决卖票的问题。作为一个金融系统背景的人来说，你应该如何分析这个春运卖票的问题呢？

A：我们先分析一下新闻数据。

从第一个新闻可以算出来，一天累计发票 1000 万张，所以约等于每秒 115 笔交易。这个平均值并不算太高。如果考虑到峰值情况，我们大致可以估算出，网站的峰值 TPS 在几百到几千左右。

从第二个新闻我们可以算出来，在峰值情况下网站的 QPS 接近 200 万。新闻里也提到了淘宝的订单 TPS 在 50 万左右，因此 12306 峰值的 QPS 是淘宝峰值 TPS 的 4 倍。

这两个数据的比较并没有给我们额外的信息，因为优化 TPS 的架构和优化 QPS 的架构非常不一样，这两者没有可比性。而且 TPS 会更难处理，TPS 值低一些很正常。

分析到这里还没完，我们还需要把这两个新闻合在一起看。春运卖票的 TPS 在几百，但是 QPS 在 200 万，因此它是一个查询量极其巨大，但是交易量正常的业务。

这就意味着，我们系统架构需要优化数据的查询。

你还记得，我们在第 7 节课讲事件溯源的时候，提到过一个优化查询的架构设计方案么？这个方案叫 CQRS，也就是读写分离。

这里我们也可以应用这个思路，把订票和余票查询分开。订票是一个独立的系统，负责维护所有的出票信息。余票的查询是另外一个系统，负责给客户端提供查询服务。订票系统负责将出票信息异步同步给余票查询系统。这个架构示意图如下：

读写分离的一个优点是可以对读和写分开做优化。我们先来看看写的优化。

第二篇新闻里提到了在写入时需要维护的状态，也就是“动态的 SKU”，其实就是每列车在每个站点的座位上坐的是谁。我们来初略估计一下状态数有多少。

假设全国有 1 万个站点，一共 10 万辆火车，每个火车可以装 1 万人，每列车每天把全国所有的铁轨跑 10 趟，那么数据量一共是 1014，也就是 100T。这个数据量并不大，现代数据中心里大概一个机柜用内存就能全部装下，所以数据量大小并不是挑战。

我们再来看看数据的操作复不复杂。我们在买票的时候有一些复杂的选项，比如是不是靠窗，几个座位是不是连在一起，是不是 VIP 等等，但是这些复杂度都在于查询。

真正的出票操作是在你准备支付的时候。这时候为了防止你已经选好的票被别人抢走，系统需要在你支付完成前锁定你的票。因此，出票的操作需要对一个车次的连续几个站点的同一个座位进行上锁操作。

所以对于数据的写入操作来说，如果我们把火车票粒度做个划分，划分成每个车在每个站点的每个座位之后，问题就变成了：1 万 TPS 以下的多个火车票座位的上锁问题。根据第一篇新闻提供的数据，这个上锁操作在 1 万 TPS 以下，因此大部分的数据系统都能够支持。

我们再来看看读的优化，这里才是春运卖票的真正复杂度。我在前面提到过，火车票在购买的时候有多种不同的查询条件。简单一点的是按照车次和日期为查询级别，复杂一点的是按照座位的位置。粒度越小，查询的复杂度越高。

由于查询需要计算，而事务的处理只需要加锁，查询是一个更加消耗 CPU 和存储空间的过程。

那么读的挑战有多大呢？第二篇新闻告诉我们，大概在 200 万 QPS。这么大的 QPS，显然不能用一台机器解决，而是需要用到多台机器来处理。

我们在第 19 节课提到过，如果一个业务可以被拆分到两台机器上处理，那么一生二，二生三，三生万物，我们可以把业务分到任意多台机器上处理，这样只要堆机器数目就可以了。那这么做可行吗？

不知道你发现了没有，票务查询有一个特点是任何两个查询之间不会互相影响，也就是两个读操作之间没有冲突。没有冲突的操作可以并发执行，而不会产生任何意料之外的结果。这就是我们在第 12 节课提到的唯一没有冲突的情况。

接下来，我们再看看读操作分布式处理的另一个可行性原理。

不知道你有没有过抢火车票的经验。也许你也有类似的这种经历，明明你可以查到票，但是在购买的时候，系统却提示你已经卖光了？

通常来说，你这时候不会投诉 12306 说给了你错误的信息，而是会再查下一趟车是不是还有票。这意味着从查票的业务来讲，我们默认接受了查询信息和实际信息不一致的情况。

所以，我们可以选择将出票信息异步推送给查询系统。查询系统可以根据查询的复杂度来做更复杂的索引。虽然查询的机器很多，数据推送需要时间，索引的建立也需要时间，但是购票的业务并没有读写事务性的要求，我们在架构选型时可以牺牲时效性来换取吞吐量。

以上就是春运卖票的架构分析。从整个分析过程中我们可以看出，架构的真正挑战在于读的部分。而因为卖票业务不要求读写在同一个事务处理，这给了读操作极大的优化空间。

王者荣耀案例分析

《王者荣耀》是一款 5V5 手游。常见的一场游戏设置有 10 个客户端。每个客户端会控制自己在游戏里的角色。所有 10 个角色都在同一个虚拟竞技场内交互，因此每个人都能看到其余 9 个角色实时的情况，比如位置、血量、技能等等。

手机玩游戏有一个不好的地方是信号不稳定。当手机信号不好时会发生掉线的情况。如果你掉了线，在别的玩家眼里，你一直站着不动，在你自己的眼里，所有其他人都站着不动。

但一旦你手机连上了线，系统马上会恢复到其他 9 个人当前的情况，你还可以继续参与这场还未结束的比赛。在极端情况下，如果游戏崩溃重启了，你会发现自己依然能进入到原来的游戏，只是加载时间稍微长了一点而已。

那如果按照我们前面学过的架构设计思路，你应该怎么设计游戏的前端和后端呢？

A：我们在第 7 节课讲事件溯源架构的时候，就给你举过一个游戏的例子。这里我们也这样做假设：用事件溯源的方式来解决游戏掉线的问题。

事件溯源架构需要定义好命令、事件和状态。那我们来看看，对于手机游戏来说，怎么分析命令、事件和状态。

按照命令的定义，命令指的是外部的指令。对于手机游戏来说，唯一的外部指令就是你在手机上的操作，比如你在某个时间点击了屏幕的某个位置。但是这个命令过于粗放，不同的手机类型不一样，屏幕大小也不一样，这个位置信息不具有通用性。

因此手机 App 需要把你的物理命令解析成在游戏中的逻辑命令。比如说，你在屏幕上点了一下，这个是物理的操作。而游戏可以把这个操作翻译成让角色往右走。

有了命令之后，接下来就需要分析什么是事件和状态了。这两者紧密地结合在一起，因此我们一起分析会比较好。

游戏状态就是 10 个角色和整个竞技场当前的情况，比如角色的位置、血量、技能等信息。事件就是能改变这些状态的、已经发生过的事情。比如说角色已经往右走了一步，或者释放了一个技能等等。

到这里还没有结束，我们还忽略了一个重要的信息，那就是玩游戏的 10 个人，怎么才能让他们看到其他人的情况呢？这意味着有一个方法能将玩家们所有的命令和事件都集中在一起。这就是游戏服务器需要处理的事情。

因此，所有 10 个人的命令会先发给游戏服务器，游戏服务器会将所有的命令进行排序，这样就能知道玩家们操作的先后顺序。然后就生成事件，更新状态。

这里又出现了一个新的问题，那就是状态存储在哪里。一个选项是将状态存储在服务器端，另一个选项是将状态存储在手机端。接下来，我们分情况做个讨论。

如果将状态放在服务器端，那么服务器用自动机计算好状态后，需要将状态传送给 10 个手机用户。如果是将状态放在手机端，那么手机需要实现自动机。由于自动机也负责将命令变为事件，这意味着事件的生成也需要放在手机端完成。

这两个选择的依据在于数据量的大小。手机游戏用的是手机信号，吞吐量并不大，因此数据量传输得越少越好。所以合理的选择是将自动机放在手机端。

下图展示了一个可能的游戏架构图。在这个架构里，服务器只负责接受所有的命令，以及对命令进行排序。手机端负责所有命令的处理和状态的更新：

由于这个游戏架构采用了事件溯源的设计，因此也具有了事件溯源提供的时光机，读模式节点等等所有能力。其中，时光机可以用来实现掉线之后重连，读模式节点可以让解说员也能实时看到游戏进度。你可以再思考一下，还有哪些游戏的使用场景也用到了时光机。

在这里我想说明的是，事件溯源的这些能力有一个假设，那就是自动机不能具有任何随机性。但是手机有很多种型号的 CPU，这些 CPU 处理浮点数的精度不一样，因此游戏公司通常都需要在 App 端实现跨平台的浮点数计算中间件。

好了，主观题我就给你分析到这里。不知道你有没有发现，复杂的系统总是在宏观上类似，在细节的地方各有千秋。

我之所以选择两个金融行业之外的案例，是想提醒你，架构的核心知识其实是通用的，关键是我们作为开发人员怎样合理去使用。

春节策划的内容到这里就全部结束了，我们下期再见！

结束语 | 金融之道，与你同行，虽远尤欣

你好，我是任杰。

时光荏苒，转眼间就到了我们专栏的最后一讲，感谢你的一路相伴。这个专栏自夏蝉构思，于冬雪完稿。我们常说温故而知新，因此借着最后一课，我想再给你分享一下我自己学习分布式金融系统的思路。你可以把它当作一种思维方式去借鉴，也可以作为本专栏的学习指南。

作品都需要有一条主线，专栏也不例外。乍一看，你也许觉得这个专栏内容十分繁杂。开始从金融业务切入，几讲之后便过渡到软件工程，随后是平时我们很少见到的事件溯源架构和双时序数据库，最后还补充了一些看上去和金融无关的分布式系统知识。

其实每个部分深入下去都可以有精彩之处，但我每当此时便戛然而止，马上转入下一个话题，所以这个专栏难免有一种浅尝辄止的嫌疑。其实不然。金融系统强调的是正确性，这个问题我们在专栏最初就讨论过，而正确性的定义可大可小。这个专栏虽然内容繁多，但是如果顺着正确性从大到小的主线来看，一切存在便都合理了。

还记得我们在开篇词给到的分布式金融系统全景图么？这三个部分，分别对应的是业务正确、架构正确以及基础正确这三个维度。

往小了说，金融系统的每一行代码都需完成应尽之责，恰如其分，不多不少。代码构成的组件也应该完成组件应有的功能。这些组件之间的交互在一台机器的情况下，应该具有合适的正确性。而多台机器之间的交互，也应该具有一定的正确性。那么这些基础组件的正确性如何衡量，如何实现、如何选择、如何验证，便成了一个问题。这就是基础正确。

进一步说，正确的基础工具也需要有正确的用法，否则就如烧琴煮鹤，对花啜茶。用的方式各有千秋，但也不外乎事前、事中与事后三个阶段。事前须认真遴选数据，仔细斟酌数据的时间。事中处理要处处留痕，步步小心。事后要勇于自我怀疑，三省吾身。组件于系统之中，犹如人于社会之中，需要往来交互彬彬有礼，应答有方。这就是架构正确。

往大了说，正确地做事不如做正确的事。金融系统赋能于金融业务，系统为辅，业务为主。良相贤臣的辅佐不能只是一味的阿谀奉承，而是要既知晓熙熙攘攘，又心怀诗和远方。金融业务虽纷繁复杂，但金钱如水，来往之中不生不灭，不垢不净，不增不减。金钱的融通抽象出来就是信息的传递，因此信息系统的规律需要和金融业务的规律相互契合。这就是业务正确。

总结来说，金融业务纷繁复杂，因此学习金融架构须庖丁解牛。先识大体，从复杂业务中抓取脉络。再将业务脉络变为架构原则，做到神形兼备。最后仔细雕塑，以匠人之艺成百年之业。

正是这样的思路主导，所以我在讲解过程中，有意避免过于深陷细节。内容过深，容易只见树木不见森林，在吹毛求疵的过程中慢慢迷失正确的方向。因此专栏尝试立于核心的金融业务之上，鸟瞰金融软件架构。此为其一。

计算机是一门实践科学，讲求知行合一。而近世互联网的发展逐速而不守格，但欲速则不达，知其然也知所以然方为正道。再则金融关系国计民生，可大可小。根基不稳，大厦必倾。因此斟酌再三，行文兼顾实践同时，亦重底层逻辑。此为其二。

一二之选决定了这个专栏的行文考量。若有语焉不详之处，还望指摘。金融之道，形单影只，与尔同行，虽远犹欣。

最后，我给你准备了一份毕业问卷，希望你能花两分钟填一下。非常期待你对这个专栏的评价与建议。