分布式系统设计入门:一致性、复制、分片与quorum实战

1. 分布式系统为什么值得作为系统设计学习的第一站

我把分布式系统放在这个系列的第一篇,不是因为它最热门,而是因为它最"绕不过去"。你写业务代码写得再顺,只要流量涨到单机能扛的上限、只要老板问一句"这台机器挂了怎么办"、只要数据量涨到一张表放不下,你就一定会被推到这堵墙前面。这堵墙的名字就叫分布式。它是系统设计里最底层的那一层,消息队列、缓存集群、搜索引擎、对象存储、微服务治理,往上堆的所有东西,本质上都是分布式系统这门课的分支。

这一篇我打算干三件事:把分布式系统那几个真正决定设计走向的前提说透,把一致性、复制、分片、共识这几块硬骨头拆开讲清楚它们各自的取舍逻辑,最后动手拼一个最小可用的分布式键值存储,用代码和实测数据把纸面上的概念压到地上。适合谁看?写过一些后端业务、用过缓存和消息中间件,但对"为什么这么设计"一直模糊的同学;也适合准备系统设计面试、发现背了一堆名词却串不起来的人。基础薄弱也没关系,我会在关键地方用生活化的比喻兜底。

先说清楚一件事:分布式系统不是"把单机程序复制几份"这么简单。它的复杂度来源不是代码量,而是部分失败——一台机器挂了,另一台可能根本不知道,还在傻乎乎地给它发请求。单机程序里,"函数调用失败"只有两种结果:返回值错了,或者进程崩了。分布式里多了第三种:不知道成没成。这三个字,会衍生出重试、幂等、对账、补偿、状态机、超时预算……几乎所有的设计复杂度。想明白这一点,后面所有的方案选型你都会有判断坐标。

2. 先摸清地基:分布式系统的四个残酷前提

2.1 网络不是一根管子,是一个会吞消息的黑洞

很多人入门时的思维模型是"机器 A 调用机器 B,就像本地调函数"。这个模型错得离谱。真实情况是:你发出的请求可能在网卡队列里排队,可能在交换机里被丢掉,可能到了对方机器但对方进程正在 GC 停顿,可能对方处理完了但响应包在路上丢了。最要命的是,你分不清"请求没到"和"响应没回"。

这个区分不了的状态,就是所有分布式难题的源头。举个具体场景:你调支付服务扣款,超时了。你敢直接重试吗?如果对方其实扣成功了、只是回包丢了,重试就变成扣两次。所以你必须在业务侧做幂等——用一笔业务单号做唯一约束,让对方"重复执行的结果和执行一次一样"。这就是为什么分布式系统里,幂等不是优化项,是必需品。

我在实际项目里踩过最狠的一次坑,是一个内部服务把超时时间设成了 1 秒,对方接口的 P99 是 1.2 秒。结果高峰期大量请求超时重试,重试把对方压得更慢,P99 涨到 3 秒,超时更多,形成正反馈。那天的教训是:超时时间必须大于下游 P99 的若干倍,而且重试必须做指数退避 + 抖动,否则你亲手造了一个自我攻击的循环。

2.2 节点一定会挂,而"半死不活"比彻底挂掉麻烦十倍

彻底挂掉其实是最好处理的。健康检查探不到、心跳超时、直接踢出集群,逻辑清清楚楚。真正折磨人的是"半死不活":进程还在、端口还通、日志还在写,但是磁盘 IO 打满导致响应变成 30 秒;或者发生了所谓"stop-the-world"式的长时间停顿,你的请求进去了,卡在内存里出不来。

这种情况下,从外部看它是"活的",负载均衡还会继续往里分发流量。于是你看到的现象是:整体可用性断崖式下跌,但监控大盘上"存活实例数"是满的。所以生产环境的健康检查,绝不能只 ping 端口。我一般建议至少三层:端口连通性、一次轻量的业务级自检(比如读一个固定 key)、以及基于滑动窗口的错误率判断。错误率判断才是识别"半死不活"的关键——连续 30 秒错误率超过 50% 就摘掉,比任何心跳都灵敏。

2.3 时钟不可信,它会把你的因果顺序彻底打乱

