XiHan Framework 处于预览阶段

查看获取方式
跳转到内容

两级缓存与键管理 ​

这一页讲三件进阶的事:Caching.Hybrid 把进程内缓存与共享缓存叠成两级、读少一次网络往返;ICacheStoreInspector / ICacheInspector / ICacheChangeFeed 给管理界面列键、看元数据、按前缀删除与订阅变更;Caching.Remote 让任意语言写的进程外插件承担共享缓存。读完你能在多实例部署下压低缓存读延迟、写出缓存管理页面,并知道远程 Provider 的边界。前置知识是缓存读写。

为什么需要 ​

装配了 Redis 之后,每次 ISharedCache 读取都是一次网络往返,读多写少的热点数据(配置、字典、权限)为此付出的延迟远高于真正变化的频率。两级缓存在本进程放一层有界的 L1,命中就不出进程,未命中再读 L2 并回填;代价是 L1 可能短时间落后于 L2,所以框架用 L2 的变更通知跨实例失效 L1,并给 L1 一个存活上限兜底。另一方面,运维与管理界面需要「看看缓存里有什么、按前缀清掉一批」,这不该混进业务契约,框架把它们放进可选接口,由存储实现自愿支持。远程 Provider 则服务于已经有别的语言的缓存实现、或后端不是 Redis 的团队。

两级缓存的概念 ​

Caching.Hybrid 提供 caching.local@1 并依赖 caching.distributed@1:装配后 ICache 变成 HybridCache,ISharedCache 仍是 L2 本身。

术语说明
L1进程内的对象缓存,有界 LRU,直接存对象引用
L2被选中提供 caching.distributed 的 ISharedCache(Redis、远程或内存实现)
LocalCapacityL1 条目上限,缺省 10000,满时淘汰最久未访问的条目
LocalTimeToLiveL1 存活上限,缺省 30 秒;条目在 L1 里的寿命是自身过期与它的较小者
ICacheChangeFeedL2 存储的变更通知;Redis 存储实现它,别的实例写入或删除后本实例 L1 立即失效
HybridCache.InvalidatesAcrossInstances是否订阅到了 L2 的变更通知

最小示例 ​

Hybrid 顶替内存实现的 caching.local,L2 取 Redis 提供的 caching.distributed:

csharp
using XiHan.Framework.Caching.Hybrid;
using XiHan.Framework.Caching.Redis;
using XiHan.Framework.Hosting.AspNetCore;
using XiHan.Framework.Profiles.Standard;

var builder = WebApplication.CreateBuilder(args);

// Hybrid 顶替内存实现的 caching.local,L2 取 Redis 提供的 caching.distributed
builder.AddXiHan(xihan => xihan
    .UseStandardProfile()
    .UseFeature<RedisCachingFeature>()
    .UseFeature<HybridCachingFeature>());

var app = builder.Build();
app.UseXiHan();
app.Run();
json
{
  "XiHan": {
    "Caching": {
      "Redis": { "Configuration": "localhost:6379" },
      "Hybrid": { "LocalCapacity": 10000, "LocalTimeToLiveSeconds": 30 }
    }
  }
}

业务代码不变:缓存读写里的 ProductCache 注入的还是 ICache,只是现在第一次 GetOrCreateAsync 两级都未命中才执行工厂,写入先到 Redis 再进 L1;同一实例 30 秒内的再次读取不出进程,别的实例第一次读取从 Redis 取到后回填自己的 L1。在别的实例上调用 InvalidateAsync 删掉这个键,Redis 发出变更通知,本实例的 L1 随之删掉,下一次读取回到 Redis。单实例开发时 L2 可以就是内存实现,行为一样,只是没有跨实例失效可谈。

读写规则 ​

  • 读:L1 命中直接返回,未命中读 L2,命中后回填 L1;批量读只把 L1 未命中的键一次交给 L2。
  • 写与删:先 L2 再 L1,L2 失败时 L1 不留下别的实例看不到的值;GetOrCreateAsync 两级都未命中才执行工厂。
  • L1 的存活时间是条目自己的过期时长与 LocalTimeToLive 的较小者,滑动过期在 L1 里按绝对时间处理;LocalCapacity 满时淘汰最久未访问的条目。
  • L1 存对象引用,取回的对象不得修改,与内存实现的约定一致。

