跳转到内容

缓存一致性开发指南

这份文档面向维护服务端缓存代码的开发者。它说明哪些读写路径必须具备强一致性、Redis version fence 如何作为权威新鲜度边界,以及新增缓存时应遵守的实现规则。

核心原则:异步失效只负责收敛,不负责正确性。任何授权、访问控制、房间设置、播放状态、成员身份和资源存在性相关路径,都不能因为某个节点没有收到失效事件而返回旧状态。

组件作用代码入口
L1 cache单节点内存缓存,降低本机重复查询成本moka cache、RoomSettingsCachePlaybackStateCache
Redis L2 cache跨节点共享缓存,降低 PostgreSQL 读取压力synctv-core/src/cache/l2_backend.rs
Redis version fence每个逻辑资源的权威新鲜度版本synctv-core/src/cache/consistency.rs
PostgreSQL row version业务状态的持久化乐观锁版本repository 层的 version 字段
invalidation stream让其他节点尽快清理本地缓存CacheInvalidationRuntime

Redis version fence 是强一致读的判定依据。L1 和 L2 中的缓存值必须带有版本;只有缓存版本满足 fence,强读才可以返回缓存。

CacheDomain 定义了可被 Redis fence 管理的逻辑资源:

影响范围当前策略
RoomSettings(room_id)房间密码、加入策略、审批策略、角色默认权限、房间访问行为Redis 分配版本,DB 存 exact version,L1/L2 按版本写入
Playback(room_id)当前播放状态、重置、自动播放、媒体清理后的播放状态Redis 分配版本,DB 存 exact version,L2 使用状态 version CAS
Permission(room_id, user_id)单个成员的有效权限成员级变更通过 reservation 推进用户 fence;强读同时校验用户 fence 和房间设置 fence
RoomMembership(room_id, user_id)成员身份、踢出、离开后的访问边界需要缓存时必须先接入 fence;当前关键路径以 DB 为准
MediaResource(room_id, media_id)媒体存在性、归属、删除后的访问边界需要缓存时必须先接入 fence;当前关键路径以 DB 为准
Playlist(room_id, playlist_id)播放列表存在性、归属、删除后的访问边界需要缓存时必须先接入 fence;当前关键路径以 DB 为准
UserAuthSecurity(user_id)用户封禁、删除、密码版本、令牌撤销、OAuth/passkey/session 状态需要缓存时必须 fail closed 或接入 fence

不要把 domain 设计成 API 路由级别。domain 应该对应会一起变更、一起判定新鲜度的业务资源。

强读必须执行以下逻辑:

  1. 读取 Redis fence。
  2. 如果 Redis 不可用或 fence store 不是 authoritative,授权和访问控制路径必须绕过缓存读取 PostgreSQL;不能因为 Redis 故障而信任旧缓存。
  3. 检查 L1。只有 cached.version >= fence 才能返回。
  4. 检查 L2。只有 cached.version >= fence 才能返回。
  5. 读取 PostgreSQL,并使用版本感知写入刷新缓存。

伪代码:

let fence = version_fence.current_version(&domain).await?;
if let Some(value) = l1.get(key).await {
if value.version >= fence {
return Ok(value);
}
}
if let Some(value) = l2.get(key).await? {
if value.version >= fence {
return Ok(value);
}
}
let value = repository.load_with_version(key).await?;
cache.set_if_version_at_least(key, value.clone()).await?;
Ok(value)

禁止在强读中使用单纯的 cache-first 逻辑。cache-first 只能用于明确标注为 eventual 的诊断或低风险路径。

对有业务 row version 的资源,Redis 是 version allocator:

  1. 从 PostgreSQL 读取当前 DB version。
  2. 通过 ConsistencyCoordinator 调用 fence begin-write,在 Redis/local fence 内部原子检查当前 committed/pending fence 是否已经超过本次观察到的 DB version,并预留下一个 pending version。
  3. 使用 repository 的 exact-version 方法把预留版本写入 PostgreSQL。
  4. PostgreSQL 事务提交后,用同一个 reservation token 提交 fence;如果 DB CAS 或事务失败,只 abort 匹配 token 的 pending reservation。
  5. 使用 set_if_version_at_least 写入 L2/L1。
  6. 发布 invalidation 和 realtime 事件,让其他节点尽快收敛。