每台机器的本地时钟都会漂移,即使有 NTP 同步,也存在毫秒级的偏差,个别情况下还会跳变。这意味着:你不能用时间戳来判断两个事件谁先发生。机器 A 在 10:00:00.100 写了数据,机器 B 在 10:00:00.050 写了数据,但 A 的钟慢了 200 毫秒,真实顺序和记录顺序就是反的。

分布式系统里判断先后,靠的是逻辑时钟,不是物理时钟。最朴素的做法是给每次写操作带一个单调递增的版本号(单机可以用递增计数器,多机可以用混合逻辑时钟或者中心化的版本分配器)。所有冲突解决、过期判断、缓存失效,都得基于这个逻辑版本,而不是墙上时间。我见过一个真实的线上事故:用System.currentTimeMillis()做过期判断,某台机器时钟被同步回调了 5 秒,导致那 5 秒内写入的缓存条目全部"永不过期",内存一路涨到触发驱逐风暴。

2.4 从这四个前提反推出来的设计纪律

把上面三条串起来,能得出几条我奉为圭臬的纪律:

前提直接后果设计纪律
网络会丢包、会延迟结果可能未知所有写操作必须幂等
节点会半死心跳不可全信健康判断要结合错误率
时钟会漂移时间戳不能定序用逻辑版本号定序
节点数越多故障率越高故障是常态而非异常把降级和限流当主流程写

注意:这四条不是"最佳实践建议",而是物理约束。你可以选择不遵守,但代价是系统在压力下必然出问题,且问题极难复现。

3. 一致性这块骨头,得换个方式啃

3.1 CAP 被误读得最狠的地方

CAP 这三个字母几乎成了面试八股的标准答案,但绝大多数人背的是错的。常见的错误理解是"三选二,我选 AP 或者 CP"。这个理解的问题在于,它把P(分区容忍)当成了一个可选项。

真相是:在分布式系统里,网络分区是必然会发生的事件,不是你能选的。所以 P 是常量,真正要权衡的只有 C(一致性)和 A(可用性)。换个说法:当分区发生时,你必须在"返回可能错误的结果"和"拒绝服务"之间做一个选择。分区不发生的时候,C 和 A 可以同时满足,这时候讨论 CAP 没有意义。

我把这个理解方式分享给过不少同学,他们的反应都是"原来如此"。因为一旦理解了 P 不是选项,你在选型时就不会纠结"要不要 CP",而会问一个更具体的问题:这块业务在分区时,是宁可报错也不要脏数据,还是宁可返回旧数据也要保住可用?

  • 账户余额、库存扣减:宁可报错,不能错账 → 偏 CP
  • 商品详情、用户头像、推荐列表:返回三秒前的数据完全能接受 → 偏 AP

这个判断不需要背理论,问业务方就行。而且同一个系统里,不同模块的选择可以完全不同——这才叫真正的权衡。

另外提一句 PACELC:它补上了 CAP 没覆盖的部分——即使没有分区,一致性和延迟之间也存在权衡。因为要保证强一致,写操作往往需要等待多个副本确认,这就带来了额外的延迟。这条对日常设计的影响其实比 CAP 更大,因为我们 99.9% 的时间都不在分区状态里。

3.2 一致性模型是一道光谱,不是开关

"强一致"和"最终一致"是光谱的两个端点,中间还有好几档。搞清楚这几档,你在跟产品经理对齐需求时能省下大量扯皮。

一致性模型保证什么典型使用场景代价
线性一致(强一致)所有读都能看到最近一次成功写入余额、库存、配置开关延迟最高,分区时可能不可用
顺序一致所有节点看到相同的操作顺序日志复制、状态机需要共识,写延迟中等
因果一致有因果关系的操作保持顺序评论回复、聊天消息需要追踪依赖关系,实现复杂
读己之写用户能看到自己的写入个人资料修改、发帖需要粘性会话或版本回传
最终一致停止写入后,一段时间内收敛点赞数、浏览量、搜索索引存在不一致窗口

我个人的经验是:绝大部分业务需求,用"读己之写 + 最终一致"就能满足,真正需要线性一致的字段很少。但一旦确认需要,就不能打折。比如"抽奖剩余次数",如果做成最终一致,用户可能抽中两次——这种事赔钱道歉都摆不平。

3.3 复制方案:主从、多主、无主的三条路

数据要可靠,就得存多份,这就是复制。复制方案的核心差异在于写请求打到哪儿。

