普通视图

发现新文章,点击刷新页面。
昨天以前CodeSky

如何解决服务中的事务问题

作者 敖天羽
2024年9月15日 21:10

我们经常会被问到这样一个问题:在一个下单流程中,如何保证数据的一致性。

如果我们在单服务单库中运行,那么很简单,使用数据库的事务就可以了。

但是正常来说,现在的所有服务都会采用微服务的架构,也就是说一个下单流程中,「订单服务」到「库存锁定」到「生成账单」到「支付交易」到「回调变更状态」,这几步将会有多个服务来共同完成。

此时我们必然不能让用户的任何一步失败,又或者必须保证失败后回滚一定成功,否则用户钱扣了,交易却没成功;或者造成了超卖,这些都会造成严重客诉。

为此才会引入分布式事务这个概念,也就是保障多个事务之间的一致性,要么全部成功,要么全部失败。

事务概念

在开始前,还是来复习一些基本概念,以便后续方案中来检查是否满足这一概念。

ACID

数据库的事务中我们会经常提到 ACID,也就是数据库事务的基本原则。

  • 原子性(Atomicity):一个事务的所有系列操作步骤被看成一个动作,所有的步骤要么全部完成,要么一个也不会完成。如果在事务过程中发生错误,则会回滚到事务开始前的状态,将要被改变的数据库记录不会被改变。
  • 一致性(Consistency):一致性是指在事务开始之前和事务结束以后,数据库的完整性约束没有被破坏,即数据库事务不能破坏关系数据的完整性及业务逻辑上的一致性。
  • 隔离性(Isolation):主要用于实现并发控制,隔离能够确保并发执行的事务按顺序一个接一个地执行。通过隔离,一个未完成事务不会影响另外一个未完成事务。
  • 持久性(Durability):一旦一个事务被提交,它应该持久保存,不会因为与其他操作冲突而取消这个事务。

而实际上,AID 都是为了保障 C 的一种手段。

CAP

而分布式系统中我们会经常听人提到 CAP 这个概念:

  • 一致性Consistency):在分布式系统中的所有数据备份,在同一时刻是否同样的值。(等同于所有节点访问同一份最新的数据副本)
  • 可用性Availability):在集群中一部分节点故障后,集群整体是否还能响应客户端的读写请求。(对数据更新具备高可用性)
  • 分区容错性Partition Tolerance):以实际效果而言,分区相当于对通信的时限要求。系统如果不能在时限内达成数据一致性,就意味着发生了分区的情况,必须就当前操作在C和A之间做出选择。

CAP 中的三点是不可能三角,也就是说永远不可能同时满足这三项,我们必须要有所取舍。

以下单场景为例:

  • 一致性问题意味着,可能用户实际付钱了,但是订单回调失败,因此显示上用户仍未付款;又或者是用户下单后没有及时减少库存,造成了超卖。
  • 可用性问题意味着,如果我有有三个节点可以负责减库存,如果其中一个节点挂了,其他两个节点能否完成减库存的重任。
  • 分区容错性问题,意味着分区通信失败的情况下是否会造成影响,比如如果下单到锁库存失败了,是否会对整体业务造成影响。

这三个点不可能同时达成,意味着我们必然要放弃其中一个:

  • 要放弃一致性,意味着假设流程中每个节点一定是基于正确的数据在处理值,不强求一致
  • 要放弃可用性,意味着假设流程中每个节点我们得假设必须是全部可用的,不强求可用
  • 要放弃分区容忍性,就意味着我们假设网络永远是可靠的,不强求网络可靠

而很显然,我们不可能假设「全部可用」和「完全可靠」,因此在大多数场景下,我们只能通过牺牲一致性来构建我们的系统。

当然,前面我们提到的无论是 ACID 的一致性,还是 CAP 的一致性,更多的是强一致性。而在业务中,我们往往更多的是保证最终一致性,这也就是为什么我们的交易过程中可能会有延迟,但很少会真的出现重大问题。

Base

  1. Basically Available(基本可用):分布式系统在出现不可预知故障的时候,允许损失部分可用性
  2. Soft state(软状态):软状态也称为弱状态,和硬状态相对,是指允许系统中的数据存在中间状态,并认为该中间状态的存在不会影响系统的整体可用性,即允许系统在不同节点的数据副本之间进行数据同步的过程存在延时。
  3. Eventually consistent(最终一致性):最终一致性强调的是系统中所有的数据副本,在经过一段时间的同步后,最终能够达到一个一致的状态。因此,最终一致性的本质是需要系统保证最终数据能够达到一致,而不需要实时保证系统数据的强一致性。

