perf: optimize authorization scoreline and workers

This commit is contained in:
2026-07-30 09:12:27 +08:00
parent 4bea745b79
commit bedc77bffd
52 changed files with 21911 additions and 347 deletions

View File

@@ -0,0 +1,14 @@
using Microsoft.AspNetCore.OutputCaching;
using Tiku.Application.Tenancy;
namespace Tiku.Api.Caching;
internal sealed class TenantPublicCacheInvalidator(IOutputCacheStore outputCacheStore)
: ITenantPublicCacheInvalidator
{
public ValueTask InvalidateCoreAsync(Guid tenantId, CancellationToken cancellationToken) =>
outputCacheStore.EvictByTagAsync($"tenant:{tenantId:N}", cancellationToken);
public async Task InvalidateAsync(Guid tenantId, CancellationToken cancellationToken = default) =>
await InvalidateCoreAsync(tenantId, cancellationToken);
}

View File

@@ -0,0 +1,48 @@
using Microsoft.AspNetCore.OutputCaching;
using Microsoft.Extensions.Primitives;
using Tiku.Application.Security;
namespace Tiku.Api.Caching;
internal sealed class TenantPublicOutputCachePolicy : IOutputCachePolicy
{
public ValueTask CacheRequestAsync(OutputCacheContext context, CancellationToken cancellationToken)
{
var request = context.HttpContext.Request;
var tenantId = context.HttpContext.RequestServices.GetRequiredService<ITenantContext>().TenantId;
var enabled = HttpMethods.IsGet(request.Method) &&
tenantId.HasValue &&
StringValues.IsNullOrEmpty(request.Headers.Authorization) &&
context.HttpContext.User.Identity?.IsAuthenticated != true;
context.EnableOutputCaching = enabled;
context.AllowCacheLookup = enabled;
context.AllowCacheStorage = enabled;
context.AllowLocking = true;
if (enabled)
{
var resolvedTenantId = tenantId.GetValueOrDefault();
context.CacheVaryByRules.QueryKeys = "*";
context.CacheVaryByRules.HeaderNames = new StringValues("Accept-Encoding");
context.CacheVaryByRules.VaryByValues["tenant"] = resolvedTenantId.ToString("N");
context.Tags.Add($"tenant:{resolvedTenantId:N}");
}
return ValueTask.CompletedTask;
}
public ValueTask ServeFromCacheAsync(OutputCacheContext context, CancellationToken cancellationToken) =>
ValueTask.CompletedTask;
public ValueTask ServeResponseAsync(OutputCacheContext context, CancellationToken cancellationToken)
{
var response = context.HttpContext.Response;
if (response.StatusCode != StatusCodes.Status200OK ||
!StringValues.IsNullOrEmpty(response.Headers.SetCookie) ||
context.HttpContext.User.Identity?.IsAuthenticated == true)
{
context.AllowCacheStorage = false;
}
return ValueTask.CompletedTask;
}
}

View File

@@ -22,9 +22,11 @@ public static class ApplicationBuilderExtensions
}
app.UseSerilogRequestLogging(SerilogRequestLogging.ConfigureRequestLogging);
app.UseMiddleware<DatabaseRequestMetricsMiddleware>();
app.UseMiddleware<ExceptionHandlingMiddleware>();
app.UseForwardedHeaders();
app.UseHttpsRedirection();
app.UseResponseCompression();
app.UseDefaultFiles();
app.UseStaticFiles();
app.UseRouting();
@@ -37,6 +39,7 @@ public static class ApplicationBuilderExtensions
app.UseMiddleware<CurrentPrincipalMiddleware>();
app.UseAuthorization();
app.UseMiddleware<SaasFeatureAccessMiddleware>();
app.UseOutputCache();
app.MapControllers();
return app;

View File

@@ -209,7 +209,11 @@ internal static class AuthenticationExtensions
if (session is null)
{
context.Fail("Session, identity, membership, tenant or role state is no longer valid.");
return;
}
context.HttpContext.RequestServices.GetRequiredService<IRequestSecurityState>()
.SetValidatedSession(session);
}
private static async Task WriteTenantConflictChallengeAsync(JwtBearerChallengeContext context)

View File

