这是「微服务从踩坑到填坑」系列的第 2 篇。上一篇我们聊了怎么把单体拆成微服务,拆完之后马上遇到一个问题:订单服务怎么知道库存服务的地址?本篇我们就来解决服务发现,并且手写一个极简注册中心,看看 Nacos 那些花哨功能背后到底在干什么。

一、IP 写死在代码里?别逗了
拆完服务的第一周,我们的调用方式是这么“原始”:订单服务的 application.yml 里,直勾勾写着库存服务的 IP 和端口。

inventory: host: 192.168.1.100 port: 8081

然后每次库存服务重启、扩容、缩容,我们就得改配置、重启订单服务。有一次半夜库存挂了,运维紧急加了两台机器,但因为订单服务没更新配置,新机器一台都没用上,所有流量还是打在那台快挂的老机器上,雪崩得一塌糊涂。

那晚我们坐在一起反思:服务发现必须动态化。

二、注册中心的本质:一个活点地图
注册中心说白了就干三件事:

注册:服务提供者启动时,把自己的地址(IP+端口)告诉注册中心。

发现:服务消费者启动时,去注册中心问:“我要调库存服务,它的地址是啥?”

变更通知:当库存服务的地址发生变化(扩容、下线、故障),注册中心要能通知消费者更新地址列表。

就像哈利波特里的活点地图,谁在哪儿、谁上线了、谁离开了,一目了然。

三、手写一个极简注册中心,看清它的骨相
为了弄明白注册中心到底怎么工作,我用一个周末写了个乞丐版。代码不多,但五脏俱全。

需求: 服务提供者可以注册、续约(心跳)、下线;消费者可以获取服务列表,并能收到实时变更通知。

实现方案: 用 Spring Boot 起一个 HTTP 服务,内存里用一个 ConcurrentHashMap 存服务信息,再通过简单的长轮询通知变更。

3.1 注册中心的数据模型

// 服务实例信息
public class ServiceInstance {
    private String serviceName;
    private String ip;
    private int port;
    private long lastHeartbeat; // 最近心跳时间
    // getter/setter 省略
}

3.2 注册与心跳接口

@RestController
@RequestMapping("/registry")
public class RegistryController {

    // key=serviceName, value=该服务的实例列表
    private final ConcurrentHashMap<String, List<ServiceInstance>> registry = new ConcurrentHashMap<>();
    
    // 用于通知消费者变更的“监听器”:key=serviceName, value=等待响应的DeferredResult集合
    private final ConcurrentHashMap<String, List<DeferredResult<String>>> watchers = new ConcurrentHashMap<>();

    /**
     * 服务注册 & 心跳续约(同一个接口)
     */
    @PostMapping("/register")
    public String register(@RequestBody ServiceInstance instance) {
        instance.setLastHeartbeat(System.currentTimeMillis());
        List<ServiceInstance> instances = registry.computeIfAbsent(
                instance.getServiceName(), k -> new CopyOnWriteArrayList<>()
        );
        // 更新已存在的实例,或新增
        boolean found = false;
        for (ServiceInstance exist : instances) {
            if (exist.getIp().equals(instance.getIp()) && exist.getPort() == instance.getPort()) {
                exist.setLastHeartbeat(instance.getLastHeartbeat());
                found = true;
                break;
            }
        }
        if (!found) {
            instances.add(instance);
        }
        // 通知等待该服务变更的消费者
        notifyWatchers(instance.getServiceName(), "INSTANCE_CHANGED");
        return "OK";
    }

    /**
     * 服务下线(主动调用或定时任务剔除死服务)
     */
    @PostMapping("/deregister")
    public String deregister(@RequestBody ServiceInstance instance) {
        List<ServiceInstance> instances = registry.get(instance.getServiceName());
        if (instances != null) {
            instances.removeIf(i -> i.getIp().equals(instance.getIp()) && i.getPort() == instance.getPort());
            notifyWatchers(instance.getServiceName(), "INSTANCE_CHANGED");
        }
        return "OK";
    }

    /**
     * 消费者获取服务列表,并注册一个长轮询监听
     */
    @GetMapping("/instances")
    public DeferredResult<String> getInstances(@RequestParam String serviceName) {
        DeferredResult<String> result = new DeferredResult<>(30000L, "TIMEOUT"); // 30秒超时
        
        List<ServiceInstance> instances = registry.getOrDefault(serviceName, Collections.emptyList());
        // 过滤掉心跳过期的实例(30秒无心跳)
        long now = System.currentTimeMillis();
        instances = instances.stream()
                .filter(i -> now - i.getLastHeartbeat() < 30000)
                .collect(Collectors.toList());
        
        if (!instances.isEmpty()) {
            // 有实例就直接返回 JSON
            result.setResult(JSON.toJSONString(instances));
        } else {
            // 无实例时挂起请求,等待新注册通知
            watchers.computeIfAbsent(serviceName, k -> new ArrayList<>()).add(result);
        }
        return result;
    }

