分布式事务:两阶段提交与 TCC

分布式事务 本文是分布式算法科普系列第四篇。前三篇讲了服务发现、共识算法、流控——都是"怎么把活分下去"和"怎么保护自己不被冲垮"。这一篇回到一个老问题:一笔业务操作跨越了多个服务,怎么保证数据要么全成功、要么全回滚? 一、故事:数据库拆了,事务怎么办 1970 年代,随着数据库从单机走向网络化,一个此前不存在的问题浮现出来——一笔业务需要同时修改两台机器上的数据,怎么保证原子性? 在单机数据库上,事务是再自然不过的事情——BEGIN → 改 A 表 → 改 B 表 → COMMIT。数据库内部用 undo log 和 redo log 保证崩溃恢复后数据的一致性。但如果 A 表在机器 1 上,B 表在机器 2 上——COMMIT 只对机器 1 生效,机器 2 没收到,或者收到了但执行到一半宕机了——怎么办? Jim Gray 在 1978 年的《Notes on Data Base Operating Systems》中首次系统描述了两阶段提交(2PC,Two-Phase Commit)——用一个"协调者"站在所有参与者中间,分两步确认:第一步问所有人"准备好了没",第二步根据所有人的答复决定"一起提交"还是"一起回滚"。 这个设计的影响延续至今。XA 规范(1991 年由 X/Open 组织发布)将 2PC 标准化为分布式事务处理的工业协议。几乎所有关系型数据库(MySQL、Oracle、PostgreSQL)都支持 XA 事务。 但 2PC 有一个众所周知的痛点——同步阻塞。协调者挂了,参与者只能干等。于是在微服务时代,一种更灵活的方案出现了——TCC(Try-Confirm-Cancel),把二阶段的"锁资源"升级为"预留资源 + 确认或释放"。 二、前置:单机事务不够用了 先从业务场景开始。一个典型的电商下单流程: 下单(Order 服务)→ 扣库存(Inventory 服务)→ 扣余额(Account 服务) 三个操作跨了三个服务、三套数据库。如果在"扣库存"成功后、“扣余额"之前——Account 服务宕机了——库存扣了,但余额没扣,钱没收,货没了。 这就是分布式事务要解决的问题——跨多个服务(多个数据库)的一组操作,要么全部成功,要么全部回滚。 单机事务靠 ACID 保证——原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)、持久性(Durability)。分布式事务的目标也是 ACID,但实现手段完全不同——它不靠数据库内部的 undo/redo log,而是靠多个参与者之间的协调协议。 ...

一月 21, 2023 · 4 分钟 · 694 字 · yaomingye

流控算法三件套:滑动窗口、漏桶与令牌桶

流控算法三件套 本文是分布式算法科普系列第三篇。前两篇讲了服务怎么找到彼此(Distro)和怎么对数据达成一致(Raft)。这一篇换一个角度——找到服务了、数据也一致了,但如果请求来得太快太多,怎么保护系统不被冲垮? 一、故事:互联网的拥塞崩溃 1986 年 10 月,互联网历史上发生了一次著名的事故——拥塞崩溃(Congestion Collapse)。劳伦斯伯克利实验室和加州大学伯克利分校之间的网络链路,带宽从通常的 32Kbps 骤降到 40bps——没错,不是 40K,是 40,下降了近三个数量级。 原因并不复杂:发送方在拼命重传丢失的数据包,但这些重传又进一步加剧了网络拥堵,导致更多丢包——恶性循环。链路上跑的全是重传包,几乎没有有效数据到达对端。 这次事件促使 Van Jacobson 在 1988 年发表了《Congestion Avoidance and Control》,提出了 TCP 拥塞控制的几个核心算法——慢启动、拥塞避免、快速重传。而 TCP 里的滑动窗口,正是用来控制"同一时刻最多有多少数据在传输途中"的机制。 同一个问题,换个场景照样发生。微服务架构普及后,服务 A 调用服务 B——如果服务 B 处理能力有限,服务 A 还一个劲地往里灌请求,服务 B 的响应会越来越慢,进而拖慢服务 A 的线程池,再拖慢服务 A 的调用方……一路传导,整个系统雪崩。 这就是流控要解决的核心问题:系统处理能力有限,请求来得太猛太快,必须有一个机制把多余的请求挡在外面——宁可拒绝一部分,也不能让整个系统被冲垮。 二、前置:固定窗口的"边界作弊" 在讲滑动窗口之前,先看一眼最简单的限流方案——固定窗口。理解它的缺陷,才能理解为什么需要滑动窗口。 固定窗口的思路很简单:把时间切成一段一段(比如每秒一段),每段内计数,超过阈值就拒绝。 窗口: [0秒 ~ 1秒) → 计数器 = 0 → 请求来了 → 计数器+1 → 计数器≤阈值 → 放行 窗口: [1秒 ~ 2秒) → 计数器归零 → 重新计数 问题出在窗口边界。假设阈值是每秒 100 个请求。有人在 0.95 秒到 1.05 秒之间发了 150 个请求——0.95 到 1 秒 80 个,1 到 1.05 秒 70 个。两个窗口各自的计数器都没超阈值(80 < 100,70 < 100),但实际上在 0.95 ~ 1.05 这 0.1 秒内系统实际承受了 150 个请求。 ...

