24、分布式系统并发:分布式锁(etcd/zookeeper)、Raft共识算法简介、CAP理论与并发、gRPC异步调用
好,咱们今天聊一个硬核话题——分布式系统里的并发。说实话,单机并发你已经玩得挺溜了,mutex、condition_variable、atomic 信手拈来。但一旦跨了进程、跨了机器,这些玩意儿全废了。为什么?因为共享内存没了。
分布式并发,核心问题就一个:怎么在没共享内存的情况下,让多个节点协调一致。我当年第一次接触分布式锁时,心想这不就是个远程 mutex 吗?后来被现实狠狠教育了一顿——网络延迟、节点宕机、脑裂,哪个都比死锁恶心十倍。
24.1 分布式锁:etcd vs ZooKeeper
分布式锁,说白了就是让多个进程互斥访问某个共享资源。但这里的「互斥」得靠外部协调服务来实现。目前主流的选择就两个:etcd 和 ZooKeeper。
24.1.1 基于 etcd 的分布式锁
etcd 用的是 Raft 协议(后面会讲),提供强一致性。它的锁实现思路很直接:利用 lease(租约)和 revision(版本号)。
我习惯用 etcd 的 v3 API 做锁,核心流程是这样的:
- 客户端创建一个 lease,比如 10 秒过期
- 在某个 key 上执行 put 操作,带上 lease ID
- 如果 put 成功,说明拿到锁了
- 如果 key 已存在,说明锁被占着,就 watch 这个 key
- 释放锁时直接 delete key,或者等 lease 过期自动释放
这里有个坑——租约续期。我曾经在生产环境遇到过,业务线程处理时间超过了 lease 的 TTL,结果锁自动释放了,另一个节点拿到锁开始写数据,两个节点同时操作同一份数据……嗯,那场面挺壮观的。解决方案是开一个后台协程定期续约,或者干脆把 TTL 设大一点,配合业务超时机制。
核心要点:etcd 锁的可靠性取决于 lease 续期机制。别把 TTL 设得太短,也别太长。我个人建议 TTL 设为业务最大处理时间的 2-3 倍。
24.1.2 基于 ZooKeeper 的分布式锁
ZooKeeper 的思路不太一样。它用的是临时顺序节点(Ephemeral Sequential Node)。
流程是这样的:
- 客户端在锁目录下创建临时顺序节点,比如 /lock/lock_0000001
- 获取目录下所有子节点,排序
- 如果自己是最小的那个,就拿到锁了
- 如果不是,就 watch 前一个节点
- 前一个节点被删除时,自己变成最小的,拿到锁
这个机制有个好处——公平锁。谁先来谁先得,不会出现 etcd 那种「抢锁」的竞争风暴。但缺点也很明显:ZooKeeper 的写性能不如 etcd,而且客户端会话超时会导致临时节点自动删除,锁就丢了。
| 特性 | etcd | ZooKeeper |
|---|---|---|
| 一致性协议 | Raft | ZAB |
| 锁实现方式 | Lease + Revision | 临时顺序节点 |
| 公平性 | 非公平 | 公平 |
| 性能 | 高(写 10k+ QPS) | 中等(写 3k-5k QPS) |
| 典型场景 | Kubernetes、服务发现 | Hadoop、Kafka 元数据 |
我的建议:如果你在云原生环境里,etcd 是首选,因为 Kubernetes 本身就依赖它。如果你在 Hadoop 生态里,ZooKeeper 更合适。别硬换,生态匹配很重要。
24.2 Raft 共识算法简介
Raft 是什么?说白了就是让一堆机器能达成一致的算法。你想想看,多个节点各自为政,怎么保证它们对同一个数据的认知是一样的?Raft 给出了一个工程上非常优雅的答案。
Raft 把节点分成三种角色:
- Leader:负责处理所有写请求,把日志复制给 Follower
- Follower:被动接收 Leader 的日志,投票选举 Leader
- Candidate:选举时的临时角色
核心流程就两步:选举和日志复制。
选举时,Follower 如果超时没收到 Leader 的心跳,就变成 Candidate,给自己投票,然后请求其他节点投票。拿到多数票(N/2 + 1)就当选 Leader。
日志复制更简单:Leader 收到客户端请求,把日志追加到本地,然后并行发给所有 Follower。等多数 Follower 确认写入后,Leader 才提交这条日志,然后通知客户端成功。
我当年实现过一个简化版的 Raft 库,踩过最大的坑是选举超时时间。如果所有节点的超时时间一样,它们会同时变成 Candidate,导致选票分裂,谁也选不出来。解决方案是给每个节点一个随机化的超时时间,比如 150ms-300ms 之间随机。
注意:Raft 要求日志是连续的,不能有空洞。如果你在实现时发现日志索引跳过了,那一定是 bug。我曾经因为这个 bug 导致集群脑裂了两次,排查了整整两天。
24.3 CAP 理论与并发
CAP 理论,你肯定听过。C(一致性)、A(可用性)、P(分区容忍性),三者只能取其二。但我想说的是,这个理论在分布式并发场景下,有更具体的体现。
为什么?因为分区是必然的。网络会断,节点会挂,消息会丢。你没法避免 P,所以实际上你只能在 CP 和 AP 之间选。
- CP 系统:比如 etcd、ZooKeeper。它们优先保证一致性,分区时可能拒绝写请求。适合存元数据、配置信息。
- AP 系统:比如 Cassandra、DynamoDB。它们优先保证可用性,分区时允许写,但数据可能不一致。适合存用户会话、日志等。
在并发编程里,这个选择直接影响你的锁设计。举个例子:
如果你用 CP 的 etcd 做分布式锁,锁的可靠性很高,但网络分区时可能拿不到锁,业务就得等。如果你用 AP 的系统做锁,网络分区时两边都能拿到锁,那就出现「双主」了——两个节点都认为自己持有锁,同时写数据,数据就乱了。
我个人习惯是:分布式锁必须用 CP 系统。宁可拿不到锁,也不能拿错锁。数据一致性比可用性重要得多。
24.4 gRPC 异步调用
最后聊聊 gRPC 的异步调用。分布式系统里,节点之间通信是家常便饭。gRPC 基于 HTTP/2 和 Protobuf,性能好、跨语言,是微服务通信的首选。
但同步调用有个问题——阻塞。你发一个 RPC 请求,线程就卡在那等响应。如果对方处理慢,或者网络延迟高,线程池很快就满了。
异步调用就能解决这个问题。gRPC 提供了两种异步方式:
- ClientAsyncResponseReader:客户端异步接收响应
- ServerAsyncReaderWriter:服务端异步处理流式请求
我拿客户端异步调用举个例子:
// 创建 CompletionQueue
grpc::CompletionQueue cq;
// 创建异步 Stub
auto stub = MyService::NewStub(channel);
// 准备请求和响应
MyRequest request;
request.set_data("hello");
MyResponse response;
grpc::Status status;
// 创建异步调用上下文
grpc::ClientContext context;
std::unique_ptr<grpc::ClientAsyncResponseReader<MyResponse>> rpc(
stub->PrepareAsyncMyMethod(&context, request, &cq));
// 发起异步调用
rpc->StartCall();
// 注册回调,等待完成
rpc->Finish(&response, &status, (void*)1);
// 从 CompletionQueue 中获取结果
void* got_tag;
bool ok;
cq.Next(&got_tag, &ok);
if (ok && got_tag == (void*)1) {
// 处理响应
std::cout << "Response: " << response.result() << std::endl;
}
这里有个关键点:CompletionQueue 必须由单独的线程驱动。你不能在主线程里阻塞等它,否则异步就变成同步了。我一般会开一个后台线程池,专门轮询 CompletionQueue,把回调分发到工作线程。
避坑指南:我曾经在异步调用里忘了检查 ok 标志。结果服务端返回错误时,我还在处理 response,直接崩溃了。记住:ok == false 表示 RPC 失败,response 内容不可用。
异步调用的好处是:一个线程可以同时管理成百上千个 RPC 调用。线程数不再受并发请求数限制,系统吞吐量大幅提升。但代价是代码复杂度增加了,你得自己管理回调状态和生命周期。
24.5 本章小结
分布式并发,核心就是解决「无共享内存下的协调问题」。分布式锁让你能互斥访问资源,Raft 让多个节点达成一致,CAP 理论帮你做架构取舍,gRPC 异步调用让你高效通信。
这些东西,每一个单独拿出来都能讲一整天。但今天我只挑最核心的讲——你真正写代码时用得上的那些。剩下的,留给你在实践中慢慢体会吧。
一句话总结:分布式并发没有银弹。etcd 锁、Raft 选举、CAP 取舍、gRPC 异步,都是工具。选对工具,比会用工具更重要。