5.4 判例四:命名服务与分布式队列 本节摘要:顺序节点的"全局唯一编号"能力衍生出两个判例:命名服务——既做全局 ID 发放,又做服务注册与发现(临时节点登记、TreeCache 全量订阅);分布式队列——序号即队位,消费即删除。本节同时划清边界:轻量队列适合 ZooKeeper,重负载请交给专业消息系统。 背景:两个"编号"需求 同一套原语常被用来解决两类编号问题。其一,ID 发放:订单号、任务号需要全局唯一且趋势递增,多台机器并发申请不能撞号。其二,成员登记:服务实例上线报到、下线销名,消费者要拿到一份"始终新鲜"的活实例名单。前者吃顺序节点的"编号永不复用",后者吃临时节点的"会话死则名单自清"。 命名服务:发号与注册 发号的最小实现就是 3.
本节摘要:顺序节点的"全局唯一编号"能力衍生出两个判例:命名服务——既做全局 ID 发放,又做服务注册与发现(临时节点登记、TreeCache 全量订阅);分布式队列——序号即队位,消费即删除。本节同时划清边界:轻量队列适合 ZooKeeper,重负载请交给专业消息系统。
同一套原语常被用来解决两类编号问题。其一,ID 发放:订单号、任务号需要全局唯一且趋势递增,多台机器并发申请不能撞号。其二,成员登记:服务实例上线报到、下线销名,消费者要拿到一份"始终新鲜"的活实例名单。前者吃顺序节点的"编号永不复用",后者吃临时节点的"会话死则名单自清"。
发号的最小实现就是 3.2 节异步 create 的那个细节:顺序节点的真实路径由服务端分配,天然全局唯一。要趋势递增的纯数字,从返回路径里截取后缀即可:
public class IdIssuer { private final CuratorFramework client; public IdIssuer(CuratorFramework client) { this.client = client; } /** 发一个全局唯一、趋势递增的号。并发安全:编号由服务端单调计数器分配 */ public long nextId() throws Exception { String path = client.create() .withMode(CreateMode.PERSISTENT_SEQUENTIAL) .forPath("/ids/order-", new byte[0]); // 空数据,节点只为编号而存在 String suffix = path.substring(path.lastIndexOf('-') + 1); return Long.parseLong(suffix); } }
两点工程注意:编号泄露吞吐信息——外部单号若直接用 ZK 序号,竞争对手能从单号差值推算你的日订单量,对外暴露前要做偏移或加盐;持久顺序节点会无限累积,发号目录要定期清理历史节点,否则目录列表越来越长,每次 getChildren 都是负担。
服务注册与发现是命名服务的另一面,也是 Dubbo 等框架的经典用法(7.2 节对照)。提供者上线创建临时节点,消费者用 TreeCache 订阅整个目录:
// 提供者侧:上线注册,会话在节点在 client.create().creatingParentsIfNeeded() .withMode(CreateMode.EPHEMERAL) .forPath("/services/pay/" + myIpPort, metaData.getBytes()); // 下线钩子里显式注销;异常崩溃则由会话过期兜底回收 // 消费者侧:TreeCache 订阅整棵名单树 TreeCache registry = new TreeCache(client, "/services/pay"); registry.getListenable().addListener((c, evt) -> { if (evt.getType() == TreeCacheEvent.Type.INITIALIZED) { loadFullList(); // 启动时先装载全量名单 } if (evt.getType() == TreeCacheEvent.Type.NODE_ADDED || evt.getType() == TreeCacheEvent.Type.NODE_REMOVED) { loadFullList(); // 名单变化:直接重建,不做增量拼装 } }); registry.start(); /** 全量重建名单:把 /services/pay 下所有子节点读成本地实例表 */ void loadFullList() throws Exception { List<String> live = client.getChildren().forPath("/services/pay"); List<Endpoint> fresh = live.stream() .map(name -> parse(name, readDataQuietly("/services/pay/" + name))) .collect(Collectors.toList()); localEndpoints.set(fresh); // 原子替换,读侧无锁 }

队列是顺序节点的第三个化身:生产即 create 一个顺序节点,消费即取序号最小的节点处理后删除。多消费者场景下各自 getChildren 排序抢队首,配合 5.2 的锁防重复消费:
// 生产者:入队一条任务 client.create().withMode(CreateMode.PERSISTENT_SEQUENTIAL) .forPath("/queue/task-", payload.getBytes()); // 消费者:取队首、加锁防抢、处理、删号 List<String> tasks = client.getChildren().forPath("/queue"); Collections.sort(tasks); // 序号即优先级 for (String t : tasks) { String p = "/queue/" + t; try { client.create().withMode(CreateMode.EPHEMERAL) .forPath(p + "-claim", myId.getBytes()); // 抢占标记 byte[] payload = client.getData().forPath(p); process(payload); // 执行任务 client.delete().forPath(p); // 删号即出队 return; // 本轮只处理一条 } catch (NodeExistsException e) { continue; // 已被他人抢占,看下一条 } }
边界要说透。ZooKeeper 队列的每一次入队出队都是一次写提案,走 4.1 节的多数派表决加 fsync——吞吐天花板明显,消息堆积会拖累整个集群的协调能力。它适合的场景是"每秒几十条的任务分发、调度指令传递";每秒上万条的消息流、需要消费组与回溯能力的业务,请用专业消息系统(7.1 节会看到 Kafka 早期把 ZK 当协调层而非数据面的选择)。一个实用的判断标准:消息丢了会致命的用专业队列,任务慢了会难堪的才轮到 ZK 队列。
问:注册节点里该存多少实例元数据? 存"能路由就够"的信息:地址端口、权重、版本号。把全量接口列表、配置项都塞进临时节点,是注册中心最常见的滥用——1 MB 上限与 ZAB 写放大都在盯着这类设计。
四个判例审结。第六章换个方向:看这些判例在现实里翻车的样子。