一月 20, 2023 · 3 分钟 · 529 字 · yaomingye

Raft 协议:选举、日志复制与强一致

Raft 协议 本文是分布式算法科普系列第二篇。上一篇讲了 Distro 协议如何用"去中心化 + 异步同步"实现 AP 模型——写完立刻返回、事后慢慢对齐。这一篇讲它的反面:Raft 如何用"选出一个老板 + 事事多数同意"实现 CP 模型——宁可暂时不可用、绝不返回错误数据。 一、故事:Paxos 太难了,于是有了 Raft 在 Raft 出现之前,分布式共识领域有一个"上古神器"——Paxos。Paxos 由 Leslie Lamport(就是写 LaTeX 的那位)在 1989 年提出,理论正确性无可挑剔,但有一个致命的工程问题:几乎没有人能真正看懂它。 Lamport 在 1998 年发表了一篇补充论文《Paxos Made Simple》,摘要第一句话就是——“The Paxos algorithm, when presented in plain English, is very simple."(用大白话讲,Paxos 其实很简单。)但工程界的反馈很统一:不,它一点也不 simple。 这不是段子,是真实历史。Google 的 Chubby 分布式锁系统在实现 Paxos 的过程中遇到了大量问题,Chubby 的作者 Mike Burrows 有一句著名的吐槽:“世界上只有两种共识算法——Paxos 和那些没人能证明正确的算法。” 2013 年,斯坦福大学的博士生 Diego Ongaro 和导师 John Ousterhout 决定正面解决这个问题。他们的出发点和前面所有人都不一样——把"可理解性"作为算法的首要设计目标,而不是附带的副产品。 Ongaro 从头设计了一个全新的共识算法,刻意把整个协议拆成三个相对独立的模块——领导者选举、日志复制、安全保证——每个模块都可以单独理解。2014 年,他们发表了论文《In Search of an Understandable Consensus Algorithm》(寻找一个可理解的共识算法),Raft 正式诞生。 ...

一月 19, 2023 · 4 分钟 · 803 字 · yaomingye

Distro 协议:去中心化与最终一致