跨实例失效 ​

跨实例失效依赖 L2 存储的变更通知 ICacheChangeFeed。Redis 存储实现了它:每次写入与删除后同步通知本实例的订阅者,并以发后不等的方式向频道 <KeyPrefix>__changes 发布,其他实例收到后删掉 L1 里的这些键或前缀(CacheChange.Keys / CacheChange.Prefix)。HybridCache.InvalidatesAcrossInstances 表示是否订阅到了。通知是尽力而为的,断线期间可能丢失;XiHan:Caching:Redis:PublishChanges 设为 false 可以关掉发布。

L2 的存储没有变更通知时(远程 Provider、内存实现),绕过本实例 HybridCache 的写入(别的实例的写入,本实例直接经 ISharedCache 或管理界面的写入)不会失效 L1,读到旧值的时间最长为 LocalTimeToLive。需要强一致的键直接用 ISharedCache,不经 L1。

键管理 ​

管理界面需要按前缀列键、看条目元数据、按前缀删除,这些放在两个可选接口里,业务代码拿不到,管理界面显式转型使用:

接口从哪里转实现者内容
ICacheStoreInspectorICache.Store / ISharedCache.StoreInMemoryCacheStore、RedisCacheStore字节条目 CacheEntryInfo:载荷字节数、剩余存活、滑动窗口
ICacheInspectorICache / ISharedCache 本身InMemoryObjectCache对象条目 CachedObjectInfo:类型全名、剩余存活、滑动窗口

内存实现的对象条目存在对象缓存自己的表里,不在字节存储里,所以要两份合起来才是一个缓存的全部条目;Redis 的条目都序列化进存储,只有 ICacheStoreInspector。远程插件两个都不实现。

csharp
using XiHan.Framework.Caching.Abstractions.Caches;
using XiHan.Framework.Caching.Abstractions.Stores;
using XiHan.Framework.Primitives;

public sealed class CacheAdmin(ISharedCache cache)
{
    public async ValueTask<List<string>> ListAsync(string prefix, CancellationToken cancellationToken)
    {
        var keys = new List<string>();
        // 对象条目(内存实现)与字节条目各列一遍
        if (cache is ICacheInspector objects)
        {
            await CollectAsync(query => objects.ListKeysAsync(query, cancellationToken), prefix, keys);
        }

        if (cache.Store is ICacheStoreInspector bytes)
        {
            await CollectAsync(query => bytes.ListKeysAsync(query, cancellationToken), prefix, keys);
        }

        return keys;
    }

    private static async ValueTask CollectAsync(Func<CacheKeyQuery, ValueTask<Result<CacheKeyPage>>> list, string prefix, List<string> keys)
    {
        string? continuation = null;
        do
        {
            // 上限来自请求时越界是 CACHING_VALIDATION_003(400);这里是常量,直接取值
            var page = (await list(new CacheKeyQuery { Prefix = prefix, Limit = 200, Continuation = continuation })).ValueOrThrow();
            keys.AddRange(page.Keys.Select(static k => k.Value));
            continuation = page.Continuation;
        }
        while (continuation is not null);
    }
}

三个方法的语义:

  • ListKeysAsync:返回 Result<CacheKeyPage>。Prefix 与键同文法,空为全部(不合法在构造查询时抛 ArgumentException);Limit 为 1 到 1000(CacheKeyQuery.MaxLimit,默认 100),越界不在构造时抛出,存储先经 CacheKeyQuery.Validate() 核对、返回 CACHING_VALIDATION_003(HTTP 400,参数 limit、max),不访问后端;没有更多时 Continuation 为 null。已过期的键不列出。
  • InspectAsync:不存在或已过期为无值;读元数据不延长滑动过期,对象条目也不读出值。
  • RemoveByPrefixAsync:返回删除数;前缀不能为空,空前缀抛 ArgumentException,没有「清空全部」。

内存实现按键的序数顺序分页,续页标记是上一页最后一个键。Redis 用单个端点的 SCAN:续页标记是游标,Limit 只是提示值,同一个键可能出现在两页;前缀里的 glob 字符会被转义,按前缀匹配而不是按模式。读值、改值、删单个键仍走 ICache / ICacheStore。经 Redis 存储删除的键同样触发变更通知,各实例两级缓存的 L1 随之失效。