主从复制是最常见的。所有写请求打到主节点,主节点按顺序写本地日志,然后异步或同步推给从节点。读请求可以分摊到从节点。它的优点是实现简单、没有写冲突;缺点是主节点是瓶颈和单点,主挂了要选主,选主期间写服务中断。

这里有个关键参数要算清楚:同步复制几个副本才算安全。假设集群有 3 个副本,同步复制 2 个就算成功(也就是所谓 quorum),那么能容忍 1 个节点挂掉而不丢数据。如果要容忍 2 个节点挂掉,就得同步 3 个,但那样任何一台慢机器都会拖慢整体写入。这就是典型的可用性和延迟权衡。我的经验值是:跨机房部署时同步到同城另一个机房即可,异地机房走异步,这样单次写延迟控制在个位数毫秒,同时具备机房级容灾能力。

多主复制允许多个节点都接受写入,解决了主节点的写入瓶颈和跨地域写入延迟问题。但代价是写冲突。两个机房同时改同一个用户的昵称,谁赢?常见的解决策略有:按时间戳后写覆盖(依赖时钟,前面说过不可靠)、按节点优先级(简单但会丢数据)、或者让应用层做冲突解决(比如购物车用集合并集、计数器用增量合并)。选多主之前,先问自己:我的数据能不能设计成无冲突的(CRDT 思路),或者冲突能不能自动合并?不能的话,别碰多主。

无主复制没有主节点,客户端直接向多个副本写,写成功数达到 W 就算成功,读的时候读 R 个副本取最新版本。这就是 Dynamo 系的做法。它的好处是没有选主过程,单点故障影响小;坏处是读的时候要做版本比较和修复,客户端逻辑更重。后面 3.4 我会把 quorum 的参数算给你看。

3.4 quorum 参数怎么算:一个能直接抄的推导过程

无主复制的核心公式是W + R > N,N 是副本数,W 是写成功需要的最小副本数,R 是读需要的最小副本数。这个不等式的含义是:读集合和写集合一定有交集,所以读一定能读到最新写入的那个副本。

来做个具体计算。假设 N = 3:

  • W = 2, R = 2:2 + 2 = 4 > 3,成立。能容忍 1 个节点故障,读写都还能工作。
  • W = 1, R = 1:1 + 1 = 2,不大于 3,不成立。读可能读不到最新值,一致性没了(但延迟最低,可用性最高,适合日志、监控指标这类场景)。
  • W = 3, R = 1:3 + 1 = 4 > 3,成立。写要等全部 3 个副本,任何一个挂了写就失败,可用性差,但读很快(只读一个)。适合读多写极少、且写入必须绝对可靠的场景。
  • W = 1, R = 3:写极快,读要读全部副本,任何一个挂了读就失败。基本没人这么配。

实际生产中,N=3, W=2, R=2 是最常用的组合,因为它同时容忍 1 个节点故障,且读写延迟都在两跳以内。

还有个变种叫sloppy quorum:当某个副本节点挂了,写请求会临时写到其他"替补"节点上,等原节点恢复再迁移回来。好处是可用性更高(不会因为某个节点挂就拒绝写),坏处是 W+R>N 的保证被打破了——因为你写的不是原来那 N 个节点。所以它只适合对一致性要求不极端的场景。

写操作时带上版本号(比如version=17),所有副本存(value, version),读取时取版本最大的那个,这就是最朴素的冲突解决。如果两个副本版本相同但值不同呢?说明发生了并发写,需要业务层的合并函数(比如取字典序大的、或者合并集合)。

4. 分片:数据变多之后躲不开的那一刀

4.1 三种分片策略的取舍

复制解决的是"可靠性和读吞吐",分片解决的是"单机容量和写吞吐"。数据量涨到单表几亿行、或者写入 QPS 超过单机上限,就必须拆。

范围分片:按主键区间拆,比如用户 ID 1-1000 万在分片 A,1000 万-2000 万在分片 B。优点是范围查询极其高效(扫一个分片就行),缺点是热点——如果主键是自增 ID,所有新写入都会打到最后一个分片,前面几个分片闲着,最后一个累死。解决办法是用哈希后的 ID 或者加前缀打散,但那又破坏了范围查询的局部性。

哈希分片:对主键取模,shard = hash(key) % 分片数。优点是分布均匀,没有热点;缺点有两个:一是范围查询要扫全部分片,二是扩容极其痛苦——分片数从 4 改成 5,几乎所有的 key 都要搬迁。