@@ -3,6 +3,12 @@ using Tiku.Application;
using Tiku.Infrastructure;
using Tiku.Infrastructure.Messaging;
using Tiku.Infrastructure.Security;
using Tiku.Api.Caching;
using Microsoft.AspNetCore.ResponseCompression;
using System.IO.Compression;
using Tiku.Api.Observability;
using Tiku.Application.Tenancy;
using Microsoft.Extensions.DependencyInjection.Extensions;
namespace Tiku.Api.Configuration;
@@ -18,6 +24,9 @@ public static class DependencyInjection
builder.Services.AddApiPresentation();
builder.Services.AddHealthChecks();
builder.Services.AddApiObservability(builder.Configuration, builder.Environment);
builder.Services.AddSingleton<OutboxBacklogSnapshot>();
builder.Services.AddHostedService<OutboxBacklogMonitor>();
builder.Services.AddApplication();
builder.Services.AddNetworkConfiguration(builder.Configuration, builder.Environment);
builder.Services.AddApiRateLimiting(builder.Configuration);
@@ -42,6 +51,31 @@ public static class DependencyInjection
throw new InvalidOperationException(
"Redis is required in Production. Configure ConnectionStrings:Redis or REDIS_URL.");
}
builder.Services.AddOutputCache(options =>
{
options.AddPolicy("TenantPublic", new TenantPublicOutputCachePolicy());
options.DefaultExpirationTimeSpan = TimeSpan.FromSeconds(60);
});
builder.Services.RemoveAll<ITenantPublicCacheInvalidator>();
builder.Services.AddSingleton<ITenantPublicCacheInvalidator, TenantPublicCacheInvalidator>();
if (builder.Environment.IsProduction() && !string.IsNullOrWhiteSpace(redisConnectionString))
{
builder.Services.AddStackExchangeRedisOutputCache(options =>
{
options.Configuration = redisConnectionString;
options.InstanceName = $"tiku:{builder.Environment.EnvironmentName.ToLowerInvariant()}:output:";
});
}
builder.Services.AddResponseCompression(options =>
{
options.EnableForHttps = true;
options.Providers.Add<BrotliCompressionProvider>();
options.Providers.Add<GzipCompressionProvider>();
options.MimeTypes = ResponseCompressionDefaults.MimeTypes.Concat(["application/json"]);
});
builder.Services.Configure<BrotliCompressionProviderOptions>(options => options.Level = CompressionLevel.Fastest);
builder.Services.Configure<GzipCompressionProviderOptions>(options => options.Level = CompressionLevel.Fastest);
var messaging = builder.Configuration.GetSection("RabbitMq").Get<MessagingOptions>() ?? new MessagingOptions();
builder.Services.AddOptions<MessagingOptions>()
.Bind(builder.Configuration.GetSection("RabbitMq"))

View File

@@ -0,0 +1,46 @@
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
using Tiku.Infrastructure.Observability;
namespace Tiku.Api.Configuration;
internal static class ObservabilityExtensions
{
internal static IServiceCollection AddApiObservability(
this IServiceCollection services,
IConfiguration configuration,
IHostEnvironment environment)
{
var endpoint = configuration["OpenTelemetry:OtlpEndpoint"];
var hasOtlpEndpoint = Uri.TryCreate(endpoint, UriKind.Absolute, out var endpointUri);
services.AddOpenTelemetry()
.ConfigureResource(resource => resource.AddService(
serviceName: environment.ApplicationName,
serviceVersion: typeof(ObservabilityExtensions).Assembly.GetName().Version?.ToString()))
.WithTracing(tracing => tracing
.AddAspNetCoreInstrumentation(options =>
options.Filter = context => !context.Request.Path.StartsWithSegments("/api/health"))
.AddHttpClientInstrumentation()
.AddSource("Npgsql")
.ApplyIf(hasOtlpEndpoint, builder => builder.AddOtlpExporter(options => options.Endpoint = endpointUri!)))
.WithMetrics(metrics => metrics
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddMeter(DatabasePerformanceTelemetry.MeterName, "Tiku.Security.Redis", "Tiku.Messaging", "Npgsql")
.ApplyIf(hasOtlpEndpoint, builder => builder.AddOtlpExporter(options => options.Endpoint = endpointUri!)));
return services;
}
private static TBuilder ApplyIf<TBuilder>(this TBuilder builder, bool condition, Action<TBuilder> configure)
{
if (condition)
{
configure(builder);
}
return builder;
}
}

