应用自管缓存(Cache-Aside)实践指南

View Source

主题:应用代码自己调用 API,用 kura_cache 做行缓存、SQL repo 做 主存储(system-of-record),如何正确地维护缓存与更新数据库。

背景结论见 cache-layering-research.md: 缓存应做在业务函数层而非 repo 层;只按主键读缓存;写后删而非写后改。 (该研究写于 kura_ets_cache 时期,结论原样适用;当时的两条"残余 竞态兜底"——TTL 与跨节点失效——现在都是 kura_cache 的内建能力。)

0. 三条核心纪律

  1. 只按主键(或唯一键)读缓存——部分缓存对扫描/条件查询语义上 是错的,列表查询永远直连数据库 repo。
  2. 先写库,库成功后再动缓存——反过来做,库写失败会留下脏缓存。
  3. 写后 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 的教训)

  1. 顺序永远是"库成功 → 再动缓存"
  2. 写后 evict,不要写后 put。put 的竞态:两个写者并发,慢者把 旧行 put 回去,脏数据一直留到下次失效。
  3. 不要在数据库事务内动缓存的写侧——事务内 evict 无害(顶多多 一次 miss),但应在提交成功后再 evict 一次(double-delete), 关掉"事务中途有读者回填旧值"的窗口。
  4. update_all / delete_all / raw SQL 之后必须 flush_rows/1 兜底——这些路径没有单行钩子可依赖。
  5. 列表/条件查询永远直连 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(不再触发广播,不会回环)。发送端本地已先删,自己 收到自己的广播再删一次无害。