HybridCache-缓存架构决策框架落地

缓存不是加速器,是副本

缓存接入不是选组件,是签一份一致性合同;

上线三个月后,客服转来一张截图:商品标题已经改成新版,页面上的活动文案还是上周那句。没有 5xx,没有慢查询,监控全绿。最后在代码里找到一行 LocalCacheExpiration = TimeSpan.FromMinutes(5)——那是接入时从示例里抄来的,没有人问过「五分钟是谁批准的」。

这不是缓存事故,是一次没有签署合同的副本上线。缓存从来不是读路径上的加速器,它是数据源之外一份有独立生命周期的副本;每引入一份副本,就要回答四个问题:能旧多久、影响多少、谁承担、怎么证明。组件只是实现手段。

标注约定:
【实测】本机 SDK 10.0.300 + 包 10.11.0 验证过
【推论】由契约或实测推出的工程结论
【假设】需业务签字与压测校准的示例数值
【待验证】版本相关
【决策】需要有人签字的选择。

0. 决策卡

一句话结论:HybridCache 值得接入,值得的不是「它快」,而是它把并发合并、两级编排、批量失效这三个最容易写错的横切关注点标准化了;代价是把一致性上界交给进程内副本的 TTL。架构师无权替业务决定这个窗口,只能把曲线摆出来,让业务选点并签字。

一个判据可以提前结束大部分争论:如果评审会上没人能回答「这个接口在 60 秒内最多返回多少次旧值、谁承担后果」,缺的是决策,不是组件。

怎么读:决策者读 0、2、3、5;实现者读 1.1、4;SRE 读 5 。文中给出的两段代码是骨架级片段(注册与配置校验),完整可编译工程(13 个带行上注释的源文件、dotnet build 0 警告 0 错误、开发环境启动通过 ValidateOnBuild)。

1. 事实:一份数据有几份副本

1.1 三条失效通道,一条时间线

HybridCache 在数据源(SoR)之外维护两级副本:L1 是进程内 MemoryCache(重启即失、每实例一份),L2 是已注册的 IDistributedCache。【契约】没有 L2 时仍提供进程内缓存与防踩踏。

【实测,10.11.0】注册进程内的 IDistributedCache(含 AddDistributedMemoryCache)之后,它会被当作 L2 正常读写——只是每个进程各有一份。所以「多实例不共享」成立,「不会被当作 L2」不成立:单机多实例时表现为「缓存生效了」,一旦扩到多 Pod,同一套配置从「有 L2」退化成「只有 L1」,收益曲线断崖而指标只显示回源变多。要跨实例共享,L2 必须是进程外实现(Redis / SQL Server / Postgres)。

一次写之后,旧值消失有三条通道,到达范围完全不同:

【契约】按 key 或按 tag 失效时,条目在当前服务器与进程外 L2 中失效,其它服务器的内存缓存不受影响。因此 TTL 是唯一对全部副本必然生效的通道,也是陈旧窗口的物理来源。

时间 →        t0 写提交      t0+Δ        t0+T_L1                 t0+T_L2
实例 A  L1    [旧] → 失效 → [新]─────────────────────────────────────
实例 B  L1    [旧] ──────────────── [旧] → 过期重取 → [新]────────────
共享 L2       [旧] → 失效/旧值留存(tag 为逻辑失效)→ 自然过期 ────────
                     ↑ 合同窗口 W_obs ≈ T_L1 + Δ ↑

【实测,同进程双实例】tag 失效对其它实例之后的新读生效(那一次会重新执行 factory),对其它实例已持有的 L1 副本不生效(仍返回旧值)。于是「分类改名后所有商品页立即更新」是不可能承诺的;能承诺的只有 W_obs ≈ T_L1 + Δ,并必须用 E3 / E7 实测它。凡是把 tag 当作跨节点即时失效手段的设计,都不是调优问题,是合同标错了。

1.2 作用域表与三个必须纠正的直觉