这个顺序避免了最危险的状态:PostgreSQL 已经是新版本,但 Redis fence 仍停留在旧版本。

Redis fence 允许存在 pending 状态。例如 CAS 冲突、事务回滚、进程崩溃或 outbox 写入失败时,pending version 可能还没有对应的 DB commit。强读看到 pending 时必须绕过缓存读 PostgreSQL,这是 fail-safe 的;代价是该 domain 暂时降低缓存命中率。

当前实现已经在 fence store 和 ConsistencyCoordinator 层引入 committed/pending 状态:强读看到 pending 会 DB fallback,房间设置、播放状态、成员身份、成员角色和成员权限写入会在 DB commit 后用同一个 reservation token 提交 fence。强读 DB fallback 后的 read-time repair,以及启动时挂载的后台 repair worker,会根据 PostgreSQL row version 与 pending version 的关系修复:DB 已达到 pending version 时 finalize pending;DB 未达到 pending version 且 pending lease 已过期时 expire abandoned pending;DB 未达到 pending version 且 lease 未过期时保持 pending。不能仅凭本地超时 abort pending,必须同时比较 PostgreSQL 版本。

业务服务不应直接操作底层 fence store。新增强一致路径必须通过 ConsistencyCoordinator begin/commit/abort reservation、seed 或记录 DB fallback。这样可以把 metrics、错误分类和 pending/committed fence 协议集中在一个替换点。

SyncTV 的 fence reservation 不是 PostgreSQL 事务的一部分。DB transaction rollback 不会自动清除 Redis/local fence 里的 pending reservation;因此 reservation 必须有明确 owner,并且 owner 必须覆盖所有退出路径。

强制规则:

  • begin_*write 成功后,reservation 必须立刻归属于当前函数、局部 owner,或被成功转移给调用方返回值。
  • 在 reservation 转移给调用方之前,后续任何 ?return Err(...)、CAS 未命中、outbox 写入失败、辅助清理失败、事务 commit 失败,都必须先 abort 对应 reservation。
  • helper 函数内部创建 reservation 时,helper 必须在本函数内清理失败路径;调用方只能清理已经成功返回的 reservation。
  • 批量 reservation 必须使用 collector/owner 模式;第 N+1 个 reservation 失败时,前 N 个已经创建的 reservation 必须立即 abort。
  • commit fence 只能发生在 PostgreSQL transaction 成功提交之后。transaction 提交前不能把 pending fence 推进为 committed。
  • commit fence 失败属于 post-commit repair 问题,不能通过 abort 已经持久化到 DB 的版本来“回滚”业务事实。

禁止模式:

let reservation = begin_write().await?;
write_db_row().await?;
delete_auxiliary_rows().await?;
tx.commit().await?;
commit_write(&reservation).await?;

正确模式必须显式收口错误出口:

let reservation = begin_write().await?;
let result: Result<_> = async {
write_db_row().await?;
delete_auxiliary_rows().await?;
Ok(())
}
.await;
if let Err(error) = result {
abort_write(reservation.as_ref()).await;
return Err(error);
}
if let Err(error) = tx.commit().await {
abort_write(reservation.as_ref()).await;
return Err(error.into());
}
commit_write(reservation.as_ref(), db_version).await?;

每次修改强一致写路径前,都要用源码搜索审计 reservation owner,并人工检查本次变更可能影响的每个 begin 点:

Terminal window
rg -n "begin_.*write|begin_observed_write|VersionFenceReservation" synctv-core/src/service synctv-core/src/cache
rg -n "abort_.*write|commit_.*write|commit_reserved_write|abort_reserved_write" synctv-core/src/service synctv-core/src/cache

源码搜索不会证明代码正确。review 前必须人工检查相关 begin 点的 owner 转移、转移前每个 ? / return Err 路径、事务 commit 失败处理、post-commit finalization 和 cache invalidation。

论文和开源系统只能提供原则,不能替代这条 lifecycle 规则。Spanner、etcd、Kubernetes watch cache 等系统把版本证明放在同一个受控系统里;SyncTV 当前实现跨 PostgreSQL transaction 与 Redis/local fence 两套状态,缺少全局事务管理器,所以 pending reservation 的 owner/abort/commit 必须由服务代码显式维护。

