Report of the Week [2] – Critical Sections and Ordered Concurrency

/ 1评 / 0

这周某种意义上是在摸鱼,但说实话没有不摸鱼的道理。

资源竞争

微服务由于其天生的并发性,资源竞争是非常常见的问题。资源竞争的传统解决方案是引入一个原子性的共享状态,即引入一个锁。在微服务中,我们也可以引入这样一个原子性的共享状态。

我们已经知道了对于关系型数据库,其可以提供原子性的操作。我们可以创建一个表用于记录当前正在临界区内的服务,并使用存储过程保证查询到锁定的原子性;我们之前使用过的 Redis, 提供 SET 作为一个原子性操作,并同时允许指定 NX 参数在键存在时不对值做出更改;我们可以使用一个公用锁文件,要求操作系统维护对该文件的锁定操作提供原子性。

以上提到的各种东西,都是锁这个概念的具体实现。锁就是一个典型的原子性的共享状态:原子性即任何关于该对象的操作要么没有执行,要么执行完毕,不存在中间状态;共享即所有参与方都可以获知该对象。

无法释放的锁

但使用一个简单锁容易导致这样一个问题:锁定该资源的实体意外退出或者由于程序编写有问题,持有的锁没有释放。这样会导致外部认为该实体一直持有该锁,即使实际上该实体已经退出了临界区,从而导致之后需要操作被锁定资源的实体无法继续进行。

要解决这个问题,一个简单的方案是使锁过期。在锁定时,实体将指定一个最大锁定时间。当超过这个时间仍未解锁时,系统自动认为程序出现了异常,并把锁解开给下一个等待中的实体使用。

不可预料的过期

上述解决方案似乎可以借阅机我们遇到的绝大部分问题,但是实际上这个解决方案要求我们可以洞察未来——去预测一段程序的运行时间。在嵌入式开发中,随着实时操作系统的加持,我们确实有能力去预测甚至指派一段程序的运行时间;但是现在我们在一个分时操作系统上,没有人能预料到下一秒自己的程序运行到哪里,甚至是否在运行。这就导致了一个头疼的边界问题:如果一个实体尚未出临界区,而这时由于锁超时产生了释放,那么被锁定的资源就变得不安全。

要解决这个问题,一个简单的思路就是与其指定一个锁的超时时间,不如指定一个锁的看门狗。看门狗是需要反复投喂的,如果在给定时间内不投喂那么这个锁就会被看门狗释放。但这样又有一点转移矛盾的意思:我们现在需要预测的内容从整个事务的运行时间变成了单个操作或者一些操作的运行时间。而且这也会让逻辑代码内充斥无关的噪音(喂狗指令)。

另一种方法就是尝试将临界区内的操作提交为一个原子性事务。与其使用锁来解决资源的竞争,我们不如学习一下数据库的做法:将关于数据的操作委托给一个可以自身保证原子性的服务去做,自己只需要等待结果就好。但这样做的困难之处就是将程序序列化。JavaScript 尚且可以做到,但其他的语言这样做几乎不可能。所以在数据库端我们最终还是发明了存储过程这种由事务服务解析的 DSL. 为了这一件事情去搞一套 DSL 或许有点得不偿失,况且现在比较新的非关系型数据库是没有这套 DSL 的,所以很遗憾,这套技术如果需要做,那么肯定需要自己造轮子。但是业内还没有这种轮子,为什么?情况有三种:一是其实这种轮子早都有了,你不知道而已;二是之前有人尝试这样做过,但结果并不理想,而且这样做根本就不是一个好主意,你应该重新设计你的逻辑而不是想办法把屎山堆得更高;三是你终于走到了业界前沿,恭喜!当然,绝大部分情况都是前两种。

锁定的有序性

有些时候我们会同时唤起多个实例处理数据,但是这多个实例处理某项数据的顺序是有先后的。这些实例会在唤醒后先并行处理一些互不干扰的数据,然后处理需要竞争的数据。这使得串行化实例唤醒成为一种性能损失。

多个实例并行处理数据我们之前已经可以通过锁的方式实现。但意识到我们之前实现的锁属于非公平锁,当多个实例同时等待锁定时,任何实例都可能会在锁定释放时拿到这个锁。当我们需要保证实例的执行顺序时,这么做可能就会产生难以预料的后果。

解决这个问题的一个朴素方法是实现一个公平锁。一个公平锁是可以保证锁的获取顺序的。当唤醒实例时,我们可以把分配到的公平锁指派给对应实例,进而解决潜在的锁定顺序不对的问题。

单点故障

我们目前的设计都只围绕着一个原子性存储实例进行。如果这个实例失效,那么整个系统就会瘫痪。我们显然是不希望发生这种事情的。

解决这种问题的朴素方法就是尽可能地增加冗余性、尽可能地堆实例上去。但是我们应该已经意识到:这个东西是有状态的,单纯增加实例还会引入实例间同步的问题。MySQL 的主从复制还好,但 Redis 的主从复制是异步的, 当主节点失效时,新推举出的主节点不能保证持有最新的提交,导致锁的状态不正确。

要解决这个问题,我们需要引入一套共识算法保证每个实例的一致性。

共识算法

对于非公平锁,并不需要一个共识算法来同步多个实例之间的状态。Redlock 的实现就是直接向所有配置内的实例发送锁定请求,超过半数锁定请求成功就视为锁定成功;否则视为锁定失败。不同的实例可能持有不同的锁的状态,但是基本上不会出大问题,因为超过半数的条件在不满足的情况下锁是不会申请成功的。Redlock 最多会出现资源可用但是就是锁不上的情况。

但是对于公平锁,就必须保证实例之间的数据同步,否则对于锁的获取顺序将不能得到保证。常见的两种共识算法为 Paxos 和 Raft, 二者都不是一篇文章的一个段落能讲完的,我们将会在后续的内容中引入对这两种算法的介绍。

探索失效状态

随着引入的逻辑逐渐复杂,逻辑的所蕴含的失效状态也会随之增长。在对系统进行设计时,我们不仅仅需要关心系统的有效状态,更应该探索系统可能进入的失效状态,并思考这是由于系统缺陷导致,抑或是系统特性所产生的必然结果。

  1. 小生化说道:

    。。。

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注

Your comments will be submitted to a human moderator and will only be shown publicly after approval. The moderator reserves the full right to not approve any comment without reason. Please be civil.