feat(cache): adopt FusionCache for business caching
Some checks are pending
ci / release-gate (push) Waiting to run
Some checks are pending
ci / release-gate (push) Waiting to run
This commit is contained in:
@@ -34,6 +34,10 @@
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.17.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.17.0" />
|
||||
<PackageVersion Include="StackExchange.Redis" Version="3.1.0" />
|
||||
<PackageVersion Include="ZiggyCreatures.FusionCache" Version="2.6.0" />
|
||||
<PackageVersion Include="ZiggyCreatures.FusionCache.Backplane.StackExchangeRedis" Version="2.6.0" />
|
||||
<PackageVersion Include="ZiggyCreatures.FusionCache.OpenTelemetry" Version="2.6.0" />
|
||||
<PackageVersion Include="ZiggyCreatures.FusionCache.Serialization.SystemTextJson" Version="2.6.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Identity.Stores" Version="10.0.10" />
|
||||
<PackageVersion Include="Microsoft.SemanticKernel" Version="1.78.0" />
|
||||
<PackageVersion Include="Minio" Version="7.0.0" />
|
||||
|
||||
@@ -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]`。
|
||||
|
||||
@@ -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 =>
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
<PackageReference Include="Serilog.Settings.Configuration"/>
|
||||
<PackageReference Include="Serilog.Sinks.Console"/>
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt"/>
|
||||
<PackageReference Include="ZiggyCreatures.FusionCache.OpenTelemetry"/>
|
||||
<PackageReference Include="ZLinq"/>
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -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<User> 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.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<IConnectionMultiplexer>())
|
||||
},
|
||||
serviceProvider.GetService<ILogger<RedisBackplane>>()));
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
private static string Normalize(string value)
|
||||
{
|
||||
return value.Trim().ToLowerInvariant().Replace(' ', '-');
|
||||
}
|
||||
}
|
||||
@@ -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<TenantFeatureCacheInvalidator> 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<IDistributedCache>();
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<TenantFeatureSnapshotProvider> 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<TenantFeatureAccessSnapshot> Snapshot)> requestCache = [];
|
||||
@@ -137,49 +125,17 @@ internal sealed class TenantFeatureSnapshotProvider(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var cacheKey = CacheKey(tenantId, operation);
|
||||
if (!bypassSharedCache &&
|
||||
memoryCache.TryGetValue<TenantFeatureAccessSnapshot>(cacheKey, out var memorySnapshot) &&
|
||||
memorySnapshot is not null)
|
||||
return memorySnapshot;
|
||||
|
||||
var distributedCache = serviceProvider.GetService<IDistributedCache>();
|
||||
if (!bypassSharedCache && distributedCache is not null)
|
||||
try
|
||||
return await cache.GetOrSetAsync<TenantFeatureAccessSnapshot>(
|
||||
cacheKey,
|
||||
(_, token) => LoadAsync(tenantId, operation, token),
|
||||
options =>
|
||||
{
|
||||
var cached = await distributedCache.GetStringAsync(cacheKey, cancellationToken);
|
||||
if (!string.IsNullOrWhiteSpace(cached))
|
||||
{
|
||||
var distributedSnapshot =
|
||||
JsonSerializer.Deserialize<TenantFeatureAccessSnapshot>(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<TenantFeatureAccessSnapshot> LoadAsync(
|
||||
@@ -251,4 +207,4 @@ internal sealed class TenantFeatureSnapshotProvider(
|
||||
{
|
||||
return $"tenant-feature-snapshot:v1:{tenantId:N}:{operation.ToString().ToLowerInvariant()}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<TenantDirectoryEntry?> 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<CacheEnvelope>(cacheKey, out var memoryValue) && memoryValue is not null)
|
||||
return memoryValue.Entry;
|
||||
|
||||
var distributedCache = serviceProvider.GetService<IDistributedCache>();
|
||||
if (distributedCache is not null)
|
||||
try
|
||||
var cacheKey = CacheKey(kind, lookup);
|
||||
var cached = await cache.GetOrSetAsync<TenantDirectoryCacheEntry>(
|
||||
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<CacheEnvelope>(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<TenantStatus>(reader.GetString(3)),
|
||||
ParseEnum<TenantMode>(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<TenantStatus>(reader.GetString(3)),
|
||||
ParseEnum<TenantMode>(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<TEnum>(string value)
|
||||
@@ -134,5 +93,8 @@ public sealed class TenantDirectory(
|
||||
return value.Trim().ToLowerInvariant();
|
||||
}
|
||||
|
||||
private sealed record CacheEnvelope(bool Found, TenantDirectoryEntry? Entry);
|
||||
}
|
||||
internal static string CacheKey(string kind, string lookup)
|
||||
{
|
||||
return $"tenant-directory:v1:{kind}:{Normalize(lookup)}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<TenantRuntimeBootstrap>(CacheKey(tenantId), out var cached) &&
|
||||
cached is not null) return cached;
|
||||
return await cache.GetOrSetAsync<TenantRuntimeBootstrap>(
|
||||
CacheKey(tenantId),
|
||||
(_, token) => LoadRuntimeAsync(tenantId, token),
|
||||
options =>
|
||||
{
|
||||
options.Duration = RuntimeCacheDuration;
|
||||
options.MemoryCacheDuration = RuntimeCacheDuration;
|
||||
},
|
||||
token: cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<TenantRuntimeBootstrap> 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}";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,9 @@
|
||||
<PackageReference Include="Senparc.Weixin.TenPayV3"/>
|
||||
<PackageReference Include="Senparc.Weixin.WxOpen"/>
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt"/>
|
||||
<PackageReference Include="ZiggyCreatures.FusionCache"/>
|
||||
<PackageReference Include="ZiggyCreatures.FusionCache.Backplane.StackExchangeRedis"/>
|
||||
<PackageReference Include="ZiggyCreatures.FusionCache.Serialization.SystemTextJson"/>
|
||||
<PackageReference Include="ZLinq"/>
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
144
Tiku.IntegrationTests/BusinessCachingTests.cs
Normal file
144
Tiku.IntegrationTests/BusinessCachingTests.cs
Normal file
@@ -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<IFusionCache>(
|
||||
BusinessCachingServiceCollectionExtensions.CacheName);
|
||||
var options = provider.GetRequiredService<IOptionsMonitor<FusionCacheOptions>>()
|
||||
.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<IFusionCache>(
|
||||
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<ITenantDirectory>();
|
||||
var cache = scope.ServiceProvider.GetRequiredKeyedService<IFusionCache>(
|
||||
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<TenantDirectoryCacheEntry>(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<IFusionCache>(
|
||||
BusinessCachingServiceCollectionExtensions.CacheName);
|
||||
var secondCache = second.GetRequiredKeyedService<IFusionCache>(
|
||||
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<IConnectionMultiplexer>();
|
||||
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<AuthorizationCacheOptions>(_ => { });
|
||||
services.AddRedisSecurity(connectionString, environment);
|
||||
services.AddBusinessCaching(environment, true);
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@
|
||||
<PackageReference Include="Serilog.AspNetCore"/>
|
||||
<PackageReference Include="Serilog.Settings.Configuration"/>
|
||||
<PackageReference Include="Serilog.Sinks.Console"/>
|
||||
<PackageReference Include="ZiggyCreatures.FusionCache.OpenTelemetry"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -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<ObjectStorageOptions>(
|
||||
builder.Configuration.GetSection(ObjectStorageOptions.SectionName));
|
||||
builder.Services.Configure<AliyunOssOptions>(builder.Configuration.GetSection(AliyunOssOptions.SectionName));
|
||||
|
||||
@@ -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:<environment>: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` 支持三种当前实现模式:
|
||||
|
||||
@@ -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:<environment>: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 为绿色不等于认证授权、跨租户隔离或后台任务恢复演练已通过,发布仍需执行对应集成测试。
|
||||
|
||||
Reference in New Issue
Block a user