实习期间经历多次频繁发版,实例扩容缩容,那么每次服务和服务器地址,应该怎么被其他服务知晓?
单机注册中心
1. 注册与发现
按照上面需求注册中心应该具有:服务表,拥有注册服务、获取服务两个功能
// Java
public class RegistryData {
// Key: ServiceName (e.g., "order-service")
// Value: List of Instances
private ConcurrentHashMap<String, List<ServiceInstance>> registry = new ConcurrentHashMap<>();
}
class ServiceInstance {
String ip;
int port;
Map<String, String> metadata; // 比如 version=1.0
}
-
注册: 服务启动时调用。
-
- POST /register
- Body: {serviceName: "order", ip: "192.168.1.10", port: 8080}
- 服务端逻辑:将实例插入 Map 对应的 List 中。
-
发现: 服务调用方查阅。
-
- GET /lookup?serviceName=order
- 服务端逻辑:返回 List 列表。
这样便能实现最简单的注册中心。但是思考一下,如果某个服务器意外宕机,注册中心是无感的。调用方会拿到包含死节点的列表,导致请求出错。
2. 失效节点剔除
为了解决这个问题,我们需要引入一个探测服务节点宕机,然后从注册表中删除的机制。比如心跳机制。
客户端:注册服务之后,定时发送心跳包
- PUT /heartbeat?serviceName=order&ip=192.168.1.10
注册中心:
- 收到心跳后,更新该服务节点的lastRenewTime
- 后台每一段时间扫描一次注册表,进行过期剔除
long now = System.currentTimeMillis();
for (Instance instance : allInstances) {
if (now - instance.lastRenewTime > 30000) { // 超过30秒没心跳
removeInstance(instance); // 剔除下线
log.info("服务失效,已剔除: " + instance);
}
}
思考一个问题,为什么不服务器发送心跳检测,而需要客户端发送?
一个服务端通常需要连接成千上万,甚至百万级的客户端。如果服务端主动发心跳:服务端需要为每一个客户端维护一个定时器,并主动发起连接和发送数据包。这会给服务端的CPU、内存和网络带宽带来O(N) 的巨大压力。在海量连接下,这几乎不可行。如果客户端主动发心跳:心跳的压力被分散到了所有客户端上。服务端只需要被动地接收和处理这些心跳包,这是一个简单的、可水平扩展的请求处理模型,服务端压力要小得多。所以由客户端发送心跳。
通过这种方式便解决了无效宕机导致的问题。继续思考,当前每次服务器调用端都得pull一次,而在注册中心注册的服务大多数情况下是不变的,只会在发布、扩缩进行变动,频繁调用效率太低且注册中心压力巨大,并且如果注册中心宕机,服务调用端将直接裂开。
3. 服务调用端缓存
为了解决这个问题,我们可以引入缓存机制,在客户端缓存一份注册表,然后定时拉取覆盖最新缓存的方式解决。
那么问题又来了,如果定时拉取的时间过长,如果A服务全部宕机了重启在注册中心重新注册,此时服务调用端还不清楚,不又会导致服务直接裂开。
4. 长轮询机制
为了解决这种问题,我们可以引入长轮询,
-
Consumer 发起请求 GET /lookup。
-
Registry 收到请求:
-
- 如果服务列表有变化(对比 MD5),立即返回。
- 如果没有变化,Hold 住请求不返回(挂起 29.5 秒)。
- 如果在挂起期间发生了注册/下线事件,立即唤醒请求并返回。
- 如果超时,返回 304 (Not Modified)。
这样就解决这个问题,我们的注册中心也逐渐完善。 但真的可以用了吗,然而并不是,试想一下,如果我们的注册中心长时间宕机,那么下线的不只是服务,还有我们........
集群版注册中心
如同服务需要多节点部署一下,我们的注册中心也需要多节点,上集群。根据CAP原则,我们可以选型CP或者AP。
那具体怎么选型,肯定是CP和AP都可以,只是适合不同的场景。在我们的营销活动来说,如果营销服务扩容了 10 台机器,注册中心因为网络延迟,导致其中 2 台机器的信息晚了 3 秒才同步到所有节点。仅仅是这 3 秒内,部分流量没打到新机器上而已。完全可接受。而对于如果用 CP,在大促高峰期,Leader 因为负载过高(或网络抖动)挂了。开始选主,持续 30 秒。这 30 秒内,所有微服务无法注册,无法扩容,甚至无法发现服务。这是灾难性的。
基于AP原则实现
AP原理的集群一般采用P2P架构, 在数据写入采用多点写入,各个服务之间进行异步数据同步。具体来说,
1.核心数据结构
// 核心存储结构 (ConcurrentHashMap)
// Map<Namespace, Map<ServiceName, Service>>
private final Map<String, Map<String, Service>> registry = new ConcurrentHashMap<>();
public class Service {
private String name;
private Set<Instance> instances; // 实例集合
private volatile long checksum; // 数据指纹(用于快速比对)
}
public class Instance {
private String id; // unique id (e.g., ip:port)
private String ip;
private int port;
private long lastBeat; // 上次心跳时间
private long lastUpdatedTimestamp; // 【关键】最后修改时间戳,用于冲突解决
private boolean isEphemeral; // 是否临时节点
}
设计含义如下:
- 第一层 Key (String):代表 Namespace (命名空间)。用于环境隔离(如 dev, test, prod)。
- 第二层 Key (String):代表 Service Name (服务名)。具体的微服务名称(如 order-service)。
- Value (Service):代表 服务集群对象。它内部存储了该服务下的所有具体实例列表(List/Set of Instances)。
在 AP 模式下,解决数据冲突的唯一真理是时间。所有数据必须携带“最后更新时间戳”。
2.写请求
当客户端向任意一个 Server 节点(例如 Node A)发起注册或心跳请求时,Node A 作为“接收节点”执行以下逻辑:
- 本地写入:直接更新 Node A 的内存 Map。
- 生成任务:将该变更封装为一个 SyncTask 对象。
- 异步广播:将任务放入队列,由后台线程池异步发送给集群中的其他节点(Node B, Node C...)。
- 立即响应:不需要等待其他节点回复,直接向客户端返回 HTTP 200 OK。
3.节点间同步
节点间同步会遇到循环复制问题,比如Node-A发给Node-B,Node-B接收更新继续发送。解决方案:
-
避免循环复制:
-
- 请求体中增加 header:x-source: replication。
- 如果收到带有 x-source: replication 的请求,只更新本地,不再转发。
Node-A和Node-B都收到节点注册,但是数据出现不一致。解决方案:
Last Write Wins (LWW),根据最近时间进行对比
public void onReceiveSync(Instance incoming) {
Instance local = getInstanceFromMemory(incoming.getId());
// 1. 本地不存在,直接插入
if (local == null) {
updateMemory(incoming);
return;
}
// 2. 比较时间戳
if (incoming.getLastUpdatedTimestamp() > local.getLastUpdatedTimestamp()) {
// 远程数据更新,覆盖本地
updateMemory(incoming);
} else {
// 远程数据旧,忽略(或者反向同步给远程,但这会增加复杂度,通常选择忽略)
log.debug("Ignore stale data from peer");
}
}
如果每一次接收到请求,就进行http发送给其他节点,会产生大量的连接。优化方案:
批量合并,不要来一个请求就发一个 HTTP 给其他节点。使用延时队列:每 500ms 把这段时间内的变更打包成一个 Batch,一次性发送给 Peer 节点。
4. 最终一致性保障
丢包或网络抖动会导致“异步广播”丢失。为了保证最终一致性,必须有定期比对机制。
实现逻辑:
-
定时任务:每隔 30 秒。
-
计算指纹:每个节点计算自己所有服务数据的 Hash 值(Checksum)。
-
交换指纹:节点之间互相发送 <ServiceName, Checksum>。
-
增量拉取:
-
- 如果 Node A 发现 Service-Order 的 Checksum 与 Node B 不一致。
- Node A 向 Node B 发送“获取 Service-Order 全量数据”的请求。
- Node A 收到数据后,根据时间戳合并到本地。
5.新节点加入
当注册中心要扩容的时候,新节点此时为空,所以要进行启动复制
- 配置种子节点:配置文件中写好其他节点的地址(或者通过 DNS 发现)。
- 全量拉取:Node D 启动时,处于 STARTING 状态。它随机选择一个 Peer(如 Node A),发送 GET /sync/all 请求。
- 加载内存:将拉取到的数据写入内存。
- 对外服务:将状态置为 RUNNING,开始接收客户端请求。
6.可用性保护
大促当晚机房网络抖动,所有服务的心跳包都发不到注册中心。注册中心根据“30秒没心跳就剔除”的规则,把所有服务都删了。导致 404 故障。
这样肯定是不行的,因为我们是要保证AP,所以要确保服务访问端不能读到空表,所以要防止因为网络问题导致删除,即使读到旧数据也比读到空好。
实现逻辑:
-
心跳计数:维护一个滑动窗口,统计每分钟实际收到的心跳次数 actual_renews。
-
期望阈值:计算期望值 expected_renews = instance_count * (60s / heartbeat_interval) * 0.85 (85% 阈值)。
-
触发保护:
-
- if (actual_renews < expected_renews):进入保护模式。
- Action:禁止执行剔除(Eviction)逻辑。即便实例过期也不删。
- 恢复:当心跳数回升后,自动退出保护模式。
完成这些设计,我们的注册中心基本可用。
那么回顾一下常见问题怎么解决?
总结:
写冲突了怎么办?
比较 Timestamp,时间大的赢
刚注册的节点读不到怎么办?
承认这是 AP 的特性(短时间不一致),依赖客户端重试和服务端采用更快的异步广播(Netty长连接)
两个节点网络断了怎么办?
各自独立工作(Split Brain),网络恢复后通过一致性保证机制自动合并数据。
循环复制怎么办?
数据源标记 (Source Tagging)在同步请求体中增加标记,如 source=replication.客户端发起的注册:处理并分发给其他 Peer。其他 Peer 发来的同步:只处理写入本地,不再二次分发。
雪崩式误剔除 ?
可用性保护机制。
全量同步的风暴?
如果有 100 个注册中心节点(虽然很少见),每来一个注册请求都要广播给 99 个节点。写入性能随着节点数增加而线性下降。参考Redis解决方案,进行数据分片。
基于CP原则实现
其实CP适合一致性要求比较高的场景,比如配置中心,但是了解CP设计也是一个很重要的事情。
设计基于 CP(一致性 + 分区容错) 的注册中心,核心架构通常采用 主从模式 (Leader-Follower),并依赖 共识算法(如 Raft 或 ZAB)来保证数据强一致性。这种架构的代表是 Zookeeper、Etcd 和 Consul。
与 AP 模式( 异步复制)不同,CP 模式的核心原则是:“多数派写入成功才算成功”。
// Raft 核心逻辑简化
public class RaftRegistryNode {
enum Role { LEADER, FOLLOWER, CANDIDATE }
Role currentRole = Role.FOLLOWER;
// 内存数据 (State Machine)
Map<String, Service> registryMap = new ConcurrentHashMap<>();
// 操作日志 (WAL)
List<LogEntry> logEntries = new ArrayList<>();
// 提交索引
long commitIndex = 0;
// 处理注册请求 (只在 Leader 执行)
public synchronized boolean register(Service service) {
if (currentRole != Role.LEADER) {
throw new NotLeaderException("Go ask node: " + leaderAddress);
}
// 1. 生成日志
LogEntry entry = new LogEntry("REGISTER", service, currentTerm);
logEntries.add(entry);
// 2. 广播给 Followers
int ackCount = 1; // 自己算一个
for (Node follower : followers) {
if (sendAppendEntries(follower, entry)) {
ackCount++;
}
}
// 3. 检查多数派
if (ackCount > clusterSize / 2) {
// 4. 提交并应用
commitIndex++;
applyToStateMachine(entry); // 写入 registryMap
return true;
} else {
// 回滚或报错
return false;
}
}
private void applyToStateMachine(LogEntry entry) {
registryMap.put(entry.key, entry.value);
}
}
1架构
和AP实现的P2P不一样,CP通常采用主从,所以服务节点分为三种(不同算法不一样,以通用的raft算法实现)
-
Leader (主节点):
-
- 集群中唯一负责处理写请求(注册、注销、心跳续约)的节点。
- 负责将变更同步给 Follower。
-
Follower (从节点):
-
- 负责处理读请求(但在强一致性要求下,读请求通常也需要转发给 Leader 或经过 Leader 确认)。
- 被动接收 Leader 的日志复制,参与投票。
-
Candidate (候选人):
-
- 当 Leader 挂掉时,Follower 变身为 Candidate 发起选举。
2写请求
这是 CP 设计最关键的部分。当一个服务发起注册(写操作)时,流程如下:
-
Client -> Leader:客户端发起 Register 请求。
-
- 如果客户端连接的是 Follower,Follower 必须将请求转发给 Leader。
-
Leader 预写日志 (WAL):
-
- Leader 将该操作包装成一条 Log Entry(日志条目),写入本地磁盘的 WAL (Write Ahead Log)。
- 此时状态机(内存 Map)尚未修改。
-
并行广播 (Replicate):
-
- Leader 并行发送 AppendEntries RPC 给所有 Follower。
-
Follower 确认 (Ack):
-
- Follower 收到日志,写入本地 WAL,返回成功 Ack。
-
多数派提交 (Commit):
-
- Leader 收到 超过半数 (Quorum, N/2 + 1) 节点的 Ack(比如 3 个节点里收到 2 个)。
- Leader 将该日志标记为 Committed。
-
应用状态机 (Apply):
-
- Leader 将 Log 应用到内存 Map(registry.put(...))。
- 此时服务才算真正注册成功。
- Leader 通知 Follower 提交日志,Follower 也更新内存 Map。
-
响应客户端:Leader 返回 HTTP 200。
代价:相比 AP 的内存直接写,CP 多了“网络广播+磁盘IO+多数派等待”,写性能会明显低于 AP。
3选主与容灾
CP 模型最大的隐患是 Leader 宕机期间,集群不可写。
-
心跳维持 (Heartbeat):
-
- Leader 周期性(如 100ms)向所有 Follower 发送心跳包,宣示主权。
-
选举触发:
-
- 如果 Follower 在 Election Timeout(如 500ms~1s)内没收到 Leader 心跳。
- Follower 认为 Leader 挂了,发起选举。
-
选举过程:
-
- 该 Follower 变成 Candidate,Term(任期)+1,给自己投一票,请求其他节点投票。
- 获得多数票的节点成为新 Leader。
-
服务中断 (Stop-the-World):
-
- 注意:在选举期间(通常几十毫秒到几秒),整个集群拒绝写请求。这对秒杀等高并发场景是致命的(这就是为什么营销场景通常不用 CP)。
4.读一致性问题
客户端来查服务列表,能不能直接查 Follower?
-
线性一致性读 :
-
- 最稳妥:所有读请求都走 Leader。保证读到最新的。
- 弊端:Leader 压力巨大,Follower 成了摆设。
-
Read Index (优化方案):
-
- 客户端读 Follower。
- Follower 询问 Leader:“现在的最新 Index 是多少?”(确认 Leader 没挂)。
- Follower 等到自己的数据追上该 Index 后,返回数据。
-
Stale Read (弱一致性 - 类似 AP):
-
- 允许读 Follower,可能读到旧数据。这在注册中心场景其实是可以接受的,但在 CP 定义上稍微放宽了。
5.CP注册中心的核心痛点与解决
痛点:扩容缩容难
- 问题:AP 模式随便加节点;CP 模式加节点会改变“多数派”基数。比如 3 台挂 1 台还能活;由 3 台扩容到 4 台,挂 1 台还能活;但扩容到 4 台后,多数派变成 3,此时写性能反而可能下降。
- 解决:通常 CP 集群维持 3、5 或 7 个奇数节点,不轻易扩容。
痛点:跨机房部署
- 问题:如果机房 A 有 Leader,机房 B 是 Follower。机房之间光缆断了。
- 后果:机房 B 的客户端无法注册(写失败),因为连不上 Leader。机房 B 内部也无法选出新 Leader(因为凑不齐多数派)。整个机房 B 的服务注册功能瘫痪。
- 对比 AP:AP 模式下,机房 B 节点会自我组成孤岛集群,内部依然可用。
通过以上便完成了注册中心的设计。
总结:CP一切为了数据准确,哪怕牺牲暂时的可用性。Leader 说了算,没凑齐人头不准写。
对比
在实习的营销活动组场景下,使用 AP 模式更好。
总结
本文完成了从零开始进行注册中心的设计与实现,梳理也是为了自己更好的理解。
世界上没有完美的架构,只有最适合当下的权衡(Trade-off)。就像人生,我们无法同时拥有 CP 的极致确定性和 AP 的极致自由,懂得知足和取舍,才是高级工程师的智慧。虽然我才刚起步~