【契约】同一实例、同一 key 只有一个并发调用者执行 factory,协调不扩展到其它实例,即使共享同一 L2;传给 factory 的取消令牌在所有等待者都取消时才取消。

【推论】容量规划必须按「回源上界 ≈ 冷 key 数 × N」,不按 1。看这张表的方式不是逐行确认有没有功能,而是问:这个能力跨过进程边界之后还剩多少? 说明书印的是特性,决定线上表现的是作用域。

三个直觉必须纠正:

  1. 「防踩踏是集群级能力」——不是,见上表。若下游超时设得偏紧,会出现「等待者陆续超时 → 共享 factory 被取消 → 全体重试」的循环,等于自己把合并拆掉;这个超时在 HttpClient / 数据库命令里,不在缓存配置里。
  2. 「强一致业务关掉 L1 就好」——【实测,10.11.0,两条件结论不同】HybridCacheEntryFlags.DisableLocalCache(值 3)在未注册 L2 时,同 key 连读 3 次执行 3 次 factory;注册 L2 时 factory 仍只 1 次,代价转为每次读都走一趟 L2 往返并重新反序列化(连读 3 次实测产生 4 次 L2 Get)。它消除的是实例间分歧,不是陈旧(L2 副本仍在),也不是回源频率。另外 LocalCacheExpiration = TimeSpan.Zero 不是禁用 L1 的写法:10.11.0 上抛 ArgumentOutOfRangeException: The relative expiration value must be positive。【决策】更干净的是让事实读根本不进缓存组件(D1 直接否掉,见 2.2)。
  3. 「tag 失效是删除 / 是内存回收」——是时间点规则,旧值留到自然过期;* 为保留 tag,可让全部数据(含无 tag 条目)逻辑失效,不支持 glob(foo* 无效);大量 tag 影响性能。【实测】条目侧传空 / 纯空白 / * 会抛 ArgumentException,但移除侧传空 tag 不抛异常、静默什么都不做——运维脚本里一个变量为空,就会得到「执行成功、缓存没失效」。失效入参必须自己校验非空。

另有两条静默边界会合成一个很难查的现象:MaximumPayloadBytes 默认 1 MB、MaximumKeyLength 默认 1024 字符,超限是记日志且不缓存,不是抛异常;默认每次取回都反序列化、给每个调用方独立实例,只有 sealed + [ImmutableObject(true)] 才能复用。key 里拼了用户输入(长 ID、邮箱、搜索词)就会踩到「只有某些用户特别慢」——它与「不把外部输入直接当 key」是同一条纪律的两面。

【契约】HybridCacheOptions 没有按字节的 L1 容量旋钮,公开属性只有六项:DefaultEntryOptions、DisableCompression、MaximumPayloadBytes、MaximumKeyLength、ReportTagMetrics、DistributedCacheServiceKey。L1 只能靠「key 长度 + 载荷上限 + TTL」间接约束,膨胀要靠进程级内存监控兜,不能指望组件自限。

【契约】HybridCache 抽象类型在 Microsoft.Extensions.Caching.Abstractions 程序集,HybridCacheOptions 在 Microsoft.Extensions.Caching.Hybrid 程序集,命名空间相同。升级后报「找不到类型」时先查程序集引用,不要改 using。项目仍需显式引用并锁定 Microsoft.Extensions.Caching.Hybrid;升级包等于升级契约,必须触发 ADR 复审——「我们升级到了 .NET 10」不构成接入理由。


2. 决策:六个变量、五级否决、一张分级表

决策不是选组件,而是逐层否掉不该缓存的读,再给剩下的读签字。

2.1 决策对象与六个变量

缓存是副本,因此真正要被决策的是读路径的分类,组件只是实现某几类读路径的一种手段【决策】:

三类读混在一个 IRepository 里、靠注释区分「这里能不能缓存」,是绝大多数缓存事故的源头——第一步是让三类读在代码结构上可区分。事实读被否掉不等于问题消失:库存这类高频读仍要扛峰值,可用的是只读副本、物化快照、请求级合并(一次请求内多次读只打一次库),都不需要「副本 + TTL」。