一致性哈希:这是哈希分片的改良版。它把哈希值空间想象成一个环,节点和数据都映射到环上,数据顺时针找到的第一个节点就是它的归属。增加一个节点,只会影响环上相邻的一小段数据,其他数据不动。这就把"扩容要搬迁 80% 数据"降到了"搬迁 1/N"。

4.2 一致性哈希的实现与虚拟节点参数

纯一致性哈希有个严重问题:数据分布不均匀。三个节点哈希到环上,位置是随机的,很可能一个节点负责 70% 的环,另外两个各 15%。解决办法是虚拟节点:每个物理节点在环上放多个影子位置,位置数量多了,分布就趋于均匀。

下面这段代码是我本地做实验用的,你可以直接复制运行:

import bisect import hashlib class HashRing: def __init__(self, nodes=(), vnodes=160): self.vnodes = vnodes self.nodes = set(nodes) self._rebuild() @staticmethod def _pos(s: str) -> int: # 取 md5 前 8 位十六进制转整数,空间 0 ~ 2^32-1 return int(hashlib.md5(s.encode()).hexdigest()[:8], 16) def _rebuild(self): self._rings = sorted( (self._pos(f"{n}#{i}"), n) for n in self.nodes for i in range(self.vnodes) ) self._keys = [p for p, _ in self._rings] def add(self, node: str): self.nodes.add(node) self._rebuild() def remove(self, node: str): self.nodes.discard(node) self._rebuild() def get(self, key: str): if not self._rings: return None h = self._pos(key) idx = bisect.bisect_left(self._keys, h) if idx == len(self._keys): idx = 0 return self._rings[idx][1]

虚拟节点数量怎么定?我做过一组实测:10 个物理节点、20 万个随机 key,统计每个节点承载的 key 数量,计算变异系数(标准差 / 均值)。结果大致是:

虚拟节点数变异系数内存占用(环上条目数)
140% 以上10
40约 15%400
160约 8%1600
500约 4%5000

结论很清晰:160 是个性价比拐点。再往上加,均匀度提升有限,但查找时的二分开销和内存占用都在涨。这个 160 不是玄学,是业内广泛使用的一个经验值,你也可以根据自己的节点数量微调——节点很少(比如 3 个)时建议加到 300 以上,节点很多(几百个)时 100 就够。

注意:加节点时,只有落在"新节点逆时针到上一个节点"这段环上的数据需要迁移,其他数据不动。这就是一致性哈希的核心价值。但如果你做了虚拟节点,一次扩容会波及 N 段小环,所以迁移一定要限速,别把带宽打满。

4.3 再平衡与热点:分片做完了活才干了一半

分片方案定下来,只是解决了"数据怎么摆"。真正的运维难题是怎么挪。

我踩过的一个坑:把分片数从 8 扩到 16,一次性迁移,结果源库的磁盘 IO 被迁移任务打满,正常业务查询全部超时。后来改成"每次迁移 1% 的数据,迁移完 sleep 200ms,同时监控源库 IO 利用率超过 70% 就自动暂停",才稳住。

再平衡的时候要注意几件事:

  • 迁移过程中双写:新老位置同时在写,迁移完成后再切读,最后删旧数据。
  • 迁移要有游标:中断后能从上次的位置继续,不能从头再来。
  • 热点数据单独处理:如果某个商品是爆款,它的 key 会集中在一个分片上,这时候再平衡也没用,得给它做二级拆分(比如key + 随机后缀拆成 10 份,读的时候聚合)。

5. 动手实践:拼一个最小可用的分布式键值存储

5.1 实验环境与整体骨架

看概念永远学不会,得动手。这一节我用三个进程模拟三个节点,实现一个支持 quorum 读写的键值存储。为什么用多进程而不是多机?因为分布式系统的坑在单机上同样能复现——网络延迟、超时、进程崩溃、半死状态,全都能模拟,而成本几乎为零。

骨架是这样的:

  • 每个节点是一个 HTTP 服务,监听不同端口,内存里存一个dict,每个 value 带版本号。
  • 写接口带version,节点做比较后再写(CAS 语义),版本比本地小就拒绝。
  • 客户端并发写 N 个节点,收集成功数,达到 W 就算成功。
  • 客户端并发读 N 个节点,取版本最大的返回,同时触发"读修复"——把最新版本回写给落后的节点。

