diff --git a/Directory.Packages.props b/Directory.Packages.props
index 5624c75..537744d 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -34,6 +34,10 @@
+
+
+
+
diff --git a/README.md b/README.md
index aaed7e7..0eb73cd 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@ TIKU Backend 是题库 SaaS 的 ASP.NET Core 模块化单体,使用 EF Core
- Entity Framework Core 10 + Npgsql 10 + PostgreSQL
- ASP.NET Core Identity + RSA JWT + 数据库存储的 Session
- Scalar + OpenAPI(仅 Development 暴露)
-- Redis(安全频控、Feature 缓存和生产输出缓存)
+- FusionCache(业务 L1/L2 与跨节点失效)+ Redis(安全频控、授权缓存和生产输出缓存)
- PostgreSQL 后台任务队列、租约和重试
- Serilog + OpenTelemetry
- xUnit 单元测试和真实 PostgreSQL 集成测试
@@ -75,6 +75,7 @@ dotnet run --project Tiku.Worker
- API 不运行后台循环;生产环境必须独立部署至少一个 `Tiku.Worker` 实例。
- 多 Worker 实例通过 PostgreSQL advisory lock、任务租约和 `FOR UPDATE SKIP LOCKED` 协调。
- Development 可不配置 Redis;Production 缺少 Redis 时 API 会拒绝启动。
+- 租户目录、Feature 快照和运行时配置使用独立的 `TikuBusiness` FusionCache;无 Redis 时退化为进程内 L1,Redis 不是业务事实源。
- 租户由可信 Host 解析;平台 Host 上只有允许的路径可通过 `x-tenant-code` 或 `tenantCode` 指定租户。
- 租户数据由 EF Query Filter、写入拦截器、租户限定外键/唯一索引和 PostgreSQL guard 共同隔离。
- 普通请求默认要求认证;匿名接口必须显式声明 `[AllowAnonymous]`。
diff --git a/Tiku.Api/Configuration/DependencyInjection.cs b/Tiku.Api/Configuration/DependencyInjection.cs
index c9458d6..c72088e 100644
--- a/Tiku.Api/Configuration/DependencyInjection.cs
+++ b/Tiku.Api/Configuration/DependencyInjection.cs
@@ -8,6 +8,7 @@ using Tiku.Application;
using Tiku.Application.Security;
using Tiku.Application.Tenancy;
using Tiku.Infrastructure;
+using Tiku.Infrastructure.Caching;
using Tiku.Infrastructure.Security;
namespace Tiku.Api.Configuration;
@@ -53,6 +54,9 @@ public static class DependencyInjection
else if (builder.Environment.IsProduction())
throw new InvalidOperationException(
"Redis is required in Production. Configure ConnectionStrings:Redis or REDIS_URL.");
+ builder.Services.AddBusinessCaching(
+ builder.Environment.EnvironmentName,
+ !string.IsNullOrWhiteSpace(redisConnectionString));
builder.Services.AddOutputCache(options =>
{
diff --git a/Tiku.Api/Configuration/ObservabilityExtensions.cs b/Tiku.Api/Configuration/ObservabilityExtensions.cs
index 3ccec6e..8d460b5 100644
--- a/Tiku.Api/Configuration/ObservabilityExtensions.cs
+++ b/Tiku.Api/Configuration/ObservabilityExtensions.cs
@@ -21,6 +21,7 @@ internal static class ObservabilityExtensions
environment.ApplicationName,
serviceVersion: typeof(ObservabilityExtensions).Assembly.GetName().Version?.ToString()))
.WithTracing(tracing => tracing
+ .AddFusionCacheInstrumentation()
.AddAspNetCoreInstrumentation(options =>
options.Filter = context => !context.Request.Path.StartsWithSegments("/api/system/health"))
.AddHttpClientInstrumentation()
@@ -28,6 +29,7 @@ internal static class ObservabilityExtensions
.ApplyIf(hasOtlpEndpoint,
builder => builder.AddOtlpExporter(options => options.Endpoint = endpointUri!)))
.WithMetrics(metrics => metrics
+ .AddFusionCacheInstrumentation()
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddMeter(DatabasePerformanceTelemetry.MeterName, WorkerTelemetry.MeterName,
@@ -44,4 +46,4 @@ internal static class ObservabilityExtensions
return builder;
}
-}
\ No newline at end of file
+}
diff --git a/Tiku.Api/Tiku.Api.csproj b/Tiku.Api/Tiku.Api.csproj
index 0afa896..af46ffd 100644
--- a/Tiku.Api/Tiku.Api.csproj
+++ b/Tiku.Api/Tiku.Api.csproj
@@ -37,6 +37,7 @@
+
diff --git a/Tiku.DbMigrator/Program.cs b/Tiku.DbMigrator/Program.cs
index 64ecda3..9ce826f 100644
--- a/Tiku.DbMigrator/Program.cs
+++ b/Tiku.DbMigrator/Program.cs
@@ -8,6 +8,7 @@ using Tiku.Application;
using Tiku.Application.Security;
using Tiku.Infrastructure;
using Tiku.Infrastructure.Bootstrap;
+using Tiku.Infrastructure.Caching;
using Tiku.Infrastructure.Persistence;
var builder = Host.CreateApplicationBuilder(args);
@@ -41,6 +42,7 @@ builder.Services.AddInfrastructure(
isDevelopment && !bootstrapPlatformAdmin && !skipDevelopmentSeed
? DevelopmentPlatformAdminSeeder.Configure
: null);
+builder.Services.AddBusinessCaching(builder.Environment.EnvironmentName, false);
// Resolving UserManager also activates Identity's default token providers.
// Bootstrap never issues a reset token, so the migrator uses a process-local provider;
// the API remains the sole owner of the persisted, certificate-protected key ring.
@@ -70,4 +72,4 @@ static string RequiredBootstrapSetting(IConfiguration configuration, string key)
return configuration[key] is { } value && !string.IsNullOrWhiteSpace(value)
? value
: throw new InvalidOperationException($"{key} is required with --bootstrap-platform-admin.");
-}
\ No newline at end of file
+}
diff --git a/Tiku.Infrastructure/Caching/BusinessCachingServiceCollectionExtensions.cs b/Tiku.Infrastructure/Caching/BusinessCachingServiceCollectionExtensions.cs
new file mode 100644
index 0000000..dfc93e6
--- /dev/null
+++ b/Tiku.Infrastructure/Caching/BusinessCachingServiceCollectionExtensions.cs
@@ -0,0 +1,56 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using StackExchange.Redis;
+using ZiggyCreatures.Caching.Fusion;
+using ZiggyCreatures.Caching.Fusion.Backplane.StackExchangeRedis;
+
+namespace Tiku.Infrastructure.Caching;
+
+public static class BusinessCachingServiceCollectionExtensions
+{
+ public const string CacheName = "TikuBusiness";
+
+ public static IServiceCollection AddBusinessCaching(
+ this IServiceCollection services,
+ string environmentName,
+ bool useRedis)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(environmentName);
+
+ var normalizedEnvironment = Normalize(environmentName);
+ var prefix = $"tiku:{normalizedEnvironment}:business:v2";
+ var builder = services.AddFusionCache(CacheName)
+ .WithOptions(options =>
+ {
+ options.CacheKeyPrefix = $"{prefix}:";
+ options.BackplaneChannelPrefix = prefix;
+ options.DistributedCacheCircuitBreakerDuration = TimeSpan.FromSeconds(2);
+ })
+ .WithDefaultEntryOptions(options =>
+ {
+ options.IsFailSafeEnabled = false;
+ options.AllowBackgroundDistributedCacheOperations = false;
+ options.AllowBackgroundBackplaneOperations = false;
+ })
+ .WithSystemTextJsonSerializer()
+ .AsKeyedServiceByCacheName();
+
+ if (useRedis)
+ builder
+ .WithRegisteredDistributedCache()
+ .WithBackplane(serviceProvider => new RedisBackplane(
+ new RedisBackplaneOptions
+ {
+ ConnectionMultiplexerFactory = () => Task.FromResult(
+ serviceProvider.GetRequiredService())
+ },
+ serviceProvider.GetService>()));
+
+ return services;
+ }
+
+ private static string Normalize(string value)
+ {
+ return value.Trim().ToLowerInvariant().Replace(' ', '-');
+ }
+}
diff --git a/Tiku.Infrastructure/Security/TenantFeatureCacheInvalidator.cs b/Tiku.Infrastructure/Security/TenantFeatureCacheInvalidator.cs
index 6e7e97e..80c6d11 100644
--- a/Tiku.Infrastructure/Security/TenantFeatureCacheInvalidator.cs
+++ b/Tiku.Infrastructure/Security/TenantFeatureCacheInvalidator.cs
@@ -1,44 +1,25 @@
-using Microsoft.Extensions.Caching.Distributed;
-using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Logging;
using Tiku.Application.Security;
using Tiku.Application.Tenancy;
+using Tiku.Infrastructure.Caching;
+using ZiggyCreatures.Caching.Fusion;
namespace Tiku.Infrastructure.Security;
internal sealed class TenantFeatureCacheInvalidator(
- IMemoryCache memoryCache,
- IServiceProvider serviceProvider,
- ITenantRuntimeCacheInvalidator runtimeCacheInvalidator,
- ILogger logger) : ITenantFeatureCacheInvalidator
+ [FromKeyedServices(BusinessCachingServiceCollectionExtensions.CacheName)]
+ IFusionCache cache,
+ ITenantRuntimeCacheInvalidator runtimeCacheInvalidator) : ITenantFeatureCacheInvalidator
{
public async Task InvalidateAsync(Guid tenantId, CancellationToken cancellationToken = default)
{
- RemoveMemory(tenantId);
await runtimeCacheInvalidator.InvalidateAsync(tenantId, cancellationToken);
- var distributedCache = serviceProvider.GetService();
- if (distributedCache is not null)
- try
- {
- await Task.WhenAll(
- distributedCache.RemoveAsync(
- TenantFeatureSnapshotProvider.CacheKey(tenantId, FeatureAccessOperation.Read),
- cancellationToken),
- distributedCache.RemoveAsync(
- TenantFeatureSnapshotProvider.CacheKey(tenantId, FeatureAccessOperation.Write),
- cancellationToken));
- }
- catch (Exception exception) when (exception is not OperationCanceledException)
- {
- logger.LogWarning(exception,
- "Tenant feature distributed cache invalidation failed for tenant {TenantId}.", tenantId);
- }
+ await Task.WhenAll(
+ cache.RemoveAsync(
+ TenantFeatureSnapshotProvider.CacheKey(tenantId, FeatureAccessOperation.Read),
+ token: cancellationToken).AsTask(),
+ cache.RemoveAsync(
+ TenantFeatureSnapshotProvider.CacheKey(tenantId, FeatureAccessOperation.Write),
+ token: cancellationToken).AsTask());
}
-
- private void RemoveMemory(Guid tenantId)
- {
- memoryCache.Remove(TenantFeatureSnapshotProvider.CacheKey(tenantId, FeatureAccessOperation.Read));
- memoryCache.Remove(TenantFeatureSnapshotProvider.CacheKey(tenantId, FeatureAccessOperation.Write));
- }
-}
\ No newline at end of file
+}
diff --git a/Tiku.Infrastructure/Security/TenantFeatureSnapshotProvider.cs b/Tiku.Infrastructure/Security/TenantFeatureSnapshotProvider.cs
index bc6106c..acb38b7 100644
--- a/Tiku.Infrastructure/Security/TenantFeatureSnapshotProvider.cs
+++ b/Tiku.Infrastructure/Security/TenantFeatureSnapshotProvider.cs
@@ -1,13 +1,11 @@
-using System.Text.Json;
using Microsoft.EntityFrameworkCore;
-using Microsoft.Extensions.Caching.Distributed;
-using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Extensions.Logging;
using Tiku.Application.Security;
using Tiku.Domain.Platform;
using Tiku.Domain.Tenancy;
+using Tiku.Infrastructure.Caching;
using Tiku.Infrastructure.Persistence;
+using ZiggyCreatures.Caching.Fusion;
namespace Tiku.Infrastructure.Security;
@@ -92,19 +90,9 @@ internal interface ITenantFeatureSnapshotProvider
internal sealed class TenantFeatureSnapshotProvider(
IPlatformControlPlanePersistence platformControlPlanePersistence,
ITenancyPersistence tenancyPersistence,
- IMemoryCache memoryCache,
- IServiceProvider serviceProvider,
- ILogger logger) : ITenantFeatureSnapshotProvider
+ [FromKeyedServices(BusinessCachingServiceCollectionExtensions.CacheName)]
+ IFusionCache cache) : ITenantFeatureSnapshotProvider
{
- private static readonly TimeSpan MemoryDuration = TimeSpan.FromSeconds(30);
-
- private static readonly DistributedCacheEntryOptions DistributedOptions = new()
- {
- AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(60)
- };
-
- private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
-
private readonly
Dictionary<(Guid TenantId, FeatureAccessOperation Operation), (long SaveVersion,
Task Snapshot)> requestCache = [];
@@ -137,49 +125,17 @@ internal sealed class TenantFeatureSnapshotProvider(
CancellationToken cancellationToken)
{
var cacheKey = CacheKey(tenantId, operation);
- if (!bypassSharedCache &&
- memoryCache.TryGetValue(cacheKey, out var memorySnapshot) &&
- memorySnapshot is not null)
- return memorySnapshot;
-
- var distributedCache = serviceProvider.GetService();
- if (!bypassSharedCache && distributedCache is not null)
- try
+ return await cache.GetOrSetAsync(
+ cacheKey,
+ (_, token) => LoadAsync(tenantId, operation, token),
+ options =>
{
- var cached = await distributedCache.GetStringAsync(cacheKey, cancellationToken);
- if (!string.IsNullOrWhiteSpace(cached))
- {
- var distributedSnapshot =
- JsonSerializer.Deserialize(cached, SerializerOptions);
- if (distributedSnapshot is not null)
- {
- memoryCache.Set(cacheKey, distributedSnapshot, MemoryDuration);
- return distributedSnapshot;
- }
- }
- }
- catch (Exception exception) when (exception is not OperationCanceledException)
- {
- logger.LogWarning(exception, "Tenant feature snapshot cache read failed; falling back to PostgreSQL.");
- }
-
- var snapshot = await LoadAsync(tenantId, operation, cancellationToken);
- memoryCache.Set(cacheKey, snapshot, MemoryDuration);
- if (distributedCache is not null)
- try
- {
- await distributedCache.SetStringAsync(
- cacheKey,
- JsonSerializer.Serialize(snapshot, SerializerOptions),
- DistributedOptions,
- cancellationToken);
- }
- catch (Exception exception) when (exception is not OperationCanceledException)
- {
- logger.LogWarning(exception, "Tenant feature snapshot cache write failed; continuing without Redis.");
- }
-
- return snapshot;
+ options.Duration = TimeSpan.FromSeconds(60);
+ options.MemoryCacheDuration = TimeSpan.FromSeconds(30);
+ options.SkipMemoryCacheRead = bypassSharedCache;
+ options.SkipDistributedCacheRead = bypassSharedCache;
+ },
+ token: cancellationToken);
}
private async Task LoadAsync(
@@ -251,4 +207,4 @@ internal sealed class TenantFeatureSnapshotProvider(
{
return $"tenant-feature-snapshot:v1:{tenantId:N}:{operation.ToString().ToLowerInvariant()}";
}
-}
\ No newline at end of file
+}
diff --git a/Tiku.Infrastructure/Tenancy/TenantDirectory.cs b/Tiku.Infrastructure/Tenancy/TenantDirectory.cs
index b4e21d2..162d537 100644
--- a/Tiku.Infrastructure/Tenancy/TenantDirectory.cs
+++ b/Tiku.Infrastructure/Tenancy/TenantDirectory.cs
@@ -1,20 +1,19 @@
-using System.Text.Json;
-using Microsoft.Extensions.Caching.Distributed;
-using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.DependencyInjection;
using Npgsql;
using Tiku.Application.Tenancy;
using Tiku.Domain.Tenancy;
+using Tiku.Infrastructure.Caching;
+using ZiggyCreatures.Caching.Fusion;
namespace Tiku.Infrastructure.Tenancy;
+internal sealed record TenantDirectoryCacheEntry(bool Found, TenantDirectoryEntry? Entry);
+
public sealed class TenantDirectory(
NpgsqlDataSource dataSource,
- IMemoryCache memoryCache,
- IServiceProvider serviceProvider) : ITenantDirectory
+ [FromKeyedServices(BusinessCachingServiceCollectionExtensions.CacheName)]
+ IFusionCache cache) : ITenantDirectory
{
- private static readonly JsonSerializerOptions SerializerOptions = new(JsonSerializerDefaults.Web);
-
public Task FindByHostAsync(
string host,
CancellationToken cancellationToken = default)
@@ -51,76 +50,36 @@ public sealed class TenantDirectory(
string lookup,
CancellationToken cancellationToken)
{
- var cacheKey = $"tenant-directory:v1:{kind}:{lookup}";
- if (memoryCache.TryGetValue(cacheKey, out var memoryValue) && memoryValue is not null)
- return memoryValue.Entry;
-
- var distributedCache = serviceProvider.GetService();
- if (distributedCache is not null)
- try
+ var cacheKey = CacheKey(kind, lookup);
+ var cached = await cache.GetOrSetAsync(
+ cacheKey,
+ async (context, token) =>
{
- var json = await distributedCache.GetStringAsync(cacheKey, cancellationToken);
- if (json is not null)
+ await using var command = dataSource.CreateCommand(sql);
+ command.Parameters.AddWithValue("lookup", lookup);
+ await using var reader = await command.ExecuteReaderAsync(token);
+ if (!await reader.ReadAsync(token))
{
- var distributedValue = JsonSerializer.Deserialize(json, SerializerOptions);
- if (distributedValue is not null)
- {
- memoryCache.Set(cacheKey, distributedValue, distributedValue.Found
- ? TimeSpan.FromSeconds(30)
- : TimeSpan.FromSeconds(20));
- return distributedValue.Entry;
- }
+ context.Options.Duration = TimeSpan.FromSeconds(20);
+ context.Options.MemoryCacheDuration = TimeSpan.FromSeconds(20);
+ return new TenantDirectoryCacheEntry(false, null);
}
- }
- catch (Exception exception) when (exception is not OperationCanceledException)
+
+ return new TenantDirectoryCacheEntry(true, new TenantDirectoryEntry(
+ reader.GetGuid(0),
+ reader.GetString(1),
+ reader.GetString(2),
+ ParseEnum(reader.GetString(3)),
+ ParseEnum(reader.GetString(4)),
+ reader.IsDBNull(5) ? null : reader.GetString(5)));
+ },
+ options =>
{
- // Tenant resolution must fall back to PostgreSQL when Redis is unavailable.
- }
-
- await using var command = dataSource.CreateCommand(sql);
- command.Parameters.AddWithValue("lookup", lookup);
- await using var reader = await command.ExecuteReaderAsync(cancellationToken);
- if (!await reader.ReadAsync(cancellationToken))
- {
- await StoreAsync(distributedCache, cacheKey, new CacheEnvelope(false, null), TimeSpan.FromSeconds(20),
- cancellationToken);
- return null;
- }
-
- var result = new TenantDirectoryEntry(
- reader.GetGuid(0),
- reader.GetString(1),
- reader.GetString(2),
- ParseEnum(reader.GetString(3)),
- ParseEnum(reader.GetString(4)),
- reader.IsDBNull(5) ? null : reader.GetString(5));
- await StoreAsync(distributedCache, cacheKey, new CacheEnvelope(true, result), TimeSpan.FromSeconds(300),
- cancellationToken);
- return result;
- }
-
- private async Task StoreAsync(
- IDistributedCache? distributedCache,
- string cacheKey,
- CacheEnvelope value,
- TimeSpan distributedDuration,
- CancellationToken cancellationToken)
- {
- memoryCache.Set(cacheKey, value, value.Found ? TimeSpan.FromSeconds(30) : TimeSpan.FromSeconds(20));
- if (distributedCache is null) return;
-
- try
- {
- await distributedCache.SetStringAsync(
- cacheKey,
- JsonSerializer.Serialize(value, SerializerOptions),
- new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = distributedDuration },
- cancellationToken);
- }
- catch (Exception exception) when (exception is not OperationCanceledException)
- {
- // L1 remains usable and the next miss will fall back to PostgreSQL.
- }
+ options.Duration = TimeSpan.FromSeconds(300);
+ options.MemoryCacheDuration = TimeSpan.FromSeconds(30);
+ },
+ token: cancellationToken);
+ return cached.Entry;
}
private static TEnum ParseEnum(string value)
@@ -134,5 +93,8 @@ public sealed class TenantDirectory(
return value.Trim().ToLowerInvariant();
}
- private sealed record CacheEnvelope(bool Found, TenantDirectoryEntry? Entry);
-}
\ No newline at end of file
+ internal static string CacheKey(string kind, string lookup)
+ {
+ return $"tenant-directory:v1:{kind}:{Normalize(lookup)}";
+ }
+}
diff --git a/Tiku.Infrastructure/Tenancy/TenantDomainLifecycleService.cs b/Tiku.Infrastructure/Tenancy/TenantDomainLifecycleService.cs
index bb81fcf..764c5e5 100644
--- a/Tiku.Infrastructure/Tenancy/TenantDomainLifecycleService.cs
+++ b/Tiku.Infrastructure/Tenancy/TenantDomainLifecycleService.cs
@@ -2,12 +2,14 @@ using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
-using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Tiku.Application.Tenancy;
using Tiku.Domain.Operations;
using Tiku.Domain.Tenancy;
using Tiku.Infrastructure.Persistence;
+using Tiku.Infrastructure.Caching;
+using ZiggyCreatures.Caching.Fusion;
namespace Tiku.Infrastructure.Tenancy;
@@ -131,12 +133,13 @@ public sealed class HttpDomainGatewayProvisioner(
}
public sealed class TenantRuntimeCacheInvalidator(
- IMemoryCache cache,
+ [FromKeyedServices(BusinessCachingServiceCollectionExtensions.CacheName)]
+ IFusionCache cache,
ITenantPublicCacheInvalidator publicCacheInvalidator) : ITenantRuntimeCacheInvalidator
{
public async Task InvalidateAsync(Guid tenantId, CancellationToken cancellationToken = default)
{
- cache.Remove($"tenant-runtime:{tenantId:N}");
+ await cache.RemoveAsync(TenantFrontendConfigService.CacheKey(tenantId), token: cancellationToken);
await publicCacheInvalidator.InvalidateAsync(tenantId, cancellationToken);
}
}
@@ -214,4 +217,4 @@ public sealed class TenantDomainLifecycleService(
domain.Status = configured ? TenantDomainStatus.Failed : TenantDomainStatus.Pending;
domain.LastFailureReason = reason;
}
-}
\ No newline at end of file
+}
diff --git a/Tiku.Infrastructure/Tenancy/TenantFrontendConfigService.cs b/Tiku.Infrastructure/Tenancy/TenantFrontendConfigService.cs
index 56aac18..966ddea 100644
--- a/Tiku.Infrastructure/Tenancy/TenantFrontendConfigService.cs
+++ b/Tiku.Infrastructure/Tenancy/TenantFrontendConfigService.cs
@@ -1,12 +1,14 @@
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
-using Microsoft.Extensions.Caching.Memory;
+using Microsoft.Extensions.DependencyInjection;
using Tiku.Application.Security;
using Tiku.Application.Tenancy;
using Tiku.Domain.Identity;
using Tiku.Domain.Platform;
using Tiku.Domain.Tenancy;
+using Tiku.Infrastructure.Caching;
using Tiku.Infrastructure.Persistence;
+using ZiggyCreatures.Caching.Fusion;
namespace Tiku.Infrastructure.Tenancy;
@@ -14,7 +16,8 @@ public sealed class TenantFrontendConfigService(
ITenancyPersistence tenancyPersistence,
IIdentityPersistence identityPersistence,
IPlatformControlPlanePersistence platformControlPlanePersistence,
- IMemoryCache cache,
+ [FromKeyedServices(BusinessCachingServiceCollectionExtensions.CacheName)]
+ IFusionCache cache,
IFeatureAccessService featureAccessService,
ITenantPublicCacheInvalidator publicCacheInvalidator) : ITenantFrontendConfigService
{
@@ -86,7 +89,7 @@ public sealed class TenantFrontendConfigService(
config.ConfigVersion++;
config.PublishedAt = DateTimeOffset.UtcNow;
await tenancyPersistence.SaveChangesAsync(cancellationToken);
- cache.Remove(CacheKey(tenantId));
+ await cache.RemoveAsync(CacheKey(tenantId), token: cancellationToken);
await publicCacheInvalidator.InvalidateAsync(tenantId, cancellationToken);
return ToItem(config);
}
@@ -95,9 +98,21 @@ public sealed class TenantFrontendConfigService(
Guid tenantId,
CancellationToken cancellationToken = default)
{
- if (cache.TryGetValue(CacheKey(tenantId), out var cached) &&
- cached is not null) return cached;
+ return await cache.GetOrSetAsync(
+ CacheKey(tenantId),
+ (_, token) => LoadRuntimeAsync(tenantId, token),
+ options =>
+ {
+ options.Duration = RuntimeCacheDuration;
+ options.MemoryCacheDuration = RuntimeCacheDuration;
+ },
+ token: cancellationToken);
+ }
+ private async Task LoadRuntimeAsync(
+ Guid tenantId,
+ CancellationToken cancellationToken)
+ {
var tenant = await tenancyPersistence.Tenants.AsNoTracking().SingleOrDefaultAsync(
item => item.Id == tenantId && item.Status == TenantStatus.Active,
cancellationToken)
@@ -124,7 +139,7 @@ public sealed class TenantFrontendConfigService(
: config.PublishedAt.HasValue
? "active"
: "ready_to_launch";
- var result = new TenantRuntimeBootstrap(
+ return new TenantRuntimeBootstrap(
config.SchemaVersion,
config.ConfigVersion,
tenant.Slug,
@@ -149,8 +164,6 @@ public sealed class TenantFrontendConfigService(
.Distinct(StringComparer.Ordinal)
.Order(StringComparer.Ordinal)
.ToArray());
- cache.Set(CacheKey(tenantId), result, RuntimeCacheDuration);
- return result;
}
private static TenantFrontendConfig CreateDefault(Guid tenantId, string tenantName)
@@ -236,8 +249,8 @@ public sealed class TenantFrontendConfigService(
"Frontend configuration cannot contain HTML or executable scripts.");
}
- private static string CacheKey(Guid tenantId)
+ internal static string CacheKey(Guid tenantId)
{
return $"tenant-runtime:{tenantId:N}";
}
-}
\ No newline at end of file
+}
diff --git a/Tiku.Infrastructure/Tiku.Infrastructure.csproj b/Tiku.Infrastructure/Tiku.Infrastructure.csproj
index 5af1fdf..1b9b156 100644
--- a/Tiku.Infrastructure/Tiku.Infrastructure.csproj
+++ b/Tiku.Infrastructure/Tiku.Infrastructure.csproj
@@ -27,6 +27,9 @@
+
+
+
diff --git a/Tiku.IntegrationTests/BusinessCachingTests.cs b/Tiku.IntegrationTests/BusinessCachingTests.cs
new file mode 100644
index 0000000..5d9e149
--- /dev/null
+++ b/Tiku.IntegrationTests/BusinessCachingTests.cs
@@ -0,0 +1,144 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using StackExchange.Redis;
+using Tiku.Application.Security;
+using Tiku.Application.Tenancy;
+using Tiku.Infrastructure;
+using Tiku.Infrastructure.Caching;
+using Tiku.Infrastructure.Tenancy;
+using ZiggyCreatures.Caching.Fusion;
+
+namespace Tiku.IntegrationTests;
+
+public sealed class BusinessCachingTests
+{
+ [Fact]
+ public async Task Local_cache_coalesces_concurrent_factories_and_uses_safe_defaults()
+ {
+ await using var provider = BuildLocalProvider("Integration Tests");
+ var cache = provider.GetRequiredKeyedService(
+ BusinessCachingServiceCollectionExtensions.CacheName);
+ var options = provider.GetRequiredService>()
+ .Get(BusinessCachingServiceCollectionExtensions.CacheName);
+ var calls = 0;
+ var releaseFactory = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ var tasks = Enumerable.Range(0, 16).Select(_ => cache.GetOrSetAsync(
+ "stampede",
+ async cancellationToken =>
+ {
+ Interlocked.Increment(ref calls);
+ await releaseFactory.Task.WaitAsync(cancellationToken);
+ return 42;
+ },
+ entry => entry.Duration = TimeSpan.FromMinutes(1))
+ .AsTask()).ToArray();
+
+ Assert.True(SpinWait.SpinUntil(() => Volatile.Read(ref calls) > 0, TimeSpan.FromSeconds(2)));
+ releaseFactory.SetResult();
+
+ Assert.All(await Task.WhenAll(tasks), value => Assert.Equal(42, value));
+ Assert.Equal(1, calls);
+ Assert.Equal(BusinessCachingServiceCollectionExtensions.CacheName, cache.CacheName);
+ Assert.Equal("tiku:integration-tests:business:v2:", options.CacheKeyPrefix);
+ Assert.Equal("tiku:integration-tests:business:v2", options.BackplaneChannelPrefix);
+ Assert.False(cache.DefaultEntryOptions.IsFailSafeEnabled);
+ Assert.False(cache.DefaultEntryOptions.AllowBackgroundDistributedCacheOperations);
+ Assert.False(cache.DefaultEntryOptions.AllowBackgroundBackplaneOperations);
+ }
+
+ [Fact]
+ public async Task Unavailable_redis_falls_back_to_the_value_factory()
+ {
+ await using var provider = BuildRedisProvider(
+ "localhost:6399,connectTimeout=200,syncTimeout=200,asyncTimeout=200,abortConnect=false",
+ $"business-unavailable-{Guid.NewGuid():N}");
+ var cache = provider.GetRequiredKeyedService(
+ BusinessCachingServiceCollectionExtensions.CacheName);
+
+ var value = await cache.GetOrSetAsync(
+ "fallback",
+ _ => Task.FromResult(42),
+ entry => entry.Duration = TimeSpan.FromMinutes(1));
+
+ Assert.Equal(42, value);
+ }
+
+ [Fact]
+ public async Task Tenant_directory_caches_negative_results()
+ {
+ await using var factory = new Api.ApiTestFactory();
+ _ = factory.Services;
+ using var scope = factory.Services.CreateScope();
+ var directory = scope.ServiceProvider.GetRequiredService();
+ var cache = scope.ServiceProvider.GetRequiredKeyedService(
+ BusinessCachingServiceCollectionExtensions.CacheName);
+ var host = $"missing-{Guid.NewGuid():N}.example.test";
+
+ Assert.Null(await directory.FindByHostAsync(host));
+ Assert.Null(await directory.FindByHostAsync(host));
+
+ var cached = await cache.TryGetAsync(TenantDirectory.CacheKey("host", host));
+ Assert.True(cached.HasValue);
+ Assert.False(cached.Value.Found);
+ Assert.Null(cached.Value.Entry);
+ }
+
+ [Fact]
+ public async Task Redis_backplane_removes_another_nodes_local_entry()
+ {
+ var connectionString = Environment.GetEnvironmentVariable("TIKU_TEST_REDIS");
+ if (string.IsNullOrWhiteSpace(connectionString)) return;
+
+ var environment = $"business-backplane-{Guid.NewGuid():N}";
+ await using var first = BuildRedisProvider(connectionString, environment);
+ await using var second = BuildRedisProvider(connectionString, environment);
+ var firstCache = first.GetRequiredKeyedService(
+ BusinessCachingServiceCollectionExtensions.CacheName);
+ var secondCache = second.GetRequiredKeyedService(
+ BusinessCachingServiceCollectionExtensions.CacheName);
+ const string key = "shared-entry";
+
+ try
+ {
+ await firstCache.SetAsync(key, 1, entry => entry.Duration = TimeSpan.FromMinutes(1));
+ Assert.Equal(1, await secondCache.GetOrSetAsync(
+ key,
+ _ => Task.FromResult(99),
+ entry => entry.Duration = TimeSpan.FromMinutes(1)));
+
+ await firstCache.RemoveAsync(key);
+ var reloaded = await secondCache.GetOrSetAsync(
+ key,
+ _ => Task.FromResult(2),
+ entry => entry.Duration = TimeSpan.FromMinutes(1));
+
+ Assert.Equal(2, reloaded);
+ }
+ finally
+ {
+ var multiplexer = first.GetRequiredService();
+ var server = multiplexer.GetServer(multiplexer.GetEndPoints().Single());
+ var keys = server.Keys(pattern: $"tiku:{environment}:business:v2:*").ToArray();
+ if (keys.Length > 0) await multiplexer.GetDatabase().KeyDeleteAsync(keys);
+ }
+ }
+
+ private static ServiceProvider BuildLocalProvider(string environment)
+ {
+ var services = new ServiceCollection();
+ services.AddLogging();
+ services.AddBusinessCaching(environment, false);
+ return services.BuildServiceProvider();
+ }
+
+ private static ServiceProvider BuildRedisProvider(string connectionString, string environment)
+ {
+ var services = new ServiceCollection();
+ services.AddLogging();
+ services.Configure(_ => { });
+ services.AddRedisSecurity(connectionString, environment);
+ services.AddBusinessCaching(environment, true);
+ return services.BuildServiceProvider();
+ }
+}
diff --git a/Tiku.Worker/Tiku.Worker.csproj b/Tiku.Worker/Tiku.Worker.csproj
index 6180482..07c4ec6 100644
--- a/Tiku.Worker/Tiku.Worker.csproj
+++ b/Tiku.Worker/Tiku.Worker.csproj
@@ -23,6 +23,7 @@
+
diff --git a/Tiku.Worker/WorkerDependencyInjection.cs b/Tiku.Worker/WorkerDependencyInjection.cs
index 9693bda..e1d631f 100644
--- a/Tiku.Worker/WorkerDependencyInjection.cs
+++ b/Tiku.Worker/WorkerDependencyInjection.cs
@@ -1,5 +1,6 @@
using Microsoft.Extensions.Options;
using OpenTelemetry.Metrics;
+using OpenTelemetry.Trace;
using Serilog;
using Tiku.Application;
using Tiku.Application.PlatformBilling;
@@ -7,6 +8,7 @@ using Tiku.Application.Security;
using Tiku.Application.Storage;
using Tiku.Application.Tenancy;
using Tiku.Infrastructure;
+using Tiku.Infrastructure.Caching;
using Tiku.Infrastructure.Assets;
using Tiku.Infrastructure.Observability;
using Tiku.Infrastructure.Security;
@@ -43,11 +45,19 @@ internal static class WorkerDependencyInjection
else if (builder.Environment.IsProduction())
throw new InvalidOperationException(
"Redis is required in Production for authorization cache invalidation retries.");
+ builder.Services.AddBusinessCaching(
+ builder.Environment.EnvironmentName,
+ !string.IsNullOrWhiteSpace(redisConnectionString));
var otlpEndpoint = builder.Configuration["OpenTelemetry:OtlpEndpoint"];
- var telemetry = builder.Services.AddOpenTelemetry().WithMetrics(metrics =>
- metrics.AddMeter(WorkerTelemetry.MeterName, AuthorizationCacheTelemetry.MeterName));
+ var telemetry = builder.Services.AddOpenTelemetry()
+ .WithTracing(tracing => tracing.AddFusionCacheInstrumentation())
+ .WithMetrics(metrics => metrics
+ .AddFusionCacheInstrumentation()
+ .AddMeter(WorkerTelemetry.MeterName, AuthorizationCacheTelemetry.MeterName));
if (Uri.TryCreate(otlpEndpoint, UriKind.Absolute, out var endpoint))
- telemetry.WithMetrics(metrics => metrics.AddOtlpExporter(options => options.Endpoint = endpoint));
+ telemetry
+ .WithTracing(tracing => tracing.AddOtlpExporter(options => options.Endpoint = endpoint))
+ .WithMetrics(metrics => metrics.AddOtlpExporter(options => options.Endpoint = endpoint));
builder.Services.Configure(
builder.Configuration.GetSection(ObjectStorageOptions.SectionName));
builder.Services.Configure(builder.Configuration.GetSection(AliyunOssOptions.SectionName));
diff --git a/docs/architecture/security-and-tenancy.md b/docs/architecture/security-and-tenancy.md
index a3fbe19..6197617 100644
--- a/docs/architecture/security-and-tenancy.md
+++ b/docs/architecture/security-and-tenancy.md
@@ -125,9 +125,22 @@ Production 默认激活链接为 `https://{primaryHost}/activate/{activationId}#
跨租户 Worker、迁移、seed 和平台级后台操作必须通过 `ITenantContextInitializer.InitializeSystem` 或受审计的 `ITenantExecutionScope` 进入 System Scope,并提供明确原因。业务代码不得直接关闭 Query Filter。
- Session、成员、租户和套餐状态始终从 PostgreSQL 重新校验。
-- 租户、套餐和 Feature 变更在数据库提交后直接失效当前 API 进程与 Redis 中的相关缓存。
+- 租户、套餐和 Feature 变更在数据库提交后通过 FusionCache 失效本节点、Redis L2 和其他节点的 L1。
- 后台任务在业务事务提交后持久化到 PostgreSQL,独立 Worker 使用租约执行;延时和重试由 `RunAfter` 控制。
+## FusionCache 业务缓存
+
+租户目录、租户 Feature 快照和租户运行时配置共用命名缓存 `TikuBusiness`。FusionCache 管理独立 L1,不与鉴权使用的 `IMemoryCache` 共享;配置 Redis 时复用现有连接作为 L2,并通过 StackExchange.Redis Backplane 传播 `Set`、`Remove` 和过期通知。缓存 key 与 Backplane channel 使用 `tiku::business:v2` 前缀,防止环境间串用。
+
+| 数据 | L1 | L2 | 失效方式 |
+| --- | --- | --- | --- |
+| 租户目录命中 | 30 秒 | 300 秒 | TTL;域名状态只查询 Active 租户和域名 |
+| 租户目录未命中 | 20 秒 | 20 秒 | 自适应负缓存 TTL |
+| 租户 Feature 快照 | 30 秒 | 60 秒 | Feature、套餐或租户变化后按读写 key 主动失效 |
+| 租户运行时配置 | 120 秒 | 120 秒 | 配置发布、Owner 激活、Feature 或域名生命周期变化后主动失效 |
+
+这些缓存不启用 fail-safe、eager refresh、后台分布式写入或分布式锁。Redis 缺失或暂时不可用时,读取回到 PostgreSQL 工厂;正常 TTL 之外不能继续返回旧租户状态、订阅或 Feature 数据。DbMigrator 只注册本地 L1,不依赖 Redis。FusionCache 不接管 ASP.NET Core Output Cache,也不参与下面的安全状态和版本化权限快照。
+
## Redis 授权缓存模式
`Security:AuthorizationCache:Mode` 支持三种当前实现模式:
diff --git a/docs/operations.md b/docs/operations.md
index 3c2d51b..dd0945d 100644
--- a/docs/operations.md
+++ b/docs/operations.md
@@ -6,11 +6,11 @@
| 进程 | PostgreSQL | Redis | 说明 |
| --- | --- | --- | --- |
-| `Tiku.Api` | 必需 | Development 可选;Production 必需 | 提供 HTTP API、认证、授权和缓存 |
-| `Tiku.Worker` | 必需 | Development 可选;Production 必需 | 承载周期任务、任务队列、商业账务和授权缓存失效重试 |
-| `Tiku.DbMigrator` | 必需 | 不需要 | 执行 Migration、内置目录 seed 和管理员引导 |
+| `Tiku.Api` | 必需 | Development 可选;Production 必需 | 提供 HTTP API、认证、授权、FusionCache L1/L2 和 Output Cache |
+| `Tiku.Worker` | 必需 | Development 可选;Production 必需 | 承载周期任务、业务缓存 Backplane 和授权缓存失效重试 |
+| `Tiku.DbMigrator` | 必需 | 不需要 | 执行 Migration、内置目录 seed 和管理员引导;FusionCache 仅使用本地 L1 |
-Development 未配置 Redis 时,安全服务使用进程内/数据库防线。Production 不允许 Redis 降级;后台任务在所有环境统一使用 PostgreSQL。
+Development 未配置 Redis 时,业务 FusionCache 只使用进程内 L1,安全服务使用进程内/数据库防线。Production 不允许 Redis 降级;后台任务在所有环境统一使用 PostgreSQL。
## 数据库
@@ -50,10 +50,14 @@ dotnet run --project Tiku.DbMigrator -- --bootstrap-platform-admin
连接串读取 `ConnectionStrings:Redis` 或 `REDIS_URL`。当前用途:
- 密码、短信发送和短信校验的安全窗口计数;
-- 租户 Feature 分布式缓存;
+- `TikuBusiness` FusionCache 的租户目录、Feature 快照和运行时配置 L2;
+- FusionCache Backplane,用于跨 API/Worker 节点驱逐业务 L1;
+- 可选的 Session、安全状态和版本化权限快照缓存;
- Production 的 ASP.NET Core Output Cache。
-Redis key 使用环境前缀;配置解析会强制 `AbortOnConnectFail=false`。Redis 不是用户、Session、权限、套餐或用量的权威数据源。
+业务缓存 key 和 Backplane channel 使用 `tiku::business:v2` 前缀;配置解析会强制 `AbortOnConnectFail=false`。FusionCache 复用现有 `IConnectionMultiplexer`,不创建额外 Redis 连接。业务缓存禁用 fail-safe 与后台分布式写入,Redis 异常时回退 PostgreSQL;Redis 不是用户、Session、权限、套餐或用量的权威数据源。
+
+`TikuBusiness` 的 TTL 和安全边界见[认证、授权与租户隔离](architecture/security-and-tenancy.md#fusioncache-业务缓存)。FusionCache、授权缓存与 Output Cache 是三套不同语义:业务读模型使用 FusionCache,安全状态保留版本化专用实现,HTTP 响应继续由 ASP.NET Core Output Cache 管理。
授权缓存由 `Security:AuthorizationCache` 配置,默认 `Mode` 为 `Disabled`。切换为 `Shadow` 或 `Active` 前,应先确认 API 与 Worker 使用同一 Redis 和 PostgreSQL,并观察 `Tiku.Security.AuthorizationCache` 的 mismatch、fallback 与 Redis 延迟指标。回滚只需切回 `Disabled`,不应回退授权版本或失效事件相关数据库结构。详细故障语义见[认证、授权与租户隔离](architecture/security-and-tenancy.md#redis-授权缓存模式)。
@@ -191,7 +195,7 @@ Worker 使用 ClamAV `INSTREAM` 协议,不在本地落盘待扫描对象。启
- `GET /api/platform/operations/health`:需要 `platform:operations:view`,返回 PostgreSQL、Redis、Worker heartbeat、ClamAV 和对象存储配置状态。
- `GET /api/platform/operations/workers`:查询 Worker 心跳、周期循环和 stale 状态。
- `GET /api/platform/operations/job-metrics`:查询队列状态、最老 Pending 任务与过期租约。
-- 设置 `OpenTelemetry:OtlpEndpoint` 后导出 ASP.NET Core、HTTP client 和数据库观测数据。
+- 设置 `OpenTelemetry:OtlpEndpoint` 后导出 ASP.NET Core、HTTP client、数据库和 FusionCache 的 hit/miss、L1/L2、工厂、Backplane 指标与 trace。
- Serilog 输出结构化请求日志;数据库性能拦截器记录慢查询指标。
Readiness 为绿色不等于认证授权、跨租户隔离或后台任务恢复演练已通过,发布仍需执行对应集成测试。