Day 12:定义与数据缓存的跨节点失效

打开一张表单,框架要读的定义不只一份:FormSchema、TableSchema、FormLayout、ProgramSettings、LanguageResource,一份都不能少。这些定义上线后还是会改,但运行期间很少变更,这类数据最适合缓存。剩下的都是缓存自己的问题:哪些数据适合、切多细、放多久、什么时候该释放,还有源头被改掉之后怎么换上新的一份。

本篇说明四件事,Day 1 提过的那个通知机制是第三件:

  1. 哪些东西放进缓存,以什么为单位
  2. 一份缓存怎么知道它的来源变了
  3. 通知表长什么样,那一行由谁写
  4. 判定「真的变了」要看版本,不要看时间

第一件决定这个缓存划不划算,后三件决定它会不会安静地给出错的答案。


一、哪些东西放进缓存,以什么为单位

痛点:上千份定义的系统,两个极端都走不通。

  • 每次请求现读 → 把前面几天省下来的成本全部还回去
  • 启动时整批加载 → 内存用量跟着定义总量计费

框架没有在两个极端之间选,而是把问题换小:这份东西每次被读的时候,读的是「整份」还是「其中一个」?

基底 适用 用什么当键
ObjectCache<T> 整份就是一个对象,全系统只有一份 类型名
KeyObjectCache<T> 同一种东西很多份,每次只读其中一份 类型名加上那一份的键

十三种定义照这条线一分为二:

  • 单对象(九种):SystemSettings、DatabaseSettings、DbCategorySettings、ProgramSettings、MenuSettings、PluginSettings、PermissionModels、CurrencySettings、UnitSettings
  • 带键(四种):FormSchema、TableSchema、FormLayout、LanguageResource

Day 4 那句「缓存的单位就是读取的单位」,这一节是它的实现面。一个带键的缓存只要交代一件事:没命中的时候怎么用这个键把东西读出来。FormSchemaCache 就是这个形状:

csharp 复制代码
public class FormSchemaCache : KeyObjectCache<FormSchema>
{
    protected override FormSchema? CreateInstance(string key)
    {
        string progId = key;
        return _storage.GetFormSchema(progId);
    }
}

没有预热、没有加载清单。第一个人打开哪张表单,那张表单的定义才进内存。留多久则由使用频率决定:缓存用的是滑动过期,被读到就重新计时,所以越常被读到的那几份留得越久,冷掉的自己退场。

员工与部门这种被到处指着的表单落在最热的那一头:有人打开它要读一次 FormSchema,别张表单的关联指过来、组查询的时候又要读一次,所以运行期间它们几乎不会冷掉。

粒度往粗切一格会怎样:假设 FormSchema 全部装进同一个对象,读取这一侧没有差别。代价出现在另一侧,任何人改了任何一张表单,所有表单的定义一起失效、一起重读。

缓存的粒度不只是「一次读多少」,它同时决定了「一次要丢掉多少」。

缓存里不只有定义

框架的缓存里还有另一类东西,来源不是定义文件,是数据库里的实际数据:公司的基础数据、公司层的角色授权、部门的组织树。形状跟定义缓存一模一样,没命中就往来源读一次。

它们留在内存里的理由跟定义一样,还多一条:会用到它们的那一段路径不该再多开一次查询(判断一个调用有没有权限、决定这次要连哪一个数据库,都发生在请求的最前面)。

应用自己的数据也一样。ERP 里不常变更、每次请求又都要查的东西不少:税率、仓库、审批层级,还有各式各样的参数表,每一次都回数据库问一遍并不划算。上面那两个基底不是框架自己专用的,应用要的那几份走的是同一条路。

查过没有,也要记下来

还有一种痛点跟命中无关:有人拿一个不存在的 ProgId 一直打进来,每次都没命中,于是每次都去读一遍根本不存在的文件。

带键那个基底把「查过没有」也记下来,之后同一个键直接回没有。这个标记用绝对过期而不是滑动过期,所以反复戳同一个键不会延长它的寿命。

反面也有一个例子:有一种缓存刻意把这个行为关掉。它的键是任何人都能自己送进来的,记「查过没有」会让那些键在内存里各留一个标记,而它省下的只是一次走索引、回零行的查询。默认值该不该套用,看的是那个缓存的键是谁在决定。

为什么是留在自己的内存里

这是一个两派各有理的题目,本系列不选边:缓存放在每个进程自己的内存里,还是放在一台共用的缓存服务器上。

框架这一层的需求很具体:定义是只读的、每次请求要读好几份、读到的是同一个共用实例。这种访问形状放到进程外面,等于在每次请求里加上几趟网络往返,去换一份本来就不太会变的东西。