真正的输入不是组件参数,而是六个变量:

前五个是大家都会问的,λ_w 是最常被漏掉的第六个:没有它,暴露量只是一个孤立的「次/事件」,既换算不成每小时业务影响,也没法与基础设施成本放在同一张表。另两个价格参数由业务给:c=单次可见后果的成本(客诉、价差补偿、履约失败),r=旧值展示导致可见后果的转化率【假设】。

2.2 否决树与签字权

D0  是否存在可测量的性能 / 成本问题?(DB QPS、P99、Redis 带宽)
      否 → 不引入任何缓存组件,结束        ← 前置筛除,不是否决
D1  该读是否是事实读?
      是 → 直读库 / 事务,禁止进入缓存组件,结束
D2  一致性组内是否「必须一起新」?
      是 → 合并成单条目,或整组不缓存;做不到 → No-Go
D3  业务能否逐组签署明确的 W_g 并接受暴露量?
      否 → No-Go;不要试图用「把 L1 关掉」蒙混过去
D4  是否真的需要「单飞 / 两级编排 / tag 批量失效」中至少一项?
      否 → 换更便宜的替代,本方案结束
D5  能否在峰值 QPS 下跑 E1–E9,并保留可热切换的旧读路径?
      否 → No-Go,先补可观测与回退能力
      是 → Go:按一致性组灰度,签 ADR

【决策】这棵树的关键不是顺序,而是否决权的位置:D3 由业务签字,D5 由 SRE 一票否决,架构师只负责 D1 / D2 / D4 与把约束写进代码。任何一级试图用配置项绕过前一级(「业务没签字,先把 T_L1 设小一点上线」),都等于把合同改成默认条款。

两个「否」的分量不同,评审时要分开说:D0 / D4 否掉只是换方案(不引入缓存、或改用 OutputCache / CDN / 只读副本),D3 / D5 否掉才是 No-Go。混成一句「任一不过即 No-Go」,会让一个本只该「改用 CDN」的需求被误判成项目停摆。

架构师可以给出「T_L1 = 30s 时命中率与暴露量分别是多少」,但不能替业务判断「一次旧价格展示值多少钱」。 只把最难的一环推给业务却不给工具,签字就会流于形式,四条取值路径:

  1. 历史工单反推:过去 N 个月「显示了旧价 / 旧文案」类客诉的赔付、优惠券、人工成本 → c 的下界。
  2. 财务 / 风控敞口:一次错价展示的最大赔偿或合规处罚上限 → c 的上界;若合规红线是「不允许出现」,则 c → ∞,D1 直接否掉。
  3. 频次上限反推:业务直接给「每万次请求最多允许 X 次旧值」,由 E 反解 W_g,跳过货币化。
  4. A/B 或人工选点:把两个候选 T_L1 换算成「每小时多返回多少次旧值」,让业务在两个具体数字之间选,签字即定价。

【决策】四条都拿不到时,c 记为「未定价」,D3 不通过,No-Go——而不是让开发自己填一个配置项。

2.3 分级表、桶与替代矩阵

分级单位是一次响应里必须同版本的一组字段,不是单个 key。

【决策】若某组业务签了比同桶其它组更严的 W_g(例如 G1 只签 20 s),必须为该组单独开桶,不能靠注释或调用处覆盖 options——后者会让启动校验形同虚设。分桶的代价要说清:它不会自动放大回源上界,但会增加解析面与配置面,且每个桶一份私有 L1,排查时 N × 桶数 份副本要逐个看。另两条:T_L2 ≥ 5 × T_L1 由启动校验强制;缓存 DTO 而非被跟踪的 EF 实体,标 sealed + [ImmutableObject(true)] 前先确认业务代码真的不会修改它。

分组只划定范围,还要证明更便宜的路确实不合适:

选择规则【决策】:G0 → 第一行;高频事实读 → 第二行(只读副本比「缓存 + TTL」更接近事实);G1 无法合并 → 第一行;G4 先看物化视图是否比缓存更省心;静态资源先给 CDN,不要拿缓存组件解决边缘分发;G2 / G3 且 D4 通过 → 倒数第三行。

