This commit is contained in:
louzefeng
2024-07-11 05:50:32 +00:00
parent bf99793fd0
commit d3828a7aee
6071 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,186 @@
<audio id="audio" title="06 | 计算输入的正确性:怎么选择正确时间的数据?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/1e/70/1edcd7bff0c182cce356e186dba1d070.mp3"></audio>
你好,我是任杰。今天起我们进入了第二个模块:系统正确性保障。
在前面第一个模块“金融业务及系统”里,我带你了解了常见的金融业务、盈利模式和对系统工具的要求。在第一个模块的最后,我们讲了**领域驱动设计**,它是一个在金融行业行之有效的方法论。
但是领域驱动设计只是从顶层设计来分析应该怎么做金融系统,并没有说在具体实践的时候怎样才能把系统做好。所以我们在这个模块会重点解决怎么做才能达到金融系统的最重要的要求:正确性。
所谓巧妇难为无米之炊,如果在处理金融业务的时候没有用到正确的金融数据,那计算出的结果是万万不能相信的。所以正确的数据是所有正确性的基础。那让我们来一起看看怎么解决正确性的第一个问题:怎么选择正确时间的数据。
## 业务举例
和前面一样,我们在分析技术之前先看一个金融业务的例子。
在国外有一种金融机构叫作养老基金,大家平时交的养老保险有时候就是养老基金在负责处理。由于养老基金的收益是在你退休之后才能获得,所以养老基金一个最重要的衡量指标就是,这个基金未来能不能给你足够的生活费用。
你应该能感觉到,现在生活费用越来越贵,同样的东西第二年就会涨价。那一个可能的衡量指标就是,养老基金每年的收益率能不能超过每年生活费用的涨幅。
生活费的涨幅一般用**通货膨胀率**CPIConsumer Price Index来表示。通货膨胀率每个月都会公布它就是一个数字而已这是一个比较简单的金融市场数据。但是它的特点在于数据公布时间特别晚。当前只能公布一个现在通货膨胀率的预期值。真正的值可能要几个月之后才能公布而且之后还有可能会修改。
比如下面这张图的例子里我们公布了两份数据分别是2018年3月和6月的通货膨胀率。2018年3月的数据是一个月之后公布的在3个月之后和1年之后又公布了两次修改。6月份的数据也类似。
<img src="https://static001.geekbang.org/resource/image/bf/2b/bf56d6f1beyy37a8986527de876e132b.png" alt="">
那问题来了。上面这个例子里公布了5次共2个数据每次公布的数据都不一样。你怎么确保每次数据的更新都不会影响前面已经完成的金融业务呢
这个问题看起来非常简单,但是如果我给你介绍一下大的背景,你就不会觉得简单了。金融公司会面临成千上万的金融数据,通货膨胀率只是其中的一种。而且,像保险这种金融业务可能一旦签署就需要执行好几十年,这几十年里公司的信息系统会发生翻天覆地的变化。
如果你成为了金融公司的CTO。有一个人拿了30年前的养老保险投诉你说通货膨胀率用错了。这时候监管人员来到你的办公室要求你一步一步证明合同数据是正确的。合同的签订需要好几个月也会涉及到很多部门。你怎么能保证这么长的时间内所有部门都没有因为之后的数据更新而用错数据呢
再假设你是这家金融公司的CFO。你想分析一下2018年3月的通货膨胀率看看每次调整对保险合同价格的影响是多少这样你才能知道通货膨胀对整个公司的影响有多大所以你想用更新后的数据来算老合同的价格。
那么问题来了你怎样才能保证你只是用了更新后的2018年3月的数据而不是用了更新后的其他月份的通货膨胀率数据呢如果你想对公司层面所有合同做类似的计算怎样才能保证所有人把所有数据都更新正确了呢
所以,**金融公司的数据正确性是一个正确性的管理问题。**你需要让业务、运营、财务、合规等所有部门的信息系统都用统一的数据访问方式。这种数据访问方式还需要可重现。不管是过了多少年,系统更新换代了多少次,开发人员换了多少批,你都能正确地知道过去发生了什么。这也是我们这节课叫作“正确时间”的原因。
对一家小金融公司来说这些都不是问题。但是如果是一家大的金融机构,立志于流芳百世,那么这些就会是非常困难的架构挑战和管理挑战。
既然我们要找到一个能满足金融公司所有部门的长期的数据使用方案,那么这个方案一定要和金融数据的核心原理相关。在金融系统里这个解决方案叫作双时序数据库。
我需要提醒你一下,**双时序数据库和领域驱动设计一样,只适合解决复杂的问题。**所以它一般用来解决机构金融业务,而很少用来解决普惠金融业务。
那话不多说,让我们来看看它是怎么从原理上解决数据时间的问题吧。
## 如何理解双时序数据库?
### 双时序坐标轴
从前面的通货膨胀率的例子你可以看到,一个金融数据会有两个时间,一个是数据对应的业务发生时间,一个是数据的修改时间。在双时序数据库里这两个时间分别叫作**发生时间**和**记录时间**。发生时间也叫作**valid time**,一般缩写成 **VT**。记录时间叫作**transaction time**,一般缩写成 **TT**
既然存在双时序数据库,那么一定还有单时序数据库。你平常见到的时序数据库其实就是单时序数据库。这两者的区别在于,**单时序数据库解决的是数据增加问题,双时序数据库解决的是数据修改问题。**
时序数据库作为双时序数据库的简化版本,在复杂度要求不高的场景有广泛的应用。时序数据库在金融行业和互联网行业都有很多相关介绍,你如果有兴趣可以查看相关文档。
为了方便你理解核心概念,我把双时序数据库的时间逻辑画了出来。由于双时序数据库有两个时间,我们需要二维平面才能表示,所以我们需要画一个坐标系。
按照行业惯例,**横轴是记录时间,纵轴是发生时间。**理论上每个轴都指向正负无穷远,但是在实际展示中通常将坐标原点表示为负无穷远,主要是为了画起来好看。示意图如下:
<img src="https://static001.geekbang.org/resource/image/54/7d/54da2ba123ayy1cb7338f0bb7ed44d7d.png" alt="">
### 数据可见范围
那根据前面的例子我们在2018年4月收到了一个月前的通货膨胀率的数据。这个数据对应了坐标系的一个点坐标为2018年4月2018年3月。画出来就是下面这个样子
<img src="https://static001.geekbang.org/resource/image/f1/d0/f1db234f264059yy5a95331923ef37d0.png" alt="">
你会发现,上面图中这个坐标系里有一个粉红色的方块。这个方块表示了数据的在系统内的**可见范围**。那什么叫可见范围呢?这就涉及到如何查询双时序数据库的数据了。
既然数据的存储有两个时间,那么**数据的查询也需要同时提供两个时间,也是发生时间和记录时间。<strong>先看记录时间的逻辑。通货膨胀率的发生时间是2018年3月这意味着这个时间点之前事件并没有发生之前的数据没有任何意义。再看看记录时间。通货膨胀的记录时间是2018年4月这意味着这个时间点前数据还不存在。所以**可见范围指的是数据既存在而且有意义的时间范围。</strong>
下面这幅图表示了4种查询范围。其中在粉红色方块的查询能查到数据其余3个都查不到数据。你可以感受一下具体的查询过程
<img src="https://static001.geekbang.org/resource/image/6c/63/6c897760cb58c167e04683ee4f6efc63.png" alt="">
### 可见范围的覆盖
根据假设在3个月之后的2018年7月收到了一个月前的通货膨胀数据。这时候坐标系在2018年7月2018年6月多了第二个点。我们将这两个点的可见范围画出来就是下面这幅图的样子
<img src="https://uploader.shimo.im/f/sLSlYm6i106s2qS7.png!thumbnail" alt="">
可以看出来,第二个点加入之后新增了一块蓝色的矩形区域,覆盖了原来矩形的右上角。还是按照之前对可见范围的定义,不同颜色的区域表示了你能看到的具体是哪个数据。
所以如果你查询用的两个时间点的坐标刚好在蓝色区域时看到的就是2018年7月新增的通货膨胀率数据如果你的点坐标在粉红色区域时看到的就是2018年4月增加的数据就像下面这幅图表示的一样<br>
<img src="https://static001.geekbang.org/resource/image/47/bd/47f80a35eee18bd51d528d9b50e829bd.png" alt="">
### 可见范围的正确定义
假设又过去了2个月。在2018年9月的时候机构更正了2018年3月的通货膨胀率也就是更正了我们录入的第一个数据。这是我们坐标系的第三个点。你会发现这第三个数据和第一个数据的发生时间都是一样的但是记录时间差了半年。下图展示了第三个数据加上去之后各个数据的可见范围
<img src="https://static001.geekbang.org/resource/image/18/c5/189f8f62aa3071cb43c02e1048f48fc5.png" alt="">
你会发现这第三个点的可见范围有些奇怪。第三个点的纵坐标碰到第二个点的时候就停下来了,而其它点的纵坐标都向上到无穷远。这个奇怪的现象要怎么理解呢?这就需要我们先理解一下可见范围的正确定义了。
可见范围是和数据查询息息相关的。我们是用查询的结果来定义这个结果的可见范围。当我们在做数据查询的时候,我们关心的是**离当前查询时间点最近的合理数据**。
这里需要一点解释。**定义里的“合理”指的是数据既存在,且有意义**,也就是说查询的记录时间和发生时间不能比数据的时间要早。**定义里的“最近”指的是当有多个数据都是合理的时候,选择发生时间最晚的数据。**
你要注意的是,可见范围定义里的“最近”和金融业务里的“最近”的定义是基本一致的。
比如在金融业务中我们经常会问到现在的股价是多少,或者现在的利率是多少。由于**金融数据的变化永远是离散的,而不是连续的,所以并不存在一个时间叫“现在”。**当你问现在是多少,其实从逻辑上来讲,你问的是离现在最近的数据是多少。
所以当你在双时序数据库查询的时候,你表达的意思是当你坐上“时间机器“返回到查询所对应的记录时间,然后查询在发生时间点以前就已经生效的所有数据之中,哪个数据离你最近。
所以,**正确的数据可见范围定义是能查询到这个数据的查询时间点的范围。数据的可见范围和查询是互相定义的,你需要仔细思考。**
当我们解释完最终版的可见范围之后你就能理解为什么下面这幅图查询到的是第2个数据而不是第3个数据而且为什么第3个数据的可见范围只有一小部分。
<img src="https://static001.geekbang.org/resource/image/e3/34/e350415782fde172e585b08ea24bd334.png" alt="">
## 优缺点分析
说到这里,你应该已经了解双时序数据库的基本原理和使用方法了。在我们实际应用之前,还需要知道它的优缺点,这样你才能设计之初就会有个合理判断。
### 优点
双时序数据库最大的优点是**数据的不变性**。没有特殊要求的情况下,金融行业要求数据不可被覆盖和篡改,这种业务需求决定了系统数据一定要具有不变性。
另一个优点是**数据的唯一性**。所有数据都有唯一标识符,也就是数据对应的记录时间和发生时间。所有数据的可见范围也可以由这个数据的唯一标识符来唯一决定。
如果数据有唯一标识符,而且数据永远不变,那么数据的使用就有了正确性保证。这里逻辑环环相扣,你一定要跟上。数据的使用由数据查询开始。数据查询对应的坐标点属于某一个可见范围之内,而这个可见范围有对应数据的唯一标识符。所以我们就可以从一个确定的查询时间定位到确定的数据时间。
那回到我们开头提的第一个问题你怎么才能知道30年前养老保险涉及到的所有数据当你用合同定制时间作为记录时间和发生时间就能查询到30年前这个合同用到的所有数据。之后的修改一定不会影响你查询的结果。
最开始提到的第二个问题也有了答案。如果你想知道每次对2018年3月的通货膨胀率的修改究竟会带来什么影响就需要**保持2018年3月这个发生时间不变然后依次调整记录时间。**这样就能保证你只更新了2018年3月的数据而没有意外地用到其他月份的数据。
需要指出的是,**通过调整记录时间来选择性地引入数据变化**的方法在金融行业有很广泛的应用。金融行业在进行风险分析的时候会采用**情景计算**Scenario Analysis的方式进行。监管机构会提出一些假设性的事件What if比如银行挤兑、地震、贸易战等等。为了完成计算需要对金融合同数据进行修改。
在引入双时序数据库之前,我们需要花费很大的人力物力来保证情景计算的修改不会影响到真实数据的使用。在引入双时序数据库之后,由于每次修改只会影响到记录时间,我们只需要使用合同中记录的原始的业务时间,就能保证所有的业务数据不会受到情景计算的影响。
**数据的时间正确性是所有金融计算正确性的开始。我们会在下一节课学习事件溯源的架构设计,这个架构能保证计算过程的正确性。一旦这个架构的数据输入是正确的,那么整个架构就能真正达到金融级别的正确性。**
### 缺点
优点说完了,我们再看看缺点,缺点有两个。
双时序数据库的第一个缺点是**学习成本高。**以往处理数据的时候都只有一个时间,现在变成了两个时间。所有开发人员都需要了解二维情况下的数据可见范围。有时候我们跟业务方和产品经理沟通,也会发现他们也需要用双时序数据来定义自己的数据使用规则。这些都是很高的教育成本。
双时序数据库的另一个缺点是**执行速度慢。**和时序数据库相比,双时序数据库多了一个维度的时间,所以需要多加一个索引。这个额外的索引在数据插入和查询时候都会消耗额外的时间,因此不太适合于延时要求非常高的使用场景。
你还记得[第4节课](https://time.geekbang.org/column/article/325378)里,我们说过**金融讲的是投资回报比,而不是只单纯考虑成本。**虽然双时序数据库的学习成本和使用成本都不低,但是作为整个公司层面的数据正确性框架来说,它能让所有人深入理解数据的时间本质,从框架层面排除了不正确的使用方式,从而降低出错的可能性。从长期来看,有十年磨一剑的功效。
## 理论与实际的区别
我们在最开始介绍双时序数据库的可见范围时,没有说过发生时间的可见范围有多大。所以可见范围默认是一直可见的。但是理论上并没有这个假设。**理论上数据的可见范围可以是有限的。**
拿房贷举个例子。房贷最长时间是30年所以30年以后房贷合同就无效了也就是房贷合同的可见范围只有30年。这意味着在双时序数据库里你的房贷合同的可见范围是一个高度为30年的矩形看起来应该是下图这个样子
<img src="https://static001.geekbang.org/resource/image/de/8f/de6bf3c396fa7a91b2785c09265d868f.png" alt="">
虽然有发生时间限制的房贷合同看起来非常合理,但是在实际处理过程中却碰到了操作复杂度上的问题。
比如你几年后和贷款公司商量要延长房贷合同的期限将期限延长到100年。但是又过了几年贷款公司觉得100年太长又调整成50年。从理论上来讲这时候会有3个高度有限的可见范围互相覆盖。如果用带有可见范围约束的双时序数据库来表示结果就是下面这张图
<img src="https://static001.geekbang.org/resource/image/e0/y8/e04e37dc56a4b2610b9036549a185yy8.png" alt="">
虽然逻辑上是正确的,但是在实际使用时人们发现可见范围的定义会变得过于复杂,同时在数据库实现上也会碰到很多查询优化问题,所以实际一般不推荐对发生时间做可见范围的约束。如果数据真的失效了,你可以通过保存一个新的无效版本来覆盖原来的可见范围。
## 小结
我们这节课学习了如何用双时序数据库来正确存储和查询金融数据。
因为金融数据大多都与时间相关,用时序数据库可以很好地解决一些金融数据的使用场景,但是无法很好地处理数据的修改问题。这样一来,我们就需要新的解决方案,也就是双时序数据库。
双时序数据库除了存储数据的发生时间外,还保存了系统的记录时间,所以对于每个数据都有两个相关时间,组成了一个坐标系。**双时序数据库的数据插入和查询操作都可以理解为坐标系节点之间的可见范围的处理。**
由于多了一个维度的时间,双时序数据库有了额外的优点和缺点。优点是数据的唯一性和不变性得到了保证。缺点是系统的学习成本和使用成本偏高。
理论上双时序数据库里的数据发生时间范围并不一定是无限的,而是可以有一定区间范围。但是在实践过程中会导致额外的使用复杂度,所以并不建议采用。
下一节课我们会学习事件溯源这个保证计算正确性的框架。双时序数据作为框架的数据输入,是整个流程正确性的保证。
双时序数据库尽管看起来有些复杂,但是它是一个金融级的数据正确性解决方案,金融公司的规模越大,历史越悠久,就越能显示出这种方法的威力。这就是高盛和摩根士丹利这些华尔街的大型投资银行的核心竞争力。
<img src="https://static001.geekbang.org/resource/image/85/df/856a5ca8c7964eda5edd0beec47f82df.jpg" alt="">
## 思考题
双时序数据库里的一种存储方式是将坐标空间切割成尽量多的矩形,然后将这些矩形存储在数据系统内。数据库的索引建立在矩形的左下角和右上角这两个坐标点。
具体的切割做法是当坐标系内新增一个数据节点时以这个点为中心将整个坐标系进行水平和垂直切分。下图展示了系统中有3个数据点时的一个切割方式3个数据点将坐标系切割成了16个矩形
<img src="https://static001.geekbang.org/resource/image/a1/a0/a1bc85864e72888e9025efbcd96cd1a0.jpg" alt="">
每个插入操作都会对已有的矩形进行切割。每次查询都会遍历相关的矩形。那么你能算一算这个方案的**存储空间复杂度**和**查询时间复杂度吗**
欢迎你在留言区分享你的感悟和疑问。如果有所收获,也欢迎你把这篇文章转发给自己的朋友、同事,一起交流学习。

View File

@@ -0,0 +1,261 @@
<audio id="audio" title="07 | 计算过程的正确性:如何设计正确的数据处理架构?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/8a/fb/8a8bdae116c8383f4f2295551201dffb.mp3"></audio>
你好,我是任杰。这节课我和你聊一聊怎么设计一个能正确处理数据的架构。
只把一件事情做正确很容易,难的是把所有的事情都做正确。当然了,绝对的完美是很难达到的,那退而求其次,我们有没有可能设计出一种架构来减少犯错误的可能性呢?或者再退一步,如果出现了错误,我们能不能准确地知道错误出在什么地方呢?
金融行业是有强监管要求的。金融系统不仅仅要求你正确地实现系统,而且还要求你能解释系统为什么是正确的。
所以这节课我会带你掌握**事件溯源**Event Sourcing的核心设计。这个架构是金融行业多年来沉淀下来的行之有效的正确性解决方案。你掌握了这个架构金融行业正确性的问题也就基本解决了。
## 基本概念
### 游戏举例
不知道你有没有玩过联网的5v5即时对战手游。10个人通过手机玩游戏每个人都能看到其他人在游戏里的情况。虽然手机信号不太稳定可能还会临时断网但不管网络条件怎么恶劣所有人手机里的游戏情况都是一样的。这就是多人游戏的正确性。
金融系统和游戏一样,对正确性都有很高的要求,这两个行业的架构也有类似之处。所以接下来我在介绍事件溯源设计的时候也会举一些游戏的例子,方便你理解。
### 关键术语
我们在[第4节课](https://time.geekbang.org/column/article/325378)和[第5节课](https://time.geekbang.org/column/article/327137)介绍了领域驱动设计。事件溯源是领域驱动设计理论关于正确性的重要内容。在事件溯源里有三个重要的术语:
1.命令command<br>
2.事件event<br>
3.状态state
**命令指的是系统收到的外部指令。**比如你在玩游戏时,键盘和方向键的输入就是命令。
系统在收到外部的命令后,并不会马上执行,而是会先做一些检查,如果合理才会执行,不合理就不执行。比如说游戏里的地图都有边界,如果你控制的角色已经走到了墙角,再往前走就会碰到墙。这时如果游戏收到向前走的命令,游戏的边界碰撞检查算法就会判断这个命令是非法的。
**命令检查的结果就是事件。<strong>事件是合理的、一定要执行的事情。由于事件是正确性检查后的结果,事件的执行一定不会出问题。所以**从逻辑上来说,只要生成了事件就一定要执行。**一般我们会**用英语的过去式来表示事件。</strong>
比如在游戏里,让角色向右走的命令叫作"move right",而对应的事件是"**moved** right"。这个小小的区别很重要,你要仔细体会。
**事件执行的结果是改变状态**。还是沿用游戏的例子,你在游戏里看到的画面就是游戏的状态,比如你游戏角色的位置、装备、属性等等。
当系统的状态改变之后,外界会根据最新状态再产生新的命令,周而复始地执行。这就是用事件溯源设计的术语来描述你玩游戏的过程。
命令、事件和状态这三者之间的关系可以用下图来表示。<br>
<img src="https://static001.geekbang.org/resource/image/e5/85/e50b2540c68e8b718908f5cacbfa3385.jpg" alt="">
上面这幅图展现的是三者之间的**静态关系**。另一个角度是**从时间的维度**看这三者之间的动态关系。如下图所示:
<img src="https://static001.geekbang.org/resource/image/e8/ab/e8da0fd566134661b34f4797227fa9ab.jpg" alt="">
### 账务系统举例
前面游戏的例子主要是为了方便让你理解命令、事件和状态这3个术语。掌握了这三个核心术语之后我们再来看一个账务系统的例子。
账务系统负责记账,所以它管理着所有用户的账户金额信息,比如说你的现金余额、贷款等等。这些**账户金额信息就属于状态**。假设你现在账户余额有100元你朋友的账户余额有200元你们俩的金额状态示意图如下
<img src="https://static001.geekbang.org/resource/image/6e/49/6e506f40c3eeeff7c0d1c746abd2fa49.jpg" alt="">
假设你想通过手机转账的方式,转给你的朋友一块钱。这个**转账请求是命令**,会发送到账务系统。
账务系统在收到这个命令后会进行检查,判断这个命令是否合理。现在需要转账一块钱,而你的账户金额大于一块钱,所以转账是合理的。
既然合理,那么账务系统就会从命令生成事件,一共有两个。一个是从你的账户扣款一块钱,另一个事件是给你朋友账户入账一块钱。从这个账务系统的例子中你可以发现,**一个命令可以生成多个事件。**
在我们这个转账例子里,一个转账命令会生成两个事件。示意图如下:
<img src="https://static001.geekbang.org/resource/image/fd/67/fdaa301dbc34efbb5c98e76394918b67.jpg" alt="">
接下来是**执行**这两个事件。执行后会改变系统状态也就是改变你们俩的余额情况。你的余额会变为99元而你朋友的余额则变为201元示意图如下
<img src="https://static001.geekbang.org/resource/image/37/41/3713d648efd2f1a1ac554c66e405d741.jpg" alt="">
这时候你发现自己账户上还有一些余额于是想尝试给你朋友转100元钱。但是当账务系统收到你的新转账命令后会发现余额不足无法完成转账。这时候系统应该怎么处理呢
**当命令的检查不通过时系统可以选择不生成事件或者选择生成一个空事件NOP。**生成空事件的好处是能在系统中记录某个命令在历史上曾经存在过。空事件的执行结果是不改变任何状态。这里你这两次转钱的流程示意图如下:
<img src="https://static001.geekbang.org/resource/image/c9/43/c9c04898c904aeb0297c1a57ef2d3243.jpg" alt="">
## 如何处理命令和事件队列?
掌握了事件溯源设计的三个核心术语后,我们再来看看相应的系统应该如何实现。
事件溯源设计的一个核心设计是**所有的命令或者事件的处理都要有确定的顺序。**同样的两个命令,如果它们俩到达的顺序不一样,生成的事件可能就会不一样。
比如说你现在的余额有100元。接下来有两个命令一个命令是给你转账100元一个命令是你打200元钱给你朋友。
如果你先收到100元钱再付出去200元钱那么你付钱的时候账户里刚好有200元因此这两个命令的检查都能通过。但是如果你先转出去200元再收到100元的话系统会发现你在转200元钱出去的时候余额不足所以这个命令会失败。
保证顺序的方法也不难就是将所有的命令和事件都分别放到两个先入先出的队列First InFirst OutFIFO。一般这些队列会被保存到文件中。系统会从命令队列中逐一读取下一个命令判断这个命令是否合理然后将生成的所有事件放到事件队列末尾。示意图如下
<img src="https://static001.geekbang.org/resource/image/9c/44/9c535af3cca79e65c81b1b82c6e50d44.jpg" alt="">
在实现时还可以做一个小的优化。命令队列和事件队列虽然是两个不同的队列,但是由于它们的先后顺序是完全一致的,我们可以将这两个队列合并为一个队列。这时候的处理逻辑需要做一些小的调整。命令收到了之后,我们并不会马上存储下来。而是先处理这个命令,得到了对应的事件之后,再将命令和事件打包到一起,存到队列中。
下图列出了这个优化后的存储情况。你可以结合图片体会一下具体的区别:
<img src="https://static001.geekbang.org/resource/image/70/d0/70d8e62c864c772fe3b8dd339ff20bd0.jpg" alt="">
## 怎样实现队列存储?
事件溯源设计对于存储设备非常友好。无论是基于碟片的传统硬盘还是新一代的SSD存储事件溯源设计都能非常有效地利用存储设备提供的吞吐能力。
这是因为命令和事件这两个队列只会在末尾增加新的内容,而不会修改中间的内容。我们一般把这种访问方式叫作**顺序写**。与之对应的是**随机读写**。
你在挑选硬盘的时候,一般能看到硬盘生产商会公布两个硬盘速度,一个是顺序读写速度,另一个是随机读写速度。你会发现顺序读写的速度会快很多。所以事件溯源设计一般都能达到很高的读写效率。
请注意,当你将每个队列存储到文件时,需要存储的是两个文件,而不是一个。其中一个文件显然是队列的内容。另一个文件则是这个**队列的索引文件**,它记录了每个内容在队列中的位置。
在一些场景下我们需要能定位到指定位置的内容比如第3个命令是什么或者第10个事件是什么。由于每个命令或者事件的内容大小会不一样我们需要额外的索引文件来帮助我们定位。
由于位置信息和偏移量这两个数据的长度都是固定的,索引文件的每个内容都有固定大小,所以我们可以根据我们要的位置直接计算出索引文件的偏移量,然后根据索引文件找到队列文件的位置。计算的示意图如下:
<img src="https://static001.geekbang.org/resource/image/45/1c/4522c6bac1d2ecae4c94a6e9f93c011c.jpg" alt="">
## 怎样执行事件和改变状态?
解决了如何处理命令和事件之后,我们就剩下最后一件事情,那就是怎么执行事件和改变状态。
### 自动机执行
事件的执行用到了计算机里最经典的计算模型,叫作**自动机**。你可以将事件队列当作一个有始无终的磁带。你会从头开始依次读取每个事件。读取之后按事件内的指示来改变内存状态。然后挪到下一个位置,继续处理下一个事件。是不是很简单?自动机的示意图如下:
<img src="https://static001.geekbang.org/resource/image/57/fe/57f6826aa93b86a78c8490b4b8acb3fe.jpg" alt="">
这里有一个非常重要的限制你要牢记:**自动机在执行事件的过程中不能有任何随机行为**。这是为了保证整个系统能准确复现每一步计算,因为这样才能满足金融行业对每一步计算过程都能审计的要求。
对于没有随机性,我们要注意两点。
第一点是**不要使用随机数。**这里的随机数指的是真实的随机数,而不是伪随机数。真实的随机数一般会采用硬件的随机数发生器,每次读取都会读到不同内容。
伪随机数是一个算法和对应的初始值(也叫随机数的**种子**)。初始值一旦确定,伪随机数发生器所有接下来的随机数也就确定了,所以**伪随机数其实并不是一个随机的事情。**你需要将随机数的算法和初始的种子也记录到事件中,这样虽然看起来有随机数这几个字眼,但运行起来还是完全确定的。
另一点是**不要有I/O**(输入/输出)。准确地说是**不要有来自外部的输入。**外部输入有很多不确定性,比如输入到达的时间不确定,或者到达的内容每次都会变化,或者消息超时,什么都收不到。由于外部输入有太多的不确定性,一般要求不能有外部输入。
但是我们不能完全取消所有外部输入。这时候有一个折衷处理方式。你可以**提前从外部获得输入,然后存储在事件队列中。**这样在执行事件的时候就不会受到外部输入不确定性的影响了。
### 时光机
我们还是拿游戏举例,给你说明什么是时光机功能。一般来说游戏都可以存档。如果你游戏玩不下去了还可以读档,恢复之前的游戏状态。这个存档读档的过程就是坐时光机回到过去的过程。
事件溯源提供了更完美的时光机time machine功能。它能恢复到过去任何一个时间点的状态。你需要做的事情也很简单只需要重置自动机状态然后把事件一个一个执行直到运行到你指定的时间点。如果你按照我前面指出的要求保证自动机在执行过程中的每一步都是完全确定的那么最终一定能准确地回到过去的状态。
**时光机给了金融系统审计的能力**。由于过去所有的命令都得到了保留,你能解释状态是怎样一步一步从最开始的情况变到现在的样子。**在互联网架构里我们更关注的是当前事实,所以架构设计时会倾向于记录状态,而不是原因。但是在金融系统里,我们更关注的是为什么,而非是什么,所以架构设计会倾向于记录原因**。
### 系统快照
**时光机还给系统架构带来了一个副产品,那就是容灾能力。**如果机器出了问题,状态全都丢失了。只要事件都在,事件溯源设计能保证一定能恢复到出问题前的状态。
但是这种容灾有一个问题。系统恢复的时间长短和事件的个数有关。事件多了可能恢复的事件会变得太长。所以我们需要针对性地优化恢复速度。
优化的方法很简单,只要将当前的系统状态全都保存到文件就可以了。我们一般称呼这个过程为**打快照**Snapshot。过了一段时间之后如果想要恢复到系统的最新状态你只需要先将快照文件加载到自动机里然后从打快照的时间点开始执行后面的事件。
为了能让自动机找到下一个需要执行的事件,你**需要将快照对应的事件位置也记录到快照里。**打快照的示意图如下:
<img src="https://static001.geekbang.org/resource/image/8a/a2/8a55da94f3172527f0acb73f132d0ba2.png" alt="">
有了打快照这个优化之后,系统恢复时间只和那些不在快照里的事件个数有关,跟事件的历史长度无关。所以**打快照的频率决定了恢复时间,而不是事件的总个数。**
打快照频率有多种选择。你可以选择频繁地打快照,这样会减短系统恢复时间。但是考虑到系统打快照也需要时间,系统的运行时间会增加。或者你可以选择偶尔打快照,这样恢复时间变长,但是系统运行时间会变短。
幸运的是金融系统不需要过多思考打快照频率的问题。因为金融系统里有一个**日切**的概念。日切指的是在每天晚上12点的时候你需要对当天的所有业务进行清点确认无误后再开始下一天的工作所以系统需要在每晚12点打一个快照。
除了每晚12点以外金融行业一般还需要按月、季度和年度来进行业务清点工作。通常这些特殊的时间点也需要晚上12点整的状态因此可以复用每天晚上日切的快照内容。但是也有可能碰到特殊时间点的要求这时候需要单独打快照。
## 怎样查询数据?
到目前为止,我给你解释了事件溯源设计如何进行存储和计算,但是还没有说怎么查询数据。事件溯源设计对于查询有专门的术语,叫做**CQRS**Command Query Responsibility Segregation就是我们通常说的**读写分离**。这里的Command就是事件溯源里的Command。
**读写分离指的是写入的组件只负责写,查询的组件只负责读。**这样做的优势是,写部分的存储和读部分的存储可以根据访问的特点来分别做优化。
读写分离不仅仅是事件溯源需要在其他架构中也经常能看见。比如有些K/V存储在写入的时候会选择一些写入速度较快的数据结构像LSM树。在读取数据的时候则会选另一些读取速度快的数据结构比如B+树。
事件溯源和其他设计不一样的地方在于,**事件溯源既能查到当前内容,也能查到任何过去内容。**我们先来看看怎么查询最近的内容。
思路很简单。如果我们将事件队列实时地复制出来,然后在另一台机器上用自动机执行这些事件,那么我们不就有最新的状态了吗?这就是状态机的**读模式**Read Mode。在读模式下状态机只负责执行事件不负责处理命令。示意图如下
<img src="https://static001.geekbang.org/resource/image/2a/8d/2a57e5ee3543d977ff2d2ffab606638d.jpg" alt="">
读模式自动机在游戏行业也经常能碰到。5v5即时对战手游在进行比赛的时候会有现场直播讲解员会在电脑上实时讲解当前所有选手的对战情况。电脑就是用读模式复制了手机上所有的实时状态。
我们再来看看怎么查询历史状态。最直接的方案显然是利用时光机的功能。我们先找到距离查询时间最近的快照,然后从这个快照开始执行事件,直到碰到查询时间点。这时候的状态就是我们需要的状态。一般我们把这个重新计算历史状态的过程叫作**回滚**。
在进行架构设计时你可以选择将实时查询和历史查询的优势结合起来。你需要做的是搭建多个读模式自动机。其中一个永远保持在最新状态,剩下的根据历史查询的频率来选择固定在过去某个时间点,比如日切的时候。多个读模式自动机的示意图如下:
<img src="https://static001.geekbang.org/resource/image/1e/0b/1e0fbc8747a48ee71e3209b4f26f170b.png" alt="">
## 事件溯源正确性的数学本质是什么?
我们在开篇词里提到会带你透过现象看本质。所以在给你讲完怎么实现事件溯源之后,最后我来带你了解一下事件溯源正确性的本质。
事件溯源的框架隶属于一个更大的系列,叫做**不可变架构**Immutable Architecture。在不可变架构里所有数据都不能发生变化。所有这些不能变化的数据分为两大类分别是事件Event和状态State分别用 `e``S` 来表示。
我们把前面讲到的自动机在数学上用函数 `f` 来表示。这个函数接受一个状态和事件,返回一个新的状态。如果我们把事件、状态和自动机结合在一起看,整个事件溯源的运行逻辑其实就是下面这个数学公式:
$$<br>
S_{n}=f\left(S_{n-1}, e_{n}\right)<br>
$$
如果你把公式里的所有 `S` 都展开,那么数学公式就会变成下面这个样子:
$$<br>
S_{n}=f\left(f\left(\ldots f\left(f\left(f\left(S_{0}, e_{1}\right), e_{2}\right), e_{3}\right) \ldots\right), e_{n-1}\right)<br>
$$
上面这个数学公式可能看不出来什么熟悉的东西。但是如果换个表现方式你可能就熟悉了。我们可以把 `f` 换成 `+` ,这样事件溯源的公式就会变成将当前状态和事件的求和,从而生成新的状态,所以数学公式也可以变成下面这个样子:
$$<br>
S_{n}=S_{n-1}+e_{n}<br>
$$
我们把简化后的数学公式展开之后可以发现,在事件溯源的设计里,**任何一个时间点的状态等于之前所有事件效果的累积**,就像下面这个公式表现的一样:
$$<br>
\begin{aligned}<br>
S_{n} &amp;=S_{0}+e_{1}+e_{2}+\ldots+e_{n-2}+e_{n-1} \\\<br>
&amp;=\sum_{i=0}^{n-1} e_{i}<br>
\end{aligned}<br>
$$
说到这里,我就可以给你解释,为什么在事件溯源里的我们会有那些假设了。
我们要求**自动机是没有随机性的,原因是在数学里所有的数学函数都没有随机性**,这样才能保证数学计算的结果是可以一步一步推演出来的。
另外,我们在**记录事件的时候要求事件之间有顺序这是因为自动机对应的函数一般是不可交换的Non-commutative。**
也就是说函数的参数交换顺序后会导致结果不一样,这也导致**数据之间是线性序列Linear Order的关系。这个线性序列关系导致我们在存储的时候选择用FIFO队列的存储格式。**
**由于我们可以通过逻辑推导来验证数学计算的正确性,当事件溯源和数学公式之间有严格一一对应关系之后,我们就可以像验证数学公式一样来验证事件溯源结果的正确性。这就是事件溯源能保证金融系统正确性的本质原理。**
上面这些公式是用**求和**的方式来表示最终的状态是怎么得到的。在极限情况下,我们还可以有**积分**和**微分**表现形式。用积分的概念去理解的话,**任何一个时间点的状态等于过去所有事件的积分**,表示出来就是下面这个公式:
$$<br>
S(T)=\int_{t=0}^{T} e(t) d t<br>
$$
微分的形式可能更有意义一些。**每个事件是状态关于时间的导数**,也就是下面的这个公式:
$$<br>
e(t)=\frac{d S(t)}{d t}<br>
$$
微分和积分的形式更多的是让你从时间的角度来理解事件和状态之间的关系。你可以仔细体会一下。
## 小结
这节课我给你讲解了事件溯源设计这个架构设计思路。在事件溯源设计里,你重点要关注命令、事件和状态这三个术语。**命令指的是我想要做什么,事件是我合理的行为会做出什么改变,状态就是改变的对象和结果。**
命令和事件都需要按照事件的先后顺序来处理。它们的存储也需要遵循同样的先后顺序。为了能定位到指定位置的内容,我们需要在存储数据的时候还同时存储一个位置的索引文件。
命令和事件都存储好之后,事件溯源设计里的状态机就可以从零开始,按顺序一一执行所有事件。我们要求所有执行的操作都具有可重复性,也就是不允许有随机性。这样就能确保我们多次从头执行,最终都能得到一样的结果。
这样要求有很多好处:既可以审计所有的状态变化过程,也可以有一定的容灾能力,同时还可以通过时光机和快照来让系统回滚到历史中任意一个时间点的状态。
事件溯源设计的查询需要遵循CQRS也就是读写分离的架构。系统会有一个自动机负责处理所有的命令和事件另外还有很多读模式的自动机负责提供查询服务。这些读模式自动机会将系统回滚到打快照的时间点然后从这个时间点出发计算查询时刻的历史状态。
<img src="https://static001.geekbang.org/resource/image/36/36/36cfb08a48ed311c89b94d27c55e6a36.jpg" alt="">
## 思考题
我们在存储事件队列的时候需要存储两个文件。一个存储事件,另一个存储事件的索引。在现实中会出现各种异常的情况,比如机器可能会中途死机,这样有可能文件只写了一部分。
1.这时你应该如何检测文件是否完整?
2.这两个需要存储的文件,应该按照怎样的先后顺序存储呢?
欢迎你在留言区留下你的思考和疑问。如果这节课让你有所收获,也欢迎分享给你的同事、朋友,和他一起交流进步。

View File

@@ -0,0 +1,203 @@
<audio id="audio" title="08 | 计算结果的正确性:怎么保证计算结果是正确的?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/09/02/0922885591661eb64c2ba793cdeaeb02.mp3"></audio>
你好,我是任杰。这一讲我想和你聊一聊怎么保证计算结果的正确性。
在前面几节课里我们学习了如何保证数据输入的正确性,如何用事件溯源的架构来保证数据计算的正确性。但这只能保证一个组件是正确的。系统里还有很多其他组件,我们也需要保证它们的交互也是正确的,这就需要一个系统性的指导方案。所以,这节课我们一起来看看如何保证最终计算结果正确性。
从抽象的角度来讲,任何一个计算过程都分为三个步骤:收到请求、处理请求和输出结果,也就是分为事前、事中和事后三个步骤。接下来,我们就从这三个方面逐一分析,看看如何系统性地保证最终结果的正确性。
## 事前
如果计算的输入错了,计算的结果就很难正确。输入不正确有两种可能性。一种是单个数据内容不正确,另一种是多个数据之间的顺序关系不正确。接下来我们就看看怎么解决这两方面的问题。
### 内容正确性
在这里我们可以假设,系统组件之间的信息交流方式是上游系统负责将数据传输给下游系统。所以**上游系统需要保证数据内容的正确性。**
我们在[第6节课](https://time.geekbang.org/column/article/328625)提到过,在数据可以被修改的情况下,我们很难保证所有人都能使用正确的数据。所以金融公司**需要用双时序数据库来保证我们能查询到正确时间的数据。**
我还想提醒你,尽管你找到了正确时间的数据,但数据本身还是有可能会出问题,比如在读取的时侯出现部分数据丢失,或者在传输的过程中出现了数据损坏。这时候我们需要给数据**增加完整性校验**的功能比如在存储的时侯增加HMAC验证这些也都是常规操作。
最后,也是最容易忽略的一点,就是我们需要给数据**增加版本号**。版本号代表了当前的数据格式,下游可以用来做校验。这么做在进行系统升级后的向下兼容处理时有奇效,所以一般建议你加上。
### 顺序正确性
顺序的正确性是指上游发给下游的多个消息之间需要保证正确的顺序。这不再只是上游一个人的独角戏,下游也要参与,上下游两方面通力合作。
顺序问题简单来说就是上游按顺序发送了1、2、3这三个消息下游需要按顺序收到1、2、3这三个消息。这就表示接收消息需要满足这三点**顺序不能乱,个数不能少,也不能多。**那我们来看下应该怎么保证。
#### 不乱序
请你注意,这里的不乱序指的是接收端不乱序,对发送端没有什么要求。其实由于网络通讯协议是异步的,就算发送端按顺序发送,接收端也可能会乱序。
TCP解决乱序问题的方案是在发送方在每个消息里包含了一个**自增ID**。接收方将所有收到的乱序消息先放到一个**消息缓冲区**。如果自己等待的ID出现在了缓冲区再从缓冲区里将这个消息捞出来。
在一般情况下TCP自带的方案就能很好地解决乱序问题。但是在云计算的处理框架下消息的发送方可能会通过多个TCP链接来给接收方发消息。这样的话虽然单个TCP内消息是不乱序的但是多个TCP链接之间还是有可能乱序。
举个例子。发送端和接收端中间有两个路由分别是路由A和路由B。发送端一前一后分别给这两个路由发了一个消息。这两个路由将消息传递给接收端的速度不一样最终导致消息的接收顺序错位。示意图如下
<img src="https://static001.geekbang.org/resource/image/c2/c6/c2faf539e9d0da7d2ccbf3977391d6c6.png" alt="">
解决乱序的方法很简单。**发送方和接收方之间可以实现和TCP完全一样的乱序解决方案也就是通过自增ID和消息缓冲区来解决乱序。**
从理论上来讲你需要的不乱序其实是要求系统具有线性一致性linearizability。这个内容我们会在[第13节课](https://time.geekbang.org/column/article/335994)“多机无容灾有哪几种不同的一致性实现”里给你详细讲解。
#### 消息投放至少一次
**上游系统架构**
通俗来说,至少一次的意思就是消息不要丢。在理想情况下,发送方把消息发送出去之后就可以不管了,消息系统或者网络会将保证接收方一定会收到这个消息。
不过事实本不完美,我们面对的是一个随时会丢失消息的不稳定网络。在没法完全相信网络的情况下,发送方只有在收到接收方的回执了,这时才能肯定接收方确实已经收到了消息。
可是这里有个悖论。回执消息是接收方传递给发送方的消息,也会碰到消息丢失问题。这就是非确定性网络所带来的消息丢失问题。
这个问题的解决方法也很简单,那就是发送方需要一直不断地重发消息,直到收到了至少一次接收方的回执。从逻辑上来讲,从一次回执消息的接收可以推算出至少一次的消息接收。
由于消息的发送和金融系统的主营业务无关,消息处理部分一般会单独作为一个消息中间件来处理。按照我们在[第4节课](https://time.geekbang.org/column/article/325378)的分类,消息系统属于通用组件。
这个消息中间件有自己的数据库,负责存放所有需要投放的消息。每个消息还有一个**回执标识位**负责记录对应的消息回执是否已经收到。如果这个回执标识位一直为空消息中间件会不断地往下游发送消息。整个处理过程分为6个步骤
1.业务系统将消息发给进程内的消息中间件。
2.消息中间件将该消息保存在数据库中,并将回执位设置为空。
3.消息保存好后,消息中间件通知业务系统发送成功。
4.消息中间件将消息发送给下游系统。这时候消息中间件会分情况做处理:如果没有收到下游系统的回执,消息中间件要持续发送消息;如果上游系统重启,消息中间件会从数据库中找到所有还没有收到回执的消息,重发给下游系统。
5.下游系统收到消息后返回回执消息。
6.消息中间件收到回执消息后,改变数据库中的回执消息位。
架构的简单示意图如下:
<img src="https://static001.geekbang.org/resource/image/52/59/5261c6443d989b96f95db138dff1bf59.jpg" alt="">
虽然你很可能一眼就看出来,上面这个架构能保证至少一次的要求。不过,我还是要给你说说里面蕴含的一些道理。
至少一次指的是下游至少收到了一次消息,这是下游系统的客观状态。这个状态和上游系统无关,因此我们需要保证就算上游系统出了问题,系统也能正确记录下游状态。这种出了问题还能正确记录状态的能力,我们叫作**数据库事务**。这就是为什么我们需要用具有事务能力的数据库来存储回执状态。
另外,回执消息还有一个数学特性叫作**幂等性**idempotency。幂等性指的是同一个操作执行多次的结果和执行一次一样。我们将回执位设置为成功多次和一次的结果是完全相同的所以回执位的多次更新不会改变逻辑的正确性。正因为**有了事务和幂等性这两个保证,上面的架构才能保证消息投放至少一次。**
**下游系统架构**
讲完了上游系统作为发送方的架构,我们还需要弄清楚作为接收方的下游系统应该如何处理。
常见的下游系统错误是先返回消息回执,然后再处理消息。如果下游系统重启了,就会导致下游系统当前消息丢失。而这时上游系统已经收到了回执,也不会重发消息。这样这个消息就从系统中整个丢失了。
解决方法和上游系统类似下游系统也会通过独立的消息中间件实现和上游系统的正确交互。消息中间件在收到上游发过来的消息后先记录到数据库然后再通知下游的业务系统处理。整个过程也同样分为6步
1.上游系统给下游系统的消息中间件发送消息。
2.消息中间件收到消息后记录到数据库。此时消息是未执行状态。
3.消息中间件发送回执给上游系统。
4.消息中间件将消息传给下游的业务系统进行处理。此时消息中间件要处理以下几种情况:如果消息中间件没有收到下游业务系统已执行完的通知,需要持续发送消息给下游业务系统;如果系统重启,中间件要从数据库中找到所有未执行的消息,然后按正确的顺序发给下游业务系统。
5.下游的业务系统处理完成后,通知消息中间件。
6.消息中间件将消息变为已执行状态。
架构的简单示意图如下:
<img src="https://static001.geekbang.org/resource/image/8c/dc/8c4853c9f9e6061fe3b81db1d7yyacdc.jpg" alt="">
和上游系统的逻辑一样,**下游系统也是通过事务和幂等性来保证消息反馈的正确性。**
**上下游结合**
前面我们分别学习了上游系统和下游系统应该分别如何处理消息。如果我们将两者的架构图结合起来看的话,就是下面这个样子:
<img src="https://static001.geekbang.org/resource/image/3e/3d/3eaba118bdd96e512a7bb51afb1b323d.jpg" alt="">
这个架构有一个隐含的假设:上下游分别由不同的人来设计。所以上下游系统需要在假设对方是正确的情况下,各自维护自己的逻辑。那我们可以再想想,如果有人同时设计上下游两个系统,有没有可能将架构设计得更加简洁呢?
你可能已经想到了。只需要将上下游的消息中间件剥离出来然后合并在一起这样系统就分成了3个组件上下游系统和消息系统。
这时候上下游之间也需要6步来完成所有的交互比之前两者分开的情况节省了4步。当中也有一些消息检查和重发的机制你可以仔细想想都在哪些地方。合并之后的架构图如下
<img src="https://static001.geekbang.org/resource/image/0b/bb/0b738df1f564f7f8840c3fd858fa92bb.jpg" alt="">
上面这个架构图有很多名字,比如消息系统、企业总线等等。我们在开篇词提到过要透过现象看本质。你需要重点关注它究竟解决了什么问题,为什么可以解决这些问题,以及在不同环境下的不同使用方法。
#### 消息投放至多一次
刚才我们在保证消息投放至少一次的时侯,也制造了一个问题,那就是下游系统有可能收到多条同样的消息。
比如你本来给人转了一笔钱,可在系统内却转了多笔一样金额的钱,这样的系统显然是不可接受的。要想解决很简单,我们可以**将消息的处理变为具有幂等性的操作。**
实现幂等性的方法是去掉重复的消息,只保留第一个消息,这个行为简称**去重**。去重要求你能够判断不同的消息是否重复,这就要求消息有**唯一标识符**。
唯一标识符有两种方法可以生成,一种是消息自带,另一种是由上游系统生成。
消息如果想要自带唯一标识符的话就需要用到自己内部的属性也分为两种情况。一种是利用和业务有关的属性比如支付订单号。另一种是用和业务无关的属性。这时候一般会将消息当作是数据库的一行记录利用数据库对应的主键或者具有完整性校验功能的字段作为唯一标识符比如MD5或者SHA1。
上游系统有时候也能帮助生成消息的唯一标识符。前面我们说到保证消息不乱序的解决办法时就用到了自增ID。这个自增ID就可以用来作为消息的唯一标识符。当然了这里有个要求就是消息系统需要能控制消息的格式。
总结一下如果上游系统有自增ID就可以用这个ID来去重。否则就需要用到业务或者数据库的某些唯一性来去重。
## 事中
对于消息处理正确性而言,事前准备工作是最重要的,这也是为什么前面花了大量篇幅去讲它。而消息的处理则跟架构关系不大,更多和软件工程相关,我们可以从函数式编程和计算精度两个角度理解。
### 函数式编程
[上节课](https://time.geekbang.org/column/article/326583)我们提到过,事件溯源和数学计算很类似,所以才能一步一步证明正确性。其实有一类编程语言也和数学计算很类似,叫作函数式编程语言。
在函数式编程语言里所有的数据都不可以被修改所有函数也不允许有随机性。这样我们就可以将函数随意地组合然后生成下一个确定性的新函数。这种可以将函数像乐高一样随意组合也能保持正确性的特性Composibility保证了我们在编写程序的时候程序不会因为代码的增加而导致正确性变化。
这就是函数式编程语言在软件工程上相对于其他编程语言的优势。我们在开篇词提到过金融行业注重投资回报比。函数式编程语言有很高的学习成本,但是长期来看它的正确性维护成本很低。
所以函数式编程语言虽然小众在金融行业也有很大规模的应用。比如高盛公司发明的函数式编程语言Slang和用它实现的数据系统SecDB摩根士丹利发明了A+和修改了ScalaJane Street的Ocaml以及渣打银行的Haskell。我们在[第10节课](https://time.geekbang.org/column/article/332304)“金融业务应该如何选择数据存储类型”里会给你介绍KDB/Q这也是一个函数式编程语言和基于它的数据库。
### 计算精度
现在编程语言众多,数据传输格式多种多样,数据中心的硬件系统也多,很容易出现精度问题。你可能对这个问题没有什么感觉,因为只有在金额特别大的时侯才有可能出现精度的问题。
我给你举个例子。在2015年的时侯175千万亿津巴布韦元可以换5美元。在2018年日本的GDP约为500万亿日元。如果不小心设计这么大的金额很可能会出现存储方面的精度问题。我们在设计金融系统的时侯需要知道可能的业务边界。业务对接的机构越大资金的金额越高越容易出现计算精度的问题。
所以如果你有志成为一个伟大的架构师,致力于解决大型金融机构的系统架构问题,那么对于计算精度你一定要提前做好应对。
## 事后
计算完毕不代表正确性相关的工作就结束了,我们还需要在事后对计算的结果进行验证。
举一个实际发生过的例子:我们在一次计算中偶然发现算出来的金融合同市场风险的值非常高。因为市场风险的各个数值之间有一定的数学逻辑关系,我们通过数学计算判断确实是某一个数值偏高。
由于我们的系统采用了双时序数据库和事件溯源可以在云计算环境一直重复这个有问题的计算。最后终于发现是某台机器CPU的一个核的浮点数寄存器出了问题在计算的时候会出现随机数值。CPU的厂商解释说可能是宇宙射线的问题损坏了CPU。
这个例子说明错误的计算结果并不一定是人为的,周边的软硬环境也有可能导致错误。因此我们在对计算结果进行验证的时候,一定要选择不同的计算环境,这样才能**降低和之前计算结果的相关性。**
降低计算结果和验证结果的相关性有一些常见思路。**首先我们可以选择不同的编程语言。其次可以选择由不同的人实现。最后还可以选择不同的架构设计和云服务提供商。**
那么验证多少次你才会放心呢在极其重要的场景比如飞机或者航天器材上面的软件系统一般会验证3次。因为这些系统和人命相关。这种场景下同时会有4个系统在一起工作。这4个系统彼此验证只有在至少3个结果完全一样的情况下才会向外输出结果。而金融软件的要求一般没有这么高所以**验证1次**基本就够了。
验证结果还有一个时效性的问题。你可以选择**实时验证**,在验证通过之后再往外公布计算结果,或者可以选择**异步验证**,在公布结果之后再择机进行验证。
实时验证的好处是可以防范于未然,在造成不好的影响前解决问题,但是代价是增加系统的延时。**异步验证刚好相反,可能会对外造成不好的影响,因此需要业务有事后补偿的能力。在金融系统中常见的日切或者对账其实就是异步验证的解决方案。**
## 小结
这节课我们从事前、事中和事后三个部分学习了如何保证整体计算结果的正确性。
事前我们可以做好准备工作。首先我们要保证数据内容是正确的。这要求我们使用正确的查询,比如双时序数据库。同时我们还不能假设结果的完整性,也要进行验证。
保证了数据内容正确性之后,接下来还要保证数据的接收顺序也是正确的。这要求数据的接收不能乱序,而且保证数据只处理一次。
事中的正确性要通过软件工程来解决,而不是通过架构设计。金融系统推荐使用函数式编程语言。在实现过程中还需要注意计算精度的问题。
事后需要验证结果的正确性。验证的时候尽量不要和之前的计算有任何关系。一般验证1次就可以多的可以验证3次。验证的时间可以选择实时验证或者异步验证。如果采用异步验证需要业务方对应的业务补偿能力。
**总之,单个组件的正确性并不能保证整个系统的正确性。我们需要在架构设计上将组件之间不确定的交互行为变得确定,同时在软件工程实现上要选择一些不容易出错的解决方案。**
<img src="https://static001.geekbang.org/resource/image/2f/29/2f60e47ea87fbce6b50ec01271344529.jpg" alt="">
## 思考题
我们在讲如何保证消息至多投放一次的时候,说过可以用数据库来做去重工作。不过数据库的容量一般是有限的。
假如你设计的系统预期会运行10年以上。数据库由于存储不了这么久的数据一定会将过期不用的数据进行归档后删掉。这会造成你用来去重的数据有一部分会不见了。这样如果来了一个请求这个请求恰好用了被删掉的ID系统就会重复处理。那么你应该如何做呢
欢迎你在留言区分享你的思考或者疑问。如果这篇文章让你有所收获,也欢迎转发给你的朋友,一起学习进步。

View File

@@ -0,0 +1,183 @@
<audio id="audio" title="09 | 数据传输的质量:金融业务对数据传输有什么要求?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/e9/3f/e98180e5a04fbd2a1c8ceaae99d6573f.mp3"></audio>
你好,我是任杰。这节课我想和你聊一聊怎么做好金融数据的传输。
我们在开篇词提到过,如果你对系统的要求高,通常都会说要按照金融级的标准来设计。所以当我们提到金融数据传输的时候,你可能也会觉得数据传输应该也是要求非常高的,既要速度快,又要流量大,而且还有极强的容灾能力。
没错,在金融行业很多地方会有这样的要求,但是也有一些地方要求并不高。所以这节课我会带你分析一下金融业务在不同场景下对数据传输的要求是什么,以及解决方案都有哪些。
## 案例分析
按照惯例,我们还是从一个具体的例子开始。和我们之前讲扫码支付一样,这里的例子既要典型,又要简单。所以思考再三,我选择了简化版的券商算法交易平台对接交易所的例子,原因有这些:
1.涉及场景多。既有事务数据,也有市场数据。<br>
2.模型简单。只涉及到2个主体。<br>
3.复杂度可选。连接交易所的要求可以很高,也可以很低,具体取决于你愿意出多少钱,有多久的研发时间。
那我们来看看简化的模型是怎样的。
**在券商对接交易所的例子里,一共包括两个主体:券商和交易所。券商的任务是将券商提供的市场交易信息发送到自己的算法交易平台,平台分析这些信息之后发送买卖订单给交易所。交易所负责处理收到的订单,在处理完之后将交易信息传递给券商**。整个过程都是用软件实现,而不是用硬件。
下图画出了这个简单的交互流程:
<img src="https://static001.geekbang.org/resource/image/b0/f1/b020e8330bea1e64e4035c64a0d0f1f1.jpg" alt="">
## 交易数据
券商发给交易所的**订单数据属于事务数据**。这里的事务指的是数据库事务Transaction。所以交易数据的传输需要满足我们在[上一节课](https://time.geekbang.org/column/article/330288)提到的**顺序正确性**要求,也就是**既要保证顺序的正确性,也要保证消息处理的一次性**。不了解的同学可以回到上一节课复习一下相关内容,这里就不做复述了。
既然我们这节课讲的是数据传输质量问题,那么我们还是要分析一下可能的异常情况。数据传输已经具有事务性了,还能出什么问题吗?
不知道你有没有在电商促销的时候抢过东西。如果你网页刷新太快,可能会收到远端服务器拒绝访问的消息。这意味着电商服务器觉得你的访问频率过高,临时降低了提供给你的服务质量。
金融行业里的服务器容量也有上限,所以也普遍采取了**限流**这种保护措施。那我们接下来聊聊限流该怎么做?
### 限流
常用的限流方法有两种,分别是漏桶算法和令牌桶算法。
**漏桶算法**的原理是消息的生产者将所有请求放到一个容量固定的桶里。消费者会匀速地从桶里消费消息。因为桶的消息满了就丢掉,所以这个桶叫作漏桶。
**令牌桶算法**和漏桶算法一样,也有一个容量固定的桶,只不过这个桶里装的不是消息,而是令牌。系统会按固定的速度往令牌桶里放令牌,满了之后就会溢出。消费者每处理一个消息都需要消耗一个令牌,所以桶里面的令牌总数决定了处理消息的最快速度,而放令牌的速度决定了处理消息的平均速度。
漏桶算法和令牌桶算法是互联网常见的限流算法,你有兴趣的话可以上网查询相关的实现细节。我们在这里主要看一下这两种算法应该如何选择。
先看看券商应该如何选择。**当两个不同组织之间的金融系统进行对接的时候,接收方一般要假设发送方是恶意的。**因此交易所需要限制券商的消息发送速度,比如一秒钟内最多只能发多少消息。这时候券商可以使用漏桶算法来限制自己对外的消息发送速度。
再来看看交易所应该怎么选择。尽管交易所明文规定了每家券商的速度上限,但是交易所不会相信券商会遵守规则,因此从系统安全的角度考虑,交易所依然会对券商的消息进行限流。这些经过限流的券商流量最终会汇集到一起,再集中处理。
现在又有一个新的问题产生了。虽然对每家的流量都做了限制,但是他们的总流量还是有可能会超出系统承载上限。所以交易所还需要**对总流量做一个限流**。
这时候你就有一个选择,如果你想对总流量做一些微调,就可以选择令牌桶算法,这样就可以通过调整生成令牌的速度来调整处理速度。还有另外一个好处是令牌桶里的总令牌数目代表了系统的峰值处理流量,这样系统还具有一定峰值处理能力。当然你也可以选择安全一点的漏桶算法。
**一旦上下游之间做了限流,那么整个系统就需要假设数据会丢失。**因此你需要处理好订单发送不出去,或者发送出去后无法被执行这两种情况。
## 市场数据
交易数据的处理一般具有事务性,所以选择灵活度比较小。市场数据就不一样了,选择面要宽泛很多。
我们先来看看什么是市场数据。这里的市场指的是金融交易市场,**所以市场数据指的是金融市场成交信息。**我们平时关心的股价就是股票买卖双方的成交价格。
交易数据是事务型数据,那**市场数据也是事务型数据吗?这个问题是市场数据处理的最核心问题。**
如果你关心的是自己订单的成交信息,那么这个成交信息是事务类数据。但是在我们的例子里,券商的算法交易平台关心的不是自己的交易信息,而是当前所有人的交易信息,所以算法交易平台并不需要数据有事务的保证,这也意味着**在我们的例子里是允许掉数据的。**
正因为放松了这个假设,我们对市场数据的处理才有了多种不同的选择。因为不同能力的算法交易平台对数据的实效性要求不一样,所以我们先来看看非实时的情况是怎样的。
### 非实时市场数据
**非实时在这里主要指的是那些对延时要求不是特别高的使用场景。**这时候消息的传输本着尽量快的原则,稍微慢了几百毫秒或者几秒钟问题不大。绝大多数情况下,你碰到的都是这种非实时的市场数据场景。
我们先看看非实时市场数据的分类,然后再结合前面的例子分析怎么选择数据处理方式。
#### 订阅发布与消息
数据的传输方式分为**订阅发布**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收到数据后会将数据先同步给备份节点这个备份的过程需要一定时间。备份的节点个数叫作同步数ISRIn Sync Replica。我们可以将Apache的同步数设为0这样我们就能牺牲掉部分不重要的容灾能力换来更快的处理速度。
当然了,就算数据可以丢失,也要避免这种情况频繁出现。所以,你要建立合理的监控机制,当数据频繁丢失的时候能及时反应。
### 实时市场数据
金融行业的非实时市场数据的处理和互联网行业的方式比较接近,用的系统架构也比较类似,所以你会有种似曾相识的感觉。接下来我们看看实时市场数据的处理,这和互联网的处理区别比较大。
#### 特殊部署
**实时市场数据指的是对延时要求非常高的数据场景。<strong>低延时的系统维护对交易所本身是一笔很大的开销,而且因为能支持的用户数有限,所以一般都需要收费,也就是常说的**席位费</strong>
席位费也分不同的等级。当你交完席位费后,一般就能连到交易所,获取一些低延时的数据,同时还有可能得到更详细的数据内容。
一般来说你的服务器和交易所有一定的物理距离。我们都知道光的传播有速度上限,所以这个物理距离会导致一定的延时。比如跨省传播数据的话,延时可能在几毫秒到几十毫秒左右。
如果你觉得这几十毫秒很重要,你可以再用更多的钱来解决这个问题。交易所一般会提供同机房主机服务,你可以将自己的系统部署在交易所的机房内。这样你的系统和交易所系统的物理距离只有几米左右,光速带来的影响基本可以忽略不计。
不过,因为交易所机房的物理机器有限,你需要和其他有钱的金融公司竞争这为数不多的位置,可能需要付出天价的运营费用。
#### 数据分发
从前面讲的特殊部署你可以发现,实时市场数据的消费者之间并不是平等关系。谁的钱多,谁的延时就低。这表示**实时数据分发的系统架构是一个层级结构**,越接近上层的人收到的消息越快。
我们先从交易所出发。当交易所生成完数据之后,会将数据传送给顶层的数据节点。这一层会部署多个节点以防单点故障。
假如是按照Apache Kafka的模式交易所数据应该发给一个主节点主节点负责和备份节点之间通讯。但是这样会有一个网络延时所以交易所采用的是局域网内广播的方式所有第一层节点都会同时收到所有数据信息如下图所示
<img src="https://static001.geekbang.org/resource/image/cf/84/cf33c652c587f09ab474f15a908e3d84.jpg" alt="">
顶层数据节点收到数据之后会做两件事情分别是推送给优先级最高的VIP客户以及推送给下一层的数据分发节点。
这里需要注意的是,低延时架构优化的是系统延时,而不是系统的吞吐量。互联网常见的**消息系统普遍采用的是消费者定时拉取数据的模式。**这样做的优点是能支持大量的数据消费者但也会带来问题就是两次拉取之间有一定的时间间隔。比如Apache Kafka的默认客户端就是这种行为。
数据的实时推送会消耗很多推送端的硬件资源但是由于交易所的VIP客户数目很少实时推送对系统的影响可控所以数据可以通过顶层数据节点直接推送给用户。
其实我们还可以从经济学的角度来考虑这个问题。VIP席位费的总盈利是总VIP客户数乘以席位费VIP客户数减少之后VIP之间的价格竞争会更激烈所以席位费会增加。因为总盈利是这两者的乘积很有可能总盈利会因为席位费的增加而大幅增加。
所以从利润的角度来考虑系统也不一定需要支持很多的VIP用户。这也说明了我们在选择系统架构的时候一定要结合业务来一起考虑。当然了为了让这一点能成立VIP席位需要具备一定的**价格弹性**Price Elasticity你有兴趣的话可以了解一下经济学的相关内容比如诺贝尔经济学奖获得者Paul Samuelson写的《经济学》。
数据除了顶层的VIP用户之外还有非VIP的付费实时数据用户。但是由于数据已经有了延时这时候不一定需要用到数据广播所以我们可以选择让顶层节点推送数据给下层节点。这时候的架构示意图如下
<img src="https://static001.geekbang.org/resource/image/7c/77/7c534c5c367a76ec69ef4b385d664277.jpg" alt="">
以此类推每一层都能往下继续分发数据。这时候数据分发的对象有交易所的非VIP付费客户也有其他的数据节点。
当交易所解决完所有付费用户的实时数据推送问题之后,接下来要解决怎么把**实时数据变为非实时数据**,也就是怎么让非付费用户也能访问到数据。
其实这个过程很简单,只要将某一层的实时数据节点对接到非实时数据系统就可以了。这时候**数据由实时的推送方式变为非实时的拉取方式。**更新后的系统架构如下:
<img src="https://static001.geekbang.org/resource/image/30/cc/30e905a3751d4aa128ddc7edfe2693cc.jpg" alt="">
#### 数据压缩
实时数据系统大部分的时间都花费在数据的解码和编码上。所以要想速度快,首先要数据量小,这也是金融系统架构和互联网架构很大的一个区别。
所以你平时经常能见到的Json或者XML格式的数据一定不能用在实时数据系统里。因为这些传输格式是为人准备的里面有多余的信息所以你需要换成只有机器看得懂的二进制表达方式。
如果要求不高的话一般来说Google Prototol Buffer协议就足够了。这个协议会按照你定义好的二进制表现形式来进行编码能对数据进行很大幅度的压缩。
在要求更高的金融场景下,普遍会使用金融行业专用的**FIX通讯协议**。这个协议定义了通讯规则,同时也定义了数据传输方式。
市场数据有一个显著特点是,连续两个数据之间大部分内容都是一样的。比如说你去比较连续两个股票价格信息数据,就会发现它们很有可能只有价格这一个指标会有变化,其他的信息完全一样。
FIX协议就利用了这个特性在很多情况下**只需要传输数据变动的部分**,这样就能减少很多数据传输量。其实这个设计思想和视频压缩算法非常类似,视频压缩的时候是以关键帧为基准,其他帧只存储相对于关键帧的变化。
## 小结
这节课里我们学习了怎么做好金融数据的传输。
金融数据分为交易数据和市场数据两种。金融交易数据的处理和互联网处理方法非常类似,在处理的时候需要做好数据限流的架构选型。
市场数据的处理分为非实时和实时两种。非实时市场数据的处理也和互联网处理方法类似,在处理的时候,对订阅发布和消息这两种不同架构选择,我们要做好区分。因为市场数据具有实效性,我们可以容忍偶然的数据丢失,这也给了数据系统一个很大的优化空间。
实时市场数据的消费者分为不同的级别。实时数据系统的架构和用户一样也是分为不同级别。数据会层层分发下去不同层级有不同的延时情况和部署方案。实时系统的优化主要体现在数据压缩上金融行业有自己特有的FIX二进制通讯协议。
<img src="https://static001.geekbang.org/resource/image/c6/d9/c679b590702993870a66e654af6580d9.jpg" alt="">
## 思考题
实时数据系统的数据节点通常都是价格昂贵的机器。这些机器的处理速度极快。交易所机器运行太快了之后,会导致推送给实时用户的数据量过大,用户来不及处理。你这时候应该怎么处理这两者速度不一致导致的问题呢?
欢迎你在留言区和我交流互动。如果这节课对你有帮助的话,也欢迎你分享给朋友、同事,一起学习和讨论。

View File

@@ -0,0 +1,187 @@
<audio id="audio" title="10 | 数据存储的合理性:金融业务可以不用关系型数据库吗?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/c6/36/c6293db506bca9135463c61e91c73736.mp3"></audio>
你好,我是任杰。这节课我和你聊一聊金融业务应该如何选择数据存储类型。
提到金融行业的数据存储,我们的第一反应肯定是要用关系型数据库。但是如果我追问一句,为什么一定要用关系型数据库?估计很少有人能答上来。最常见的理由是别人在用,所以我也得用,但是这个并不是理由,而是借口。
其实金融行业的数据存储有很多种选择,今天我们就一起看看都有哪些。
## 数据分类
我们都知道,不同的数据对存储和使用有不同的要求,所以我们选择数据存储类型前先要分析数据有哪些特点,然后才能根据这些特点来针对性地选择适合的存储方案。
通常我们会按照数据与数据之间关系的复杂度来对数据进行分类。最简单的显然是数据之间没有什么关系,比如常见的市场数据就属于这一类。复杂一点的是数据之间有单向的关系,这些关系形成一个树状结构。最复杂的是网状结构的数据,也叫图数据类型。
虽然这些数据在金融系统里都有,但是它们的重要性和出现的频率都不一样,所以在做存储选型的时候也有不同的考量标准。
按照数据出现的频率,数据大体可以分成这样三类:图数据类型、没有关系的数据类型和树状数据类型,它们分别对应了图数据库、时序数据库和关系型数据库。接下来我们就分别看看。
## 图数据库
顾名思义,图数据库存的是图。图数据库除了提供数据的存储以外,还支持图的查询,比如常见的相邻关系查询,或者连通关系查询。
但是金融行业里很少有图这种类型的数据结构。主要是因为图是一种非结构化数据,而金融业务里处理的数据都要有非常清晰的结构,所以金融数据从本质上就不是非结构化数据类型。
虽然金融行业里图用得比较少也不是完全没有一般都出现那些跟数据分析相关的部门。比如在和客户进行业务往来之前先要对客户进行背景调查KYCKnow Your Customer或者查看用户是否存在洗钱行为AMLAnti-Money Laundering。这就需要分析客户的社会关系和财务状况这时候用图来表示这些彼此关联的信息就比较合适。
## 时序数据库
不知道你有没有注意到,**金融市场数据一般都带有时间?<strong>你可以回想一下,我们平时在新闻里听到的和金融市场相关的数据,比如大盘、汇率、指数等等,它们都是指某个特定时间点的数据。这些带有时间的数据有特殊的存储方式,叫作**时序数据库</strong>
关系型数据库也可以用来存储时间序列数据,但是会慢一些。为什么时序数据库会更快呢?这就要提到数据库存储数据的方式了。
### 行存储和列存储
时序数据库会更快的原因是它们普遍采用了**列存储**的方式来存储数据,而你熟悉的关系型数据库一般都用**行存储**的方式来存储。
举个例子。假设你要存储外汇信息那至少需要处理这些数据时间、汇率币种、买价格、卖价格和平均价格。下面这幅图展示了连续3个时间点的外汇信息
<img src="https://static001.geekbang.org/resource/image/91/31/91cae6b82139525972a94678f45cfa31.jpg" alt="">
上面这幅图从数学上说是个矩阵,有两个维度。但是存储设备只有一维的地址,不是二维的,所以我们需要把这个矩阵从二维变为一维,这样才能存储到磁盘上。
**关系型数据库采用的降维方法是将矩阵横向切割。**这样每行会作为一个整体存储下来,行与行之间挨在一起。
就像下面这幅图展示的存储方式一样外汇信息被分为3个单位存储每一行是一个单位
<img src="https://static001.geekbang.org/resource/image/4d/5d/4d64e35dfcbea56e5cb0de8219a51a5d.jpg" alt="">
这样存储似乎看起来也可以。问题在于在进行数据查询的时候,需要将每行作为一个整体从文件上加载到内存,这样会拖慢速度。
比如说如果你想算一下这3个时间点对应的买入价格的平均值。虽然你只用到了整个数据的一小部分但是你要将这3个时间点所有数据都加载到内存之后才能完成计算。
列数据库选择了另一种存储方式。**它降维的方式是将矩阵纵向切割。**这样存储的单位就不再是一行而是一列。还是同样的外汇信息现在被分为5个单位存储每一列是一个单位
<img src="https://static001.geekbang.org/resource/image/1f/14/1fc8d3129a2527e432147c03efaaf514.jpg" alt="">
这时候你再想计算这3个时间点对应的买入价格的平均值只需要加载上面这幅图粉红色的部分就可以了。由于大部分数据都不需加载到内存这样就能节省大量的读取时间。很显然对于金融市场数据来说时序数据库是一种更加有效的存取方式。
那为什么会出现这种情况呢?这就要提到数据的业务性了。
金融市场数据和金融业务数据不一样。**市场数据一般是业务处理的结果。**比如你看到的股票价格信息是股票交易所进行了买卖撮合之后的结果,外汇信息是外汇交易之后的结果,利率、指数等等也都是这样生成的。既然市场数据是业务处理的结果,那它就不是业务问题了。
关系型数据库在最开始研发出来的时候是为了解决业务问题。业务有个共同的特点是需要对单个业务数据进行完整的读写。在关系型数据库里,一个业务一般用一行来表示,因此数据库在进行存储优化的时候,选择优化了行的整体读取能力。
而金融市场数据不是业务数据,并不太适合用关系型数据库处理,所以我们在选择存储金融市场数据的时候,会优先选择基于列存储的时序数据库。
### KDB简介
金融行业很早就知道关系型数据库不太适合市场数据的处理所以有自己的行业解决方案。这些方案中最出类拔萃的数据库叫作KDB我在这里也做一个简单说明。
KDB不仅仅是一个数据库它还有自己的编程语言Q和K。其中K源自于一个编程语言叫作A+。A+是KDB作者在摩根士丹利的时候发明的一种编程语言。A+又来源于一门数学编程语言叫作A。
所有这些语言和Lisp一样都属于函数式编程语言所以你在使用KDB的时候会看到很多Lisp的身影。目前A+已经开源,你可以在[这里](http://www.aplusdev.org/)找到它。
先介绍一下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适用的数据量范围是GBTB之间。**比如你的金融市场数据在几十G左右的话是完全没有问题的。同时KDB会大量使用内存因此内存尽量大一点好。
当然KDB也不是没有缺点的。**最主要的缺点是学习门槛高。**KDB的Q和Lisp一样是函数式编程语言市面上会的人不多教材和文档也比较缺乏。因此需要使用者有很强的抽象能力和学习能力很多人学着学着就半途而废了。
KDB**另一个缺点就是太贵。**它的价格非常高,一般只有顶级的金融公司才能承担得起。而且需要整个团队进行周边工具的开发,这就是一笔很高的运营成本。
不过我们一直强调在金融行业要讲究投资回报比而不只是价格。虽然KDB成本这么高但是一旦学会了就能有很快的开发速度和运行速度在每秒几千万上下的金融市场往往能有奇效。
KDB一直以来都在很专业的领域内发展比如金融和医药等。这些年来互联网行业的列数据库也越来越成熟比如现在风头正盛的ClickHouse里面的技术和KDB大同小异。
行业技术的出圈和彼此融合值得我们高兴,在这里我也希望当不同行业的解决方案在进行碰撞的时候,你能够独立思考特殊的方案是如何解决行业的特殊问题,这样你才能形成自己的架构思想,而不是人云亦云。
### 双时序数据库
我们在前面[第6节课](https://time.geekbang.org/column/article/328625)里讲了双时序数据库。虽然双时序数据库的名字里也有“时序数据库”这几个字眼,但是它的实现和时序数据库完全不同,因此适用的场景也不同。
简单说一下双时序数据库的实现有哪些不同。双时序数据库由于多了一个时间维度,就不能按照列存储的方式进行存储。
其实我在第6节课的思考题里已经给你提示了双时序数据库的存储空间复杂度和时间复杂度这些复杂度并不低。而且当你把内容加载到内存之后会发现无法使用CPU的向量指令来加速运算。
这些都导致**双时序数据库不适合吞吐量特别高的业务**,比如股票和外汇业务这些高频交易类业务。但是**它比较适合交易量稍小一些的场外交易类业务**,像债券、期货、资产证券化等等。
我再说一下双时序数据库的实现。虽然这个理论提出来很早,但是市面上的通用产品不多,一般都是金融公司自己研发。
你还记得,我们在[第4节课](https://time.geekbang.org/column/article/325378)领域驱动设计中把系统组件分成了3个部分么其中最重要的就是核心组件。核心组件代表了公司的核心竞争力需要自己研发。**双时序数据库对于大型金融公司来说就是核心竞争力**,所以外界很少知道这个产品。
实现双时序数据库的挑战主要在时间索引的生成和查询你可以参考第6节课思考题的方法或者使用空间树的数据结构来实现。
## 关系型数据库
讲完了时序数据库以后,我们最后来看看关系型数据库这个最有争议的地方。
### 对象关系阻抗不匹配
关系型数据库的争议点主要集中在和面向对象编程之间的冲突。学术界甚至有个专业名词来形容这种冲突对象关系阻抗不匹配Object relational impedance mismatch
面向对象编程里的所有对象之间的关系形成了一个图因此研究方法需要用到数学上的图论。而关系型数据库的模式schema是基于关系代数Relational Algebra是一系列同构Homomorphic的列表组成的集合Set因此用到的是数学上的集合论。
其实你将对象存储到关系型数据库的过程,就是一个将图论翻译到集合论的过程。因为这是两个关系不大的数学理论,所以你在翻译的时候会觉得很不自然。因此,这两者**不匹配的原因是图论和集合论的区别。**
除了原理不匹配之外,它们在数据封装上也有区别。你在学习面向对象编程的时候,老师一定教过你要隐藏类的实现细节,只向外界暴露行为或者接口,类与类之间通过接口来进行交互。但是关系型数据库会暴露所有的内部细节,你在数据库里看到的是所有数据最原始的表现形式。数据库的表与表之间交互是原始数据的直接交互,没有任何抽象出来的行为或者接口。
所以**面向对象编程里有对象和行为,而关系型数据库里只有数据**,这两者有本质的区别,这点你需要仔细去体会。
虽然面向对象编程和关系型数据库里都有数据,但是它们的数据并不一样。面向对象编程里的对象本身也是数据,这是一个更高级和复杂的数据。而数据库里存储的是基本数据格式。**这两者的数据抽象程度不一样**。
仔细想想,你会发现它们俩还有很多原理上就不一致的地方,比如说面向对象编程有公有和私有属性,有访问权限,还有一致性校验和继承。所有这些都不能直接反映到关系型数据库里。
所以在日常开发中我们不得不使用一些奇技淫巧来强行将业务对象存储到关系型数据库里。时间久了大家也会试着解决这个对象关系阻抗不匹配的问题所以就有了NewSql以前叫NoSql这个新的概念。
### 树状数据存储
大多数情况下业务数据之间不是图的关系而是树状结构。这颗树的根节点是业务交易交易的对象和细节作为子节点一步一步向下展开所以也叫这种结构为雪花snowflake
NewSql在诞生的时候解决了两个问题。第一个解决的问题是高并发和高流量我们会在[第13](https://time.geekbang.org/column/article/335994)和[14节课](https://time.geekbang.org/column/article/336686)详细给你讲解。第二个解决的问题就是树状数据的存储问题。最开始学术界主推的是XML的存储格式但是没有流行起来它被后来工业界推行的JSON格式取代了。
对于你来说树状数据存储格式并不重要。重要的是在NewSql里面向对象编程里的对象可以作为一个原子单元来存储这样就解决了大多数在前面提到的对象关系阻抗不匹配问题。
虽然NewSql解决了对象的存储问题但是它没有完美解决对象的查询问题。NewSql普遍采用了分布式架构设计我们会在第14节课给你讲解最终一致性甚至分布式事务在解决二级索引一致性上有非常大的时间开销因此**二级索引一般会采用最终一致性的实现方式,这样会导致查询不准**。这也是金融行业对于NewSql一直采取观望态度的原因。
那查询不准有问题吗?如果你继续沿用现在关系型数据库的同步处理思路,肯定是有问题的。但是如果你是按照异步架构的思路来解决业务问题,在一些特定领域也存在应对的办法。
异步处理会增加架构难度,而关系型数据库之所以成为金融行业万金油,主要是因为事务的支持极大简化了架构难度。所以从投资回报比的角度来考虑,只有在业务量大到逼迫金融公司使用分布式数据存储方案的时候,才会升级到异步处理架构上。
## 小结
在这节课我们学习了金融业务应该如何选择数据存储类型。
在选择存储类型前先要对数据类型分类。按照数据之间关系的复杂度,我们可以把金融数据分为图数据类型、没有关系的数据类型和树状数据类型,它们分别对应了图数据库、时序数据库和关系型数据库。
因为金融业务需要准确地定义数据,所以很少用到图的数据结构。一般会在风控和反洗钱领域用到图相关的工具。
金融市场数据一般使用时序数据库。相比关系型数据库常用的行存储方式时序数据库用了列存储的方式这个方式在存储、读取和计算上都有很大的速度优势。KDB是金融行业的专用列存储数据库它具有更高的执行效率。双时序数据库适合交易量稍小的场外市场业务一般是金融公司自研。
关系型数据库和面向对象编程之间有天然的矛盾。现在的NewSql在解决对象存储方面有更多优势但是由于NewSql普遍采用了分布式架构在使用的时候我们需要小心处理异步处理和最终一致性等关系型数据库不存在的问题。
<img src="https://static001.geekbang.org/resource/image/aa/8a/aaf51cd0f96bf8489c667c518928888a.jpg" alt="">
## 思考题
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格式的数据。你觉得从整个公司层面推广这个特性的话有哪些需要注意的问题呢
欢迎你在留言区和我交流。如果这节课对你有帮助,也欢迎转发给你的朋友,同事,一起学习进步。

View File

@@ -0,0 +1,192 @@
<audio id="audio" title="11 | 系统优化:如何让金融系统运行得更快?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/24/fa/248aa2f702d5c2d7e188279261095efa.mp3"></audio>
你好,我是任杰。
这节课是我们第二个模块“系统正确性保障”的最后一节课。在第二个模块里,我们一起学习了如何正确地处理数据和计算,以及如何做好数据的传输和存储。
不过系统设计得再好,如果不能及时地完成业务处理也不行。所以,在最后一节课里我给你讲讲如何让金融系统运行得更快。
我们重点来看为什么不同业务有不同优化需求,以及常见的优化方式和问题有哪些。吃透了这些优化思路,不但能让你对金融系统的优化有一个系统性的认识,也方便你后续根据自己的需要有针对性地学习提高。
## 背景分析
“快”在不同的环境下有不同的定义。**对于互联网业务来说,快一般意味着吞吐量大。对于金融业务来说,快意味着延时低**。
那为什么会有这两种定义的区别呢?我们先来分析一下互联网业务。**互联网业务在经济学上有一个特点是边际成本Marginal Cost基本为零**。
边际成本决定了业务扩张的成本,所以既然扩张成本很低,那么互联网业务倾向于扩张,而且是大规模扩张。扩张的结果就是互联网业务会有大量的用户,这也决定了互联网业务需要解决的是大流量问题。
那流量大为什么和速度快扯上关系了呢?我说个实际例子你就清楚了。
不知道你有没有在网上秒杀过商品。秒杀的时候你会发现网页变得非常卡,半天显示不了内容。这时候你肯定会抱怨网站速度慢,这是因为在解决秒杀这种大流量问题的时候,互联网通常采用解决方案是用延时来换吞吐量,也就是通过降低你的网页加载速度来支持更多的人秒杀。
这时虽然吞吐量上去了,但延时也增加了。虽然所有人的体验都因此变得更差,但是至少大家还能买到东西,没有出现网站宕机这种更差的结果。**所以互联网里的“快”,指的是服务器集群的处理能力快,能同时处理很多东西。**
让我们回到金融业务,**金融业务的边际成本一般都很高。**金融机构在和机构用户进行对接前双方都要做详细的客户身份识别KYCKnow Your Customer。对接之后就是业务、财务、合规、风控等一系列的流程。因为这些流程都有一定的时间和人力物力成本所以机构类的金融业务很难像互联网一样大规模扩张。
既然机构类的金融业务无法大规模扩张,那么金融机构就无法靠规模优势来彼此竞争,只能靠质量优势来吸引客户。在金融行业里,**时间就是金钱**,所以金融机构就会想办法把速度提高,这样才能帮客户省钱。这也决定了**金融行业的“快”指的就是延时低。**
互联网业务和金融业务有一个重合点,那就是普惠金融,比如说第三方支付、小额信贷等等。普惠金融的业务特点更接近于互联网业务,所以对于快的要求也和互联网业务一样。
尽管金融业务多种多样,但我们把握住相应背景中“快”的本质定义,就能更合理地选择优化方向了。那么接下来,我们就从吞吐量和延时这两个方面,分别来看看金融系统的优化要点。
## 吞吐量优化
首先我们看看吞吐量优化的两种常见方法,分别是分库分表和使用消息队列。
### 分库分表
吞吐量最常见的解决方式就是分库分表。
分库分表指的是将数据库表做横向切割和纵向切割。单个数据库表容易受到单机硬件处理速度的限制,但是在拆封成为了多个部分之后,每个部分都可以放在不同的机器上处理,这样就能使用更多的硬件资源。所以分库分表之后,我们可以用大量的硬件来应对大流量的问题。
我们在切分的时候需要注意一个原则,那就是**切分完的子表需要互相独立,但是也要完全穷尽**MECEMutually Exclusive Collectively Exhaustive。互相独立指的是子表之间不要有任何内容的重复。完全穷尽指的是把原始的表切割完之后不要有任何数据丢失。简单来说在切分表的时候你要保证切分后的结果不多不少。
#### 按哈希值和主键切分
了解了切分的原则之后具体应该怎么切分呢我们来看看最常见的水平切分水平切分的时候会按照数据库的主键来切分。切分的方法一般有两种一种是按主键的哈希Hash值来分另一种是按主键的范围Range来分。
如果按哈希值来分你需要注意哈希函数的值域大小。一般来说我们会把每个哈希值对应的数据都放在一台机器上。因为机器数量有限所以哈希函数的值域一般不大比如10或者100。
**按哈希值切分有一个很大的优点是有一定的随机性。**用户的访问并不一定很随机,有可能出现某些主键范围的访问量特别集中的情况。由于哈希值会将原来的值打散,所以有可能将流量分散在不同的机器上,这样就会避免单台机器过载。
不过哈希值的随机性也带来了一个缺点,那就是连续访问性能差,不过互联网应用很少看到需要主键连续访问的情况。
但是在金融行业有一类数据叫市场数据,比如说股票和期货的实时交易价格信息。这类数据的特点是,在使用的时候一般会访问一个时间段内所有数据,因此在时间上需要连续访问。那么对于这类数据,按照时间的哈希值来切分就不太适合使用场景。
#### 按范围切分
这里我还要简单介绍一下按范围划分的方法,它比较简单。它的优点是主键的范围连续,对于市场数据的访问很友好。
但是,范围连续的优点也会成为缺点。范围连续可能会**导致访问过于集中**,这样有可能造成单机过载。比如说在进行量化分析的时候,可能会大量访问最近几分钟的市场数据,按照范围划分会导致存有最近数据的服务器被大量访问。
#### 分库分表带来的问题
分库分表将原来在一台机器上处理的事情,变成了在多台机器上处理。无论你是按照哪种切分方法,都会带来多机环境下的一些问题。
第一个问题是**正确性**。分库分表之后,大家可以同时更改多个表的内容。由于这些表在不同的机器上,网络通讯需要一定的时间,你很难确定别的机器上的内容是正确的。就算你验证了是正确的,在网络消息传回来的那段时间也可能会发生变化。因此分库分表之后,你需要一定的分布式正确性保障,也就是需要**分布式事务**。
第二个问题是**延时**。分布式事务需要至少两次网络沟通,这也决定了分库分表方案的最低延时。对于个人用户来说,网络延时带来的问题不大,但是对于高频交易相关的机构来说会延时过高。
第三个问题是**容灾**。机器不可能一直在线,一定会出问题,只是时间早晚的问题。如果你学过概率论就会更理解这点,机器数目越多,出问题的概率越高,所以分库分表的情况下一定需要考虑集群的容灾。
第四个问题是**容量限制**。分库分表的过程一旦完成就很难再调整分库的数量。因此有经验的架构师在最开始会做一些看起来“超前”的准备。比如说如果分10个库就够了的情况下可能你会分为20个或者50个。但是互联网应用的增长速度谁都说不准很有可能会突然爆发增长这时候依然会出现集群整体容量不足的情况。
在课程的第三模块,我还会给你详细讲解分布式正确性、容灾,以及动态分库分表的方法,这里你先掌握分库分表会带来哪些问题就可以了。
### 消息队列
吞吐量的另一种优化方式是从消息队列下手,消息队列的核心思想是将流量先写入到消息队列中,然后服务器按照固定的速度处理消息队列内的消息。这样就算是峰值流量进来了,也不会造成服务器过载。
消息系统是很常见的一种处理峰值流量的架构,在这里我也给你举个具体例子。
电商公司在重大节日会举行秒杀抢购的活动。秒杀会生成大量的支付订单,因此会对支付系统产生极大的压力。我们来看看这种压力有什么规律。
秒杀一般分为三个阶段。第一个阶段是准备期,这时候支付系统的流量和平常一样,不需要有特殊的准备。
第二阶段是秒杀开始之后的几分钟。这时候从系统监控上可以看到一个很高的流量峰值。这个峰值是电商系统丢给支付系统的流量,代表了理论上最高的并发量。
支付系统的流量并不会直接处理,而是会写入到消息队列中。消息队列也有一个消息写入速度的上限,但是这个上限非常高,通常不会成为瓶颈。
支付系统会尽最大能力从消息系统中拉取要处理的消息。这个处理的速度是有上限的,一般是分库分表后所有机器的处理能力。和消息系统不同的是,支付系统的处理速度会低于电商系统丢过来的峰值流量。所以你能看到的是,电商系统丢过来的流量很高,但是时间很短。支付系统处理的速度慢,时间也长。
第三阶段是秒杀结束之后。这时候系统流量会慢慢恢复到秒杀开始之前的情况,一切回归正常。
下面这幅图给你展示了简化版的秒杀三个阶段。中间第二阶段,我们一般形象地比喻成**削峰填谷**
<img src="https://static001.geekbang.org/resource/image/3e/bb/3e639e01dayy5e5916829d14f46093bb.jpg" alt="">
那除了削峰填谷,还可不可以继续优化呢?当然是可以的,不过要结合业务。虽然你是在零点秒杀,但是货品需要过一段时间才能送到你手上。等你确认收货之后,商家才能收到你的钱。所以从你付钱到商家收钱中间有很长一段时间,这就给了我们进一步优化的空间。
在秒杀的时候,钱不是从买家账号直接打到卖家账号,而是先打到中间账号,也叫作**担保账号**。所以我们在处理秒杀的时候,只需要处理买家账号到中间账号的流量问题。
买家账号到中间账号还可以进一步切分。我们在学关系型数据库事务的时候,常常提到转账的例子,转账要求两个账号需要同时都变,或者都不变,这就是事务性要求。
可是我们从支付系统角度来看,只要买家账号能正确扣款就行,中间账号稍微延迟一点打款完全没问题。其实再进一步分析,中间账号的打款就算丢了也问题不大。支付系统在每天晚上日切的时候进行所有账户的对账。如果中间账号的打款丢了的话,会通过补账的方式把钱再补回来。
这就意味着中间账号可以异步处理,所以一种可能的优化方法是这样的。
首先,在秒杀开始的时候,也就是在秒杀的第二阶段,支付系统只处理买家账号,将对中间账号的处理暂时搁置下来,这样就能减少一半的账号操作。到了第三阶段,也就是促销结束后,再慢慢恢复对中间账号的处理。
这时候系统多了第四阶段,也就是快递送货之后打款给卖家的阶段。示意图如下:
<img src="https://static001.geekbang.org/resource/image/ab/d4/ab6bdd6ef0927defc1cb2d5295afd3d4.jpg" alt="">
## 延时优化
**吞吐量优化是系统能力的横向扩展,是宏观资源的调配。而延时是系统能力的纵向扩展,是微观资源的调配,因此需要不同的解决方案。**
因为不同的编程语言、操作系统和硬件情况都会有独特的优化手段,介绍起来可能是挂一漏万,所以为了让你理清思路,我选择了相对更常见的单机优化和网络优化。
### 单机优化
提高单机性能有一个反直觉的解决方案是**单线程处理**。我们在[第7节课](https://time.geekbang.org/column/article/326583)讲事件溯源架构的时候提到过单线程的自动机。在[第10节课](https://time.geekbang.org/column/article/332304)也讲过单线程的列数据库。那你有没有想过,为什么单线程可以有这样高的处理速度呢?
我们谈到多线程优势的时候常常会提到可以用到计算机的多个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` 的事情,检查哪些网络链接可以开始读写。然后内核会多做一步,帮你处理网络数据。
如果你的操作是写网络的话,会把你内存的数据写出去。如果你的操作是读操作的话,会把数据读到你预先分配的内存。内核操作完之后会把这些操作的状态记录在另一个列表里,返回给你的用户态进程。示意图如下:
<img src="https://static001.geekbang.org/resource/image/72/31/72c9b7902yy72c08e4175c0529edcc31.jpg" alt=""><br>
你会发现,从架构上来说,`io_uring` 替你把 `epoll` 和之后的**读写操作在内核态批量处理**,同时用户进程和内核共享数据页表,这样**既节省了状态切换开销,也节省了数据拷贝开销。**
目前有一些网络操作频繁的应用正在实验这种新技术。不过相对于已经存在近10年的 `epoll` 来说,`io_uring` 从文档和工具上来说都还不太成熟,所以你要做好挑战的准备。
## 小结
在这节课里我们学习了如何优化金融系统。
首先我们分析了为什么金融系统会有吞吐量和延时这两个优化的方向。普惠金融和互联网业务类似,面向大众,对系统吞吐量要求非常高。机构金融专业性特别强,对延时要求非常高。
接下来我们了解了吞吐量优化的两种常见方法,分别是分库分表和使用消息队列。分库分表有按哈希值和范围这两种不同的划分方式。这两种划分方式都有各自的优缺点,但是它们都有正确性、延时、容灾和容量限制这四个问题。我会在第三个模块讲解应该如何解决这些问题。
消息队列的作用是对流量做削峰填谷。我们用秒杀场景下的支付系统为例,讲解了在碰到问题时应该如何分析业务规律,应该如何利用业务规律的特点来优化系统架构。
最后我们讲了延时优化的一些常见方法。延时优化要从单机优化开始优化对CPU、内存和文件这些资源使用。网络优化主要是通过 `epoll` 来减少在高并发情况下的线程开销同时用io_uring来进一步减少网络操作的用户态和内核态的切换开销。
通过这节课,我希望你还能了解金融业务的多样性所带来的金融系统架构的多样性。
普惠金融的架构是从宏观层面解决架构的横向扩张问题,是互联网云计算的标准使用场景。机构金融是从微观层面解决架构的纵向扩张问题,需要对用户进程、操作系统和硬件做特别的优化和控制,因此非常不适合云计算的解决方案。知道这些区别之后,你还要根据具体业务进行相应的优化和选择。
<img src="https://static001.geekbang.org/resource/image/2e/fd/2e0eafb5836f390f7c65999a2168e8fd.jpg" alt="">
## 思考题
支付系统会有一些超级大账户。这些账户的交易极其活跃,不在秒杀的情况下也会有很高的流量,那秒杀的时候系统压力就更大了。比如说一些卖低价体育类用品的网店,或者收水电煤气费用的公司,都有这些行为特征。那对于这些超大流量的账户,你应该怎么应对呢?
欢迎你在留言区分享你的思考和疑问,如果这节课对你有帮助的话,也欢迎转发给你的同事、朋友,一起讨论金融系统优化的问题。

View File

@@ -0,0 +1,205 @@
<audio id="audio" title="答疑集锦(二) | 思考题解析与账务系统优化" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/6f/e2/6f3a15d58675ff27924ef87f8f283de2.mp3"></audio>
你好,我是任杰。
到今天为止,第二模块的系统正确性保障的内容就告一段落了。在专栏更新的过程中,也很开心看到同学们的留言,我要为你们认真学习、主动思考的精神点赞。
今天我为你准备了这篇加餐,把第二模块的思考题做一个系统梳理。我还是建议你先看完前面每一讲的内容,自己独立思考之后,再来看我这份参考答案。
## 思考题答案
[**第6节课**](https://time.geekbang.org/column/article/328625)
Q双时序数据库里的一种存储方式是将坐标空间切割成尽量多的矩形然后将这些矩形存储在数据系统内。数据库的索引建立在矩形的左下角和右上角这两个坐标点。
具体的切割做法是当坐标系内新增一个数据节点时以这个点为中心将整个坐标系进行水平和垂直切分。下图展示了系统中有3个数据点时的一个切割方式3个数据点将坐标系切割成了16个矩形
<img src="https://static001.geekbang.org/resource/image/a1/a0/a1bc85864e72888e9025efbcd96cd1a0.jpg" alt="">
每个插入操作都会对已有的矩形进行切割。每次查询都会遍历相关的矩形。那么你能算一算这个方案的**存储空间复杂度**和**查询时间复杂度吗**
A存储的空间复杂度是O(n^2)查询的时间复杂度是O(log n)或者O(1)。你可以沿着这个思路继续想想这样的复杂度对双时序数据库的使用有怎样的影响换句话说就是双时序数据库适用的业务有什么特点这个问题你可以在第10节课找到答案。
[**第7节课**](https://time.geekbang.org/column/article/326583)
Q我们在存储事件队列的时候需要存储两个文件。一个存储事件另一个存储事件的索引。在现实中会出现各种异常的情况比如机器可能会中途死机这样有可能文件只写了一部分。
<li>
这时你应该如何检测文件是否完整?
</li>
<li>
这两个需要存储的文件,应该按照怎样的先后顺序存储呢?
</li>
A我们先看看如何判断文件是否完整。一般来说判断文件是否完整是针对于整个文件来说的。如果文件完整就继续使用如果文件有一点点不完整就整个丢弃。
但是,对于事件溯源架构来说,一下子就丢弃到整个文件是比较可惜的,很有可能会造成容灾时的数据恢复成本过高。所以基于事件溯源的架构会尽量找出来哪些文件内容是可以用的,哪些是需要丢弃的。
事件文件和索引文件的完整性检测可以合起来做。我们计算出事件文件每一个内容的校验码比如MD5SHA1HMAC。这个校验码长度是确定的因此可以放在对应的索引文件内这样我们就可以用索引文件来检测事件文件的完整性了。
接下来就是如何判断索引文件的完整性。对于线性写的文件,出问题只会出在文件末尾。因此我们可以先把索引文件的大小裁剪到单个索引的整数倍,然后再检验最后一个索引是否完整。如果不完整就删掉最后一个索引,然后再检查新的最后一个索引是否完整,以此类推。
那就剩下最后一个问题,如何检查单个索引内容是否正确?方法也很简单,每个索引内容的最后面是前面所有内容的校验码即可。
由于我们这种方法是用索引文件来校验事件文件,所以需要先存储事件文件,再存储索引文件。
[**第8节课**](https://time.geekbang.org/column/article/330288)
Q我们在讲如何保证消息至多投放一次的时候说过可以用数据库来做去重工作。不过数据库的容量一般是有限的。
假如你设计的系统预期会运行10年以上。数据库由于存储不了这么久的数据一定会将过期不用的数据进行归档后删掉。这会造成你用来去重的数据有一部分会不见了。这样如果来了一个请求这个请求恰好用了被删掉的ID系统就会重复处理。那么你应该如何做呢
>
<p>**用户“小动物”的留言回复:**<br>
感觉做不到很完美。<br>
1.数据删除时能否留下去重用的字段,因为是有限的个别字段,数据量有限,空间会小一些。但这种只增不减的数据还是会判断空间有限的问题。<br>
2.唯一ID是否可控若可控可带上一些规则如时间、自增ID等。通过规则判断是否已经超过合理期限。但这个的可能性很低因为ID是别人的基本没法介入。<br>
3.消息中增加时间,业务发生时间。超过合理时间范围的数据不做处理。</p>
A我们来看看这个问题的本质是什么。问题要求我们检查一个ID是否属于已经被删掉的ID其实这是一个如何检测元素是否存在于一个集合的问题。由于集合数目偏大所以才造成了检测困难。
用户“小动物”的留言就是一个优化的思路。如果所有ID之间有线性关系而且删掉的内容恰巧是一个连续区间的话我们只需要简单判断一下看看新来的ID是否在删掉的区间的最大和最小值以内就行。
所以接下来就是怎么让ID之间实现线性关系的问题。线性关系意味着任何两个ID之间都可以比大小而且大小关系具有传递性。
你再分析一下就会发现我们需要让ID之间的大小关系遵循它们产生时的物理时间的关系也就是说后生成的ID需要更大。我们一般把这时候的ID叫作**逻辑时间**。逻辑时间反映了事件之间的顺序关系。
所以就像用户“小动物”指出来的一样我们可以在ID中增加时间或者自增ID而且需要业务系统自己来保证逻辑时间的正确性。
[第9节课](https://time.geekbang.org/column/article/331395)
Q实时数据系统的数据节点通常都是价格昂贵的机器。这些机器的处理速度极快。交易所机器运行太快了之后会导致推送给实时用户的数据量过大用户来不及处理。你这时候应该怎么处理这两者速度不一致导致的问题呢
>
<p>**思考题09 tt的回答**<br>
问题中说的速度的差异还会带来数据量的积压,所以还需要有“削峰填谷”的能力,这个正好是消息系统最主要的职责之一。</p>
>
但是,等一下,客户来不及处理的数据真的需要放入数据的“水库”中等待用户后续处理么?这可是实时数据,也许等到客户可以处理的时候,数据的价值已经消失了,所以此时的数据已经不值得用户再去花那么多钱了。
>
所以,**用户在和交易所买席位费的时候先评估自己的需求:要么花费和交易所等量的钱去对接,要不就降低自己的层级。**
>
对交易所来说,提供的就是实时数据,**第一是不能被下游系统阻塞;第二是不必缓存没有被消费的数据,因为缓存完再提供,那提供的就不是实时数据服务了。**
>
推导来推导去,得出的结论不过是:**实时数据就是传输过程中发生丢失就不需要找回的数据。**
A我们先来看看发送端太快会发生什么问题。发送端发送太快接收方就来不及处理因此就会像用户“tt”指出来的一样接收方会发生数据积压。
一种做法是接收方主动丢弃掉积压的数据。但这样会造成一些资源的浪费,我们具体分析一下。
比较容易想到的一个浪费是接收方的CPU。CPU需要处理网络数据包放到内存后丢弃数据。但是CPU浪费的影响还不大影响最大的是时间的浪费。网络处理需要时间如果网络数据处理后就直接丢弃那么这个处理的延时就白费了。因此这个方法最大的问题是增加了有效数据的接收延时。
所以如果发送方和接收方都是内部系统的话我们可以做一个处理速度的协调。接收方如果处理不过来需要丢弃数据那么丢弃之后需要返回给发送方一个丢弃的消息。发送方这时候会降低自己的发送速度直到接收方在一段时间内稳定住不再丢数据。这个做法和TCP最大带宽发现的算法有些类似你可以仔细体会。
在正式部署机器之间,公司一般都会对自己的机器做性能评估,在机器的处理能力上限和自己席位费的上限之间选择一个最小值。
[**第10节课**](https://time.geekbang.org/column/article/332304)
QNewSql出现之后确实解决了很多问题所以传统的关系型数据库也在大力向这方面靠拢。比如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节课](https://time.geekbang.org/column/article/332958)
Q支付系统会有一些超级大账户。这些账户的交易极其活跃不在秒杀的情况下也会有很高的流量那秒杀的时候系统压力就更大了。比如说一些卖低价体育类用品的网店或者收水电煤气费用的公司都有这些行为特征。那对于这些超大流量的账户你应该怎么应对呢
A我在文稿里选取了两条回复这两位同学的答案刚好给了我们两个可以参考的思路。
>
<p>**思考题11 tt回答节选**<br>
这样的账户往往是入账或贷记操作比较多,即要让它可以很快地增加余额而不出错。这样可以把它分成多个子账号,每个账号分别做入账,然后日终的时候再汇总。<br>
或者把金额记录到一个科目里,由于是入账,可以没有余额的概念,这样也不会出错,这样连累加的过程也可以省掉了。而且记录的过程都是新增,可以顺序写,也可以提高性能。</p>
>
<p>**用户luke回答**<br>
NUMA架构线程绑定CPU缓存内核旁路低延迟网卡……</p>
如果出现了超级大账号,最直接的方案是单独给他们分一个库,这样可以直接复用现在分库分表的架构和配置文件。由于他们交易量大,对应的营收也高,所以如果对比时间成本、机器成本和收益,单独拿一个库出来也许是性价比较高的方案。
问题在于如果单个库的处理速度也不够了怎么办。这时候就可以考虑用户“tt”指出来的方法可以将用户账户拆分一个拆成多个。虽然用户用起来不方便但是至少能支持业务。
用户“luke”也指出了另一个思路。如果单个库处理速度不够那么我们也可以纵向扩容增加单机的处理能力。
好了第二模块的思考题解析到这里就结束了。技术的世界总是日新月异我这里稍微闲谈一条新闻不知道你有没有关注到最近历时4年的分布式文件系统 JuiceFS 正式开源了,你有没有思考过,这个系统对于金融行业来说,有没有应用的可能性呢?
我们在第二个模块已经介绍过了,金融系统并不是所有部分的要求都非常高,因此我认为它一定能找到用武之地。
不过,对于金融业务最核心的交易及账务数据来说,它们的数据存储方案需要有过往的长期大规模正确性验证证据,而且对应的数据提供商需要有能力解决对应规模的问题。这就是一个留给你的问题了。
如果你是一家金融公司的CTO系统管理了2万亿的人民币资产每天有1亿日活你会不会将最核心组件搭建在这个开源系统上呢
好了,既然是加餐模块,我还想给学有余力的同学额外补充一个知识点,那就是账务系统的特殊优化。我认为,完整的软件系统并不是一蹴而就,而是逐步迭代和升级的。感兴趣的同学可以仔细体会后面的优化思路,希望可以给你带来更多启发。
## 账务系统的特殊优化
### 举例
你还记得前面第7节课我们讲了一个账务系统的例子么通过那个例子我给你讲了命令和事件存储打快照以及读写分离的查询。不过这些都是一般性的解决方案。因为账务系统特别简单所以它还有特殊的优化方案。
在这里我给你简单梳理一下思路。**这些优化方法并不只局限于账务系统。如果你发现自动机的状态是K/V结构那么很有可能这种优化都适用。**
我们先看看账务系统的初始状态。在最开始所有人的余额显然都是0之后随着转账的发生各个账号余额会发生变动。
假设系统最开始有2笔转账分别是从用户A转给用户B共100元。用户C也转给用户B共100元。同时我们假设允许用户欠款贷记账号。这两笔转账分别对应了两个命令他们的执行情况如下图
<img src="https://static001.geekbang.org/resource/image/45/05/451ab54f1ce2172757f3ac7e9782f205.png" alt="">
### 合并存储
优化的第一个目标是优化事件的存储内容。在账务系统中,如何进行用户操作非常简单,所以我们有能力在生成事件的同时,假装执行这个事件,这样就能得到用户的最终余额情况。
这样我们在生成转账事件的同时,就能知道这笔转账执行前和执行后的用户余额情况。我们可以把事件和前后两个状态都保存在一起,像下图展示的这样:
<img src="https://static001.geekbang.org/resource/image/4d/71/4d6294404a086d25dc3414e065c9c871.jpg" alt="">
上面这幅图可能看不出什么特点。但是如果我们稍微做一些展示上的简单修改,给每个用户一条单独的时间线,你就会发现大不一样了,就像下面这幅图展示的一样:
<img src="https://static001.geekbang.org/resource/image/e1/d2/e1ac3a4a25e8yyba832cfd75699ff2d2.jpg" alt="">
那现在的历史查询就变得非常简单。由于现在每个事件都有对应的前后状态,我们只需要寻找离查询时间最近的事件就可以了。找到了对应的事件,我们就可以找到对应的状态,就像下图展示的一样:
<img src="https://static001.geekbang.org/resource/image/e2/a4/e27a642d8653d631503ce4a8c71e2ba4.jpg" alt="">
因此,采用将事件和状态变化存储放在一起的方式,可以大幅简化查询的复杂度。常用的时序数据库都支持相应的查询语句。如果你再仔细思考一下,会发现**我们其实也不用打快照了**,这样就能进一步节省系统运行的时间。
### 对账优化
将事件和状态变化存储放在一起存储,这样做的另一个好处是可以简化内部对账。账务系统有一个硬性要求是需要对账户余额和余额变动细节进行一一比对。常见的方法是用另一套系统计算所有的余额变动总和,然后和日切余额做对比。我们来看看优化后的版本应该如何处理。
我先给你交代一下,后面的优化需要用到账务系统的两个等式:
1. 前一个事件的最终余额等于下一个事件的初始余额。
1. 每个事件的最终余额等于这个事件的初始余额加上事件变动金额。
优化的思路是将每个用户所有相邻的余额变动都链接起来,同时将一个事件的前后余额也链接起来。链接之后,我们之前举的两个转账的例子,就会变成下面这幅图描述的样子:
<img src="https://static001.geekbang.org/resource/image/5b/e6/5b669f69cb4387e24191ecf36ea9fae6.jpg" alt="">
这样对于任何一个用户的任何一个状态,我们都可以顺着链条找到所有金额变动的过程,并对这个过程进行校验。
这种链接的方式和区块链的思路很像,其实本质是完全一样的。区块链只是以非中心化共识的方式构建了这个链接关系。因为我们的主题不是区块链,所以你如果有兴趣的话可以查看相关资料。
好了,第二模块的思考题我就说到这里,希望能够给你一些启发。也欢迎你继续积极思考,畅所欲言。下节课,我们将要进入到第三个模块了,希望你再接再厉,跟上我的脚步,一起深入学习金融系统的分布式正确性及高可用内容。