框架自己也没把这件事钉死:底层存储是一个只有五个方法的接口(Contains / Set / Get / Remove / GetCount),换掉它不必动任何一个缓存类。不过随包发布的实现只有一个,包的是 .NET 的 MemoryCache,换别的是留着的空间,不是已经有的功能。

网站有好几个节点分摊流量,数量还会跟着流量自动增减;另外还有不接请求的后台服务,调度与批处理各跑各的。一个节点就是一个进程,它们读的是同一批定义。

改定义只会发生在其中一个节点上。那一个好办,顺手把自己的缓存清掉就结束;其余的节点手上那一份旧定义不会有人去动,它们会继续照旧的形状组 SQL、渲染界面、跑验证规则,一声不响。

每个节点各有一份,所以每个节点都得自己知道什么时候该丢掉它。


二、一份缓存怎么知道它的来源变了

痛点在开发时就会遇到:改了一份定义文件、刷新界面,看到的还是旧的。一个进程就会这样,多节点只是把同一件事放大。

Day 4 写过「每一份缓存各自盯着自己的来源」,这一节是它的展开。而「来源」不是同一种东西:可能是一个文件,也可能是数据库里的一张或多张表。前者看得到修改时间,后者没有。

框架的分工是让存储体自己回报它能提供什么信号,缓存那一侧再翻成自己的过期策略。这一步就在上面那个 FormSchemaCache 的另一个方法里:

csharp 复制代码
var changeSource = _storage.GetChangeSource(DefineType.FormSchema, progId);
policy.ChangeMonitorFilePaths = changeSource.FilePaths;
policy.ChangeNotifyKey = changeSource.NotifyKey;

定义放在文件里,存储体回报的就是一组文件路径;放在数据库里,回报的就是一个通知键。两个不会同时有值。也有两种都给不出来的时候,那一笔就只能等时间到了自己过期。缓存这一边不必知道自己拿到的是哪一种,存储体给什么就盯什么。

信号由存储体回报,不是由缓存去猜。

换成缓存自己猜,每一个缓存类都得写一次「我的东西放在哪里、所以我该盯什么」;哪天多一种存储体,每一个都要回头改。

运行时是多节点,定义因此要放进数据库

上一节那些进程摆在哪里有两种形状,形状决定拿得到哪一种信号。

文件那一种是开发期的形状:定义文件要进版本控制、要跟着分支走。而个人环境本来就是同一台主机共用一个定义目录,谁改了文件,其他进程下一次读取就看得到。

实际运行环境通常是多台主机。各台看的是自己的文件系统,改一台的文件,另外几台不会知道。这不是效率差一点,是完全收不到信号。所以定义要搬进数据库,失效改走通知表,代价是它从此不在版本控制里。

这条线只切定义那一半。第一节那些来源是数据库的缓存没有文件可以盯,不论定义放在哪里都走通知表。所以后面两节谈的都是通知表那一条,而它在任何部署形状下都在。

一笔条目身上挂着哪些过期条件

上面那个过期策略,拆开来是四个条件,决定一笔条目能活多久。它们分成两类,每一类二选一:

类 过期条件 怎么判定
时间 滑动过期 一段时间没被读到才丢掉,被读到就重新计时
时间 绝对过期 到了那个时刻就丢掉,读取不会延长
变更信号 文件修改时间 记下创建当下的修改时间,不一样就过期
变更信号 通知键版本 记下创建当下的版本号,不一样就过期

定义缓存走滑动,「查过没有」那个标记走绝对;变更信号那两个则看存储体回报哪一种。

变更信号的比对要等缓存被用到才发生,没有任何东西会主动来推。所以失效不重载,只是让下一次读取拿到新的值;没有人在读的那些键,来源改了一百次也不会替它们白跑一百趟。


三、通知表长什么样,那一行由谁写

上一节说数据库那一种信号是一个通知键。键记在哪里、谁去累加它的版本,这一节讲完。

字段 内容
cache_key 那个通知键,同时是主键
cache_version 这个键被改过几次,逐次累加
sys_update_time 数据库服务器的时间,供增量抓取用

机制本身两步:来源数据被改的时候,那一段业务逻辑顺手往通知表写一行、把版本累加;每个节点各有一个轮询器盯着这张表,看到版本变大,就让挂在那个键上的缓存失效。

中间没有路由表。通知键的形状是一个惯例,分组加上实体,例如 FormSchema:Order。分组就是被缓存的那个类型的名字,键本身就把「这一则该通知谁」讲完了,所以添加一个缓存不必登记到任何地方。

通知表只有一张,在 Day 11 那张分类表的 common 上,轮询器盯的也是它。来源数据在公司那一边,累加版本的那一行仍然写回这一张。