Distro 协议 本文是分布式算法科普系列第一篇。系列面向完全没接触过分布式的业务开发者,用历史故事开场、比喻辅助理解、不涉及数学证明和代码实现。 一、故事:微服务来了,电话号码本怎么办 早年的单体应用,一个进程内部互相调用,不需要"发现"对方——函数调用就行。微服务架构来了,服务实例的数量和位置开始动态变化:扩容加几台、某台机器宕机撤掉、滚动发布换一批新实例。服务 A 要调用服务 B,必须知道此时此刻服务 B 在哪些 IP 和端口上。 最朴素的想法是——搞一个电话号码本。所有服务启动后把自己的地址登记上去,调用方去电话本里查。这个"电话本"就是注册中心。 但问题来了:电话本自己怎么保证不挂?如果只有一个电话本,挂了所有服务都变瞎子。那就多搞几个电话本,每个存一份完整的地址副本。新问题又来了——服务 A 的地址变了,怎么保证所有电话本上写的都一样? 用个比方来理解这个场景: 一个小区有三家传达室,每家都有一本住户登记簿。住户搬家了会通知最近的那家传达室更新记录。有人来访时,随便问哪家传达室都能查到住户的门牌号——哪怕其中一家的登记簿还没来得及更新。 这就是 Distro 协议要解决的问题:在一个多节点集群中,如何让写入请求快速得到响应(高可用),同时保证各节点上的数据最终会变得一致(最终一致)。 二、前置:为什么不能又一致又可用 在深入 Distro 之前,需要先理解一个约束——CAP 定理。 📌 前置知识:CAP 定理说的是,在一个分布式系统中,当网络发生分区(Partition,即节点之间网络不通)时,你只能在一致性(Consistency)和可用性(Availability)之间二选一。网络没出问题时,一致性和可用性可以同时满足。 用电话本的比方说:一号传达室和二号传达室之间电话线断了(网络分区)。此时有人去一号传达室改了一个住户的门牌号(写操作)。一号传达室有两个选择: 选一致性(C):拒绝这个修改请求,因为无法同步给二号传达室。结果:修改失败,但所有传达室的数据保持一致。 选可用性(A):先接受修改,等电话线恢复后再同步给二号传达室。结果:修改成功,但二号传达室暂时还是旧数据。 注册中心这个场景天然更适合选 AP(可用 + 分区容忍)。原因很现实:返回一个略微过期的实例地址(可能已经下线了),调用方最多重试一次换另一个实例;但注册中心如果拒绝查询,整个调用链直接断了。两害相权取其轻。 Raft 协议选了 CP(后面一篇会讲),Distro 协议选了 AP。这就是它们在同一套 Nacos 系统里分工的原因——服务发现走 Distro(AP),配置中心走 Raft(CP)。 三、Distro 的核心设计 3.1 没有主节点 这是 Distro 和 Raft 最根本的区别。Raft 通过选举产生一个主节点(Leader),所有写操作必须经过主节点——主节点把日志复制给从节点,多数确认后提交。如果主节点挂了,必须重新选举,选举期间集群无法写入。 Distro 没有主节点。集群里每个节点都是平等的。写请求可以打到任意一个节点,该节点立刻返回成功,然后异步把变更同步给其他节点。 用一个比方来理解这个差异: Raft 像公司报销流程——所有报销单必须部门经理(Leader)签字才能入账。经理出差了?等着,等他回来或者换新经理。 Distro 像小组共享文档——任何人改了一段,改了就先保存,其他同事打开文档时看到最新版就行。就算有人离线没同步到,等他上线后会自动补上。 去中心化带来的直接好处:没有主节点,就不存在主节点宕机后的"选举窗口"。任何时候任何节点都能处理读写。 3.2 数据分片与一致性哈希 Distro 虽然每个节点都能独立处理写请求,但为了减少冲突和降低同步开销,它对数据做了分片——每个服务实例的注册信息只由一个"负责节点"来权威维护,其他节点虽然也存了这份数据,但只是副本。 分片机制用了一致性哈希。一致性哈希把存储空间组织成一个首尾相连的环(0 ~ 2^32-1)。每个节点在环上占据一个位置,每条数据根据 Key 的哈希值落在环上的某个点,顺时针方向遇到的第一个节点就是这条数据的负责节点。 ...

一月 18, 2023 · 2 分钟 · 376 字 · yaomingye

Redis 高可用架构:主从、哨兵、集群与分片