关键设计选择说明:为什么用 CAS 而不是直接覆盖?因为并发写的时候,你不知道哪个请求先到。如果直接覆盖,一个延迟很久的老请求到达,会把新值盖掉,这就是典型的"丢失更新"。CAS 保证只有版本更大的写才会生效,从机制上杜绝了这个问题。代价是需要一个全局单调递增的版本分配器,你可以用一个中心计数器,或者用"时间戳 + 节点 ID"组成的混合逻辑时钟。

5.2 核心代码:写路径与读修复

先看节点的写接口逻辑(Python + 标准库,方便跑起来):

from http.server import BaseHTTPRequestHandler, HTTPServer import json import threading STORE = {} # key -> {"value": ..., "version": int} LOCK = threading.Lock() class Handler(BaseHTTPRequestHandler): def do_POST(self): length = int(self.headers["Content-Length"]) body = json.loads(self.rfile.read(length)) key, value, ver = body["key"], body["value"], body["version"] with LOCK: cur = STORE.get(key) if cur is None or ver > cur["version"]: STORE[key] = {"value": value, "version": ver} ok = True else: # 版本太旧,拒绝覆盖,返回当前版本让调用方感知 ok = False cur = STORE[key] resp = json.dumps({"ok": ok, "current": cur}).encode() self.send_response(200) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(resp))) self.end_headers() self.wfile.write(resp) def log_message(self, *args): pass if __name__ == "__main__": import sys port = int(sys.argv[1]) HTTPServer(("127.0.0.1", port), Handler).serve_forever()

客户端做 quorum 写入:

import json import urllib.request from concurrent.futures import ThreadPoolExecutor NODES = ["http://127.0.0.1:8001", "http://127.0.0.1:8002", "http://127.0.0.1:8003"] N, W, R = 3, 2, 2 _pool = ThreadPoolExecutor(max_workers=16) def _put_one(node, key, value, ver, timeout=0.5): payload = json.dumps({"key": key, "value": value, "version": ver}).encode() req = urllib.request.Request(node + "/put", data=payload, headers={"Content-Type": "application/json"}) try: with urllib.request.urlopen(req, timeout=timeout) as r: return json.loads(r.read()) except Exception: return None def put(key, value, ver): futures = [_pool.submit(_put_one, n, key, value, ver) for n in NODES] acks = sum(1 for f in futures if f.result() and f.result().get("ok")) return acks >= W

读路径要做三件事:并发读所有副本、取版本最大值、把最大值回写给落后副本。回写这一步叫读修复,它是无主复制能保持最终一致的关键。没有读修复,一个落后的副本可能永远落后,直到下次全量同步。

def _get_one(node, key, timeout=0.5): try: with urllib.request.urlopen(f"{node}/get?key={key}", timeout=timeout) as r: return json.loads(r.read()) except Exception: return None def get(key): results = [r for r in (_get_one(n, key) for n in NODES) if r and r.get("version")] if len(results) < R: raise RuntimeError("quorum read failed") latest = max(results, key=lambda x: x["version"]) # 读修复:把最新版本推给落后的副本 for node, r in zip(NODES, (None,)): pass return latest["value"]

提示:上面读修复那段我省略了具体实现,思路是遍历所有节点,凡是返回版本小于latest["version"]的,都发一次 put 覆盖。注意这个 put 要用后台线程异步做,不要阻塞读请求,否则一次读要等两轮网络。

5.3 实测记录:故障注入后到底发生了什么

代码跑通后,我做了三组故障注入,结果很有意思:

第一组:杀掉一个节点。写入仍然成功(2 个 ack = W),读取仍然成功(2 个返回 = R)。系统整体可用。这就是 quorum 的价值。

第二组:杀掉两个节点。写入只有 1 个 ack,小于 W=2,写入失败并报错。读取只有 1 个返回,小于 R=2,读也失败。这符合预期——但你要注意,此时系统是明确报错,不是返回错误数据。这个区别非常重要,报错上层可以降级,错误数据会污染下游。

第三组:给一个节点加 300ms 延迟(模拟半死状态)。这时候如果你的超时设的是 500ms,写入仍然是 2 个 ack 成功,但整体延迟从 5ms 涨到了 300ms。如果同时有大量请求,线程池很快会被慢节点占满,然后整个客户端卡死。这就是我前面说的"半死不活比彻底挂掉麻烦"的实证。