Base 是对 CAP 中 AP 的补充,牺牲了强一致性来保证高可用。

我们把实现了 ACID 的事务叫做刚性事务(强一致性),而 Base(最终一致性)叫做柔性事务。

同库场景

在开始事务之前,我们先来分析一下数据库事务的做法。

我们都知道数据库设计了 Undo / Redo 两种日志,Undo 日志拿来记录修改行、原值和新值,而 Repo 日志同样也会记录这些值,只是他们的用法并不相同,具体可以异常恢复的执行步骤:

  1. 分析:扫描日志并找到所有没有 End Record 的事务,准备恢复
  2. Redo:重新回放需要执行的事务,执行完毕后增加 End Record 行表示事务结束
  3. Undo:事务 Redo 失败,剩下的就是需要回滚的,根据对应的 Undo 信息回滚

因此,Redo 和 Undo 中的操作都需要是幂等操作。

不同库同服务场景

如果不同库但是同服务,那么久不能简单的使用数据库的事务操作了,因为几个数据库之间是分开提交的,此时越来越接近我们想要讨论的分布式事务了。

当然,由于是在同一个服务中,所以我们直接在代码中进行操作就可以了,在这种情况下,我们会提到两个方案:2PC 和 3PC。

两段式提交:2PC

两段式提交中引入一个协调者来解决多库间的操作,假设我们需要同时操作 ordergoodsuser三张表,2PC 中一共有两个阶段:

  1. 准备阶段:准备阶段需要准备好事务操作,也就是说,协调者先会给参与者(也就是各库)发请求,询问是否准备完毕。各库会先开始执行内部操作,但不进行 commit,而是在确定执行完之后,给协调者回复是或否,如果是否,则回滚。
  2. 提交阶段:如果全部收到了是,那么协调者将会通知所有参与者进行 commit,而如果收到了其中一个否,则通知所有参与者回滚。

但是 2PC 看似美好的背后我们一眼就能看出的问题是:

  1. 单点问题:协调者本身是个单点,如果协调者出现问题,那么大家就都不能正常运行了
  2. 同步阻塞:如果其中一个参与者出现了网络问题,那么所有参与者都会卡着不进行提交,在此期间数据库是上锁的,将造成严重的性能问题。
  3. 网络问题:我们无法保证 commit是百分百送达的,如果部分参与者没收到 commit,那么他们的操作可能是 pending、提交或者回滚中的一种,无法保证数据一致性。

三段式提交:3PC

三段式提交修改了两段式提交,将准备阶段拆细,先询问是否有把握执行成功,再发送给参与者需要写入 redo(不执行 commit),最后再执行 commit。

但是其实 2PC 遇到的问题仍没有得到很好的解决,它发送的指令更多了,也依旧不能解决网络问题,唯一改良的是准备阶段这个低性能操作的提前确定一定程度上对性能有所改善。

分布式事务

分布式事务基本都是为了实现最终一致性,也就是说,我们允许在中间过程中有一段时间的不一致,只要数据最终是一致的就可以了。

TCC

TCC(Try-Confirm-Cancel)又被称为补偿事务。它一共分为三步:

  • Try:尝试执行阶段,完成所有业务可执行性的检查(保障一致性),并且预留好全部需用到的业务资源(保障隔离性)。
  • Confirm:确认执行阶段,不进行任何业务检查,直接使用 Try 阶段准备的资源来完成业务处理。Confirm 阶段可能会重复执行,因此本阶段所执行的操作需要具备幂等性
  • Cancel:取消执行阶段,释放 Try 阶段预留的业务资源。Cancel 阶段可能会重复执行,也需要满足幂等性

Try 中我们冻结了所需要的资源,这样就可以保证不会因为不一致而导致诸如超售之类的问题。

Confirm中我们消费冻结了的资源;而 Cancel则是一种回滚操作。

《凤凰架构》中有图来表示这一过程(其实主要就是懒得画图)。

Pasted image 20240913231235.png
Try 中,账号服务、仓库服务、商家服务会对资源进行预留,并通知成功与否。

如果全部成功,则执行 Confirm流程完成操作。

如果存在失败则执行 Cancel流程取消交易并且解除 Try对资源的冻结。