Redis L2 不能无条件覆盖旧值。任何从 DB reload 后写入 L2 的路径都必须使用版本感知写入:

cache.set_if_version_at_least(key, value).await?;

这避免读路径在竞态中把版本 N 的旧状态重新写回 Redis,覆盖已经由写路径提交的版本 N+1。

有效权限不是一张独立快照表,而是读取时计算出来的结果:

effective_permissions =
f(global_defaults, room_settings.role_defaults, room_member.role, member_overrides)

因此权限缓存必须同时记录两个版本:

字段来源表示
user_versionPermission(room_id, user_id) fence该成员自身角色和 override 的新鲜度
room_settings_versionPostgreSQL 中 _settings row version本次权限计算使用的房间设置版本

强权限读只有在同时满足下面两个条件时才能返回缓存:

cached.user_version >= Redis Permission(room_id, user_id) fence
cached.room_settings_version >= Redis RoomSettings(room_id) fence

修改单个成员的角色或权限 override 时,只推进该成员的 Permission(room_id, user_id) fence。

房间默认权限属于 RoomSettings。设置写入推进 RoomSettings(room_id) fence 后,旧权限缓存会因为 room_settings_version 不满足新 fence 而被强读拒绝。invalidate_room_cache(room_id) 只负责本地清理和广播收敛,不承担正确性。

Redis Streams、本地 broadcast 和 PostgreSQL notification 都是收敛机制:

  • 降低旧 L1 驻留时间。
  • 减少下一次强读回 DB 的概率。
  • 驱动 Realtime 资源观察重新评估。

它们不是强一致性的来源。新增强一致路径时,必须先设计 fence 和版本校验,再考虑 invalidation。

新增或改造缓存时,缓存设计必须满足下列约束:

约束规则
授权、访问控制、存在性和关键用户可见状态使用 strong/fence 协议;无法接入 fence 时保持 DB-authoritative
缓存值版本来源优先使用业务 row version;派生值保存参与计算的源版本
Redis fence 与 DB version 的关系强读不得看到落后于 DB 的 committed fence;写入前必须先安装 pending reservation 屏障
L2 覆盖语义所有写入使用 set_if_version_at_least,旧 reload 不能覆盖新值
Redis 不可用语义授权路径 fail closed 或绕过缓存读 DB
异步失效语义只作为收敛优化,不作为正确性前提
业务层接入通过 ConsistencyCoordinator 接入 fence;不要在 service 中直接调用底层 VersionFenceStore 原语

一致性指标用于发现“读安全但性能退化”或“写路径进入修复态”的问题:

指标含义
cache_fence_operations_total{domain,operation,result}fence 当前值读取、begin/commit/abort、seed 的成功、冲突、超时和错误次数
cache_db_fallback_total{domain,reason}强读因 missing fence、stale cache、L2 错误等原因回 PostgreSQL 的次数
cache_stale_write_reject_total{cache_type,level}版本感知缓存写入被 L1/L2 中更新版本拒绝的次数
cache_fence_pending{domain}当前 domain 是否存在 pending fence
cache_fence_repair_total{domain,result}强读 DB fallback 后根据 PostgreSQL version 修复/推进 fence 的结果
cache_fence_db_compare{domain,relation}repair/巡检时观察到的 Redis fence 与 PostgreSQL version 关系,例如 fence_behind_dbfence_ahead_dbpending_ahead_db

强一致缓存改动至少覆盖以下场景:

  • L1 存在旧值,Redis fence 已推进,强读必须拒绝 L1。
  • L2 存在旧值,Redis fence 已推进,强读必须拒绝 L2。
  • 写路径预留 Redis version 后,DB 存储 exact version。
  • 旧版本 reload 不能覆盖 L2 中的新版本。
  • 派生缓存的上游资源版本变更后,强读必须拒绝旧派生值。

权限相关改动还要覆盖:

  • 成员级权限变更后,只影响对应用户的 permission fence。
  • 房间默认权限变更后,旧权限缓存会被房间设置 fence 拒绝。