DisableLocalCache 这一行要按实测重述代价,否则会被当作「强一致的折中方案」:它不是每次回源,而是每次读都走一趟 L2 并重新反序列化(未注册 L2 时才是每次回源)。你付了组件成本,买回的是一个已有的 IDistributedCache 的行为——经济上通常不成立,除非明确需要「单飞 + tag」但不需要 L1 收益。最后一行只在业务坚持 W_g → 0 且仍要缓存时进入,必须重做容量评审。

2.4 ADR 与反模式

ADR 不必长,六段足够,缺一段就不该签字:

Context      峰值 QPS / 回源 QPS / P99 / λ_w;一致性组划分;不在范围的事实读;L2 类型与 N、桶数
Decision     逐组签:W_g | Δ | T_L1 | T_L2 | E_event | 签字人;c 与 r 的取值依据
约束          T_L1 + Δ ≤ 0.8 × W_g;T_L2 ≥ 5 × T_L1;由启动校验强制
失效          先提交 DB 再按组失效(含分类改名整组覆盖);失败进 outbox 补偿;并发回填三条修法择一
Consent      其它实例已持有的 L1 不受影响,由 T_L1 兜底(已知并接受);tag 带服务前缀;禁止直接调 RemoveByTagAsync
Triggers     扩容 / QPS 翻倍 / λ_w 或 c 变化 / 新增一致性组 / 包版本升级 / Redis 锯齿告警

反模式清单用于反向验收:说不出「这条要求对应哪个决策」,就说明 ADR 没写完。


3. 定价:把「不一致」变成可解的最小值问题

3.1 三本账与两个旋钮

两个 TTL 归两个账户,这是全文最容易归错账的地方:T_L2 决定回源频率、Redis 内存与僵尸值存活时长,归成本账户、SRE 签;T_L1 决定陈旧窗口与实例间分歧时长,归一致性账户、业务签。把两者设成一样,会得到「指标最好看、一致性最差」的组合——L1 命中率漂亮、成本漂亮,而 W_obs 被拉到整个 T_L2,且单实例测试环境完全测不出来:它同时通过性能与功能测试,是配置事故里最隐蔽的一类。启动校验用 T_L2 ≥ 5 × T_L1 把它变成启动失败。

3.2 六个式子、Δ 与目标函数

① 实测陈旧窗口 W_obs ≈ T_L1,g + Δ
② 合同约束 T_L1,g + Δ ≤ 0.8 × W_g(设计余量 20%),红线 W_g
③ 单次失效事件暴露量 E_event(T) ≈ Σ_{k ∈ 受影响 key} QPS_k × (T + Δ)|次请求 / 事件
④ 单位时间暴露率 Ė(T) = λ_w × E_event(T)|次请求 / 秒
⑤ 观察期业务影响 L(T) = Ė(T) × 观察秒数 × r × c|元
⑥ Redis 峰值内存 ≈ 活跃 key 数 × 平均载荷 × (1 + 僵尸滞留比)

Δ 的定义:从「数据源已提交」到「所有实例都不再返回旧值」之间,除 T_L1 之外的额外延迟预算——失效链路耗时、tag 状态在其它实例 / L2 的刷新延迟、补偿重试、时钟与采样误差。上限由 E3 / E7 实测,未实测前按【假设】预置,不得取 0。本例取 Δ = 5s:同机房 Redis 失效链路通常 10–50 ms,跨实例 tag 刷新按包版本在 0–1 s 量级,补偿一旦介入按重试间隔放大到分钟级——5 s 是偏保守的预置,不是测量值。

⑥ 的机制:逻辑失效不删除旧条目,也不需要「新旧各存一份」——同一条目的下次回填是同 key 覆写(key 里只有业务版本号,不含写入时间),不存在副本翻倍。真实开销是另一种:失效后没人再读的条目会一直占内存,直到自己的 T_L2 过期。所以 ⑥ 的「僵尸滞留比」=失效后未被覆写的条目占比,由「失效到下次读的间隔 / T_L2」决定,不能取 0,也不能简单按 1 翻倍。