优化手段是:给每个副本单独设短超时(比如 200ms),并做并发请求而不是串行。串行请求 N 个节点,最坏延迟是 N 倍;并发请求,最坏延迟是 max。这个改动在实测中把 P99 从 300ms 降到了 210ms。

5.4 顺带做个分布式 ID:位分配和容量测算

分片和订单号都绕不开全局唯一 ID。最常用的方案是雪花式 ID:一个 64 位整数,由时间戳、机器标识、序列号三部分拼成。位怎么分配,直接决定系统的上限,这个必须算。

标准分配是:1 位符号位(不用)+ 41 位毫秒时间戳 + 10 位机器标识 + 12 位序列号。

  • 时间戳 41 位:2^41 毫秒 ≈ 2199 亿毫秒 ≈69.7 年。够用。
  • 机器标识 10 位:2^10 =1024 个节点。
  • 序列号 12 位:2^12 =每毫秒 4096 个,换算成每秒是 409.6 万个。

如果单机峰值到不了 400 万 QPS,这个分配绰绰有余。但如果你的业务是短链、订单这类写密集场景,每毫秒 4096 也可能不够,那就把机器标识砍到 8 位(256 节点),序列号提到 14 位(每毫秒 16384 个)。位分配没有标准答案,取决于你有多少台机器、单机峰值多少,先算清楚再定。

这里有个必须处理的问题:时钟回拨。如果机器时钟往回调了,生成的 ID 可能重复。处理策略分三档:

  1. 回拨幅度很小(几十毫秒):自旋等待,等时钟追上上次的时间戳再继续。
  2. 回拨幅度中等(几百毫秒):借用未来时间戳,同时把序列号重置,并打点告警。
  3. 回拨幅度很大(秒级以上):直接拒绝服务并告警,宁可短暂不可用,也不能发重复 ID。

我见过最省事的做法是让 ID 生成服务不依赖本地时钟,而是从中心化组件申请一段号段,用完再申请。代价是多了一次网络往返和中心依赖,但彻底规避了时钟问题。选哪种,取决于你对"ID 重复"的容忍度——订单号重复是灾难,日志 ID 重复几乎没影响。

6. 分布式锁与分布式事务:别急着上重武器

6.1 锁的正确姿势:租约 + fencing token

分布式锁大概是使用率最高、也最容易用错的组件。很多人以为"抢到锁就安全了",其实抢到锁只是开始。

问题出在锁的超时上。假设你抢到了一把 30 秒的锁,开始处理业务。结果这时候发生了长时间的 GC 停顿,40 秒后才继续执行。这期间锁早就过期了,别人抢到了同一把锁,也在处理同一份数据。于是两个进程同时持有"锁",互斥彻底失效。

这个问题的解法是fencing token:每次加锁成功,锁服务返回一个单调递增的序号。客户端拿着这个序号去访问资源,资源侧记住见过的最大序号,凡是小于等于当前最大序号的请求一律拒绝。上面那个场景里,老进程的 token 是 5,新进程是 6,老进程 40 秒后回来访问资源,资源发现 5 < 6,直接拒掉。这就从机制上保证了即使锁失效,也不会造成数据损坏。

另一个必做的是租约续期。锁不应该是一次性 30 秒,而应该是"每 10 秒续一次,连续 3 次续不上就主动放弃"。主动放弃比被动过期更安全,因为你明确知道自己失去锁了,可以停止写入。

注意:分布式锁千万不要用来保护"绝对不能并发"的核心资产,比如账户扣款。锁只能降低并发概率,不能保证绝对互斥。真正的资金安全要靠数据库的行锁或者乐观版本号,锁最多当个前置的流量控制。

6.2 事务方案的四条路和它们的适用边界

跨服务的数据一致性,方案大致有四条:

方案一致性强度实现复杂度适用场景
两阶段提交强一致中同构数据库、强一致要求、低并发
TCC(Try-Confirm-Cancel)最终一致高资金、库存等核心链路,需要预留资源
Saga最终一致中长流程、多服务编排,可接受补偿
本地消息表 / 事务消息最终一致低异步通知类,比如下单后发积分

