PostgreSQL 索引与 服务发现 的正确姿势
前阵子要给内部系统搞个服务发现,同事劝我直接上 Consul,但我看了一眼要注册的服务,满打满算十来个实例,为了这点东西再维护一套 Consul 集群,属实有点杀鸡用牛刀了。项目本身就在 PostgreSQL 上跑,那干脆注册信息也塞 PG 里算了,一张表加两个接口的事儿。
然后就开始写表结构、写接口,测试一切正常,上线。结果跑了大概两周,发现服务查询接口越来越慢,从几毫秒慢慢涨到两百多毫秒,虽然还没到不能用的地步,但作为一个“就查一张表”的接口,这速度实在有点说不过去...
下面是这次排查和优化的完整过程,索引这块踩的坑比我预想的多,记录一下,说不定你哪天也会遇到。
表结构长什么样
先贴一下注册表,简化过了,核心字段都在:
CREATE TABLE service_registry (
id bigserial PRIMARY KEY,
service_name text NOT NULL,
instance_id text NOT NULL,
host text NOT NULL,
port int NOT NULL,
status text NOT NULL DEFAULT 'alive', -- alive / down
expires_at timestamptz NOT NULL,
registered_at timestamptz NOT NULL DEFAULT now()
);
服务启动时 INSERT 一行,之后每隔 10 秒上报一次心跳,心跳就是一条 UPDATE,把 expires_at 往后推 30 秒。服务发现接口则要找出所有还活着的实例:
SELECT service_name, host, port
FROM service_registry
WHERE service_name = $1
AND status = 'alive'
AND expires_at > now();
看着挺简单对吧?我一开始也是这么想的。
慢在哪,先看 EXPLAIN 再说
遇到慢查询别瞎猜,直接 EXPLAIN ANALYZE 一下:
Seq Scan on service_registry (actual time=0.15..45.20 rows=3 loops=1)
Filter: ((service_name = 'order-service') AND (status = 'alive') AND (expires_at > now()))
Rows Removed by Filter: 480000
好家伙,全表扫描,Rows Removed by Filter: 480000。这时候我才反应过来:服务下线的时候我只是把 status 改成 down,从来没删过数据,表里堆了几十万行历史实例,每查一次都要把它们全部过滤一遍。
其实想想也对,过期数据不清理,行数只会一直涨,全表扫描只会越来越慢。这真不是 PostgreSQL 的问题,是我设计的时候没想清楚。
索引不是建了就完事
那还不简单,加个索引呗。第一版是这么建的:
CREATE INDEX idx_registry_lookup ON service_registry (service_name, status, expires_at);
建完之后查询确实从 45ms 降到 1ms 左右,本以为事情到此结束,结果过了几天又发现不对劲——pg_stat_user_indexes 里显示这个索引体积一直在涨,已经比表本身还大了。
原因就出在心跳上。每 10 秒一次 UPDATE 推 expires_at,十几个实例一天下来就是十几万次更新。PostgreSQL 的 MVCC 机制决定了每次 UPDATE 都会写入新版本行,而 expires_at 又在索引里,旧的索引条目也会跟着变成死条目,时间一长索引里全是垃圾。
后来改成部分索引(partial index)才彻底解决。仔细想想,发现接口只关心 status = 'alive' 的行,而表里绝大部分行都是 down 或者早就过期的历史数据,那索引里根本没必要放它们:
CREATE INDEX idx_registry_alive
ON service_registry (service_name, expires_at)
WHERE status = 'alive';
注意我把 status 从索引列里拿掉了,因为 WHERE 条件已经保证进索引的行都是 alive,查询里的 status = 'alive' 会自动匹配上这个谓词。重建之后索引体积缩到原来的十分之一不到,心跳更新也轻快了不少,毕竟只有活着的实例才需要维护索引条目。
心跳太频繁,膨胀还是会来
不过就算用了部分索引,高频 UPDATE 带来的死元组问题依然存在,只是没那么严重了。这里我做了两件事。
一是把这张表的 autovacuum 调激进一点:
ALTER TABLE service_registry SET (
autovacuum_vacuum_scale_factor = 0.05,
autovacuum_vacuum_cost_delay = 0
);
默认的 scale_factor 是 0.2,也就是死元组占到 20% 才触发清理,对这种持续高频更新的表来说太懒了,调到 5% 能让清理跟上更新的节奏。
二是找个低峰期定期重建索引。PostgreSQL 12 之后可以直接 REINDEX INDEX CONCURRENTLY idx_registry_alive;,不锁写,挂个凌晨的 cron 跑一下就行,重建完索引体积肉眼可见地缩回去。
过期数据总得有人管
索引只解决了查询快慢,历史数据该删还得删。实例正常下线时会发注销请求把行删掉,但总有进程被 kill -9 的情况,这些行就永远留在表里了。所以我用 pg_cron 加了个每小时跑一次的清理任务:
SELECT cron.schedule('cleanup-expired', '0 * * * *',
$$DELETE FROM service_registry WHERE expires_at < now() - interval '1 day'$$
);
只删过期超过一天的,是想着万一定位问题的时候还想看看哪些实例最近掉过线。另外提醒一句,大批量 DELETE 同样会产生死元组,别一上来就删几百万行,分批删或者交给 autovacuum 善后都行。
写在最后
回头看这次折腾,其实就三个教训:一是别因为功能简单就忽略数据会一直增长,服务注册表天生就是持续写入的表;二是 EXPLAIN ANALYZE 永远是第一步,我要是一开始就看了执行计划,也不至于绕这么大的弯路;三是部分索引在这种“只查活数据”的场景下是真的好用,比堆一堆复合索引列优雅多了。
至于要不要上 Consul——等哪天服务多到 PG 扛不住了再说吧,反正现在这套跑了两个多月,稳得很。
评论
还没有评论。
发表评论
提交后评论将经过自动审核,审核通过后公开展示。