缓存一致性开发指南
这份文档面向维护服务端缓存代码的开发者。它说明哪些读写路径必须具备强一致性、Redis version fence 如何作为权威新鲜度边界,以及新增缓存时应遵守的实现规则。
核心原则:异步失效只负责收敛,不负责正确性。任何授权、访问控制、房间设置、播放状态、成员身份和资源存在性相关路径,都不能因为某个节点没有收到失效事件而返回旧状态。
| 组件 | 作用 | 代码入口 |
|---|---|---|
| L1 cache | 单节点内存缓存,降低本机重复查询成本 | moka cache、RoomSettingsCache、PlaybackStateCache |
| 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 应该对应会一起变更、一起判定新鲜度的业务资源。
强读必须执行以下逻辑:
- 读取 Redis fence。
- 如果 Redis 不可用或 fence store 不是 authoritative,授权和访问控制路径必须绕过缓存读取 PostgreSQL;不能因为 Redis 故障而信任旧缓存。
- 检查 L1。只有
cached.version >= fence才能返回。 - 检查 L2。只有
cached.version >= fence才能返回。 - 读取 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:
- 从 PostgreSQL 读取当前 DB version。
- 通过
ConsistencyCoordinator调用 fence begin-write,在 Redis/local fence 内部原子检查当前 committed/pending fence 是否已经超过本次观察到的 DB version,并预留下一个 pending version。 - 使用 repository 的 exact-version 方法把预留版本写入 PostgreSQL。
- PostgreSQL 事务提交后,用同一个 reservation token 提交 fence;如果 DB CAS 或事务失败,只 abort 匹配 token 的 pending reservation。
- 使用
set_if_version_at_least写入 L2/L1。 - 发布 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 协议集中在一个替换点。
Reservation lifecycle
Section titled “Reservation lifecycle”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 点:
rg -n "begin_.*write|begin_observed_write|VersionFenceReservation" synctv-core/src/service synctv-core/src/cacherg -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_version | Permission(room_id, user_id) fence | 该成员自身角色和 override 的新鲜度 |
room_settings_version | PostgreSQL 中 _settings row version | 本次权限计算使用的房间设置版本 |
强权限读只有在同时满足下面两个条件时才能返回缓存:
cached.user_version >= Redis Permission(room_id, user_id) fencecached.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) 只负责本地清理和广播收敛,不承担正确性。
Invalidation 的角色
Section titled “Invalidation 的角色”Redis Streams、本地 broadcast 和 PostgreSQL notification 都是收敛机制:
- 降低旧 L1 驻留时间。
- 减少下一次强读回 DB 的概率。
- 驱动 Realtime 资源观察重新评估。
它们不是强一致性的来源。新增强一致路径时,必须先设计 fence 和版本校验,再考虑 invalidation。
新增缓存设计规则
Section titled “新增缓存设计规则”新增或改造缓存时,缓存设计必须满足下列约束:
| 约束 | 规则 |
|---|---|
| 授权、访问控制、存在性和关键用户可见状态 | 使用 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_db、fence_ahead_db、pending_ahead_db |
强一致缓存改动至少覆盖以下场景:
- L1 存在旧值,Redis fence 已推进,强读必须拒绝 L1。
- L2 存在旧值,Redis fence 已推进,强读必须拒绝 L2。
- 写路径预留 Redis version 后,DB 存储 exact version。
- 旧版本 reload 不能覆盖 L2 中的新版本。
- 派生缓存的上游资源版本变更后,强读必须拒绝旧派生值。
权限相关改动还要覆盖:
- 成员级权限变更后,只影响对应用户的 permission fence。
- 房间默认权限变更后,旧权限缓存会被房间设置 fence 拒绝。
研究对比与当前结论
Section titled “研究对比与当前结论”本轮设计审计参考了不少于 15 个现代缓存一致性、版本化读写和大型开源系统的实现/文档:
| 来源 | 可借鉴点 | 对 SyncTV 的结论 |
|---|---|---|
| Scaling Memcache at Facebook | leases、失效广播、热点保护、把缓存当作独立系统治理 | SyncTV 把 invalidation 作为收敛机制;生产环境还应观测 fence lag、CAS skip 和 DB fallback |
| TAO: Facebook’s Distributed Data Store for the Social Graph | graph/cache 层按对象版本和 leader/follower 复制组织 | CacheDomain 按业务资源建模是正确方向;派生对象必须记录源对象版本 |
| RAMP-TAO | 多对象读要避免 fractured reads | 当前权限缓存是 member row + room settings 的派生值,必须同时保存两个源版本 |
| Polaris / Cache Made Consistent | 生产系统需要独立一致性检测,而不是只靠代码审查 | Redis fence 与 DB version 的 lag、L2 旧版本写入拒绝次数是关键观测信号 |
| Amazon Dynamo | object 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 concepts | resourceVersion 同时用于变更检测和一致性要求 | RoomSettings.version、无客户端缓存版本、RoomMember.version 应作为缓存源版本 |
| Kubernetes consistent reads from cache | 从 watch cache 提供一致读需要进度/版本证明 | SyncTV 的 L1/L2 只有在满足 Redis fence 时才能作为强读来源 |
| etcd API guarantees | linearizable read 与 serializable/stale read 明确分离 | Redis 故障时授权路径不能退化为 stale cache;必须 DB fallback 或 fail closed |
| Envoy xDS protocol | version + nonce 避免 ACK/NACK 竞态 | SyncTV Realtime/缓存失效可以后续补“已观察版本”调试字段,但强一致性不依赖 ACK |
| CockroachDB follower reads | stale read 必须来自一致历史快照,并显式声明可陈旧 | SyncTV eventual path 只能用于低风险读取,不能混入授权和访问控制 |
| TiDB stale read | 历史读依赖 TSO/safe point 边界 | 可接受陈旧读取必须声明 explicit staleness bound,不能用 TTL 暗含一致性 |
| Cassandra LWT | CAS/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 读取。 |