    private void notifyWatchers(String serviceName, String message) {
        List<DeferredResult<String>> list = watchers.remove(serviceName);
        if (list != null) {
            for (DeferredResult<String> r : list) {
                r.setResult(message);
            }
        }
    }
}

上面这段代码里,几个关键设计点:

心跳复用注册接口:每次调 register 都更新 lastHeartbeat,一举两得。

过期剔除:在消费者拉取实例时,过滤掉超过 30 秒没心跳的实例,这就是“自我保护的简化版”。

变更通知用长轮询:消费者调用 getInstances,如果没有新变化,就挂起 30 秒,直到有服务变动时主动推送。比客户端轮询高效得多。

服务提供者只需要起一个定时任务,每 10 秒调一次 register 接口,就实现了心跳。

3.3 客户端:一个简单的服务发现 SDK

public class ServiceDiscovery {
    private String registryUrl;
    private RestTemplate restTemplate = new RestTemplate();

    public ServiceDiscovery(String registryUrl) {
        this.registryUrl = registryUrl;
    }

    // 拉取服务列表
    public List<ServiceInstance> getInstances(String serviceName) {
        String url = registryUrl + "/registry/instances?serviceName=" + serviceName;
        String resp = restTemplate.getForObject(url, String.class);
        if ("TIMEOUT".equals(resp) || resp == null || resp.isEmpty()) {
            return Collections.emptyList();
        }
        return JSON.parseArray(resp, ServiceInstance.class);
    }
}

跑通以后,订单服务调用 discovery.getInstances(“inventory-service”) 就能拿到库存服务的实时地址列表。扩容、下线、故障自动感知,再也不用改配置文件了。

四、生产环境,这些朴素逻辑远远不够
手写的 Demo 让我明白了注册中心的核心机制,但真上了生产,还有无数细节要打磨。当年从自研注册中心迁移到 Nacos 时,我们踩了一地鸡毛。

4.1 健康检查到底谁来负责?

我那个 Demo 是消费者拉列表时剔除死服务,这叫客户端主动检测。但更好的方式是注册中心主动探测。Nacos 既支持客户端心跳上报,也支持服务端主动发起 TCP/HTTP 探测。如果只依赖心跳,提供者 GC 卡顿导致心跳延时,就可能被误踢;只依赖服务端探测,又会有额外性能开销。两者结合,互相兜底,才是工业级做法。

4.2 网络分区了怎么办?

有一次机房交换机故障,注册中心和部分服务提供者之间的网络断了。注册中心收不到心跳,直接把那些健康的提供者全踢下线。可消费者和提供者之间的网络是好的,业务还能正常调用。我们强行剔除了健康的节点,导致消费者拿不到地址,服务彻底不可用。

这就是经典的 CAP 抉择。Nacos 支持 AP 模式(类似 Eureka),牺牲一致性保全可用性,在网络分区时不强制踢除实例,而是推给客户端做故障转移。我们当时图省事用了 CP 模式的配置,吃了个大亏。

4.3 服务上下线的坑

上线:服务启动成功后不要立刻注册,等内部组件(数据库连接池、缓存预热)就绪后再注册,否则早期的请求会报错。

下线:直接 kill 进程是找死。需要先调 deregister 接口主动摘除自己,然后等消费者刷新缓存(Nacos 默认 30s 左右),期间不再接收新请求,处理完存量请求再关闭。我们后来写了 preStop 钩子,强制保证这一流程。

4.4 元数据与灰度

注册中心不只是存 IP 和端口,还能存元数据。我们利用 Nacos 的元数据实现了简单的灰度发布:给新版本实例打上 version=v2 标签,网关根据请求头路由到 v2 实例上。没有注册中心的元数据能力,这个功能得自己搭一套分发系统。

五、主流注册中心怎么选?
我们团队经历过的几个:

Nacos:阿里出品,现在我们在用。支持服务发现和配置中心,AP/CP 可切换,有控制台,功能全面。缺点是对非 Java 语言 SDK 成熟度一般。

Eureka:Spring Cloud 亲儿子,AP 强一致性,配合 Feign 很丝滑。但已经停止维护,新项目不推荐。

Consul:HashiCorp 家的,强在健康检查和多数据中心支持,但使用复杂度稍高,社区相对小。

ZooKeeper:老牌 CP 强一致性系统,更适合做分布式协调而不是海量服务发现,Dubbo 早期用过,后来也转向 Nacos。心跳太频繁容易造成连接风暴。

选型这件事,没有最好,只有最合适你现在团队现状和未来两年的规划。

六、写在第二篇末尾
手写注册中心的那两天,我深刻体会到,一个优秀的中间件,本质上就是把这些看似简单的逻辑做到极致可靠。心跳的间隔、剔除的阈值、通知的及时性、分区的容错,任何一个参数调不好,生产环境里都是血与泪的教训。

现在面试的时候,如果候选人说他用过 Nacos,我会问他一个问题:“Nacos 的临时实例和持久实例有什么区别?心跳断了会立即剔除吗?”能答清楚的,说明他真的在线上扛过事。

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