分布式系统设计入门:一致性、复制、分片与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 数量,计算变异系数(标准差 / 均值)。结果大致是:
| 虚拟节点数 | 变异系数 | 内存占用(环上条目数) |
|---|---|---|
| 1 | 40% 以上 | 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 可能重复。处理策略分三档:
- 回拨幅度很小(几十毫秒):自旋等待,等时钟追上上次的时间戳再继续。
- 回拨幅度中等(几百毫秒):借用未来时间戳,同时把序列号重置,并打点告警。
- 回拨幅度很大(秒级以上):直接拒绝服务并告警,宁可短暂不可用,也不能发重复 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 还是消息表,底下都踩着一个东西:幂等。没有它,任何重试都是灾难。
幂等的实现方式有三种,按可靠性排序:
- 唯一索引:最可靠。用业务单号建唯一索引,重复插入直接报错,捕获后返回成功。数据库层面保证,没有并发漏洞。
- 状态机:订单从"待支付"只能流转到"已支付",重复的支付回调会发现状态已经是"已支付",直接忽略。适合有明确状态流转的业务。
- 去重表 / 分布式缓存标记:用
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,背得滚瓜烂熟,但一遇到具体场景就懵。后来我换了个方法:每学一个概念,就问自己三个问题——它解决的是什么具体故障?不用它会出什么事?用了它我要付出什么代价?这三个问题答不上来,说明我根本没懂。
第二条是只学不用。看再多文章,不如自己搭一个三节点的小集群,然后手动杀进程、加延迟、注入网络分区,看系统怎么反应。我上面那些"实测记录",都是在本地用三个进程跑出来的,成本极低,但带来的理解深度是看十篇文章都比不了的。
还有一个心得:优先学"怎么发现问题",而不是"怎么设计方案"。方案网上到处都是,抄一个不难;难的是系统上线后出了问题,你能不能在十分钟内定位到根因。这需要你对每个组件的失败模式有肌肉记忆——知道它会怎么坏,比知道它怎么好更重要。
后面这个系列我打算继续往下拆:缓存与一致性哈希的工程细节、消息队列的投递语义与顺序保证、微服务的超时与熔断预算怎么算、以及怎么从一个单体应用平滑演进到分布式架构。每一篇我都会尽量带上可运行的代码和实测数据,别的不敢保证,至少不会给你一堆背了也没用的名词。