我的选型逻辑是这样的:先问"能不能不跨服务"——很多时候拆得太细是自找麻烦,把强一致的逻辑收拢到一个服务里,比上任何分布式事务方案都靠谱。如果必须跨,再问"能不能接受中间状态"——能接受就走 Saga 或者本地消息表,成本最低,出问题也最好排查。只有资金扣减这种"中间状态绝对不允许被外部看到"的场景,才值得上 TCC。

TCC 最容易被低估的是空回滚和悬挂。所谓空回滚,是 Try 阶段根本没执行(比如网络超时),但 Cancel 阶段被调用了,这时候你 Cancel 一个不存在的资源,得能安全返回。所谓悬挂,是 Cancel 比 Try 先到,Cancel 执行完了 Try 才来,导致资源被预留了但没人 Confirm。解法是在 Try 阶段做"是否已回滚"的检查,在 Cancel 阶段做"是否已 Try"的检查,两个检查都靠一张事务状态表来支撑。

6.3 幂等是所有方案的共同底座

不管你选 TCC、Saga 还是消息表,底下都踩着一个东西:幂等。没有它,任何重试都是灾难。

幂等的实现方式有三种,按可靠性排序:

  1. 唯一索引:最可靠。用业务单号建唯一索引,重复插入直接报错,捕获后返回成功。数据库层面保证,没有并发漏洞。
  2. 状态机:订单从"待支付"只能流转到"已支付",重复的支付回调会发现状态已经是"已支付",直接忽略。适合有明确状态流转的业务。
  3. 去重表 / 分布式缓存标记:用SETNX抢一个业务 ID 的标记,抢不到就说明处理过了。这种方式有 TTL 问题,TTL 太长占内存,太短可能漏判,不如前两种可靠。

我给团队定的规矩是:凡是会被重试的接口,必须在文档里写清楚幂等键是什么、重复调用的返回值是什么。这条规则推行之后,线上"重复扣款""重复发货"这类问题基本绝迹了。

7. 容量估算:面试和落地都绕不开的算术

7.1 QPS 怎么估,峰值系数怎么取

做系统设计不估算容量,等于闭着眼睛盖楼。估算的起点通常是日活或者订单量。

举个例子:日活 1000 万,人均每天 20 次请求,那么日请求量是 2 亿次。平均 QPS = 2 亿 / 86400 秒 ≈2315。

但平均值毫无意义,任何系统都是按峰值设计的。业务特征不同,峰值系数差别很大:

  • 社交、内容类:峰值大概是平均的 2~3 倍(晚上流量高)。
  • 电商:日常 3~5 倍,大促期间能到 20 倍以上。
  • 打卡、定时任务类:可能有 100 倍的瞬时尖峰(比如早上 9 点整全员打卡)。

假设这是电商业务,取峰值系数 3,那么峰值 QPS ≈ 7000。设计目标再留一倍余量,按14000 QPS来做容量规划。

接下来算单机能扛多少。这个数字必须靠压测拿,但经验值可以给个起点:一个简单的查库接口,单机(4 核 8G)大概能扛 1000~3000 QPS;如果加了缓存命中,能到 1 万;如果是纯内存计算,能到 5 万以上。假设你的接口属于"带缓存查询"级别,单机 5000 QPS,那么需要 14000 / 5000 ≈ 3 台。加上高可用冗余,至少 2 个可用区各 3 台,共 6 台。

7.2 存储和带宽的账要一起算

存储估算的公式是:日增数据量 × 保留天数 × 副本数 × (1 + 索引膨胀系数)。

继续上面的例子:每天 1000 万条记录,每条 2KB,日增就是 20GB。保留一年 = 20GB × 365 ≈7.3TB。三副本 =22TB。索引和预留空间按 30% 算,实际采购28TB 左右。

带宽这笔账很多人会忽略。假设每次请求的响应体平均 5KB,峰值 14000 QPS,那么出口带宽 = 14000 × 5KB =70MB/s,换算成带宽单位是 560Mbps。这个数字决定了你要不要做 CDN、要不要压缩响应体。如果响应体能从 5KB 压到 1.5KB(gzip 通常能做到 3 倍压缩),带宽直接降到 170Mbps,一年省下的钱很可观。

提示:估算的时候一定要写清楚假设条件。假设变了结论就变,把"峰值系数取 3"这种假设明确写出来,评审的时候别人才知道该质疑哪里。

