From 603bc24c26cf941d88c44dbcfd3647e22d0b1070 Mon Sep 17 00:00:00 2001 From: xiong Date: Wed, 5 Aug 2026 09:30:48 +0800 Subject: [PATCH] feat(cache): adopt FusionCache for business caching --- Directory.Packages.props | 4 + README.md | 3 +- Tiku.Api/Configuration/DependencyInjection.cs | 4 + .../Configuration/ObservabilityExtensions.cs | 4 +- Tiku.Api/Tiku.Api.csproj | 1 + Tiku.DbMigrator/Program.cs | 4 +- ...inessCachingServiceCollectionExtensions.cs | 56 +++++++ .../Security/TenantFeatureCacheInvalidator.cs | 45 ++---- .../Security/TenantFeatureSnapshotProvider.cs | 74 ++------- .../Tenancy/TenantDirectory.cs | 112 +++++--------- .../Tenancy/TenantDomainLifecycleService.cs | 11 +- .../Tenancy/TenantFrontendConfigService.cs | 33 ++-- .../Tiku.Infrastructure.csproj | 3 + Tiku.IntegrationTests/BusinessCachingTests.cs | 144 ++++++++++++++++++ Tiku.Worker/Tiku.Worker.csproj | 1 + Tiku.Worker/WorkerDependencyInjection.cs | 16 +- docs/architecture/security-and-tenancy.md | 15 +- docs/operations.md | 18 ++- 18 files changed, 354 insertions(+), 194 deletions(-) create mode 100644 Tiku.Infrastructure/Caching/BusinessCachingServiceCollectionExtensions.cs create mode 100644 Tiku.IntegrationTests/BusinessCachingTests.cs 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 为绿色不等于认证授权、跨租户隔离或后台任务恢复演练已通过,发布仍需执行对应集成测试。