如何保证万无一失
最后更新于:2022-04-02 04:29:16
## 如何保证万无一失
![](http://cdn.aipin100.cn/18-6-17/87926503.jpg)
假如我们是一家银行,收到第三方发来的一笔转账请求(比如支付宝,微信支付里面的提现操作),收到这笔交易请求后,会进行下面三个动作(有简化,但是能体现核心部分):
1. 创建一条交易记录
2. 将转账操作扔到队列
3. 消费者消费队列,最终完成转账操作
>题外话:这就是为什么我们提现有延时的原因(2小时内到账),因为最先放到队列了,而不是直接处理,为什么不直接处理呢,因为交易量很大,计算能力有限,一时处理不过来。
这关键的三步,怎么保证安全性呢?
什么是安全性呢,在这里,正确性就是安全性。
再来看会出现哪些问题呢?下面我们提出一些问题:
1. 转账失败了。
2. 重复转账了。
3. 转账成功了,但状态还未显示。
……
不管有哪些情况,我们只知道,正确的结果只有一种,那就是转账成功,没有多转也没有少转,并且转账记录显示为成功。
**如果三步都能成功,和预期的一样执行,那么就不会有任何问题了,但关键是没有人能够担保一定不会出问题。**
所以我们就要用一些方法来解决这个不确定性问题了。
先来一步一步的看,假设执行到哪一步失败的话会怎样,以及如何解决。
1. 第一步失败的话,交易记录不能创建,也就没有任何交易被创建。直接响应请求失败。这不会造成什么问题,毕竟这是第一步,第一步失败就不会有后面的事情了,失败了又能有什么问题呢。
2. 第二步失败的话,扔到队列失败了,可是第一步交易已经成功创建了,这可怎么办。
好,先来解决这个问题。
**Q:扔队列操作失败了怎么办?**
**A:** 没人能保证任何操作都一定能成功,队列系统刚好不可用了,只能怪老天了,那此时怎么保证业务不出问题呢,总不能丢失这笔转账吧。这里有几种方式:1. 重试,放队列失败,就不断重试,如果有多条消息,后面消费时要检查交易状态(上锁,不能出现并发问题),要保证幂等性,不能重复消费消息。2. 补偿:在放队列之前(交易创建之后),做一个补偿标记,10小时候或者每隔10小时检查该笔交易的状态(检查交易状态,检查确认,检查队列),如果有问题就重试,或者做好失败记录,记好日志。(这个时间是根据系统处理情况来定的,直到交易状态为完成才删除这个补偿标记)
**解决了这个问题,那么其实下面的n个问题就都解决了,比如队列消息丢失啊,都是同样的思路。不管你哪一步失败,会出现什么问题,我们只知道有且只有一种正确结果,只要保持这种正确结果的最终正确性,系统就是可靠的,万无一失的。**
(如果系统确实出问题了,比如由于前期设计考虑不周全、疏忽而导致的问题,那我们也要最大限度的减少由此带来的影响,最小化损失,并且设法挽回。)
>[danger] 关键词:**重试、补偿、事后校正、确认、ACK确认、容错、柔性事务、两阶段提交、最终一致性、锁、防止并发问题、幂等性、防止重复消费、日志记录**。
* * * * *
### 没有绝对的健壮,但是有相对的
保持悲观的同时也不要忘记乐观。
由于每段代码执行逻辑不同,所处环境也不同,所以出错的几率也不同,**一般主进程存在较小的崩溃概率,因为它逻辑直观,不会掺杂任何的业务逻辑代码,所以几乎不会出错中断(甚至设计中可以认为此部分不会出错,负载均衡部分也同理)**,但是worker进程就不同了,它是业务逻辑的具体执行部分,这里出错是不可预料的,所以对于这部分代码,可以理解为一定会出错,主进程应做好维护工作。
这世界上并没有万无一失,就像两座城堡的通信,信使总是可能不可靠的,你无法确定他一定不会叛变或者旅途中遇到突发情况,不论是什么情况发生,只要城堡是坚固的,我们就是安全的。任何时候我们都不能将安全的赌注压在信使身上。如果你理解这个道理,你构建的系统就是坚固可靠的。
*****
### 一致性保证
例:转账操作
1. **转账表** remittance: (id, from, to, amount, status, create_time, complete_time) : 增加一条转账记录
2. **转账队列消息记录表** remittance_queue: (id, remittance_id status[待消费, 正在执行, 处理完成, 已关闭]) : 增加一条消息记录
> 消费失败,则转入失败记录表进行相应的业务逻辑(如转账日志),而不是标记为处理失败
(有人可能认为 `remittance_queue` 表是不必要的,的确只有 `remittance` 表也做得到行锁并发控制,但是有这张表可以记录某条转账的队列处理记录,并且将锁开销转移到用户不回访问的表上了,所以我认为这张表有存在的必要。同时对于系统内多种这样的操作,可以抽象出一张专门记录队列操作的表,如 队列操作记录表: [操作标识, 对应资源ID, 状态] 这样整个系统只需要这一张表保证就可以。)
3. **插入一条队列消息** `Resque::enqueue()`: task, 载荷(包含 remittance_queue_id)
这样应该被处理的业务逻辑就被 **“装”** 到了队列中,即Broker中。而队列消息是不可靠的(存/取):丢失消息、消息重复(同样的消息存在多条)、重复消费……,即便如此,我们也要在这样的情况下保证整个转账的业务逻辑正确性。
具体要面对和解决的问题就是:幂等性
具体做法:
1. **防止并发问题:** task 消息消费时,必须使用 事务行锁 检查 `remittance_queue` 队列消息的状态是否为 `待消费`,只在 待消费 的状态下执行任务处理。是否已经消费过了,(防止重复消费,或者队列消息不稳定)
2. **补偿机制:** 当一段时间过了还没有到账,说明队列消息可能丢失了,或者其他原因,这时需要重发消息,相当于 **“再次执行转账操作”** (不过只是进行重发队列消息),也就是 **启动补偿机制**。
此时的 **补偿操作** 为:
1. 先 事务行锁 检查这条 转账记录 对应的 队列消息表,根据 `remittance_id` 查到 `remittance_queue` 中所在行,`status` 是否还为 `待消费`,是则将 `status` 标记 `关闭`,相当于抛弃对应的队列消息了,不管你那条队列消息此时到底是跑哪里了。
2. 然后,再次增加一条 队列消息记录
3. 再次插入一条队列
> 上面说补偿时的重发消息相当于 再次执行转账,但这个 “再次执行转账操作” 是加引号的,这个操作与转账操作不同的是,没有其他的业务逻辑(如转账前的相关逻辑),只有安全的队列操作。(操作对象是remittance_queue和队列)
#### 补偿机制如何实现
如:发起一笔转账时,就建立一个补偿,采用延时队列实现,延时时间根据预估,比如转账发起后的12小时。这样当转账开始后的12小时后,补偿机制就会执行。补偿发现转账不成功,会有相应的机制,如重发消息,日志记录,报警等等。如成功了,那么补偿时就什么都不做。
除了延时队列实现补偿,还可以手动触发,比如后台的“重试”按钮,就相当于是手动的补偿机制了。
并且每次操作/补偿,都伴随着补偿(补偿重发时也要再次加个延时补偿),直至最终操作成功。
补偿就像一个护花使者,它不直接与你同行,而是用另一种方式伴随着你。
**另外没有绝对的安全,系统应该定时执行财务对账,这样才能及时发现和规避风险。**
~~~
### 最终一致性 补偿
砍价服务 ⇆ 砍价应用
砍价服务成功调用 砍价应用成功了,但自身的后续处理失败了,那就没办法了,所以调用方需要自己确保自己也能落盘成功
分布式事务很难,只能做到最终一致性,如果失败只能补偿处理了
补偿方案:
1. 被调用方 执行成功后 写入一条 巡视确认记录 后再返回结果
2. 调用方 执行成功后 处理 其巡视记录(删除或标记)
3. 监控经常 定期扫描 超时待确认的巡视记录 就能发现 数据不一致的记录
4. 程序或人工手工修复数据不一致的记录,分析问题原因,这样就能实现 最终一致性
~~~
#### 如果补偿也失败了怎么办?
还有人打破砂锅问到底,说如果补偿、确认等机制也失败了怎么办,好吧,抱着务实的态度,也来说一下:
一般来说,越简单的系统越不容易出错,出错的系统一般是复杂性较大的系统,但凡事总有万一,万一最简单的补偿机制也失效了怎么办?还有报警啊,还有日志啊,那如果报警和日志也都失效了怎么办?好吧。你是不打算放过我了是吧。
其实你不用这么轴,这种情况即使出现我们也不怕,认真的告诉你,我们真的有考虑过这种情况的。别忘了我们还有最后一道防线,人工。人工审查/纠错(成熟的系统都有财务、对账、审查的,即使系统没有这样做,公司也会要求审查对账的)。试想转账超过10天还没有到账的,就算没有审查出来,用户也会打电话来投诉的。
虽然你很烦,不过我还是喜欢这么轴这么认真的你,好样的。
#### 没有绝对的完美
还有人说,我代码很完美了,怎么会有那么多的失败呢,有可能吗?
首先世界上没有最完美的代码,没有永远没有BUG的系统,最完美的代码就是不断进化、升级、更新的代码,最可靠的系统就是得到长期支持和维护的系统。
就算你的代码不出问题,你能控制内因,你也无法控制外因啊。你能预料到地震,海啸,台风等自然灾害吗?硬盘爆炸呢,内存烧焦呢,CPU冒烟呢,停电呢,临时工挖断网线呢,这些你能控制吗,**所以啊,任何时候,任何指令都可能执行失败或者没被执行**,如果你能在脑内模拟出硬件,那么你就能很简单的看出程序是怎么运行的。就能在大脑里面想象程序运行的原理,你就知道这是怎么一回事了。
* * * * *
### 代码的失败率
';
db.connect() // failure rate: 1/千
db.beginTransaction() // failure rate: 1/万
db.ping() // failure rate: 1/十万
db.query() // failure rate: 1/八万
db.query() // failure rate: 1/七万
sleep(2)db.query() // failure rate: 1/五万
db.commit() // failure rate: 1/三万
1. 每次调用都是与 db 服务端进行交互 2. 假设 sql 都是正确的,每行代码也都会有执行失败的可能,只是失败概率不同而已 3. 我们无法改变总是会有可能失败的现实,只能尽量让我们的业务代码处在一个较低的失败率之中,并做好容错处理,考虑异常的情况 4. 即代码并不是完全可靠的,但我们要有容错,确保异常是可控的,这样业务才是安全的,正确的 ---- ### 扩展 我们发现关系型数据库还是很重要的,我们的数据都交给了它,可靠性也交给了它。 [传统事务与柔性事务](https://www.jianshu.com/p/ab1a1c6b08a1) > 日志,幂等性,业务弹性,最终一致,重试,补偿 [支付宝运营架构中柔性事务指的是什么? - 知乎](https://www.zhihu.com/question/31813039) > 业务层2PC(两阶段提交),事后校正 [创始人快去跟公司技术人员落实这件事](https://mp.weixin.qq.com/s/YQbxfI389FLVuwIpYhr9sw) > 有可能出 bug 的代码最终都会出 bug。 > > 一定。 [RabbitMQ从入门到精通---ACK机制 | 菜鸟IT路](https://www.dev-heaven.com/posts/36563.html) [RabbitMQ ACK 机制的意义是什么? - 知乎](https://www.zhihu.com/question/41976893) [TCP报文到达确认(ACK)机制 - CSDN博客](https://blog.csdn.net/wjtxt/article/details/6606022) [php手册经常见到,什么是“二进制安全”? - zhuocr的博客 - CSDN博客](https://blog.csdn.net/zhuocr/article/details/70591310)(充分了解你使用的系统) [事务已提交,数据却丢了,赶紧检查下这个配置!!! | 数据库系列](https://mp.weixin.qq.com/s/-Hx2KKYMEQCcTC-ADEuwVA) * * * * * last update:2018-10-26 16:49:22