可以看出,TCC 整体的设计是非常安全而高效的,但是问题也仍然存在:

  1. 业务侵入与开发成本:要实现这样一个事务,意味着整体链路中的每一环都需要有一个 TryConfirmCancel的实现。
  2. 链路超时的影响:如果 Try 阶段有一个失败了,那么会去调用 Cancel方法,这时部分业务可能实际并没有执行 Try,可能会造成空回滚。解决方案是:

    1. 在发起事务同时生成事务 Unique ID
    2. 在每一步执行时写入事务 ID 和业务 ID 和执行步骤
    3. 如果执行 Cancel时没有对应的 Try记录,则不执行
      同样的,如果是响应慢,那么事务发起节点以为超时,准备 Cancel 的时候可能下游刚刚收到 Try命令,那么可以在同样的表中查到对应是否有 Cancel记录,如果有 Cancel,那么不执行 Try

但无论如何,这是一种业务看起来改的很辛苦的方式,如果其中有一个服务是不可控的,可能就玩不下去(比如银行负责收钱),除此以外,可以用:https://seata.apache.org/zh-cn/这样的框架来简化你的实现成本。

SAGA 事务

在 SAGA 事务中,我们不需要进行冻结资源与解冻资源,因此他更适合大多数的业务场景。

SAGA 由一堆本地事务来组成分布式事务。每一个本地事务在更新完数据库之后,会发布一条消息或者一个事件来触发 SAGA 中的下一个本地事务的执行。如果一个本地事务因为某些业务规则无法满足而失败,SAGA 会执行在这个失败的事务之前成功提交的所有事务的补偿操作。

SAGA 通常会有两种实现:

  1. 基于事件
  2. 基于命令

根据我们刚刚的思路,假设我们有「账号」、「仓库」、「商家」三个服务。

在基于事件的过程中账号服务执行成功后会发送一个事件给仓库服务,仓库服务监听并且收到这个事件后进行减库存操作,如果扣除成功,再发送事件给商家,商家在根据事件执行,最后发送事件给账号服务告诉它变更用户的交易状态。

如果商家执行失败,会发送消息给仓库和账号,并进行回滚操作。

这个模式看上去很简单,但实际想想就会发现:

  1. 各业务监听消息是不可控的,谁监听什么完全看各业务自己的开发者,万一漏了或者监听错了很有可能产生问题
  2. 因为监听是不可控的,如果两个服务各自在监听对方的事件来执行,那么形成了环,甚至可能会变成死锁

因此刚刚说的基于事件显得并不是特别靠谱,「基于命令」的实现也就是在此基础上诞生的。

在基于命令的模式中,我们考虑引入一个中央节点,用来记录执行了什么,这一设计原则比较像前面我们数据库事务中提到的 undo/redo 日志,也就是说,我们的事务系统来承担记录undoredo和发送命令(调用)的责任。

如果期间有失败,那么执行 undo日志进行回滚即可。

当然,这里也会存在问题,那就是如果执行 undo期间,业务数据表又被修改了,那么执行的 undo可能会存在问题,这个时候可能就会造成脏写。

再这种情况下,为了避免造成脏写,还需要引入一个全局锁来锁住对应的变更(类似于行锁),避免同一行在回滚时有新的操作修改了该行数据。

虽然说相比 2PC,锁更为精细化,但行锁仍要等待事务完成后释放,因此性能仍有一定的牺牲。

当然,同样的,如果不引入中间调度器,也可以在业务本地建表来存储对应的执行状态。

也就是说,本来是由中间调度器来记录 undoredo,现在由业务方本地来记录执行步骤:

  1. 数据库变更数据
  2. 记录事务操作动作为已发送
  3. 推消息给下一步骤(此处可以是一个消息中间件来保证送达)
  4. 下一服务执行并重复步骤
  5. 完成后回调
  6. 收到回调的业务更新事务操作的动作为已完成

由本地数据表来记录是否执行了指定的动作,方便重试和回滚,由消息中间件来保证送达。

但是这种实现意味着每个业务本地都会有一张事务表,看上去和 TCC 一样,就仍然依赖业务的实现。

可靠消息事务

基于 MQ 实现分布式事务本质上是将所有动作存储在 MQ 内,由 MQ 来完成送达和回滚。

比如 RocketMQ 就提供了事务消息:https://rocketmq.apache.org/zh/docs/featureBehavior/04transactionmessage/