本轮设计审计参考了不少于 15 个现代缓存一致性、版本化读写和大型开源系统的实现/文档:

来源可借鉴点对 SyncTV 的结论
Scaling Memcache at Facebookleases、失效广播、热点保护、把缓存当作独立系统治理SyncTV 把 invalidation 作为收敛机制;生产环境还应观测 fence lag、CAS skip 和 DB fallback
TAO: Facebook’s Distributed Data Store for the Social Graphgraph/cache 层按对象版本和 leader/follower 复制组织CacheDomain 按业务资源建模是正确方向;派生对象必须记录源对象版本
RAMP-TAO多对象读要避免 fractured reads当前权限缓存是 member row + room settings 的派生值,必须同时保存两个源版本
Polaris / Cache Made Consistent生产系统需要独立一致性检测,而不是只靠代码审查Redis fence 与 DB version 的 lag、L2 旧版本写入拒绝次数是关键观测信号
Amazon Dynamoobject versioning、冲突显式化Redis fence 可以领先 DB,但缓存条目必须带真实源版本,不能只带 fence 版本
Google Spanner / TrueTime单调时间戳和外部一致性依赖明确 commit 顺序SyncTV 不做全局事务;只能在单 domain 内通过 Redis 单调 fence 保证强读边界
Cloud Spanner external consistency docs强读与 stale read 必须是显式模式SyncTV 文档和 API 区分 strong 与 eventual 路径
Calvin先确定顺序再执行事务Redis 先预留版本再写 DB 的方向正确;失败后 fence 领先是可接受的安全降级
RAMP transactions派生/多分区读需要 read atomic 元数据权限缓存必须保存 member version 和 room settings version,不能只保存一个逻辑失效版本
FaRM高性能事务仍依赖明确验证阶段L2 CAS 和 DB optimistic lock 都是必要验证点;不能用无条件 set
Kubernetes API conceptsresourceVersion 同时用于变更检测和一致性要求RoomSettings.version无客户端缓存版本RoomMember.version 应作为缓存源版本
Kubernetes consistent reads from cache从 watch cache 提供一致读需要进度/版本证明SyncTV 的 L1/L2 只有在满足 Redis fence 时才能作为强读来源
etcd API guaranteeslinearizable read 与 serializable/stale read 明确分离Redis 故障时授权路径不能退化为 stale cache;必须 DB fallback 或 fail closed
Envoy xDS protocolversion + nonce 避免 ACK/NACK 竞态SyncTV Realtime/缓存失效可以后续补“已观察版本”调试字段,但强一致性不依赖 ACK
CockroachDB follower readsstale read 必须来自一致历史快照,并显式声明可陈旧SyncTV eventual path 只能用于低风险读取,不能混入授权和访问控制
TiDB stale read历史读依赖 TSO/safe point 边界可接受陈旧读取必须声明 explicit staleness bound,不能用 TTL 暗含一致性
Cassandra LWTCAS/linearizable 写入适合关键条件更新SyncTV 对关键缓存写入必须保留 CAS:DB optimistic lock + Redis L2 version CAS
Redis Lua / scripting单 Redis 命令/脚本可作为原子 compare-and-set 边界set_version_at_least 和 L2 Lua CAS 提供 Redis 侧原子版本边界

设计结论:

主题结论
派生缓存派生缓存保存实际源版本,不能把 Redis fence 当前值当作源版本。权限缓存保存 RoomMember.version 和 room settings row version。
写入顺序强读暴露的 fence 不能落后 DB;Redis 失败不能静默完成强一致写入。room settings、playback、成员身份/权限/角色写入先 begin pending fence,再写 exact DB version。
多源强读强读涉及多个 fence 时,所有缓存命中判断必须在同一组新鲜度边界下完成。
生产观测一致性系统的观测对象包括 fence lag、CAS reject、DB fallback 和 Redis fence unavailable。
删除语义删除、离开、踢出等状态变化必须具备明确版本语义;成员删除会写入 PostgreSQL lifecycle marker 作为 tombstone 版本,强读仍以 DB 判定非成员,不缓存授权成功结果。
eventual 路径eventual API 与 strong API 是不同一致性契约;授权和访问控制不能使用 eventual 读取。