主从、哨兵、集群与分片——高可用架构全解 一、问题切入:单机 Redis 能走多远 开发环境启动一个 Redis 实例,redis-cli 连上去,SET / GET 一切正常。然后某天线上出了问题: 促销活动期间,Redis 内存飙到 32GB 上限,新的写入被拒绝 服务器宕机,缓存全丢,所有请求直接穿透到 MySQL,服务雪崩 同一个 Key 被几百个并发请求同时修改,客户端频繁收到 READONLY 错误 这些问题指向同一个根因:单机 Redis 有三个硬伤。 硬伤 表现 后果 内存上限 一台机器最多几百 GB 内存,存不下全量数据 频繁淘汰 / OOM 单点故障 进程挂掉 → 整个缓存层不可用 请求穿透到 DB,服务雪崩 写吞吐瓶颈 单机只能处理几万 QPS 的写入,核心在主线程串行执行 促销期间扛不住 Redis 为了解决这三个问题,依次演进出了三种架构模式——主从复制、哨兵模式、集群模式。理解这三者之间的关系,是开发者对接云 Redis 服务的前提。 flowchart TD classDef startEnd fill:#701a4c,stroke:#e11d48,stroke-width:2px,color:#fce7f3,font-weight:bold; classDef process fill:#1e1e24,stroke:#6b7280,stroke-width:1.5px,color:#e5e7eb; classDef highlight fill:#450a0a,stroke:#dc2626,stroke-width:1.5px,color:#fecaca,font-weight:bold; S[📦 单机 Redis] --> M[📋 主从复制] M --> S2[🔍 哨兵模式] S2 --> C[🔗 集群模式] M --> M1["解决问题: 读扩展 + 数据冗余\n新增问题: 手动故障转移"] S2 --> S2A["解决问题: 自动故障转移\n新增问题: 写仍单点"] C --> C1["解决问题: 写扩展 + 海量数据\n新增问题: 跨槽限制"] class S,M,S2,C process; class M1,S2A,C1 highlight; class S startEnd; 这张演进路线图的含义:后一层架构不是替代前一层,而是叠加。集群模式内置了主从复制和哨兵的部分能力,但三者解决的问题域并不完全相同。下面逐一展开。 ...

一月 17, 2023 · 6 分钟 · 1251 字 · yaomingye

流控三板斧——Sentinel滑动窗口、令牌桶与Dubbo负载均衡

流控三板斧 前三篇讲了一个核心矛盾:分布式系统里——网络和时钟不可靠——所以你必须在一致性和可用性之间做取舍——Raft 用 majority 保证 CP——Nacos Distro 用最终一致性取 AP。 但取舍不只发生在数据一致性层面——流量控制层面同样存在。每个服务有自己的承载上限——超过上限就必须拒绝一部分请求——这就是限流。拒绝哪些请求?以什么粒度计数?桶还是窗口? 📌 前置知识:需要有 Sentinel 基本概念(知道它是限流熔断组件)和 Dubbo 基本用法(知道 @DubboReference 怎么调用远程服务)。如果还没用过 Sentinel 的 Dashboard——建议先对着官方文档跑一遍 Quick Start——不需要深入——但得知道控制台里"流控规则"长什么样。 一、为什么"每秒 100 个请求"这种限流方式有 Bug——固定窗口的边界突刺 对限流最直观的理解:系统处理能力是每秒 100 个——超过就拒绝。实现这个最简单的办法——搞一个计数器——每秒归零。 // 固定窗口计数器——最朴素的想法 class FixedWindowRateLimiter { private long windowStart = System.currentTimeMillis(); private int counter = 0; private final int limit = 100; public synchronized boolean tryAcquire() { long now = System.currentTimeMillis(); if (now - windowStart > 1000) { windowStart = now; // 新窗口——计数器归零 counter = 0; } if (counter < limit) { counter++; return true; // 放行 } return false; // 限流 } } 看起来没毛病——每秒最多通过 100 个——超过就拒绝。问题出在窗口边界: ...

一月 4, 2023 · 4 分钟 · 701 字 · yaomingye

CAP定理与一致性模型——从Nacos AP/CP双模理解取舍