三条容易混淆的口径一次定死:N 不放大按请求计的暴露量(QPS 是集群总量,N 放大的是回源次数上界与排查面,真正把 E_event 放大几个数量级的是 B);λ_w = 0 时一致性账为零(纯静态数据的陈旧窗口没有意义,T_L1 完全由成本账户决定,这类数据应单独开桶);容量按峰值不按均值(集中失效把「该过期的一起推过期」,紧接着一拨集中回填,峰值由这两件事叠加)。

【契约】LocalCacheExpiration 是 TimeSpan?;从外部缓存取回时,本地过期时间按此值计算且不超过该条目剩余的整体缓存寿命。所以 L1/L2 不是两个独立旋钮;钳制只保证「不超过」,不保证「足够短」——这正是 ② 必须单独校验的原因。【实测,10.11.0】Expiration = 2s、LocalCacheExpiration = 60s 时,第 3.1 秒的读确实重新回源。

min_{T ∈ 候选集}  C(T) = C_infra(T) + C_stale(T) + C_mem

C_infra(T) = ( q_l2(T) × k_l2 + q_db(T) × k_db ) × 3600    [元/小时]
             q_l2(T) ≈ Σ QPS_k × (1 − h_k(T))                  L1 未命中落到 L2 的速率
             q_db(T) ≈ Σ QPS_k × (1 − h_k(T)) × (1 − h_l2,k)    L2 也没命中、落到 DB 的速率
             h_k(T)、h_l2,k 由 P0 峰值压测实测填表,不求解析解
C_stale(T) = (λ_w × 3600) × E_event(T) × r × c               [元/小时]
C_mem      ≈ 峰值活跃 key 数 × 平均载荷 × 单 GB 小时价 × (1 + 僵尸滞留比)

求解:对定价表的每个候选 T 各算一次 C(T),取最小。
      最小值落在最短候选且 C_stale 仍很高 → c 太高 → 回到 D1 / D3,考虑不缓存。
      最小值落在最长候选 → 继续试探更长的 T,直到 C(T) 回升或触及成本上限。

三笔成本缺一不可:只算 L2 请求成本,会让「长 T 省下的 DB 回源」完全不入账,模型系统性偏向短 TTL。C_mem 在载荷固定时与 T 近似无关,不改变 argmin,但它是真实支出,必须出现在总账里。目标函数刻意保持成「一张表 + 一次加法」,因为评审会上能被复算的模型才会被签字。

3.3 算例:最优点由谁决定

全部数字均为【假设】,只演示计算路径。 设某 G2 接口峰值 QPS = 2000,λ_w = 1/3600 次/秒(每小时一次),一次写影响整组 50 个 key(分摊后 Σ QPS_k 仍是 2000),r = 0.001,Δ = 5s;单价 k_l2 = 0.00002 元/次、k_db = 0.0002 元/次,C_mem = 30 元/小时。

  • c = 0.5(少量客诉):最优 30 s,落在中间;
  • c = 5(一次旧值很贵):C_stale 涨到 150 / 350 / 650 → 最优 10 s,且总成本高得离谱 → 提示回到 D1 / D3,这条读可能根本不该有副本;
  • c → 0(字典):C_stale ≈ 0 → 最优落在最长候选,应继续试探更长 T_L1 并单独开桶。

【推论】这个算例要证明的不是任何具体数字,而是:最优点完全由业务给的 c 决定,架构师能做的只是把表摆出来。 c 一变,同一个系统的最优配置就变——所以 c 必须写进 ADR,业务口径变化即触发复审。实际投产时把这张表换成签字模板:候选 T 摆成行,列留 L1/L2 命中率、回源 QPS、W_obs、E_event、C(T),空格由 P0 峰值压测填。