View File

@@ -57,6 +57,12 @@ public sealed class ScorelineQueryDto
[Range(1, 2000)]
public int? Limit { get; set; }
/// <summary>
/// 版本化的不透明游标,仅用于游标分页接口。
/// </summary>
[StringLength(2000)]
public string? Cursor { get; set; }
public ScorelineFilter ToFilter(
Guid tenantId,
IReadOnlyCollection<ScorelineDynamicFilter>? dynamicFilters = null)

View File

@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.OutputCaching;
using Microsoft.EntityFrameworkCore;
using Tiku.Application.Assets;
using Tiku.Api.Contracts;
@@ -18,6 +19,7 @@ namespace Tiku.Api.Controllers;
[AllowAnonymous]
[Produces("application/json")]
[Route("api/catalog")]
[OutputCache(PolicyName = "TenantPublic")]
public sealed class CatalogController(
ICatalogQueryService catalogQueryService,
IContentNavigationQueryService contentNavigationQueryService,

View File

@@ -4,9 +4,9 @@ using Tiku.Api.Contracts;
using Tiku.Application.Security;
using Tiku.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
using MassTransit.EntityFrameworkCoreIntegration;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Tiku.Infrastructure.Messaging;
using Tiku.Api.Observability;
namespace Tiku.Api.Controllers;
@@ -19,7 +19,8 @@ public sealed class HealthController(
TikuDbContext dbContext,
IRedisSecurityStore redisSecurityStore,
MessagingOptions messagingOptions,
HealthCheckService healthCheckService) : ControllerBase
HealthCheckService healthCheckService,
OutboxBacklogSnapshot outboxSnapshot) : ControllerBase
{
[HttpGet]
[EndpointSummary("健康检查")]
@@ -43,17 +44,8 @@ public sealed class HealthController(
registration => registration.Tags.Contains("ready"),
cancellationToken);
var rabbitMq = !messagingOptions.IsConfigured || rabbitHealth.Status == HealthStatus.Healthy;
var outboxPending = database
? await dbContext.Set<OutboxMessage>().CountAsync(cancellationToken)
: -1;
var outboxOldestSentTime = database
? await dbContext.Set<OutboxMessage>()
.Select(message => (DateTime?)message.SentTime)
.MinAsync(cancellationToken)
: null;
var outboxOldestAgeSeconds = outboxOldestSentTime is null
? 0
: Math.Max(0, (DateTimeOffset.UtcNow - new DateTimeOffset(outboxOldestSentTime.Value)).TotalSeconds);
var outboxPending = outboxSnapshot.Pending;
var outboxOldestAgeSeconds = outboxSnapshot.OldestAgeSeconds;
var outboxAlert = outboxPending >= messagingOptions.OutboxBacklogAlertCount ||
outboxOldestAgeSeconds >= messagingOptions.OutboxOldestMessageAlertSeconds;
var ready = database && redis && rabbitMq;

View File

@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.OutputCaching;
using Tiku.Application.Security;
using Tiku.Application.Tenancy;
@@ -15,6 +16,7 @@ public sealed class RuntimeController(
ITenantFrontendConfigService frontendConfigService) : ControllerBase
{
[HttpGet("bootstrap")]
[OutputCache(PolicyName = "TenantPublic")]
[EndpointSummary("获取租户前端运行时配置")]
[ProducesResponseType<TenantRuntimeBootstrap>(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status304NotModified)]

View File

@@ -1,5 +1,6 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.OutputCaching;
using Microsoft.EntityFrameworkCore;
using Tiku.Api.Contracts;
using Tiku.Application.Catalog;
@@ -24,6 +25,7 @@ public sealed class ScorelineController(
private static readonly string[] DynamicPrefixes = ["field.", "min.", "max."];
[HttpGet("fields")]
[OutputCache(PolicyName = "TenantPublic")]
[EndpointSummary("查询分数线字段配置")]
[ProducesResponseType<CatalogList<ScorelineFieldItem>>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
@@ -49,6 +51,20 @@ public sealed class ScorelineController(
cancellationToken));
}
[HttpGet("records/cursor")]
[EndpointSummary("游标分页查询分数线记录")]
[ProducesResponseType<ScorelineRecordCursorPage>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
public async Task<ActionResult<ScorelineRecordCursorPage>> GetRecordsCursor(
[FromQuery] ScorelineQueryDto query,
CancellationToken cancellationToken)
{
return Ok(await scorelineQueryService.GetRecordsCursorAsync(
query.ToFilter(await ResolveTenantIdAsync(query, cancellationToken), ResolveDynamicFilters()),
query.Cursor,
cancellationToken));
}
[HttpGet("trend")]
[EndpointSummary("查询历年分数线趋势")]
[ProducesResponseType<CatalogList<ScorelineRecordItem>>(StatusCodes.Status200OK)]
@@ -63,6 +79,7 @@ public sealed class ScorelineController(
}
[HttpGet("years")]
[OutputCache(PolicyName = "TenantPublic")]
[EndpointSummary("查询分数线可用年份")]
[ProducesResponseType<CatalogList<int>>(StatusCodes.Status200OK)]
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]

View File

@@ -0,0 +1,12 @@
using Tiku.Infrastructure.Observability;
namespace Tiku.Api.Middleware;
public sealed class DatabaseRequestMetricsMiddleware(RequestDelegate next)
{
public async Task InvokeAsync(HttpContext context)
{
using var metrics = DatabaseRequestMetrics.Begin();
await next(context);
}
}

View File

@@ -0,0 +1,66 @@
using System.Diagnostics.Metrics;
using Npgsql;
using Tiku.Infrastructure.Messaging;
namespace Tiku.Api.Observability;
public sealed class OutboxBacklogSnapshot
{
private static readonly Meter Meter = new("Tiku.Messaging", "1.0.0");
private long pending;
private double oldestAgeSeconds;
public OutboxBacklogSnapshot()
{
Meter.CreateObservableGauge("tiku.outbox.pending", () => Interlocked.Read(ref pending));
Meter.CreateObservableGauge("tiku.outbox.oldest_age", () => Volatile.Read(ref oldestAgeSeconds), "s");
}
internal long Pending => Interlocked.Read(ref pending);
internal double OldestAgeSeconds => Volatile.Read(ref oldestAgeSeconds);
internal void Update(long count, DateTime? oldestSentTime)
{
Interlocked.Exchange(ref pending, count);
Volatile.Write(ref oldestAgeSeconds, oldestSentTime is null
? 0
: Math.Max(0, (DateTimeOffset.UtcNow - new DateTimeOffset(oldestSentTime.Value)).TotalSeconds));
}
}
internal sealed class OutboxBacklogMonitor(
NpgsqlDataSource dataSource,
OutboxBacklogSnapshot snapshot,
MessagingOptions messagingOptions,
ILogger<OutboxBacklogMonitor> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
if (messagingOptions.IsConfigured)
{
await using var command = dataSource.CreateCommand(
"SELECT count(*), min(sent_time) FROM outbox_message;");
await using var reader = await command.ExecuteReaderAsync(stoppingToken);
if (await reader.ReadAsync(stoppingToken))
{
snapshot.Update(reader.GetInt64(0), reader.IsDBNull(1) ? null : reader.GetDateTime(1));
}
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception exception)
{
logger.LogWarning(exception, "Outbox backlog metric collection failed.");
}
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
}
}
}

View File

@@ -16,7 +16,12 @@
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="Microsoft.AspNetCore.OutputCaching.StackExchangeRedis" />
<PackageReference Include="Microsoft.OpenApi" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" />
<PackageReference Include="Scalar.AspNetCore" />
<PackageReference Include="Serilog.AspNetCore" />
<PackageReference Include="Serilog.Enrichers.Environment" />

View File

@@ -26,6 +26,9 @@
"Application": "Tiku.Api"
}
},
"OpenTelemetry": {
"OtlpEndpoint": ""
},
"Cors": {
"AllowedOrigins": [],
"AllowedHeaders": [