CAP 定理与一致性模型 上篇讲了两件事:网络不可靠、时钟不可信。结尾留了一句话——这两个不确定性叠加——迫使你在"等精确答案"和"快速给大致答案"之间选边站。 这句话有一个更正式的名字:CAP 定理。 但 CAP 被误解的程度——大概仅次于"TCP 三次握手"——绝大多数文章都把它简化成"一致性、可用性、分区容错性三者选其二"——就像点菜时三选二。 真正的 CAP 远比这复杂——而且它不是一个开关——而是一条光谱。 📌 前置知识:建议先读上篇——理解网络分区和时钟漂移的成因。另外需要有 Nacos 的基本使用经验(知道它可以做注册中心和配置中心即可)。 一、CAP 的经典定义——先搞清楚每个字母到底在说什么 CAP 是 Eric Brewer 在 2000 年提出的——后来由 Gilbert 和 Lynch 在 2002 年给出了形式化证明。注意——CAP 里的"证明"不是实验验证——是数学上严格证明了这三个性质不可能同时满足。 先搞清楚每个字母的精确含义: 字母 全称 经典定义 一句话翻译 C Consistency 每次读操作——都能读到最近一次写操作的结果——所有节点在同一时刻看到的数据完全一致 “你刚写的——马上就能读到” A Availability 每个发给非故障节点的请求——都能在有限时间内得到一个非错误的响应 “请求一定有人接——不会晾着你” P Partition Tolerance 系统在部分节点之间的网络被切断后——仍然能继续对外提供服务 “网线拔了——系统还能撑——不至于完全挂掉” ⚠️ 新手提示:CAP 里的 P(分区容错)不是"系统可以容忍多少台机器宕机"——那叫容错。P 的精确含义是——任意数量的消息丢失或延迟——系统不能进入不可恢复的状态。换句话说——P 不是在问"系统会不会出分区"——分区是客观物理现象——P 是在问"分区发生时——系统还能不能运转"。 现在用一张图看清楚:没有分区时的理想状态 vs 分区发生时的两难。 flowchart TD subgraph nopartition["无网络分区——理想状态"] direction TB c1["客户端写 x=1"]:::startEnd --> n1["节点 A\nx=1"]:::data c1 -.-> n2["节点 B\nx=1\n从 A 同步"]:::data r1["客户端读 x"]:::startEnd --> n2 n2 --> res1["返回 x=1 ✅\nC 和 A 都满足"]:::data end subgraph partition["网络分区发生——A 和 B 互相不可达"] direction TB c2["客户端写 x=2"]:::startEnd --> p1["节点 A\nx=2"]:::data p1 -.->|"❌ 分区——无法同步"| p2["节点 B\nx=1(旧值)"]:::data r2["客户端读 x"]:::startEnd --> p2 p2 --> choice{"节点 B 怎么回复?"}:::condition choice -->|"返回 x=1\n(旧值——保留可用性)"| ap["选了 A——牺牲 C\n❌ 一致性被破坏"]:::reject choice -->|"拒绝响应——\n等网络恢复"| cp["选了 C——牺牲 A\n❌ 可用性被破坏"]:::reject end classDef startEnd fill:#701a4c,stroke:#e11d48,stroke-width:2px,color:#fce7f3,font-weight:bold; classDef data fill:#052e16,stroke:#16a34a,stroke-width:1.5px,color:#bbf7d0,font-weight:bold; classDef condition fill:#2a1147,stroke:#a855f7,stroke-width:1.5px,color:#ede9fe,font-weight:bold; classDef reject fill:#450a0a,stroke:#dc2626,stroke-width:1.5px,color:#fecaca,font-weight:bold; 分区发生时——你只能在"接受不一致"和"拒绝服务"之间二选一。 ...

一月 2, 2023 · 4 分钟 · 759 字 · yaomingye

如果网络会骗你,时钟也会骗你——分布式世界的两个不确定性

如果网络会骗你,时钟也会骗你 单机程序写了几年,什么 bug 都见过——NullPointerException、死循环、线程不安全——但至少有一个信念是牢不可破的:调用一个方法,它要么返回结果,要么抛异常,不会凭空消失。 // 单机世界——确定性 boolean ok = service.deductStock(productId, 5); if (ok) { orderMapper.insert(order); // 扣成功了才下单 } 这段代码在单机上运行了成千上万次——从来没出过问题。if/else 的逻辑像物理定律一样可靠。 然后某天系统拆成了微服务。扣库存从本地方法调用变成了远程 RPC 调用: // 分布式世界——不确定性 boolean ok = rpcService.deductStock(productId, 5); // 这一行代码可能: // - 正常返回 true // - 正常返回 false // - 抛异常 // - 永远不返回——线程一直卡着 // - 扣库存成功——但响应包在网络丢了——你以为失败了 if (ok) { orderMapper.insert(order); } 从那一刻起——之前所有关于"确定性"的直觉——全部失效。 📌 前置知识:本文不需要任何分布式系统经验,但建议有基本的 TCP/HTTP 通信认知(知道"请求-响应"模式即可)。如果写过 Spring Boot 项目,理解 RPC 调用的概念,阅读体验会更好。 一、单机世界 vs 分布式世界——一张图看懂差异 单机程序中——所有事情都发生在一个进程里。方法调用是在同一块内存里跳转指令,操作系统保证要么执行完成、要么异常退出——不存在"不确定有没有执行"这种状态。 ...