两条纪律:必须在峰值 QPS 测(低峰测出的「影响很小」是用最有利条件测最不利场景,两个方向的偏差叠加,结论必然乐观);先由业务签 W_g,再取满足 ② 的 T_L1(上限来自业务,不是组件默认值)。

3.4 混合版本与换账单的时机

商品详情页一次渲染读 product:42、category:7、price:42,一次写只改前两个,某实例的 L1 里就可能出现「标题是新的、活动文案还挂着旧活动」——接口不报错、日志无异常,只是偶尔一张自相矛盾的页面。混合版本比「整页都旧」难查一个量级,因为旧版本至少内部自洽。

【推论】「同 tag、同 TTL 就能保证一起新」是过度乐观:tag 能收敛共享层与读时判定,但各 key 在远端 L1 的剩余 TTL 不同。可靠做法只有两种:把必须一起新的字段合并进同一个缓存条目,或让这一组整体不进缓存。

怎么采:给每个一致性组条目带 GroupVersion(写入时取自 SoR 的业务版本号,不要用 EF 的 rowversion——那是 byte[],进不了 int 字段),端点在同一次渲染里把参与组装的所有来源版本收成集合,交由采样器判断;集合大小 > 1 时按 1% 采样并记一条带 render_id 的结构化日志。这个哨兵的定位要说清楚:G1 合并成单条目后来源只有一个,指标恒为 0——这是设计结果,不是没实现,因此它不能当「运行期门禁」(一个结构上永不为 0 的量证伪不了任何东西),真实身份是回归检测:一旦有人把组内字段拆到另一个条目却没同步版本,它立刻活过来。所以门禁不能写「采样为 0 即通过」,而要写「已接线,且注入演练能触发」;一旦非 0,含义是「有人改了分组结构」,动作是回滚那次变更,不是调 TTL。

当定价曲线把最优点推到 T_L1 → 0,或业务要求跨节点即时失效时,正确动作是换账单而不是给配置打补丁:


4. 落地:把合同编译进配置校验

纸面结论要变成代码,只有三件事值得写进工程:配置在启动时校验、桶按一致性组切开、读写路径各自守纪律。其余都是实现细节。

4.1 三条落地纪律

  1. 能由启动器守住的约束,不要留给代码评审。 T_L1 + Δ ≤ 0.8 × W_g 与 T_L2 ≥ 5 × T_L1 都写成 ValidateOnStart(),非法配置表现为进程起不来,而不是上线三周后被客诉发现。
  2. 事实读不进缓存组件,靠类型系统而不是注释。 故意不注册无键的 AddHybridCache():只注册 keyed 实例时 GetService<HybridCache>() 返回 null(【实测】),裸注入的服务在 ValidateOnBuild 下启动即抛 InvalidOperationException;两个 keyed 桶解析为不同对象。于是任何新代码想用缓存,必须显式声明它属于哪个一致性桶。
  3. TTL 只允许出现在一个地方。 一组一个桶,桶的 TTL 写在注册处;调用点只传 null。若某组签了更严的 W_g,为该组单独开桶——在调用处覆盖 options 会让启动校验形同虚设。

配置段与分级表一一对应,两个 TTL 分属两本账:

{
  "CachePolicy": {
    "PropagationMargin": "00:00:05",
    "AlertSafetyFactor": 0.8,
    "DistributedToLocalMinRatio": 5,
    "Groups": {
      "Structural": { "Budget": "00:01:00", "LocalTtl": "00:00:30", "DistributedTtl": "00:30:00" },
      "Display":    { "Budget": "00:05:00", "LocalTtl": "00:03:00", "DistributedTtl": "00:30:00" }
    }
  },
  "Cache": { "Enabled": true, "ProbeEnabled": false }
}

校验规则只有三条,但足以拦掉 3.1 那类「指标最好看、一致性最差」的配置:

// 绑定 CachePolicy 并挂校验规则:非法配置在启动时就失败,而不是上线后才发现
builder.Services.AddOptions<CachePolicy>()
    // 从配置树的 CachePolicy 节点读取数值
    .Bind(builder.Configuration.GetSection("CachePolicy"))
    // 规则一:全局参数必须有效——Δ 为正、安全系数落在 (0,1)、L2/L1 比例大于 1
    .Validate(p => p.PropagationMargin > TimeSpan.Zero
                && p.AlertSafetyFactor is > 0 and < 1
                && p.DistributedToLocalMinRatio > 1,
        "Δ / 安全系数 / T_L2 比例不合法")
    // 规则二:逐组校验,任何一组不满足都不许启动
    .Validate(p => p.Groups.Count > 0 && p.Groups.Values.All(g =>
    {
        // 安全线 = 业务预算 × 安全系数,本例即 0.8 倍 W_g
        var safetyLine = TimeSpan.FromTicks((long)(g.Budget.Ticks * p.AlertSafetyFactor));
        // 四个条件同时成立才算通过:预算为正、L1 寿命为正、
        // T_L1 + Δ 不超过安全线、T_L2 不低于比例下限
        return g.Budget > TimeSpan.Zero
            && g.LocalTtl > TimeSpan.Zero
            && g.LocalTtl + p.PropagationMargin <= safetyLine
            && g.DistributedTtl >= TimeSpan.FromTicks(
                   g.LocalTtl.Ticks * p.DistributedToLocalMinRatio);
    }), "每组必须满足 T_L1 + Δ <= 0.8 × W_g 且 T_L2 >= ratio × T_L1")
    // 规则三:把校验挂到 host 启动阶段,避免「注入时才报错」
    .ValidateOnStart();

注册按一致性组分桶,桶名同时是 key 前缀;多个桶共享同一个 IDistributedCache,分桶是策略隔离,不是存储隔离:

// 取原始配置用于注册:注册必须在 Build() 之前完成,而校验结果要等 host 启动才可用
var policy = builder.Configuration.GetSection("CachePolicy").Get<CachePolicy>()
    // 配置段缺失属于部署缺陷,直接抛,不要用默认值兜底
    ?? throw new InvalidOperationException("缺少 CachePolicy 配置段");

// 逐组注册:配置里加一组就多一个桶,组名拼错会在启动时抛出带实际值的异常
foreach (var (name, group) in policy.Groups)
{
    // 先取出本组的两个 TTL,闭包捕获的是值,不是可变对象
    var local = group.LocalTtl;
    var distributed = group.DistributedTtl;
    // keyed 注册:第一个参数是桶名,第二个是选项名,第三个才是真正写选项的委托
    builder.Services.AddKeyedHybridCache(CacheTiers.KeyFor(name), $"{name}-policy",
        o => ApplyPolicy(o, local, distributed));
}

// TTL 与上限的唯一落点:显式写出 1 MB 与 1024 字符,让默认值变成可审计的契约
static void ApplyPolicy(HybridCacheOptions o, TimeSpan local, TimeSpan distributed)
{
    // 载荷上限,超限是「记日志且不缓存」,不抛异常(1.2)
    o.MaximumPayloadBytes = 1024 * 1024;
    // key 上限,超长的请求会永远不缓存,表现为「只有某些用户特别慢」(1.2)
    o.MaximumKeyLength = 1024;
    // 两个 TTL 分别归两本账:Expiration 归成本,LocalCacheExpiration 归一致性
    o.DefaultEntryOptions = new HybridCacheEntryOptions
    {
        Expiration = distributed,
        LocalCacheExpiration = local
    };
}

版本降级:AddKeyedHybridCache 在 10.11.0 可用,最低版本未考证。若锁定包没有该 API,退化为单一 AddHybridCache() + 调用处传 HybridCacheEntryOptions,桶前缀与 TTL 常量集中到静态策略类;决策框架不变,只是逐组隔离从 DI 层退回代码约定。

4.2 读写路径的纪律清单

读侧四条:key 必须带桶前缀与业务版本号(display:v1:{id}),不把外部输入直接当 key;必须同版本的字段合并进同一个 DTO 条目(ProductPageDto 一次取回标题、分类名、活动文案),拆开就是 3.4 的混合版本;DTO 标明不可变,否则每次取回都重新反序列化;工厂不要返回 null——【实测】null 会被当作有效值缓存,新建实体在 TTL 内返回 404。