远程 Provider ​

Caching.Remote 让任意语言写的进程外插件承担 caching.distributed(规范 RFC-0019《远程 Provider》)。插件实现固定的操作集 cache-entries.get / set / try-add / remove / try-remove / get-many / set-many / remove-many(常量在 RemoteCacheOperations),由 Plugins.OutOfProcess 按目录发现并登记代理操作;Caching.Remote 按操作 ID 直接执行这些代理操作(不经过管道,否则幂等中间件会递归),把它接成 ISharedCache、IDistributedLock、IDistributedCache 与幂等中间件。

csharp
using XiHan.Framework.Caching.Remote;
using XiHan.Framework.Hosting.AspNetCore;
using XiHan.Framework.Plugins.OutOfProcess;
using XiHan.Framework.Profiles.Standard;

var builder = WebApplication.CreateBuilder(args);

builder.AddXiHan(xihan => xihan
    .UseStandardProfile()
    .UseFeature<OutOfProcessPluginsFeature>()
    .UseFeature<RemoteCachingFeature>());
json
{
  "XiHan": {
    "Plugins": { "OutOfProcess": { "Directories": ["plugins/cache-go"] } },
    "Caching": { "Remote": { "TimeoutMilliseconds": 5000 } }
  }
}

每次操作带 TimeoutMilliseconds(默认 5000)的截止时间。插件返回的缓存域错误(CACHING_VALIDATION_001 键不合法、CACHING_VALIDATION_002 过期选项不合法)原样成为 FrameworkException;插件未加载、操作未登记、进程不可达、超时都映射为 CACHING_UNAVAILABLE_001。Go 实现见 framework-sdk-go 的 caching 包,示例插件在 framework/samples/CacheGoPlugin,它读 XiHan:Cache:DefaultTimeToLiveSeconds 作默认存活时间(缺省 300)。远程 Provider 是 Experimental,没有键管理与变更通知;插件机制见进程外插件。

配置 ​

键类型缺省说明
XiHan:Caching:Hybrid:LocalCapacity整数10000两级缓存 L1 的条目上限
XiHan:Caching:Hybrid:LocalTimeToLiveSeconds数字30L1 存活时间上限
XiHan:Caching:Redis:PublishChanges布尔trueRedis 写入与删除后是否向变更频道发布
XiHan:Caching:Remote:TimeoutMilliseconds正整数5000远程 Provider 每次操作的截止时间

数值项不是正数、布尔项不是 true / false 时装配抛出 ArgumentException。

常见问题 ​

L1 最多会落后 L2 多久 ​

L2 带变更通知(Redis)且通知没有丢时,别的实例一写入本实例的 L1 就失效,落后只有网络传播的时间。通知丢失或 L2 没有变更通知时,上限是 LocalTimeToLive(缺省 30 秒)。对一致性要求高的键直接用 ISharedCache,不经 L1。

单实例要不要装两级缓存 ​

不必要。单实例下 ICache 本来就是内存实现,没有网络往返可省。两级缓存的价值在于 L2 是 Redis 或远程存储、而读又很频繁的多实例部署。

装了 Hybrid 之后 ISharedCache 也走 L1 吗 ​

不走。ISharedCache 始终直接指向 L2,所以经它的写入与读取总是看到共享状态。只有 ICache 变成了 HybridCache。

怎样清空整个缓存 ​

没有「清空全部」的接口:RemoveByPrefixAsync 要求非空前缀。给应用的键统一加一个前缀(如 catalog:),需要整体失效时按这个前缀删除;Redis 上还可以换一个 KeyPrefix 让旧条目自然过期。

Redis 列键时同一个键出现了两次 ​

SCAN 的语义就是这样,Limit 只是提示、游标分页不保证不重复。管理界面在客户端去重即可;内存实现按序数顺序分页,没有这个问题。

远程 Provider 能当生产方案吗 ​

它是 Experimental,没有键管理与变更通知,错误只有不可用与校验两类。适合已有别的语言实现的缓存服务、或要验证进程外插件机制的场景;生产上多实例共享缓存优先用 Caching.Redis。

相关包与下一步 ​

Released under The MIT License