一月 1, 2023 · 4 分钟 · 694 字 · yaomingye

MySQL 锁与日志系统:从并发控制到崩溃恢复

锁与日志:并发控制如何实现崩溃恢复 📌 前置知识:前三篇分别讲了 B+树索引、Join 原理、MVCC。这篇讲两个主题——锁(LBCC,基于锁的并发控制)和日志(Redo Log + Binlog)——它们分别在"正确性"和"持久性"上补足了 MVCC 的短板。MVCC 解决读-写冲突,锁解决写-写冲突;日志保证写入的数据断电不丢。 1. 锁的类型:InnoDB 到底有哪些锁 MVCC 让读者不需要锁就能看到一致的数据版本。但当两个事务同时修改同一行时,多版本帮不上忙——因为最终只能有一个版本成为"当前版本"。这就需要锁来协调写-写冲突。 InnoDB 的锁按粒度分为两级:表级锁和行级锁。 表级锁 锁类型 SQL 关键字 行为 表共享锁(S) LOCK TABLE t READ 自己可读不可写,其他人可读不可写 表排他锁(X) LOCK TABLE t WRITE 自己可读写,其他人连读都不行 意向共享锁(IS) 自动加 “我打算对其中某行加 S 锁”——在行上加 S 锁前必须先在表上加 IS 意向排他锁(IX) 自动加 “我打算对其中某行加 X 锁”——在行上加 X 锁前必须先在表上加 IX AUTO-INC 锁 自增列插入 插入自增主键时确保值连续递增 意向锁是 InnoDB 实现多粒度锁的关键。加行锁之前先加表级意向锁,这样其他事务要加表锁时只需检查表的意向锁就能知道该表是否有行锁,不需要逐行检查。比如事务 A 对某行加了 X 锁(先在表级加 IX 锁),事务 B 想 LOCK TABLE t WRITE(加表级 X 锁),B 一检查发现表上有 IX 锁,直接等待,不需要扫描所有的行。 ...

十二月 30, 2022 · 4 分钟 · 663 字 · yaomingye

MySQL 事务与 MVCC:多版本并发控制的完整原理

事务与 MVCC:多版本并发控制原理拆解 📌 前置知识:这篇需要理解前两篇的 B+树结构和聚簇索引。核心概念——隐藏列、Undo Log、ReadView——都是在 B+树的聚簇索引叶子页上工作的。建议读到这里时回想前文 InnoDB 页结构中 User Records 的记录头信息。 0. 60 秒速览:用一句话记住 MVCC 先别管术语,用一个生活场景建立直觉。 想象你正在写一份共享文档(Google Docs / 腾讯文档)。你打开它时,看到的是当时那个版本。别人在你之后改了几版,你不会突然看到"文档变了"——除非你刷新。你写的部分,别人在你保存前也看不到。 MySQL 的 MVCC 就是这个机制: 每次修改不覆盖原数据,而是生成一个新版本。读的人看到的是"自己开始读那一刻"的版本快照,写的人不影响正在读的人。 flowchart LR subgraph "同一行数据 (id=1, age=25)" V3["版本3 age=30DB_TRX_ID=300(当前行)"] V2["版本2 age=28DB_TRX_ID=200"] V1["版本1 age=25DB_TRX_ID=100(INSERT 原始版)"] end T1["事务A开始读"] -->|"ReadView 快照看到版本1"| V1 T2["事务B修改两次"] --> V2 T2 --> V3 V3 -.->|"DB_ROLL_PTR回滚指针"| V2 V2 -.->|"DB_ROLL_PTR"| V1 classDef data fill:#052e16,stroke:#16a34a,stroke-width:2px,color:#bbf7d0,font-weight:bold; classDef process fill:#1e1e24,stroke:#6b7280,stroke-width:2px,color:#e5e7eb; classDef root fill:#0f172a,stroke:#3b82f6,stroke-width:2.5px,color:#bfdbfe,font-weight:bold; class V1,V2,V3 data; class T1,T2 root; 这张图里有 MVCC 的全部核心零件,读完这篇你会逐个认识它们: ...

十二月 29, 2022 · 7 分钟 · 1350 字 · yaomingye
Cat Radio