Pasted image 20240914132340.png
详情可见单页文档连接,简单的来说,和 2PC 类似,会先发送半消息 MQ Server 是否能接收。能,则向生产者返回 ACK。

生产者收到 ACK 后开始执行,并向 MQ Server 提交 Commit or Rollback,决定是回滚还是推给下游服务。

如果 MQ Server 未收到二次确认,那么在一定时间后 MQ 将对生产者发送消息回查。

生产者针对消息会检查事务执行结果来决定二次提交 Commit or Rollback

优点在于和中间调度者一样,和业务本身解耦了。但问题是需要两次网络请求,以及业务需要根据其标准实现回查接口。

最大努力交付

最大努力交付这种模式中,如果下游业务没有接收到上游投递的消息,那么可以调用上游提供的补偿查询接口进行事务的补偿。

此时由下游消费者来保证事务的一致性。中间同样通过 MQ 来保证消息投递的可达。

当然,也可以是借由 MQ 来进行的不断重试,但无论如何,这种方式意味着不停地轮询。

Pasted image 20240914133958.png

总结

在实际学习中,我发现不同的文章对这些分布式事务解决方案有不同的归类和细节上的出入,但从套路上来说,解决方案就是这几种,因为他们各有优劣,所以还是需要根据业务进行结合或者改造。

在实际操作的过程中,也可以使用成熟的分布式事务框架来简化开发流程,而不必重复造轮子,文章更多的是介绍范式和怎么选轮子的问题。

参考资料

新电脑纪念:Windows 平替 Mac 尝试

作者 敖天羽
2024年6月22日 21:29
总算在 618 换了新电脑,写篇文章简单纪念下。
本文(基本)不涉及代码,望周知。

在写上一篇稿子的时候,甚至早在之前写那篇 AI 相关稿子的时候,就发现我的 2017 年的台式机和 2019 intel core 的 Mac 可以说是相当不给力,第一没法指望 GPU 加速、第二实在是卡的不行,风扇呼呼转,愣是连写个稿子都卡(主要是需要开大量的 Chrome Tab 查阅资料,这年头的网站真是一个比一个狠),三是公司的电脑是 M2 Pro,可以说是相当流畅,以至于我整天非常气氛于自己当初一万多买的 Mac 和八千买的台式机怎么就那么卡。

痛定思痛,公司的电脑也有限限制,还是得有一个自己的新电脑!

买电脑的心路历程也是有些坎坷的,最开始的时候下单了零刻的迷你主机,但可能是因为品控问题,买回来的连接显示器没反应,而且巨烫无比,差点就把我烫伤,但是由于 Debug 的零配件缺失,所以只能连着内存和 SSD 和迷你主机一起退货了。

考虑再三后还是决定不省这个钱了,第一,还是 N 卡好,第二,Windows 的机器多少也比 Macbook 便宜。

最终买的组装机配置如下:

  • CPU:Intel i7-14700KF
  • 内存:DDR5 16G x 2
  • 显卡:4070TI Super
  • 硬盘:金士顿 1T SSD
  • 主板:微星 Z790 GAMING PLUS

系统升级

预装了 Win11 家庭版,还顺便帮我装上了鲁大师和 360 浏览器(谁要这个啊!)

第一时间就是重装成专业版、系统重置和重新分区。

专业版

群晖的第三方源本身有一个套件叫「KMS 服务器」,启动后只有后台服务,没有任何前端配置。

然后在电脑上以管理员身份运行命令符:

slmgr /upk # 卸载原来的产品密钥
slmgr /skms 192.168.x.xxx # ip 是你的群晖 IP
slmgr /ipk W269N-WFGWX-YVC9B-4J6C9-T83GX # Win11 专业版密钥
slmgr /ato # 激活
slmgr /xpr # 验证激活,查看激活时间

KMS 的密钥可见:https://learn.microsoft.com/zh-cn/windows-server/get-started/kms-client-activation-keys

系统重置与重新分区

在「设置」->「系统」->「恢复」中选择重置此计算机,不保留全部资料就能快速 reset。

分区在「系统」->「存储」->「磁盘和卷」中更改大小,可能要先把其他盘删除,把容量空出来再挪给 C 盘(总之根据经验先分了 200G,毕竟就算软件不在 C 装,还有各种软件的用户设置会往里写)。

软件平替

