分布式理论与实践
开篇:分布式系统的根本挑战
你和朋友在微信群里约饭,你说"去吃火锅",结果消息延迟了 3 秒才送到。在这 3 秒里,朋友发了"去吃日料"。你们俩看到的消息顺序完全不同,于是一个去了火锅店,一个去了日料店。
这就是分布式系统面临的根本挑战:网络是不可靠的。消息会延迟、会丢失、会乱序,节点会宕机、会断网。在这样的环境下,如何让多台机器协同工作、保持数据一致,就是分布式理论要解决的核心问题。
什么是分布式系统?
分布式系统就是把一个集中式系统拆分成多个系统,部署在不同的机器上,整体对外提供服务。对用户来说,感知上就像访问一台计算机一样。
跟集群的区别:分布式是在不同机器上部署不同的服务,通过远程调用协同工作;集群是在不同机器上部署相同的服务,通过负载均衡对外提供服务。一个分布式系统中的每个服务,通常都会以集群方式部署。
分布式系统有四个核心特征:
- 分布性:多台机器在空间上可以随意分布,没有主从之分
- 透明性:系统资源被所有计算机共享
- 同一性:多台机器协作完成同一个任务
- 通信性:任意两台机器都可以通过网络通信
分布式解决了单机的性能瓶颈和单点故障问题,但也带来了网络延迟、数据一致性、分布式事务等新问题。
一、CAP 定理
CAP 定理是分布式系统的"第一定律"。它告诉我们:一个分布式系统最多只能同时满足三项中的两项:
- C(Consistency)一致性:每次读取都能拿到最新写入的数据,或者收到一个错误。注意,这里指的是强一致性,不是我们日常说的"数据最终会一致"
- A(Availability)可用性:每个请求都能收到非错误的响应,但不保证是最新数据
- P(Partition Tolerance)分区容错性:即使网络出现分区(部分节点之间无法通信),系统仍能继续运行
三角形类比
想象一个三角形,三个顶点分别是 C、A、P。你只能选择其中两条边,无法三者兼得。
为什么不能同时满足?
假设有两个节点 N1 和 N2,之间通过网络同步数据。
正常情况:用户在 N1 上写入新数据 V1,通过同步操作 M 传给 N2,两边数据一致。用户无论请求 N1 还是 N2,都能拿到 V1。一切完美。
网络断了(分区):N1 和 N2 之间无法通信。用户在 N1 上写入 V1,数据没法同步到 N2。此时另一个用户请求 N2 读数据,怎么办?
- 选择 C(一致性):N2 拒绝响应,等网络恢复、数据同步完再返回。用户体验是"系统不可用"。放弃了 A。
- 选择 A(可用性):N2 返回旧数据 V0。用户能正常使用,但拿到的不是最新数据。放弃了 C。
这就是 CAP 的本质矛盾。
关于 P 的理解
在分布式系统中,P 是没得选的。网络分区是自然现象——机房断电、光缆被挖断、路由器故障,这些事情随时可能发生。所以实际的选择只在 C 和 A 之间做权衡,同时尽一切办法提升 P。
CAP 之父在《Spanner, 真时, CAP 理论》一文中写道:无法通过降低 CA 来提升 P,要想提升分区容错性,需要通过提升基础设施的稳定性来保障。Google 通过建设私有网络和强大的网络工程能力来保证 P。
真实世界的选择
CA without P(几乎不存在):
在分布式环境下放弃 P 就等于放弃分布式本身。传统的单机 MySQL 就是 CA 的——可用且一致,但不是分布式系统。一旦 MySQL 要考虑主备同步、集群部署,就必须把 P 考虑进来。
CP 系统(保证一致性,牺牲可用性):
- ZooKeeper:分布式协调组件,数据一致性是核心职责。网络分区时宁可拒绝请求,也不返回不一致的数据。当集群中超过半数节点挂了,或者正在进行 leader 选举时,会拒绝写请求
- HBase:分布式存储,强一致是基本要求
- 支付宝:光缆被挖断时选择停机保数据。用户体验是支付宝长时间宕机,但背后是无数工程师在恢复数据、保证一致性
AP 系统(保证可用性,牺牲强一致性):
- Eureka:注册中心,宁可返回旧的服务列表,也不能让调用方拿不到任何信息
- Redis:缓存场景下,可用性比数据绝对一致更重要。集群做数据同步时如果出现网络延迟,不同节点数据可能不一致,但客户端仍然能正常读写
- 12306:买票时提示有票但下单失败(旧数据),总比系统直接不可用要好
大多数互联网系统选择 AP + 最终一致性。牺牲的不是一致性本身,而是强一致性,退而求其次保证最终一致性。
可用性的衡量标准
我们通常用"几个 9"来描述系统的可用性:
| 等级 | 可用率 | 年停机时间 |
|---|---|---|
| 3 个 9 | 99.9% | < 8.8 小时 |
| 4 个 9 | 99.99% | < 53 分钟 |
| 5 个 9 | 99.999% | < 5 分钟 |
淘宝的系统可用性可以达到 5 个 9,意味着全年停机时间不超过 5 分钟。
一致性模型详解
CAP 中的 C 指的是强一致性,但一致性其实是一个光谱,有多个级别:
强一致性模型:
- 线性一致性(Linearizability):最强。操作在实际时间上保持顺序,A 操作在 B 之前完成,B 一定能看到 A 的结果
- 顺序一致性(Sequential Consistency):不要求实时性,但要求所有客户端看到的操作顺序一致。ZooKeeper 保证的就是这种
弱一致性模型:
- 因果一致性:有因果关系的操作保持顺序,无因果关系的可以乱序
- 会话一致性:同一个会话内保证一致性
最终一致性:
- 最宽松,只保证最终所有副本达成一致,中间可能有不一致窗口
- 适用于电商、社交等大多数互联网业务
顺序一致性和最终一致性容易混淆。顺序一致性要求操作顺序一致(虽然可以有延迟),是强一致性的一种。最终一致性不要求顺序,只要结果最终一致就行。
二、BASE 理论
CAP 告诉我们"鱼和熊掌不可兼得",但 BASE 理论告诉我们"差不多也能凑合用"。
BASE 是 CAP 的延伸和妥协:既然做不到强一致性,那就做最终一致性;既然做不到 100% 可用,那就做基本可用。
| 缩写 | 含义 | 说明 |
|---|---|---|
| BA | Basically Available(基本可用) | 允许损失部分可用性,如大促时部分用户看到降级页面 |
| S | Soft State(软状态) | 允许系统存在中间状态,如 INIT -> PROCESSING -> SUCCESS |
| E | Eventual Consistency(最终一致性) | 经过一定时间后,所有数据副本最终达成一致 |
怎么落地 BASE?
核心手段:中间状态(软状态) + 重试(最终一致性) + 降级(基本可用)
举个例子:用户下单后需要扣库存。强一致性的做法是用分布式事务把"创建订单"和"扣库存"放在一个事务里,要么都成功要么都失败。但这样性能差、复杂度高。
BASE 的做法是:
- 先创建订单(状态为 INIT)-- 这就是软状态
- 异步发消息通知库存服务扣减
- 库存扣减成功后回调更新订单状态为 SUCCESS
- 如果失败了?通过重试机制保证最终一致
- 如果重试多次都失败?人工介入或补偿
有了软状态(PROCESSING),我们才能做重试。有了重试,才能实现最终一致性。而在系统压力大时,可以降级一些非核心功能来保证基本可用。
ACID vs BASE
| 维度 | ACID | BASE |
|---|---|---|
| 适用场景 | 传统数据库 | 大型分布式系统 |
| 一致性 | 强一致 | 最终一致 |
| 可用性 | 可能牺牲 | 优先保证 |
| 设计哲学 | 宁可牺牲可用也要保证正确 | 宁可暂时不一致也要保证可用 |
实际系统中,ACID 和 BASE 往往结合使用:核心链路(如支付扣款)用 ACID,非核心链路(如积分发放、通知推送)用 BASE。
三、分布式 ID 方案
在单体应用中,数据库自增 ID 就够了。但分库分表后,多个库各自自增,ID 就会重复。这时候需要一个全局唯一的 ID 生成方案。
对分布式 ID 的要求通常是:
- 全局唯一:这是最基本的
- 高性能高可用:ID 生成不能成为瓶颈
- 趋势递增:方便数据库建索引和范围查询
方案对比
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| UUID | 基于时间+MAC+随机数 | 本地生成,性能高,简单 | 无序,太长(32位),无业务含义,不适合做主键 |
| 数据库自增 | 单表自增主键 | 简单,有序 | 单点瓶颈,性能差 |
| 号段模式 | 批量从数据库取一段 ID | 性能好,单节点内严格递增 | 跨节点不保证全局递增 |
| Redis incr | 基于 Redis 原子自增 | 性能高,集群解决单点 | Redis 有数据丢失风险 |
| 雪花算法 | 时间戳+数据中心+机器+序列号 | 趋势递增,不依赖外部存储,高性能 | 依赖时钟,有时钟回拨问题 |
| Leaf/UidGenerator | 整合号段+雪花,做了优化 | 功能完善,解决时钟回拨 | 引入额外依赖 |
雪花算法详解
雪花算法(Snowflake)是 Twitter 开源的分布式 ID 方案,生成 64 位 Long 类型 ID:
| 1 bit 符号位 | 41 bit 时间戳 | 5 bit 数据中心 | 5 bit 机器 | 12 bit 序列号 |- 符号位 1 bit:始终为 0
- 时间戳 41 bit:精确到毫秒,可用 69 年
- 数据中心 5 bit:最多 32 个数据中心
- 机器标识 5 bit:每个数据中心最多 32 台机器
- 序列号 12 bit:同一毫秒内最多生成 4096 个 ID
唯一性保证:不同毫秒时间戳不同;同一毫秒不同机器的标识不同;同一毫秒同一机器的序列号不同。三者组合保证全局唯一。每秒钟能生成数百万个 ID。
时钟回拨问题:如果服务器时间因为 NTP 同步被回调,就可能生成重复的时间戳。对于同一台机器,数据中心标识和机器标识不变,如果时间戳又相同,一旦序列号也一致,就会生成重复 ID。
产生时钟回拨的主要原因:
- NTP 同步:操作系统自动与 NTP 服务器同步时间,如果本地时间快了,NTP 会强制回调
- 人工误操作:运维手动修改系统时间
- 闰秒调整:极少数情况下,操作系统处理闰秒时可能"倒退一秒"
解决方案:
- 等待法:回拨时间短(几百毫秒)就 sleep 等一下
- 切换法:预留一个 bit,回拨时切换这个 bit,让机器标识变化
- 抛异常:阻断流程,避免脏数据。美团 Leaf 启动时校验时间,发现回拨超过阈值就启动失败
- 预生成:提前生成一批 ID 存 Redis,回拨时用备用 ID
号段模式详解
号段模式的核心思想是批量取号,本地缓存:
- 建一张 sequence 表,记录当前值(value)和步长(step)
- 机器 A 启动时,从表中取一个号段(如 value=10000, step=1000,取到 10000-10999)
- 同时把表中的 value 更新为 11000
- 机器 A 在号段范围内本地自增生成 ID,不用再查库
- 号段用完再去取下一段
如果机器 B 也来取号,它取到的是 11000-11999,跟机器 A 不冲突。
优点是减少了数据库访问次数,单节点内 ID 严格递增。缺点是跨节点 ID 不一定连续(机器 A 可能在 10003,机器 B 在 11001)。
双 Buffer 优化:美团 Leaf 引入了双 Buffer 机制——当号段消费到某个阈值(比如 10%)时,异步预加载下一个号段到内存。这样号段耗尽时不用阻塞等待数据库返回,大大提升了可用性。
四、分布式锁
在单机环境下,synchronized 或 ReentrantLock 就能解决线程安全问题。但在分布式环境下,多个 JVM 进程之间无法共享锁,就需要借助外部组件实现分布式锁。
分布式锁的核心需求
- 互斥性:同一时间只有一个客户端能持有锁
- 避免死锁:持有锁的客户端崩溃后,锁要能自动释放
- 可重入:同一线程可以多次获取同一把锁
- 高性能:加锁解锁要快
- 高可用:锁服务本身要稳定
三种实现方案对比
| 方案 | 实现原理 | 优点 | 缺点 |
|---|---|---|---|
| 数据库 | 唯一索引/排他锁 | 简单易理解 | 性能差,单点问题 |
| Redis | SETNX + 过期时间 | 性能高,非阻塞 | 主从切换可能丢锁 |
| ZooKeeper | 临时有序节点 | 强一致,自动释放 | 性能不如 Redis |
数据库分布式锁
方式一:基于唯一索引
创建一张 lock 表,method_name 字段加唯一索引。加锁就是 insert,解锁就是 delete。
-- 加锁
INSERT INTO method_lock(method_name) VALUES ('orderCreate');
-- 解锁
DELETE FROM method_lock WHERE method_name = 'orderCreate';问题:没有失效时间(客户端崩溃后锁不会释放)、非阻塞(insert 失败直接报错)、不可重入。
方式二:基于排他锁
SELECT * FROM method_lock WHERE method_name = 'xxx' FOR UPDATE;for update 会加排他锁,其他线程执行同样的 SQL 会阻塞等待。解锁通过 connection.commit() 释放。
这种方式解决了阻塞和自动释放(宕机后数据库自动释放锁)的问题,但性能差,且长时间不提交会占用数据库连接。
Redis 分布式锁
最简单的方式是 SETNX(SET if Not eXists):
# 加锁:设置 key,如果不存在则设置成功,同时设置过期时间
SET lock_key unique_value NX PX 30000-- 解锁:通过 Lua 脚本保证原子性
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
end需要注意的细节:
- value 要唯一(如 UUID + 线程 ID),防止误删别人的锁
- 设置过期时间,避免持有者崩溃后死锁
- 解锁用 Lua 脚本,保证"判断 + 删除"的原子性
- 不要先 SETNX 再 EXPIRE,要用一条命令完成(
SET key value NX PX timeout)
生产环境推荐用 Redisson,它封装了:
- 可重入锁(通过 hash 结构记录加锁次数)
- 看门狗(watchdog)自动续期(防止业务没执行完锁就过期了)
- 公平锁、读写锁、红锁(RedLock)
ZooKeeper 分布式锁
ZK 利用临时有序节点实现:
- 所有客户端在锁节点下创建临时有序子节点(如 /lock/node-0001, /lock/node-0002)
- 客户端判断自己是不是序号最小的节点,如果是则获得锁
- 如果不是,监听前一个节点的删除事件(而不是监听所有节点,避免"惊群效应")
- 持有者释放锁(主动删除节点)或崩溃(会话断开,临时节点自动删除),下一个节点收到通知,获得锁
ZK 的核心优势是自动释放:客户端崩溃后,与 ZK 的连接断开,临时节点自动删除,相当于自动解锁。而 Redis 的锁在客户端崩溃后,只能等超时时间到了才会释放。
Redis vs ZooKeeper:怎么选?
| 维度 | Redis | ZooKeeper |
|---|---|---|
| 性能 | 高(内存操作) | 中(磁盘存储) |
| 自动释放 | 依赖超时机制 | 连接断开自动释放 |
| 一致性 | AP,极端情况可能丢锁 | CP,强一致 |
| 死锁友好度 | 一般 | 好(自动释放减少死锁) |
| 引入成本 | 低(大多已有 Redis) | 高(需要额外引入) |
实际建议:大多数场景直接用 Redis 就好了。原因有三:
- 用分布式锁的场景通常对性能要求高,不然直接用数据库悲观锁就行了
- Redis 作为缓存大多数应用已经在用了,Redisson 加锁不要太方便
- 配合幂等判断 + 数据库唯一约束做兜底,即使极端情况丢锁也不会出大问题
ZK 虽然一致性好,但多引入一个底层中间件会增加系统复杂度和运维成本。除非你对一致性要求极高、完全不能接受重复加锁,那才考虑 ZK。
五、幂等性设计
什么是幂等?同一个操作执行一次和执行多次,最终效果一样。
更准确地说,我们通常关注的是业务幂等:同一次业务请求,在拿到最终状态之后的每次重复请求,结果保持一样。在没拿到最终状态之前,每次请求需要正常执行业务逻辑,直到推进到最终状态。
为什么需要幂等?
在分布式环境下,网络超时、消息重发、用户重复点击都可能导致同一个请求被执行多次。如果不做幂等,就可能出现:
- 重复下单
- 重复扣款
- 重复发短信
- 重复创建用户
万能口令:一锁二判三更新
一锁:先加分布式锁(Redis),对幂等号加互斥锁,防止并发请求同时通过
二判:判断这个操作是否已经执行过(查流水表/状态机/缓存标记)
三更新:判断通过后,执行实际的业务操作,持久化数据
// 一锁:加分布式锁
@DistributeLock(scene = "ORDER", key = "#request.identifier")
public OrderResponse apply(OrderRequest request) {
// 二判:检查是否已处理
OrderDTO existing = orderService.queryByIdentifier(
request.getIdentifier());
if (existing != null) {
// 已处理,返回幂等结果
return OrderResponse.builder()
.success(true)
.responseCode("DUPLICATED")
.data(existing)
.build();
}
// 三更新:执行业务
return orderService.createOrder(request);
}三步顺序严格不能乱:先加锁,再判断,最后更新。更新结束后释放锁。
前提是需要和上游约定一个幂等号(唯一业务标识),通过对幂等号加锁和查询来实现幂等控制。
常用幂等手段
| 方案 | 原理 | 适用场景 |
|---|---|---|
| Token 机制 | 服务端生成 token 给客户端,提交时校验并删除 | 防止表单重复提交 |
| 去重表(流水表) | 用唯一业务号插入去重表,重复则冲突 | 有明确业务流水号的场景 |
| 状态机 | 状态只能前进不能后退,天然幂等 | 业务有明确状态流转的场景 |
| 唯一索引 | 数据库唯一约束兜底 | 所有场景的最后防线 |
为什么不建议只靠数据库唯一约束?
很多人会这么做:直接 insert,触发唯一约束冲突就说明重复了,catch DuplicateKeyException 返回成功。
这种方式简单直接,小项目用没啥大问题。但有几个缺点:
- 只适用于 insert 场景,update 操作用不了
- 依赖异常控制业务流程,这不是好的编程实践。而且换了数据库/ORM/Spring 版本,异常类可能变化
- 把并发压力交给了数据库,高并发下可能成为瓶颈
建议:把唯一索引作为最后的兜底,前面该加分布式锁就加,该查流水就查。多层防御比单层防御更安全。
锁和事务粒度要把握好
"一锁二判三更新"有个容易犯的错误:事务范围超出了锁的范围。
如果事务包含了加锁之外的逻辑,可能出现:锁已释放但事务还没提交,这时另一个请求拿到锁、查询发现"没处理过"(因为前一个事务没提交),于是也去执行了。结果就是重复处理。
原则:确保锁的持有时间 >= 事务的生命周期。
六、分布式一致性协议
在分布式系统中,多个节点之间如何达成共识?比如 ZK 的 leader 选举、Nacos 的数据同步,都需要一致性协议来保证。
2PC(两阶段提交)
最朴素的分布式事务协议,分两个阶段:
阶段一 Prepare:协调者问所有参与者"你们准备好了吗?",参与者锁定资源,回复 YES 或 NO。
阶段二 Commit/Rollback:如果所有参与者都说 YES,协调者发 Commit 指令;只要有一个说 NO,就发 Rollback。
问题:
- 同步阻塞:所有参与者在等待期间锁定着资源
- 单点故障:协调者挂了,参与者一直阻塞
- 数据不一致:Commit 指令只发给了部分参与者
3PC(三阶段提交)
在 2PC 基础上增加了 CanCommit 阶段和超时机制。参与者如果超时没收到指令,会自动做出决策。缓解了同步阻塞,但仍无法完全解决数据不一致。
Raft 协议
Raft 是目前最流行的分布式一致性协议,比 Paxos 更容易理解和实现。它解决的核心问题是:如何在一组节点中选出一个 leader,并保证所有节点的数据一致。
核心机制:
- Leader 选举:集群中只有一个 Leader 负责处理写请求,其他都是 Follower。Leader 定期发心跳,如果 Follower 一段时间没收到心跳,就发起选举
- 日志复制:Leader 收到写请求后,先写入本地日志,然后复制到 Follower
- 超半数确认:当超过半数节点确认后,Leader 提交该日志并返回成功
Raft 被广泛使用:
- Nacos 的 CP 模式使用 JRaft
- etcd(K8s 的存储引擎)使用 Raft
- TiDB 使用 Raft
拜占庭将军问题
前面的协议都假设节点是"诚实的"——要么正常工作,要么宕机,不会故意发送错误信息。但如果节点可能是"恶意的"呢?这就是拜占庭将军问题。
拜占庭帝国的将军们要协同进攻一座城市,但其中有叛徒会发送虚假信息。问题是:如何让忠诚的将军在不知道谁是叛徒的情况下达成正确共识?
解决方案的核心思想是超过半数(更准确地说是 2/3 以上):只要诚实节点超过总数的 2/3,就能达成正确共识。区块链中的共识算法(如 PBFT)就是解决拜占庭问题的。
一般的业务系统不需要考虑拜占庭问题(我们信任自己的服务器),Raft/ZAB 这类非拜占庭协议就够了。
分布式 Session
在微服务集群中,用户请求可能被负载均衡到不同的服务器。如果 Session 只存在某一台机器上,用户换台机器访问就会"掉线"。
常见的分布式 Session 方案:
| 方案 | 原理 | 优缺点 |
|---|---|---|
| 客户端存储 | 把 Session 信息放 Cookie | 简单,但有安全风险 |
| 分布式存储(最常用) | Session 存到 Redis/数据库 | 可靠,但依赖第三方 |
| 粘性 Session | 同一用户固定路由到同一机器 | 简单,但有单点问题 |
| Session 复制 | 多台机器之间同步 Session | 实时性好,但有延迟和网络开销 |
推荐方案:基于 Redis 的分布式 Session。Spring Session 提供了开箱即用的支持,只需简单配置就能把 Session 存储切换到 Redis。
数据同步:Canal
在微服务架构中,经常需要把 MySQL 的数据同步到 ES、Redis、数据仓库等。Canal 是阿里开源的数据同步工具,专门做这件事。
Canal 的原理非常巧妙:它把自己伪装成 MySQL 的一个 slave,向 master 发送 dump 请求。MySQL master 以为它是一个正常的从库,就把 binlog 推送过来。Canal 解析 binlog,转换成数据变更事件,推送给下游消费者(ES、数据库、MQ 等)。
典型应用场景:
- 分库分表后同步买家表和卖家表
- MySQL 数据实时同步到 ES 做全文检索
- 数据库变更实时同步到缓存
链路追踪实战
一次用户请求可能经过多个微服务,怎么追踪整条调用链?核心思路就两步:
1. 生成 traceId:在请求入口(HTTP 网关、定时任务入口等)生成全局唯一的 traceId
2. 传递 traceId:
- 服务间传递:放在 HTTP Header、RPC Context、MQ 消息头中
- 服务内传递:存在 ThreadLocal 中,多线程场景用 TransmittableThreadLocal
- 日志打印:在日志格式中加入 traceId,方便按 traceId 检索完整链路
每个服务节点还会记录一个 Span,包含:
- Operation Name(具体接口)
- SpanId / ParentSpanId(层级关系)
- Start / Finish Time(耗时)
- StatusCode(成功/失败)
通过 traceId 把所有 Span 串起来,就能还原完整的调用链,快速定位哪个服务慢了、哪个服务报错了。
常用工具:SkyWalking(功能强大,国产)、Zipkin(轻量,Twitter 开源)。
定时任务扫表的改进
在基于本地消息表的分布式事务方案中,通常用定时任务扫表来处理未完成的消息。但随着数据量增长,会遇到几个问题:
1. 消息堆积,扫表慢
解决方案:
- 在 state 字段上建索引(虽然区分度不高,但 INIT 数据只占少量,索引仍然有效)
- 多线程并发扫表,通过 ID 分段或 biz_id 取模做线程间隔离
- 引入双 Buffer 预加载
2. 集中式扫表影响正常业务
解决方案:
- 扫备库而不是主库,备库没有业务操作,不会影响线上服务
- 做分库,用多个数据库实例分担压力
3. 定时扫表有延迟
解决方案:
- 用延迟消息替代定时扫表,实时性更高
- 同步转异步:同步先执行一次,失败了再走异步重试
七、常见面试题精选
Q1:CAP 三者能同时满足吗?为什么?
不能。在网络分区发生时,你要么等数据同步完再响应(牺牲 A 保 C),要么立即用旧数据响应(牺牲 C 保 A)。而分区在分布式环境中是不可避免的,所以 P 必须保证,只能在 C 和 A 之间取舍。
Q2:最终一致性和强一致性的区别是什么?
强一致性:任何时刻读到的都是最新写入的数据。最终一致性:数据最终会一致,但中间可能有一个窗口期读到旧数据。大多数互联网系统采用最终一致性,通过消息重试、对账等手段来保证。
Q3:分布式锁用 Redis 还是 ZooKeeper?
大多数场景用 Redis。性能高、引入成本低、配合幂等和唯一索引足够安全。除非你对一致性有极端要求(比如金融场景且完全不能接受重复加锁),才考虑 ZK。
Q4:雪花算法的时钟回拨怎么处理?
四种方案:等待法(sleep 等时间追上来)、切换法(改变机器标识位)、抛异常(阻断流程避免重复)、预生成(用备用 ID)。美团 Leaf 会在启动时校验时间,发现回拨超过阈值就启动失败报警。
Q5:一锁二判三更新中,锁和事务的粒度怎么把握?
锁的粒度要尽量小(只锁幂等号,不要锁整个表),事务的范围也要尽量小。关键原则:锁的持有时间 >= 事务的生命周期。如果锁先释放了但事务还没提交,另一个请求可能"漏过"幂等判断。
Q6:什么是一致性哈希?解决什么问题?
传统 hash 取模在节点数变化时,几乎所有数据都要重新分配。一致性哈希把节点和数据都映射到一个哈希环上,数据顺时针找到最近的节点存放。节点增减时只影响相邻的一小部分数据,大大减少数据迁移量。引入"虚拟节点"可以进一步解决数据倾斜问题(把一个物理节点映射为多个虚拟节点,让数据分布更均匀)。
小结
分布式理论不是空中楼阁,每一个定理和协议背后都对应着真实的工程问题:
- CAP 定理:告诉我们"不可能三角",必须在一致性和可用性之间取舍。P 是没得选的
- BASE 理论:教我们如何优雅地妥协——中间状态 + 重试 + 降级
- 分布式 ID:解决分库分表后的全局唯一标识问题。雪花算法是主流,号段模式是补充
- 分布式锁:跨进程的互斥访问。Redis 是最常用方案,配合幂等做好兜底
- 幂等性:分布式环境下的必备能力,牢记"一锁二判三更新"
- 一致性协议:Raft 是当前最主流的选择,核心思想是"超半数确认"
学分布式理论,关键不是背定义,而是理解每个理论在解决什么问题、做了什么权衡。面试时能讲清楚"为什么这么设计"比"是什么"更重要。
扩展阅读建议
如果你想更深入地理解分布式理论,推荐以下几篇经典论文和文章:
- Google Dapper:分布式链路追踪的启蒙之作,SkyWalking 和 Zipkin 的理论基础
- Google Spanner:全球分布式数据库的鼻祖,TiDB 大量参考了该论文
- Leslie Lamport 的逻辑时钟:《Time, Clocks, and the Ordering of Events in a Distributed System》,理解分布式时间的经典
- Raft 论文:《In Search of an Understandable Consensus Algorithm》,比 Paxos 更容易理解的一致性协议