小Cの已经记不起来的博客

PostgreSQL 索引与 缓存一致性 的正确姿势

前段时间接手了一个老接口,列表页查询要三四秒,用户天天催。打开看了一眼,表里才几十万行数据,按理说不该这么慢。EXPLAIN 一下,好家伙,Seq Scan,一条索引都没有,当初建表的人估计压根没想过这张表会长大。

索引不是加了就完事

先老老实实加索引:

CREATE INDEX CONCURRENTLY idx_orders_user_id ON orders (user_id);

注意一定要带 CONCURRENTLY,不然建索引期间表是锁着的,线上直接给你报一堆超时。我第一次就忘了加,好在大半夜跑的没出事故,但心脏还是漏跳了一拍。

加完再跑 EXPLAIN ANALYZE,确实走索引了,耗时从 3000ms 降到 20ms 左右,立竿见影。

但后来发现有的查询还是慢,查了半天才想起来联合索引有最左前缀原则。我建的是 (user_id, status, created_at),结果某个查询只按 status 过滤,根本没带 user_id,这索引就跟没建一样。所以联合索引的列顺序得按实际查询写法来,不是拍脑袋把几个字段一拼就行的。

还有个更隐蔽的坑是隐式类型转换。字段是 varchar,代码里传的是数字,PostgreSQL 会在比较时把列转成数字,索引直接失效。这种问题 EXPLAIN 一眼就能看出来,但写代码的时候真的特别容易忽略。

顺带说一句,索引不是越多越好。可以用下面这句看看哪些索引从来没人用过:

SELECT * FROM pg_stat_user_indexes WHERE idx_scan = 0;

每个索引都在拖慢写入,没用的就 DROP 掉,别留着占地方。

加了缓存之后,坑才刚开始

查询优化完了,DBA 又说 QPS 再涨数据库顶不住,于是上了 Redis。逻辑很简单:先查缓存,没有就查库,然后写回缓存。

然后问题就来了。有个运营同事改了数据,页面刷了半天还是旧的,她以为系统坏了,差点提个 P0。其实就是经典的缓存不一致:更新的时候只改了数据库,缓存里还是旧值。

网上搜“缓存一致性”,能搜到一堆八股文,什么先更新缓存再更新库、先删缓存再更新库……我一开始也看得云里雾里,后来自己实测了一圈,说说结论。

最省心也最常用的就是 Cache Aside:更新数据库,然后删除缓存。注意是删除,不是更新,下次读的时候自然会重新加载。为什么不是更新缓存?因为你更新时算出来的值,可能过一会儿又要变,白算不说,并发场景下还容易互相覆盖。

那能不能反过来,先删缓存再更新库?不太行。删除到更新完成这个空档里,别的请求读到空缓存,会把旧数据又灌回去,删了个寂寞。

先更新库再删缓存也有个小概率问题:删除失败。所以最好加个兜底,比如把 key 丢进消息队列重试,或者干脆给缓存设个过期时间,就算一致性偶尔破了,几分钟内也能自愈。我两个都做了,过期时间按业务能容忍的延迟来定,一般五分钟足够。

至于传得很玄的“延迟双删”,说实话我没用,感觉更像是在给先删缓存那个方案打补丁。读写都特别猛的场景可以试试,多数情况先更新库再删缓存加过期时间就够了。

再进一步:让数据库告诉你该删谁

后来缓存越挂越多,业务代码里到处都是删 key 的逻辑,删得人心里发毛,漏一处就是事故。最后干脆换了个思路:用 PostgreSQL 的逻辑复制把变更推出来,统一消费,统一删缓存。

大概这样开启:

ALTER SYSTEM SET wal_level = logical;
-- 改完要重启才生效
CREATE PUBLICATION cache_evict FOR TABLE orders, users;

然后起个小服务,借助 wal2json 这类插件订阅变更,收到 UPDATE/DELETE 就去删对应的缓存 key。这样业务代码完全不用关心缓存,删缓存这件事收敛到了一个地方,漏删的概率小很多。

缺点也很明显:多了一套要维护的东西,延迟大概在几十毫秒到秒级。对一致性要求特别苛刻的地方,还是老老实实读库吧。

最后

总结一下:索引先让 EXPLAIN ANALYZE 说话,别猜;建索引用 CONCURRENTLY,别锁表;缓存一致性别整花活,先更新库再删缓存,加过期时间兜底;业务复杂了再上基于 WAL 的方案。听起来都是大白话,但每一条都是我踩过坑才记住的,希望你能少踩几个。

评论

还没有评论。

发表评论

提交后评论将经过自动审核,审核通过后公开展示。

未在播放