第二十八章:多线程在分布式系统中的应用

分布式系统,说白了就是一群计算机一起干活。但怎么让它们协调一致?怎么让它们高效通信?这背后离不开多线程。今天我就聊聊RPC框架、消息队列和分布式锁这三个场景,看看多线程是怎么在分布式世界里大显身手的。

28.1 RPC框架中的线程模型

RPC(远程过程调用)是分布式系统的基石。你调用一个远程服务,感觉就像调用本地函数一样。但底层呢?线程模型决定了性能上限。

我参与过一个内部RPC框架的优化,当时线上服务经常超时。排查后发现,线程模型设计不合理是主因。

28.1.1 常见的RPC线程模型

RPC框架的线程模型,大致分三类:

模型 特点 适用场景
BIO + 线程池 一个连接一个线程,阻塞IO 连接数少,低并发
NIO + Reactor 事件驱动,少量线程处理大量连接 高并发,IO密集
异步 + Future/Promise 完全非阻塞,回调驱动 超高吞吐,延迟敏感

我个人习惯用NIO+Reactor模型。为什么呢?你想想看,一个服务端可能要处理上万连接,如果用BIO,线程数直接爆炸。线程切换的开销就能把CPU拖垮。

核心原则:线程数不是越多越好。线程切换有成本,上下文保存恢复都需要时间。一般建议线程数 = CPU核心数 * (1 + 等待时间/计算时间)。

28.1.2 Reactor模型的线程分工

Reactor模型里,线程分角色:

  • Acceptor线程:专门接受新连接。通常只有一个。
  • IO线程(Reactor):负责读写事件。一般2-4个。
  • Worker线程池:处理业务逻辑。数量根据业务调整。

我在项目中遇到过一个问题:IO线程里做了耗时操作,导致新连接无法被接受。嗯,这里要注意:IO线程只做数据收发,千万别做业务处理。业务逻辑丢给Worker线程池去做。

// 伪代码:Reactor线程模型
void reactor_loop() {
    while (running) {
        // 非阻塞获取就绪事件
        events = selector.select(100); // 超时100ms
        for (event in events) {
            if (event.isAcceptable()) {
                // Acceptor线程处理新连接
                accept_connection(event.channel());
            } else if (event.isReadable()) {
                // IO线程读取数据
                byte[] data = read_data(event.channel());
                // 丢给Worker线程池处理
                worker_pool.submit(() -> process_request(data));
            }
        }
    }
}

避坑指南:我曾经把业务逻辑直接写在IO线程里,结果压测时吞吐量上不去。后来改成异步提交到Worker池,性能提升了3倍。记住:IO线程只做IO,别干别的。

28.2 消息队列并发消费

消息队列是分布式系统的「血管」。生产者发消息,消费者处理消息。但消费者怎么并发?这里坑不少。

28.2.1 单线程 vs 多线程消费

很多消息队列(比如Kafka)的消费者默认是单线程拉取。为什么?因为要保证分区内的消息顺序。

但单线程消费太慢了。怎么办?我常用的方案是:

  • 方案一:增加分区数,每个分区一个消费者线程。
  • 方案二:消费者内部用线程池处理消息。
  • 方案三:批量拉取,批量处理。

我个人推荐方案二。为什么呢?因为分区数不能无限增加,而且分区多了,rebalance成本很高。内部线程池更灵活。

// 消息队列并发消费示例
void consume_messages() {
    while (true) {
        // 拉取一批消息
        List<Message> batch = poll(100); // 超时100ms
        if (batch.isEmpty()) continue;

        // 用线程池并发处理
        CountDownLatch latch = new CountDownLatch(batch.size());
        for (Message msg : batch) {
            thread_pool.submit(() -> {
                try {
                    process(msg);
                } finally {
                    latch.countDown();
                }
            });
        }
        // 等待所有消息处理完成
        latch.await(5, TimeUnit.SECONDS);
        // 提交偏移量
        commit(batch);
    }
}

注意:并发消费时,消息顺序可能被打乱。如果业务要求严格顺序,要么单线程处理,要么按key哈希到同一个线程。我曾经因为没注意顺序,导致数据不一致,排查了一整天。

28.2.2 消费幂等性

并发消费还有一个大坑:重复处理。消息队列保证「至少一次」投递,但不会保证「恰好一次」。所以消费者必须幂等。

我习惯的做法是:

  • 每条消息带唯一ID
  • 处理前先查数据库,看是否已处理
  • 用分布式锁或数据库唯一索引防重