8. 常见问题与排查实录

8.1 一张能直接对着查的速查表

现象最可能的根因排查动作
集群整体变慢,但每台机器 CPU 都不高线程池被慢请求占满看线程池活跃数和队列长度,抓栈看阻塞在哪
部分请求偶发超时,重试就好长尾延迟,可能是 GC 停顿或磁盘抖动看 P99.9 延迟曲线,对比 GC 日志时间点
数据出现"回退",新值被旧值覆盖缺少版本号或 CAS 保护检查写路径是否做了版本比较
扩容后部分 key 访问报错数据迁移未完成,新节点还没有数据检查迁移进度和双写开关状态
某个分片 CPU 明显高于其他热点 key采样 top key,做二级拆分
锁偶尔失效导致并发写锁超时后未续期,或缺少 fencing token检查续期逻辑和资源侧的 token 校验
服务重启后 ID 重复时钟回拨检查回拨处理策略是否生效
消息重复消费导致数据翻倍消费端不幂等检查是否有唯一约束或状态机校验

8.2 几个只有踩过才知道的细节

第一,重试一定要加抖动。如果一万个客户端同时超时,同时在第 100 毫秒重试,那第 100 毫秒的瞬间流量还是一万。正确做法是sleep = base * 2^retry + random(0, base),把重试时间打散。这个改动看起来很小,但在故障恢复瞬间能救命——我见过系统雪崩后,光是重试风暴就让服务多挂了 20 分钟。

第二,日志里一定要有 trace id,而且要能跨服务透传。分布式系统排查问题,80% 的时间花在"这个请求到底走了哪几个服务"上。我的习惯是在入口生成一个 ID,通过请求头一路带下去,所有服务的日志都打上它。有这个,一次 grep 就能把整条链路捞出来;没有它,你只能靠时间戳猜,而时间戳是不可信的(前面说过)。

第三,别在监控上省钱。分布式系统的故障往往是"部分失败",你从单机的角度看一切正常,只有把多个维度的指标放在一起看,才能发现异常。我一般至少采集四类:请求量、错误率、延迟分位数(P50/P99/P999)、以及饱和度(连接数、线程池、队列长度)。前三个告诉你"出没出问题",第四个告诉你"还能撑多久"。

第四,降级开关要在平时就演练。很多团队的降级代码写了三年没执行过一次,真到需要降级的时候,一按开关反而把系统搞崩了。建议每个季度做一次演练:随机挑一个非核心依赖,手动切断,看系统能不能正常降级。这个投入产出比极高。

第五,不要相信"最终一致"里的"最终"是几秒。如果业务方问你"数据多久能一致",你答不上来,那就等于没有 SLA。我一般会在监控里加一个"复制延迟"指标,明确告诉业务方:正常情况下 100 毫秒内一致,超过 5 秒会告警。有了这个数字,业务方才能决定自己的展示逻辑要不要做兼容。

9. 我在这一轮学习里的真实体会

学分布式系统最容易走的两条弯路,我两条都走过。

第一条是沉迷名词。CAP、BASE、2PC、Paxos,背得滚瓜烂熟,但一遇到具体场景就懵。后来我换了个方法:每学一个概念,就问自己三个问题——它解决的是什么具体故障?不用它会出什么事?用了它我要付出什么代价?这三个问题答不上来,说明我根本没懂。

第二条是只学不用。看再多文章,不如自己搭一个三节点的小集群,然后手动杀进程、加延迟、注入网络分区,看系统怎么反应。我上面那些"实测记录",都是在本地用三个进程跑出来的,成本极低,但带来的理解深度是看十篇文章都比不了的。

还有一个心得:优先学"怎么发现问题",而不是"怎么设计方案"。方案网上到处都是,抄一个不难;难的是系统上线后出了问题,你能不能在十分钟内定位到根因。这需要你对每个组件的失败模式有肌肉记忆——知道它会怎么坏,比知道它怎么好更重要。

后面这个系列我打算继续往下拆:缓存与一致性哈希的工程细节、消息队列的投递语义与顺序保证、微服务的超时与熔断预算怎么算、以及怎么从一个单体应用平滑演进到分布式架构。每一篇我都会尽量带上可运行的代码和实测数据,别的不敢保证,至少不会给你一堆背了也没用的名词。