在大学的时候 Windows to Mac 的时候我曾经寻找过一轮软件平替,到现在用了这么多年,已经习惯了 Mac 里的软件,拿互联网黑话来说就算「有了自己的方法论」,没想到风水轮流转,终于轮到 Mac to Windows 了(某种程度上可能可以算作消费降级?)

  • Markdown 写作工具:Mweb -> Obsidian,然后用群晖 Drive 做同步,就可以免费用到爽啦。目前本文也就是通过 Obsidian 写作而成的。
  • 音视频格式转换工具:Permute -> 格式工厂。格式工厂从小用到大,没想到 2024 年了还是得用格式工厂。
  • 抓包:Proxyman
  • IDE:Jetbrains 全家桶
  • 视频嗅探下载:Downie -> vidjuice(还没有试用,只是听说的)
  • 密码管理:1Password
  • API:Postman
  • Office:WPS
  • 解压缩:系统自带的可以解决大部分场景,但是发现少部分解压缩不了的还得靠 winrar 之类的软件解决
  • Terminal:Windows Terminal
  • 开发环境:WSL + Ubuntu,然后在结合上面的 Terminal 使用效果还可以。(默认会装在 C 盘,所以需要调整一下位置)。
  • 快速启动器:Alfred -> Powertoys Run。我的常见场景:软件快速启动、翻译、和剪贴板都有了,很稳。(强烈推荐 Powetoys,各种工具都很好用)
  • 输入法:本来应该用搜狗没什么疑问的,但是搜狗竟然不支持自定义直角括号,一番研究之后决定使用百度输入法(所以本文是用百度输入法打的字)。

WSL

wsl --install

安装完 WSL 之后问题就是它在 C 盘了,要修改位置,需要再操作一下:

wsl --shutdown # 先停止运行
wsl --export Ubuntu d:\ubuntu.tar # 导出镜像
wsl --unregister Ubuntu # 删除原来安装的环境
wsl --import Ubuntu d:\Ubuntu d:\ubuntu.tar # 导入

然后 WSL 就安装在 D 盘下了√。

接下来就按照 Ubuntu 的处理方式安装完 WSL 里需要的开发工具就可以了。

Jetbrains 全家桶可以直接连进 WSL 开发,之前用 Go 写了个 Demo,效果看上去挺无痕的。

物理磁盘的位置在 /mnt/c ,这样文件就可以在 Windows 的文件管理器中可见了。

远程桌面

远程桌面的方案我调整了半天,因为本身不是网线连接,所以有线唤醒 WOL 的方式肯定是用不了了,想要使用休眠或者睡眠然后再唤起的方案,调整了半天发现 WIFI 依旧会被断开,真的是从 BIOS 改到设备管理器,再改到注册表,然而并没有什么卵用,因为根据 Windows 的文档,S3 就是会在睡眠后关闭 WIFI 的。

最终采取的解决方案是:老子不睡了!PowerToys 里有个「唤醒」工具保持不睡眠。(所以说 PowerToys 很好用吧)。

系统美化

DIY

桌面使用 Fences 归类一下,留下一个壁纸纯欣赏(虽然平时也不会用到,都是用快速启动器启动的)。

然后安装好自定义鼠标指针(目前鼠标指针是我的艾蕾老婆,Windows 在这方面真好啊)。

可惜了已经凉凉的 QQ 宠物,怀念 QQ 宠物在桌面上乱跑的感觉,找了几个桌面宠物类产品都不是很香,不好玩 -^-。

丑陋的字体

Chrome 发现访问站点中宋体的部分(部分文档的代码注释部分)很丑,在chrome://settings/fonts 中将等宽字体改成 Cascadia Mono ,感觉又好了起来,顺便还下了「思源宋体」感觉有需要的时候可以再配置抢救一下。

总结

多年没有用 Windows,没想到 Windows 对于高分屏的支持度已经很不错了,而且也支持了多桌面,后续如果发现了新鲜货再来分享。

主要还是新电脑性能好呀(光拿来写文章是不是有点浪费了,但是打游戏的话其实 Switch + SteamDeck 已经能解决我的大部分游戏的诉求了,倒不如说现在垒着的游戏已经是打都打不完了)。

本文属于凑更新的同时尝试一下在 Windows 下写作,最近工作比较忙,加上还在研究习惯「Windows 的使用」(以及 FGO 又出了新剧情等等)。因此来不及收集其他技术文章的写作素材,先接着拖更了!

❌
❌