经验之谈:幂等性不是可选项,是必选项。你永远不知道网络抖动、消费者重启会发生什么。我见过因为没做幂等,导致同一笔订单被扣了两次款的事故。

28.3 分布式锁实现

分布式锁,说白了就是让多台机器上的线程互斥访问共享资源。单机锁(比如mutex)只能管一个进程内的线程,跨机器就不行了。

28.3.1 基于Redis的分布式锁

Redis实现分布式锁,最经典。核心思路:

  • 用SETNX命令尝试加锁
  • 设置过期时间,防止死锁
  • 释放锁时,检查是不是自己的锁
// Redis分布式锁示例
int try_lock(const char* key, const char* owner, int ttl_ms) {
    // SET key value NX PX ttl_ms
    // NX: 只有key不存在时才设置
    // PX: 设置过期时间
    redisReply* reply = redisCommand(conn,
        "SET %s %s NX PX %d", key, owner, ttl_ms);
    if (reply != NULL && reply->type == REDIS_REPLY_STATUS) {
        return 1; // 加锁成功
    }
    return 0; // 加锁失败
}

int unlock(const char* key, const char* owner) {
    // 用Lua脚本保证原子性
    const char* script =
        "if redis.call('get', KEYS[1]) == ARGV[1] then "
        "  return redis.call('del', KEYS[1]) "
        "else "
        "  return 0 "
        "end";
    redisReply* reply = redisCommand(conn,
        "EVAL %s 1 %s %s", script, key, owner);
    return reply->integer;
}

避坑指南:我曾经犯过一个错误:加锁后忘记设置过期时间。结果程序崩溃,锁永远不释放,所有请求都卡住了。从那以后,我每次加锁都强制设置过期时间,哪怕业务逻辑很快。

28.3.2 基于ZooKeeper的分布式锁

ZooKeeper的分布式锁,利用临时顺序节点实现。优点是可靠性高,缺点是性能不如Redis。

原理很简单:

  • 在锁目录下创建临时顺序节点
  • 检查自己是不是最小的节点
  • 如果是,获得锁;如果不是,监听前一个节点

我参与过一个金融项目,要求锁必须绝对可靠,不能有「脑裂」问题。我们最终选了ZooKeeper。虽然慢一点,但安全第一。

28.3.3 分布式锁的常见问题

问题 原因 解决方案
锁超时 业务执行时间超过锁过期时间 锁续期(看门狗机制)
锁重入 同一个线程多次加锁 记录线程ID和重入次数
脑裂 Redis主从切换导致锁丢失 Redlock算法或ZooKeeper

警告:分布式锁不是银弹。能用无锁设计就用无锁设计。比如用数据库乐观锁、CAS操作。锁会降低并发度,增加系统复杂度。我见过有人为了一个简单的计数器,搞了Redis锁+ZooKeeper锁双重保险,其实完全没必要。

28.4 本章知识体系

下面这张图,概括了多线程在分布式系统中的三个核心应用场景:

多线程在分布式系统中的应用 RPC框架线程模型 • BIO + 线程池 • NIO + Reactor • 异步 + Future 角色分工: Acceptor → IO → Worker ⚠ IO线程不做业务 消息队列并发消费 • 单线程拉取 • 内部线程池处理 • 批量拉取+批量处理 关键问题: 顺序保证、幂等性 ⚠ 必须做幂等 分布式锁实现 • Redis SETNX • ZooKeeper临时节点 • Redlock算法 常见问题: 锁超时、重入、脑裂 ⚠ 能不用锁就不用 核心思想:线程模型决定性能,并发消费决定吞吐,分布式锁决定一致性

多线程在分布式系统中的应用,说白了就是解决三个问题:怎么通信(RPC)、怎么处理(消息队列)、怎么互斥(分布式锁)。每个问题都有成熟的方案,但具体选哪个,得看业务场景。

我个人建议:先理解原理,再动手实践。别一上来就搞复杂的框架。从简单的BIO模型开始,慢慢过渡到NIO、异步。分布式锁也是,先用Redis实现,遇到问题再升级到ZooKeeper。

最后说一句:多线程编程本身就不简单,放到分布式系统里更是难上加难。但只要你掌握了本章讲的这些核心模型和避坑经验,大部分场景都能应对。记住:没有银弹,只有权衡。


公众号:蓝海资料掘金营,微信deep3321