一、服务发现要解决的到底是什么问题
先把这个词拆开。服务发现听起来像是一件很高级的事,实际要解决的问题非常朴素:A 服务要调用 B 服务,A 怎么知道 B 现在在哪台机器、哪个端口上。
小规模的时候这个问题不存在,因为地址写死在配置文件里。一台机器撑得住的时候,你根本不需要它。真正开始难受是在这几种情况下:B 服务从一台变成三台,要做负载均衡;B 换了机器或者换了机房;B 做了蓝绿发布,同一时刻有两组实例在跑;某台机器临时下线,调用方还在往上面打请求。
于是有人会直接想到 Consul、etcd 或 Nacos。这三个都是成熟方案,但它们的成本和收益需要算一下:Consul 至少三节点才算有意义的集群,etcd 要单独维护,Nacos 带一整套配置中心和控制台。对于一个只有两三台机器、五六个服务的站来说,引入它们意味着你要多维护一个高可用组件,而这个组件一旦挂了,所有服务互相都找不到——它从「解决问题」变成了「新的单点故障」。
这篇文章写的是中间态方案:一个注册表加一个查询接口,二十来行代码,能覆盖十个服务以内绝大多数真实需求。等到机器数和服务数跨过某个门槛,再换成正式的服务发现组件,那时候你换的是实现,不是思路。
二、先定清楚:谁写、谁读、多久过期
把方案设计清楚,要先回答三个问题,这三个问题答错了,后面怎么写都是错的。
谁写?由服务自己写,不要由运维手工登记。每个服务启动时 POST 自己的地址,进程退出时 DELETE。手工登记的东西一定会过期,而且你永远不会记得去删。
谁读?调用方通过一个 HTTP 接口读。不要让大家直接连数据库查表——那样每个调用方都变成了数据库的客户端,连接数、权限、SQL 写法全都不可控。中间加一层接口,将来换存储、换缓存、加过滤,调用方都不用改。
多久过期?这是整套方案里唯一需要认真设计的参数。注册表里的每一行必须带一个「最后心跳时间」,超过一定时间没更新就视为实例已死。心跳间隔和过期时间的关系是:过期时间至少要能容忍连续丢三次心跳。心跳每 10 秒一次,过期时间设 30 秒,意味着一次网络抖动不会把还活着的实例判死。
反过来,过期时间设太长(比如 5 分钟)就没意义了——实例挂了五分钟还在被调用,用户看到的是一批超时报错。设太短(10 秒)又会被瞬时的网络抖动误杀,造成服务列表频繁抖动。
三、表结构:六个字段就够
用一张表存注册信息,字段不要多,多了就没人维护。最小可用集合是这样:
CREATE TABLE service_registry (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
service VARCHAR(64) NOT NULL COMMENT '服务名,如 api、worker',
instance VARCHAR(96) NOT NULL COMMENT '实例唯一标识,通常是 主机名:端口',
addr VARCHAR(64) NOT NULL COMMENT '可访问地址',
port SMALLINT NOT NULL,
meta VARCHAR(255) NOT NULL DEFAULT '' COMMENT '环境、版本、权重',
heartbeat DATETIME NOT NULL COMMENT '最后心跳时间',
registered DATETIME NOT NULL COMMENT '首次注册时间',
UNIQUE KEY uk_inst (service, instance),
KEY idx_service_hb (service, heartbeat)
);几个设计决定值得说明:
instance 和 addr 分开存。instance 是「这台实例是谁」,用作唯一键;addr 是「怎么连它」。容器环境下这两者经常不一样,instance 用容器 ID 或者主机名,addr 用容器网络的地址。合并成一个字段的话,容器重建换了 IP,你会当成一个全新实例插进去,旧的那条还得靠过期来清理。
meta 存成一个字符串。不要为环境、版本、权重各开一列。这些属性的种类会不断变化,每加一种就改表结构,是典型的过度设计。存成 env=prod,ver=2.3.1,weight=10 这样的键值串,读取时按逗号切开,够用,且加字段不用动 DDL。
唯一键是 (service, instance) 而不是 instance。同一个进程一般只属于一个服务,但做影子流量或者一个进程同时提供多种能力时,同一 instance 可能会有多条记录。
索引按 (service, heartbeat) 建。查「某个服务还活着的实例」是最高频的查询,这个复合索引正好覆盖。
四、注册与心跳:一条 UPSERT 搞定
注册和心跳其实是同一个动作——都是「我还在,这是我的信息」。把它们写成两条 SQL 是浪费,用 INSERT ... ON DUPLICATE KEY UPDATE 一条就够:
INSERT INTO service_registry
(service, instance, addr, port, meta, heartbeat, registered)
VALUES (?, ?, ?, ?, ?, NOW(), NOW())
ON DUPLICATE KEY UPDATE
addr = VALUES(addr),
port = VALUES(port),
meta = VALUES(meta),
heartbeat = NOW();这条 SQL 同时处理了三种情况:首次启动插入;重启后 IP 变了就更新地址;只是心跳就刷新时间。registered 用 VALUES(registered) 的写法会把它也刷掉,所以这一列在 UPDATE 分支里不出现,只在首次插入时写入。
客户端这边,最简单的是一个后台线程或者 cron,每 10 秒发一次:
#!/bin/sh
# /usr/local/bin/heartbeat.sh
SVC="api"
INST="$(hostname):8080"
curl -s -m 5 -X POST "http://reg.internal/register" \
-H 'Content-Type: application/json' \
-d "{\"service\":\"$SVC\",\"instance\":\"$INST\",\"addr\":\"10.0.1.12\",\"port\":8080,\"meta\":\"env=prod,ver=2.3.1\"}" \
>/dev/null 2>&1配一个 systemd timer 或者最简单的 crontab,每 10 秒跑一次。注意 -m 5 超时必须有,注册中心挂了不能让心跳脚本堆积起来。
用 shell 发心跳有一个隐患:cron 的最小粒度是一分钟,写 */1 做不到 10 秒。所以我更推荐用 systemd timer,它的 OnUnitActiveSec=10s 是准确的;或者干脆在服务进程里起一个线程,反正服务本来就得常驻。
五、注销:进程退出时主动删除
光靠过期清理是不够的。一个优雅退出的进程应该自己把自己从注册表里删掉,这样调用方在几秒内就不会再往上面打请求,而不是等半分钟。
#!/bin/sh
# 放进服务的停止脚本,或者 systemd 的 ExecStop=
curl -s -m 3 -X DELETE "http://reg.internal/register" \
-H 'Content-Type: application/json' \
-d "{\"service\":\"api\",\"instance\":\"$(hostname):8080\"}"但必须清楚:注销是优化,不是保证。进程被 kill -9、机器断电、内核 panic 的时候,这段代码根本不会执行。所以过期清理逻辑无论如何都得写,注销只是让常见情况变得更快。
还有一个细节:如果服务是滚动重启,新进程可能在旧进程注册之前就起来了,两条记录会同时存在几秒。这没问题,调用方短暂看到两个实例、其中一个连不上,负载均衡会自动重试。不要为了消除这几秒窗口去加分布式锁,收益远小于复杂度。
六、查询接口:返回什么、不返回什么
查询接口是整个方案的对外契约,设计得好不好,直接决定调用方写起来舒不舒服。
GET /instances?service=api&env=prod
{
"service": "api",
"count": 3,
"instances": [
{"addr": "10.0.1.12", "port": 8080, "meta": {"ver": "2.3.1", "weight": "10"}},
{"addr": "10.0.1.13", "port": 8080, "meta": {"ver": "2.3.1", "weight": "10"}},
{"addr": "10.0.1.14", "port": 8080, "meta": {"ver": "2.3.0", "weight": "5"}}
]
}三个设计要点:
返回实例列表,不要返回单个地址。 如果只返回一台,那它就成了单点,而且调用方没法做负载均衡。哪怕永远只有一台实例,也返回一个长度为一的数组,将来扩容时调用方不用改代码。
过滤在服务端做。 env、版本这些过滤条件放接口参数里,别把全部实例吐回去让调用方自己筛。这样以后你想控制「某个机房暂时不接流量」,只需要改服务端逻辑。
加上 count 字段。 这个字段看着多余,但它让调用方可以低成本地做健康统计——比如某次查询返回 count=0,直接告警「api 服务没有可用实例」,比在调用方那边判断空数组更容易统一处理。
接口本身要加一个短缓存,比如 3 秒。调用方可能每秒查好几次,每次都打数据库没必要。但缓存时间绝不能超过心跳间隔,否则一个实例刚挂,你要等缓存过期才能把它从列表里去掉。
七、客户端缓存:别每次都查
调用方如果每次请求都去查一次注册中心,注册中心就成了新的性能瓶颈,而且它一挂所有调用方全挂——这正是我们想避免的事。
正确做法是本地缓存 + 定时刷新 + 失败时使用旧数据:
import time, random, threading
class Registry:
def __init__(self, base, service, ttl=15):
self.base, self.service, self.ttl = base, service, ttl
self.instances, self.fetched_at = [], 0
def refresh(self):
try:
data = http_get(self.base + "/instances?service=" + self.service)
if data["instances"]:
self.instances = data["instances"]
self.fetched_at = time.time()
except Exception:
pass # 保留旧数据,注册中心挂了不能拖垮调用方
def pick(self):
if time.time() - self.fetched_at > self.ttl:
self.refresh()
if not self.instances:
raise RuntimeError("no available instance")
return random.choice(self.instances)这段代码的核心是 except 里那个 pass。很多人会条件反射地在这里抛异常或者清空列表,结果是注册中心抖一下,所有调用方瞬间认为「没有可用实例」而集体报错。本地缓存的旧地址仍然大概率可用,让它继续工作比立刻失败要好得多。
刷新失败时可以把 ttl 逐步放大(退避),避免注册中心已经宕了,几十个进程还在每 15 秒重试打它。简单做法:连续失败 N 次后,把刷新间隔翻倍,最长不超过 60 秒。
八、过期清理:一个定时任务和一个判据问题
过期清理的 SQL 很简单:
DELETE FROM service_registry
WHERE heartbeat < DATE_SUB(NOW(), INTERVAL 30 SECOND);但这里有一个非常容易搞错的地方:时间以谁的时钟为准。
heartbeat 是 MySQL 用 NOW() 写的,清理也用 NOW(),两边是同一个时钟,这没问题。但如果你的心跳是用应用服务器的时间算好再传上去,那么任何一台应用机器的时钟比数据库慢 30 秒,它的实例就会一直被判定为已死——而实际上它活得好好的。表现是「某台机器上的服务永远不出现在列表里」,排查起来极其反直觉。
所以:所有时间戳一律由数据库生成,应用只传业务字段。这既是省事,也是消除时钟漂移这一类问题的唯一可靠办法。
清理任务放在哪?可以是一分钟一次的 cron,也可以挂在心跳接口里按概率触发(比如每 100 次请求顺手清一次)。前者更清晰,我倾向 cron。清理频率不需要太高,一分钟一次足够,一台两核机器上这条 DELETE 走索引,开销可以忽略。
如果想留一份历史,不要直接 DELETE,改成把记录挪到一个 service_registry_history 表里。这样「上周三凌晨 api 为什么只剩一台实例」这种问题还有据可查,而这个信息在排查真实故障时经常是关键。
九、什么时候该换成 Consul 或 etcd
这套方案有明确的能力边界,超过之后强行扩功能会越来越难看。出现下面任何一种情况,就该考虑换成正式的服务发现组件:
- 服务实例数跨过几十个,并且频繁扩缩容。手工设计的过期和刷新策略开始不够用,你需要更精细的健康检查(TCP、HTTP 路径、自定义脚本)。
- 需要毫秒级的变更推送。这套方案靠轮询,最快也就几秒的感知延迟。如果你做蓝绿发布,要求流量在 200 毫秒内切过去,轮询做不到,需要 watch 机制。
- 需要跨机房、多集群的服务目录。这时你要的不只是一张表,还有分区、故障域、就近路由,这些是 Consul 和 etcd 的原生能力。
- 需要强一致性的配置分发。服务发现和配置中心经常被一起需要,Consul 的 KV、Nacos 的配置管理能省掉一套自研。
反过来说,如果你的规模是「两台机器、六个服务、一年扩容两次」,上面这套表加接口可以稳定跑很久。它的运维成本几乎为零:一个表,一个接口,一个 cron。数据库本来就是你已经在备份的东西,不需要为它单独做高可用。
十、小结
服务发现的本质是「让调用方知道对方现在在哪」,方案的价值在于用多小的代价解决它。对大多数中小规模的站来说,一张带心跳时间的表、一个返回实例数组的接口、一个 30 秒的过期判据,加上调用方的本地缓存和失败回退,已经足够覆盖真实需求。
这套方案里真正决定成败的不是代码量,而是三个判断:时间戳由谁生成(数据库,不要应用)、注册中心挂了调用方怎么办(用旧数据,不要清空)、什么时候该承认它不够用(实例数、推送延迟、跨机房三条线任意一条被触碰)。把这三件事想清楚,几十行代码也能撑起一个不出事的服务目录。