写侧五条,顺序与覆盖都要守:先提交 DB 再失效缓存(反了会把旧值在提交前重新回填);失效必须覆盖整组(分类改名要失效该分类下所有商品页,而不只是当前这一页);失败进 outbox 补偿,重放延迟超过 W_g 即视为违约;并发回填竞态要显式选一种修法——版本化 key、延迟二次失效、或 epoch 门控,否则 W_obs 实际等于 T_L2,模型整体失效;负结果也要失效(新建 / 上架后清掉「不存在」的缓存)。失效入口统一收口到一个流水线类,禁止业务代码直接调 RemoveByTagAsync,入参非空由调用侧校验。

回退只能靠 IOptionsMonitor 每请求取 Cache:Enabled:用启动时读一次的局部变量做开关,运行时改配置根本不生效,「一键回退」其实必须重启;而重启会让 L1 全冷,回源脉冲集中落到 DB。两种路径代价不同,都要在 P1 演练:热切换只是不再读缓存,不清 L1、不产生脉冲;重启式回退才要避开高峰并预热。

观测只留能证伪合同的量:factory 调用次数(回源上界)、L1 命中率 × 实际 QPS(L1 是否值这个价)、失效扇出(B 实测值,乘 3.2 得 E_event)、超限日志计数、补偿队列积压与重放延迟、Redis 内存锯齿峰值。探针端点在验收期挂载、生产关闭,且探针 TTL 写在注册处,不出现在调用处。


5. 上线:门禁、回退与残余风险

5.1 路线图与 E1–E9

「观察一个完整 T_L2 周期」是为了跨过僵尸值的自然过期窗口,否则看不到真实的 Redis 峰值水位。

门禁不是「测一下看看」,而是判据:

E2 的判据特意写「≤ N、允许 > 1」:实验目的是证伪全局单飞假设,不是凑固定数字;上界写 N 而不是 N × 桶数,因为「一个 key 只归一个桶」是纪律,纪律若破这条判据自动失效,应先用 E1 找出 key 归属混乱。

5.2 阈值与回退剧本

【推论】警戒线设成 W × 0.8 却允许 T_L1 取到 W,两者相抵必致常态误报。这里由启动校验强制 T_L1,g + Δ ≤ 0.8 W_g,于是报警、设计窗口、红线三者不再互相打架。

5.3 残余风险与技术债

登记进架构债台账,每条带责任人与复审日期:远端 L1 的 T_L1 尾巴(已接受,靠 W_g 兜底)、tag 僵尸数据与内存锯齿(已接受,靠容量兜底)、tag 刷新间隔的版本依赖(待实测)、默认反序列化与分配(大对象高命中时再评估复用)、补偿队列的运维归属与重放 SLA、L2 故障行为(待演练)、回填竞态的所选修法(靠 E9 长期值守)。

复审触发:扩容(N 变化)、QPS 翻倍、λ_w 或 c 变化、新增一致性组 / 桶、包版本升级、Redis 内存告警、业务修改 W_g。合同里任何变量变了,合同就要重签。


6. 结论

  1. 每级缓存都是一份副本 → 决策对象是读路径的分类,不是组件;
  2. 能力的作用域等于承载它的存储层 → 作用域必须写进 ADR,API 不会替你声明;
  3. T_L1 是合同标价 → 业务逐组签字,启动校验守住,E3 / E7 / E9 实测验收。

一次合格的评审最后留下四样东西:ADR(谁签、签多大窗口)、分级表(哪些读不许有副本)、门禁(E1–E9 怎么证伪)、回退剧本(怎么热切换、怎么重启);接入代码只是副产品。组件值不值得接从来不是技术问题——能不能说清「旧多久、影响多少、谁承担、怎么证明、怎么退」,才是。

posted @ 2026-11-13 07:06  IT规划师  阅读(0)  评论(0)    收藏  举报