为什么那一行要由人写

改完数据要记得累加一次版本,这一行是写业务逻辑的人自己写的。看到这种形状,很容易想到让数据库自己来:在那张表上挂一个触发器,连写都不必写,而且不可能忘记。

这条路没有走,理由是依赖关系的单位不对。缓存依赖的是一个语义,不是一整张表:

  • 一份公司数据被改了十个字段,其中八个跟缓存里那个对象完全无关
  • 反过来,一次有意义的改动可能同时动到三张表,而缓存只需要被通知一次

触发器看得到的只有「这张表的这一行变了」,表达不出「这次的改动对某一份缓存有意义」这个粒度,而能判断这件事的只有写那段业务逻辑的人。

代价是那一行会被忘记,症状是其他节点一直拿旧值,而那一份越常被读到,越不会靠滑动过期自己退场。这个代价是刻意换的:忘记通知,错的只有那一份缓存;通知得太广,每一次无关的变更都让所有节点重读一遍。


四、判定「真的变了」要看版本,不要看时间

通知表上已经有更新时间了,比一下不就知道有没有变吗?三个地方会坏:

  • 同一个时间值里可能有好几笔改动
  • 写进去的更新时间,可能早于这笔改动真正被别人看见的时间
  • 每个节点手上的时钟都是自己的

框架的做法是把两件事分开:

时间只负责便宜地抓增量,版本负责判定。

更新时间那一列有索引,所以轮询器每一轮只抓「时间在我上次看到的位置之后」的那几行,而且刻意往回多看一段。抓回来之后判定完全不看时间,只看版本,比上次记下的那个大才动作。

  • 往回多看一段 → 不漏掉边界上那些「更新时间已写进去、但比较晚才被看见」的行
  • 版本要严格变大 → 那段回看不会产生任何重复动作

少了前者会漏,少了后者会重复,两个都不会报错,只会让行为时对时错。

第一轮只记下起点,不失效任何东西:刚启动的进程手上的缓存是空的,通知表里的历史记录对它没有意义。

时钟那一件只需要知道一个事实:写入的时间、每个节点记的位置、抓取用的门槛,全部来自数据库服务器,没有一个来自节点自己。

轮询循环还把每一轮包起来,数据库瞬断只记一笔警告然后继续。这个循环一旦因为一次异常而结束,失效就不是延迟,是从此停止,而且整个进程还活得好好的。


缓存换到的东西

这一整套收成一句话:服务器手上的内存只花在真的有人在用的定义上。

  • 缓存的单位就是读取的单位,所以内存用量跟着实际使用量走,不是跟着定义总量走
  • 没有预热也没有加载清单,一份定义要到第一个人打开那张表单才进内存
  • 留多久由使用频率决定,越常被读到的留得越久,冷掉的自己退场
  • 失效不重载,没有人在读的键不会白跑一趟

这四条加起来,内存怎么分配不必有人事先规划。代价在另一边:来源在数据库的那些,变更时要主动更新通知表,否则其他节点拿不到最新的值。

明天开始换一层:业务逻辑动手之前要先知道现在是谁在调用、他在哪一家公司,而那两个答案是谁准备的、又活多久。


本文为 2026 iThome 铁人赛参赛文章,原文为繁体中文。

iThome 原文 | HackMD 版

系列完整目录:ERP 架构师笔记:定义驱动的框架设计

相关推荐
CSharp精选营41 分钟前
从控制台到「会动手」的 AI:用 Semantic Kernel 搭应用(小白教程)
c#·.net·ai agent·semantickernel·functioncalling
Jeff-Tsai1 天前
Day 11:数据访问层的连接、Transaction 与查询条件
.net·架构设计·erp·definition-driven
宝桥南山1 天前
.NET 11 - 尝试一下Blazor中的新特性SupplyParameterFromTempData
microsoft·微软·c#·asp.net·.net·.netcore
rockey6271 天前
AScript之LINQ扩展
c#·.net·script·动态脚本
学以智用2 天前
runtimeconfig.json + appsettings 完整详解(.NET Core/.NET5+,对比 machine.config)
.net
Flynn Liang2 天前
AgentScope Java 深度解析:ReActAgent、HarnessAgent 与多智能体编排——JVM 上的自治 Agent 底座
java·大模型·架构设计·ai agent·spring ai·langchain4j·agentscope
qq_410194292 天前
.NET 性能优化:先找瓶颈,再谈技巧
性能优化·.net
玩泥巴的2 天前
把AI编码从自由生成到受约束声明:微信生态 .NET SDK 中用源生成器压制 AI 编码幻觉的工程实践
微信·.net·aot·roslyn