应用自管缓存(Cache-Aside)实践指南
View Source主题:应用代码自己调用 API,用 kura_cache 做行缓存、SQL repo 做 主存储(system-of-record),如何正确地维护缓存与更新数据库。
背景结论见 cache-layering-research.md: 缓存应做在业务函数层而非 repo 层;只按主键读缓存;写后删而非写后改。 (该研究写于 kura_ets_cache 时期,结论原样适用;当时的两条"残余 竞态兜底"——TTL 与跨节点失效——现在都是 kura_cache 的内建能力。)
0. 三条核心纪律
- 只按主键(或唯一键)读缓存——部分缓存对扫描/条件查询语义上 是错的,列表查询永远直连数据库 repo。
- 先写库,库成功后再动缓存——反过来做,库写失败会留下脏缓存。
- 写后 evict(删),不要写后 put(改)——put 有并发写者互相 覆盖的竞态,evict 最坏情况只是多一次 miss。
1. 配置
%% 监督树里
kura_cache:start_link(#{
kinds => #{
user_schema => 60_000, %% 行缓存:kind 就是 schema 模块,值是 TTL(ms)
fanout => 30_000 %% 自由 kind(派生值)也走同一套机制
},
broadcast => fun my_app_cache:broadcast/1 %% 集群失效,可选
}).没有列进 kinds 的 kind 不缓存——加一个 kind 是显式动作,任何
一次 stale 读都能追溯到这张配置表。
2. 业务 context 的标准写法
-module(my_users).
-export([get/1, update/2, delete/1, deactivate_all/1]).
get(Id) ->
kura_cache:fetch_row(user_schema, Id, fun() ->
kura_repo_worker:get(db_repo, user_schema, Id)
end).
update(Id, Attrs) ->
%% 写路径直读库,不读缓存
{ok, User} = kura_repo_worker:get(db_repo, user_schema, Id),
CS = user_schema:changeset(User, Attrs),
case kura_repo_worker:update(db_repo, CS) of
{ok, Row} ->
ok = kura_cache:evict_row(user_schema, Id),
{ok, Row};
{error, _} = Err ->
Err
end.
delete(Id) ->
{ok, User} = kura_repo_worker:get(db_repo, user_schema, Id),
CS = kura_changeset:cast(user_schema, User, #{}, []),
case kura_repo_worker:delete(db_repo, CS) of
{ok, Row} ->
ok = kura_cache:evict_row(user_schema, Id),
{ok, Row};
Err ->
Err
end.
%% 批量写:不知道影响了哪些主键 → 整个 schema 清空兜底
deactivate_all(Cutoff) ->
Q = kura_query:where(kura_query:from(user_schema), {last_seen, '<', Cutoff}),
case kura_repo_worker:update_all(db_repo, Q, #{active => false}) of
{ok, N} ->
ok = kura_cache:flush_rows(user_schema),
{ok, N};
Err ->
Err
end.复合主键:Spec 传 #{field => value} map(与
kura_repo_worker:get/3 同一套约定);单主键传裸值。归一化后的键与
字段顺序无关。
3. 时序规则(IdentityCache 的教训)
- 顺序永远是"库成功 → 再动缓存"。
- 写后 evict,不要写后 put。put 的竞态:两个写者并发,慢者把 旧行 put 回去,脏数据一直留到下次失效。
- 不要在数据库事务内动缓存的写侧——事务内 evict 无害(顶多多 一次 miss),但应在提交成功后再 evict 一次(double-delete), 关掉"事务中途有读者回填旧值"的窗口。
update_all/delete_all/ raw SQL 之后必须flush_rows/1兜底——这些路径没有单行钩子可依赖。- 列表/条件查询永远直连 db repo。
4. 残余竞态与内建兜底
即便全部照做,仍有一个不可消除的小窗口:
读者从库读出旧行 → 写者更新库并 evict → 读者把旧行回填 → 脏数据留存到下次失效。
kura_cache 的 per-kind TTL 就是这个窗口的兜底:忘写的 evict 和 输掉的竞态,代价都是"秒级 stale",不是"永远 stale"。TTL 按数据的 容忍度配——行缓存几十秒,派生值更短。
无负缓存:undefined 与 {error, _} 永不入缓存——把一次瞬时
故障缓存下来,是把抖动变成 bug。不存在的键如果成为热点,在应用层
另加 negative set。
5. 集群失效
broadcast 钩子把 {evict, Kind, Key} / {flush, Kind} 交给应用,
传输自选(syn / pg / 无)。接收端调 kura_cache:local_evict/2 /
local_flush/1(不再触发广播,不会回环)。发送端本地已先删,自己
收到自己的广播再删一次无害。