Hangfire 任务调度深度实战:从即时任务到卫星断点续传

适用版本 :Hangfire 1.8.x / .NET 8 +

项目场景 :船舶PMS系统报表生成、数据同步、定时提醒、后台批处理

阅读时间:约 35 分钟


目录

  1. 为什么需要后台任务调度
  2. [Hangfire 架构与核心概念](#Hangfire 架构与核心概念)
  3. 快速集成
  4. 五种任务类型
  5. 持久化与存储
  6. [Dashboard 与监控](#Dashboard 与监控)
  7. 依赖注入与作用域
  8. 错误处理与自动重试
  9. [批处理与 Continuations](#批处理与 Continuations)
  10. [PMS 场景实战:报表导出系统](#PMS 场景实战:报表导出系统)
  11. [PMS 场景实战:船岸同步调度器](#PMS 场景实战:船岸同步调度器)
  12. [PMS 场景实战:定时维保提醒](#PMS 场景实战:定时维保提醒)
  13. 性能调优与生产部署
  14. 测试策略
  15. 常见陷阱与最佳实践
  16. Checklist

1. 为什么需要后台任务调度

1.1 请求-响应之外的世界

Web 应用中不是所有操作都适合在 HTTP 请求内完成:

场景 为什么不能在请求中做 PMS 示例
耗时计算 用户等不了30秒以上 年度维修报表导出(30万行数据)
定时执行 没有HTTP请求触发 设备维保到期提醒、库存低水位预警
异步解耦 主流程不应等待副作用 审批通过后发邮件通知
失败重试 网络抖动需要自动重试 船岸数据同步失败后自动重传
批量处理 需要限流、分批执行 月末所有船舶物料成本核算
延迟执行 将来某个时间执行 提交申请24小时未审批自动催办

1.2 Hangfire vs 其他方案

方案 持久化 可视化 分布式 Cron 复杂度
BackgroundService ❌ 需自己实现 需自己实现
IHostedService + Timer 需自己实现
Quartz.NET
Hangfire ✅ Dashboard
Azure WebJobs 中(绑定Azure)
Hangfire + Redis/PostgreSQL

Hangfire 的独特优势:开箱即用的 Dashboard 、** fire-and-forget 即发即弃**、自动重试持久化透明.NET 生态集成最好

💬 互动一下:你在项目中用过哪些后台任务方案?有没有遇到过"进程重启后后台任务丢失"的事故?


2. Hangfire 架构与核心概念

2.1 架构组件

复制代码
┌──────────────────────────────────────────────────┐
│                  Client(客户端)                  │
│  BackgroundJob.Enqueue / Schedule / AddOrUpdate  │
└─────────────────────┬────────────────────────────┘
                      │ 创建 Job 记录
                      ▼
┌──────────────────────────────────────────────────┐
│              Job Storage(持久化)                 │
│  ┌─────────┬──────────┬──────────┬────────────┐  │
│  │  Job表   │  State表  │  Set表   │  Counter表  │  │
│  └─────────┴──────────┴──────────┴────────────┘  │
│  (SQL Server / PostgreSQL / Redis / SQLite)      │
└──────────────────────────────────────────────────┘
                      ▲
                      │ 轮询获取 Job
                      │
┌─────────────────────┴────────────────────────────┐
│              Server(服务端)                      │
│  ┌──────────────────────────────────────────────┐│
│  │ Worker × N(从 Storage 拉取 Job 执行)        ││
│  │ RecurringJobScheduler(Cron 调度)            ││
│  │ SchedulePoller(延迟任务触发)                 ││
│  │ Heartbeat(心跳保活)                         ││
│  └──────────────────────────────────────────────┘│
└──────────────────────────────────────────────────┘
                      │
                      ▼
┌──────────────────────────────────────────────────┐
│              Dashboard(可视化面板)               │
│  实时查看 / 手动触发 / 重跑 / 删除 / 监控         │
└──────────────────────────────────────────────────┘

2.2 任务状态机

复制代码
                    ┌──────────┐
          创建 ────▶│  Enqueued │
                    └─────┬────┘
                          │ Worker 取走
                          ▼
                    ┌──────────┐
                    │ Processing│◀───── 重试
                    └─────┬────┘
                     ┌────┴────┐
                     ▼         ▼
               ┌──────────┐ ┌──────────┐
               │ Succeeded│ │  Failed  │
               └──────────┘ └─────┬────┘
                                  │ 手动重试
                                  ▼
                            回到 Enqueued

延迟任务:Scheduled →(时间到)→ Enqueued
周期任务:通过 RecurringJobScheduler 定期创建 Enqueued 实例

2.3 关键概念

概念 说明
Job 一个可执行的工作单元(方法调用)
Background Job 即发即弃的异步任务
Scheduled Job 延迟执行的任务
Recurring Job 按 Cron 表达式重复执行
Continuations 前驱任务完成后执行后续任务
Batch 一组任务作为整体管理(Hangfire.Pro
Worker 执行 Job 的后台线程
Queue 任务队列,可按优先级分队列
State Job 当前状态(Enqueued/Processing/Failed等)
Filter Job 执行的 AOP 过滤器
Server 运行 Worker 的进程

3. 快速集成

3.1 安装

bash 复制代码
dotnet add package Hangfire
dotnet add package Hangfire.AspNetCore
dotnet add package Hangfire.SqlServer  # 或 PostgreSQL/Redis
dotnet add package Hangfire.Console    # Dashboard 增强(可选)

3.2 最小配置

csharp 复制代码
var builder = WebApplication.CreateBuilder(args);

// 注册 Hangfire 服务
builder.Services.AddHangfire(config =>
{
    config.UseSqlServerStorage(
        builder.Configuration.GetConnectionString("Hangfire"),
        new SqlServerStorageOptions
        {
            PrepareSchemaIfNecessary = true,
            QueuePollInterval = TimeSpan.FromSeconds(15),
            SlidingInvisibilityTimeout = TimeSpan.FromMinutes(5),
            CommandBatchMaxTimeout = TimeSpan.FromMinutes(5),
            UseRecommendedIsolationLevel = true,
            DisableGlobalLocks = true
        });

    // 启用 Console 日志
    config.UseConsole();
});

// 添加 Hangfire Server(Worker 进程)
builder.Services.AddHangfireServer(options =>
{
    options.WorkerCount = Environment.ProcessorCount * 2;
    options.Queues = new[] { "critical", "default", "batch" };
    options.ServerName = $"pms-server-{Environment.MachineName}";
    options.SchedulePollingInterval = TimeSpan.FromSeconds(15);
    options.HeartbeatInterval = TimeSpan.FromSeconds(30);
});

var app = builder.Build();

// 启用 Dashboard
app.UseHangfireDashboard("/hangfire", new DashboardOptions
{
    DashboardTitle = "PMS 任务调度中心",
    DisplayStorageConnectionString = false,
    AppPath = "/",
    IsReadOnlyFunc = ctx => !ctx.User.IsInRole("Administrator"),
    Authorization = new[]
    {
        new HangfireDashboardAuthFilter()
    }
});

app.Run();

3.3 Dashboard 安全

csharp 复制代码
public class HangfireDashboardAuthFilter : IDashboardAuthorizationFilter
{
    public bool Authorize(DashboardContext context)
    {
        var httpContext = context.GetHttpContext();

        // 仅允许管理员访问
        if (!httpContext.User.Identity?.IsAuthenticated ?? true)
            return false;

        return httpContext.User.IsInRole("Administrator");
    }
}

3.4 数据库初始化

Hangfire 首次启动会自动创建表结构(约 20 张表,都以 Hangfire. 前缀):

复制代码
HangFire.AggregatedCounter    HangFire.Hash
HangFire.Counter              HangFire.Job
HangFire.JobParameter         HangFire.JobQueue
HangFire.List                 HangFire.Server
HangFire.Set                  HangFire.State
HangFire.Schema               HangFire._MigrationLog

生产环境建议:

  • 为 Hangfire 创建独立数据库或独立 Schema
  • 数据库连接字符串使用专用账号(最小权限)
  • 不要和业务数据库混用(避免锁竞争)

4. 五种任务类型

4.1 Fire-and-Forget(即发即弃)

csharp 复制代码
// 最基础:立即执行
BackgroundJob.Enqueue<IEmailService>(service =>
    service.SendAsync("user@example.com", "欢迎", "欢迎使用PMS系统"));

// 指定队列(不同优先级)
BackgroundJob.Create<ISyncService>(service =>
    service.SyncShipDataAsync(shipId, CancellationToken.None))
    .WithParameter("Queue", "critical")
    .Enqueue();

// 从 IBackgroundJobClient 注入(推荐,可测试)
public class ApplyAppService
{
    private readonly IBackgroundJobClient _backgroundJobs;

    public ApplyAppService(IBackgroundJobClient backgroundJobs)
    {
        _backgroundJobs = backgroundJobs;
    }

    public void OnApproved(Guid applyId)
    {
        // 审批通过后,后台异步发通知
        _backgroundJobs.Enqueue<INotificationService>(svc =>
            svc.NotifyApprovalAsync(applyId));

        // 后台异步生成PDF
        _backgroundJobs.Enqueue<IPdfGenerator>(svc =>
            svc.GenerateApplyPdfAsync(applyId));
    }
}

4.2 Delayed(延迟执行)

csharp 复制代码
// 24小时后执行(自动催办)
BackgroundJob.Schedule<INotificationService>(service =>
    service.SendReminderAsync(applyId, "pending_approval"),
    TimeSpan.FromHours(24));

// 指定具体时间
var remindAt = new DateTimeOffset(2026, 9, 1, 9, 0, 0, TimeSpan.Zero);
BackgroundJob.Schedule<IMaintenanceService>(service =>
    service.CheckUpcomingMaintenanceAsync(),
    remindAt);

4.3 Recurring(周期任务)

csharp 复制代码
// 每天凌晨2点执行库存预警
RecurringJob.AddOrUpdate<IInventoryAlertService>(
    "inventory-daily-alert",
    service => service.CheckLowStockAsync(),
    "0 2 * * *",  // Cron 表达式:分 时 日 月 周
    TimeZoneInfo.FindSystemTimeZoneById("China Standard Time"),
    queue: "batch");

// 每周一早上8点生成周报
RecurringJob.AddOrUpdate<IReportService>(
    "weekly-maintenance-report",
    service => service.GenerateWeeklyReportAsync(),
    "0 8 * * 1",
    TimeZoneInfo.FindSystemTimeZoneById("China Standard Time"));

// 每月1号凌晨3点月结
RecurringJob.AddOrUpdate<IBillingService>(
    "monthly-billing",
    service => service.MonthlySettlementAsync(),
    "0 3 1 * *",
    TimeZoneInfo.FindSystemTimeZoneById("China Standard Time"),
    queue: "batch");

// 每15分钟同步一次船舶状态
RecurringJob.AddOrUpdate<ISyncService>(
    "ship-status-sync",
    service => service.SyncAllShipsStatusAsync(),
    "*/15 * * * *");

// 每小时清理过期临时文件
RecurringJob.AddOrUpdate<ICleanupService>(
    "temp-file-cleanup",
    service => service.CleanupTempFilesAsync(),
    "0 * * * *");

Cron 表达式速查:

表达式 含义
* * * * * 每分钟
*/5 * * * * 每5分钟
0 * * * * 每小时整点
0 2 * * * 每天凌晨2点
0 9 * * 1-5 工作日早上9点
0 0 1 * * 每月1号午夜
0 0 ? * MON 每周一午夜

4.4 Continuations(后续任务)

csharp 复制代码
// 第一步:导出数据
var exportJobId = BackgroundJob.Enqueue<IReportService>(service =>
    service.ExportRawDataAsync(DateTime.Now.AddMonths(-1), DateTime.Now));

// 第二步:导出完成后生成PDF
BackgroundJob.ContinueJobWith<IPdfGenerator>(
    exportJobId,
    generator => generator.GenerateReportPdfAsync(exportJobId));

// 第三步:PDF生成后发送邮件
BackgroundJob.ContinueJobWith<IEmailService>(
    parentJobId: exportJobId,  // 注意:挂在第一步之后
    continuation: emailService => emailService.SendReportEmailAsync(
        "manager@example.com", exportJobId));

// 只有前驱任务成功才执行(默认)
BackgroundJob.ContinueJobWith<INotificationService>(
    jobId,
    svc => svc.NotifySuccessAsync(),
    JobContinuationOptions.OnlyOnSucceededState);

// 只有前驱任务失败才执行
BackgroundJob.ContinueJobWith<INotificationService>(
    jobId,
    svc => svc.NotifyFailureAsync(),
    JobContinuationOptions.OnlyOnFailedState);

4.5 Batch(批处理)

Batch 是 Hangfire Pro 的功能,开源版可以用 Continuations 组合实现类似效果:

csharp 复制代码
// 开源版模拟 Batch:多个任务完成后触发汇总
var jobIds = new List<string>();

foreach (var shipId in shipIds)
{
    var jobId = BackgroundJob.Enqueue<ISyncService>(svc =>
        svc.SyncShipDataAsync(shipId, CancellationToken.None));
    jobIds.Add(jobId);
}

// 用一个"协调任务"等待所有任务完成
BackgroundJob.Enqueue(() =>
    WaitForAllAndAggregate(jobIds));

5. 持久化与存储

5.1 存储方案选择

存储 优势 劣势 适用
SQL Server 稳定、生态好 需要License Windows技术栈
PostgreSQL 免费、性能好 - Linux/容器化
Redis 极高性能 内存成本、持久化需配置 高吞吐、任务量大
SQLite 零配置、嵌入式 并发性能有限 开发/船端本地

5.2 PostgreSQL 存储

bash 复制代码
dotnet add package Hangfire.PostgreSql
csharp 复制代码
builder.Services.AddHangfire(config =>
{
    config.UsePostgreSqlStorage(
        builder.Configuration.GetConnectionString("HangfirePg"),
        new PostgreSqlStorageOptions
        {
            QueuePollInterval = TimeSpan.FromSeconds(15),
            InvisibilityTimeout = TimeSpan.FromMinutes(5),
            DistributedLockTimeout = TimeSpan.FromMinutes(2),
            TransactionSynchronisationTimeout =
                TimeSpan.FromMinutes(2),
            SchemaName = "hangfire"
        });
});

5.3 Redis 存储

bash 复制代码
dotnet add package Hangfire.Redis.StackExchange
csharp 复制代码
config.UseRedisStorage(
    builder.Configuration.GetConnectionString("Redis"),
    new RedisStorageOptions
    {
        Prefix = "hangfire:pms:",
        Db = 2,
        InvisibilityTimeout = TimeSpan.FromMinutes(5),
        FetchTimeout = TimeSpan.FromMinutes(3)
    });

5.4 多实例部署

Hangfire 天然支持多 Server 实例:

复制代码
┌──────────┐     ┌──────────┐     ┌──────────┐
│ Server 1 │     │ Server 2 │     │ Server 3 │
│ Worker×4 │     │ Worker×4 │     │ Worker×4 │
└────┬─────┘     └────┬─────┘     └────┬─────┘
     │                │                │
     └────────────────┼────────────────┘
                      ▼
            ┌─────────────────┐
            │  Job Storage     │
            │  (SQL/Redis)    │
            └─────────────────┘

多个 Server 共享同一个 Storage,通过分布式锁保证 Job 不被重复执行。Worker 数量和队列可以不同:

csharp 复制代码
// 船岸同步服务:只处理 critical 队列
builder.Services.AddHangfireServer(options =>
{
    options.Queues = new[] { "critical" };
    options.WorkerCount = 8;
    options.ServerName = "sync-worker";
});

// 报表服务:处理 batch 队列
builder.Services.AddHangfireServer(options =>
{
    options.Queues = new[] { "batch" };
    options.WorkerCount = 2;  // 报表耗资源,少几个Worker
    options.ServerName = "report-worker";
});

6. Dashboard 与监控

6.1 Dashboard 功能

  • Jobs:查看所有任务,按状态筛选
  • Recurring Jobs:管理周期任务(手动触发/禁用)
  • Servers:查看活跃的 Worker 节点
  • Retries:重试中的任务
  • Succeeded/Failed:成功/失败记录
  • 实时图表:任务吞吐量、失败率

6.2 自定义监控页面

csharp 复制代码
// 在 Dashboard 中添加自定义统计
app.UseHangfireDashboard("/hangfire", new DashboardOptions
{
    StatsPollingInterval = 5000,
    DashboardTitle = "PMS 任务调度中心",
    Authorization = new[] { new HangfireDashboardAuthFilter() },
    FaviconPath = "/favicon.ico"
});

// 通过 Hangfire API 获取统计
public class JobStatsService
{
    public JobStats GetStats()
    {
        var monitor = JobStorage.Current.GetMonitoringApi();

        var stats = monitor.GetStatistics();
        var queues = monitor.Queues();

        return new JobStats
        {
            Succeeded = stats.Succeeded,
            Failed = stats.Failed,
            Enqueued = stats.Enqueued,
            Scheduled = stats.Scheduled,
            Processing = stats.Processing,
            Servers = stats.Servers,
            Queues = queues.Select(q => new QueueStats
            {
                Name = q.Name,
                Length = q.Length,
                Fetched = q.Fetched
            }).ToList()
        };
    }
}

6.3 告警接入

csharp 复制代码
// Job 失败时发送告警
public class FailureAlertFilter : JobFilterAttribute, IApplyStateFilter
{
    private readonly IAlertService _alerts;

    public void OnStateApplied(
        ApplyStateContext context, IWriteOnlyTransaction transaction)
    {
        if (context.NewState is FailedState failed)
        {
            _alerts.SendCriticalAsync(
                "Hangfire任务失败",
                $"任务: {context.BackgroundJob.Job.Method.Name}\n" +
                $"异常: {failed.Exception.Message}\n" +
                $"时间: {DateTime.Now:yyyy-MM-dd HH:mm:ss}",
                "hangfire");
        }
    }
}

// 全局注册
GlobalJobFilters.Filters.Add(new FailureAlertFilter());

7. 依赖注入与作用域

7.1 Job 中的 DI

Hangfire 使用 JobActivator 从 DI 容器解析 Job 类型。ASP.NET Core 集成默认使用 AspNetCoreJobActivator

csharp 复制代码
// ✅ 正确:通过接口注入,每个 Job 执行时创建独立 Scope
public class ReportGenerationJob
{
    private readonly IServiceProvider _serviceProvider;

    public ReportGenerationJob(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }

    public async Task ExecuteAsync(Guid reportId)
    {
        // 为每个 Job 创建独立 Scope(和 HTTP 请求隔离)
        using var scope = _serviceProvider.CreateScope();
        var dbContext = scope.ServiceProvider
            .GetRequiredService<PmsDbContext>();
        var emailService = scope.ServiceProvider
            .GetRequiredService<IEmailService>();

        var report = await dbContext.Reports
            .FindAsync(reportId);
        // ... 生成报表
        await emailService.SendReportReadyAsync(report!);
    }
}

// 也可以直接依赖 Scoped 服务,Hangfire 会自动创建 Scope
public class SyncJob
{
    private readonly IRepository<Ship> _shipRepo;
    private readonly ISyncService _syncService;

    // Hangfire 自动解析,每个 Job 执行创建独立 Scope
    public SyncJob(
        IRepository<Ship> shipRepo,
        ISyncService syncService)
    {
        _shipRepo = shipRepo;
        _syncService = syncService;
    }

    public async Task ExecuteAsync(Guid shipId)
    {
        var ship = await _shipRepo.GetByIdAsync(shipId);
        await _syncService.SyncShipAsync(ship!);
    }
}

7.2 注意:不能在 Job 构造函数中注入 Scoped 服务到 Singleton

Hangfire Server 是单例的,但 Job 实例在每次执行时创建。构造函数注入的 Scoped 服务会跟随 Job 实例的 Scope,这是安全的。

7.3 获取 Job 信息

csharp 复制代码
public class MyJob
{
    public void Execute(PerformContext context)
    {
        var jobId = context.BackgroundJob.Id;
        var cancellationToken = context.CancellationToken;
        var connection = context.Connection;

        // 写日志到 Dashboard Console
        context.WriteLine("开始执行...");

        // 设置进度
        context.SetJobParameter("Progress", "50%");
    }
}

8. 错误处理与自动重试

8.1 默认重试策略

Hangfire 默认自动重试10次,指数退避延迟:

复制代码
重试1: 等 ~16秒
重试2: 等 ~32秒
重试3: 等 ~1分钟
重试4: 等 ~2分钟
...
重试10: 等 ~2.3小时

8.2 自定义重试

csharp 复制代码
// 全局配置
GlobalJobFilters.Filters.Add(new AutomaticRetryAttribute
{
    Attempts = 5,
    DelayInSeconds = 30,
    OnAttemptsExceeded = AttemptsExceededAction.Fail
});

// Job 级别配置
[AutomaticRetry(Attempts = 3, DelaysInSeconds = new[] { 30, 60, 120 })]
public class SyncJob
{
    public async Task ExecuteAsync(Guid shipId) { ... }
}

// 不重试
[AutomaticRetry(Attempts = 0)]
public class NonRetryableJob
{
    public void Execute() { ... }
}

// 针对特定异常不重试
public class SmartRetryAttribute : AutomaticRetryAttribute
{
    public override void OnStateElection(ElectStateContext context)
    {
        if (context.CandidateState is FailedState failed)
        {
            if (failed.Exception is BusinessException)
            {
                // 业务异常不重试
                Attempts = 0;
            }
        }
        base.OnStateElection(context);
    }
}

8.3 自定义错误处理过滤器

csharp 复制代码
public class ErrorHandlerFilter : JobFilterAttribute,
    IServerFilter, IElectStateFilter
{
    private readonly ILogger<ErrorHandlerFilter> _logger;

    public void OnStateElection(ElectStateContext context)
    {
        if (context.CandidateState is FailedState failed)
        {
            _logger.LogError(failed.Exception,
                "Job {JobId} 执行失败: {JobName}",
                context.BackgroundJob.Id,
                context.BackgroundJob.Job.Method.Name);

            // 可根据异常类型决定是否告警
            if (failed.Exception is HttpRequestException)
            {
                // 网络异常,重试即可,不告警
                return;
            }

            // 其他异常告警
            _logger.LogCritical("需要立即关注的任务失败!");
        }
    }

    public void OnPerforming(PerformingContext context)
    {
        _logger.LogInformation("开始执行 Job: {JobName}",
            context.BackgroundJob.Job.Method.Name);
    }

    public void OnPerformed(PerformedContext context)
    {
        if (context.Exception != null)
        {
            _logger.LogError(context.Exception, "Job 执行异常");
        }
        else
        {
            _logger.LogInformation("Job 执行成功");
        }
    }
}

9. 批处理与 Continuations

9.1 工作流编排

用 Continuations 编排多步骤任务:

csharp 复制代码
public class ReportWorkflow
{
    public string StartMonthlyReport(int year, int month)
    {
        // 步骤1:收集数据
        var collectId = BackgroundJob.Enqueue<DataCollector>(c =>
            c.CollectMonthlyData(year, month));

        // 步骤2a:数据收集后生成报表
        var generateId = BackgroundJob.ContinueJobWith<ReportGenerator>(
            collectId,
            gen => gen.GenerateReport(year, month));

        // 步骤2b:同时生成图表
        var chartId = BackgroundJob.ContinueJobWith<ChartGenerator>(
            collectId,
            ch => ch.GenerateCharts(year, month));

        // 步骤3:两个都完成后合并PDF
        var mergeId = BackgroundJob.ContinueJobWith<PdfMerger>(
            generateId,
            merger => merger.MergeReport(year, month));

        // 步骤4:发送邮件
        BackgroundJob.ContinueJobWith<EmailSender>(
            mergeId,
            email => email.SendReportToManagement(year, month));

        return collectId;
    }
}

9.2 并行任务

csharp 复制代码
// 多船并行同步,全部完成后汇总
public async Task SyncAllShipsAsync()
{
    var shipIds = await _shipRepo.GetAllShipIdsAsync();
    var syncJobIds = new List<string>();

    foreach (var shipId in shipIds)
    {
        var jobId = BackgroundJob.Enqueue<ShipSyncJob>(job =>
            job.SyncShipAsync(shipId, CancellationToken.None));
        syncJobIds.Add(jobId);
    }

    // 注册一个"门控"任务检查所有任务完成
    BackgroundJob.Schedule(
        () => CheckSyncCompletion(syncJobIds, 0),
        TimeSpan.FromMinutes(2));
}

public void CheckSyncCompletion(List<string> jobIds, int checkCount)
{
    var monitor = JobStorage.Current.GetMonitoringApi();
    var allDone = true;
    var hasFailed = false;

    foreach (var jobId in jobIds)
    {
        var details = monitor.JobDetails(jobId);
        if (details == null) continue;

        var stateName = details.History.FirstOrDefault()?.StateName;
        if (stateName != "Succeeded")
        {
            allDone = false;
            if (stateName == "Failed") hasFailed = true;
        }
    }

    if (allDone)
    {
        if (hasFailed)
        {
            BackgroundJob.Enqueue<INotificationService>(s =>
                s.NotifySyncPartialFailureAsync());
        }
        else
        {
            BackgroundJob.Enqueue<ISummaryService>(s =>
                s.GenerateSyncSummaryAsync());
        }
    }
    else if (checkCount < 30)  // 最多检查1小时
    {
        BackgroundJob.Schedule(
            () => CheckSyncCompletion(jobIds, checkCount + 1),
            TimeSpan.FromMinutes(2));
    }
}

10. PMS 场景实战:报表导出系统

10.1 场景分析

PMS 系统需要导出多种报表:

  • 备件库存明细(可能5万行)
  • 维修历史(可能10万行)
  • 月度成本分析(多船汇总)
  • 设备运行记录(可能30万行)

用户在浏览器点击导出后,不能同步等待。需要:

  1. 创建后台任务
  2. 实时显示进度
  3. 完成后提供下载链接
  4. 失败可重试

10.2 实现

csharp 复制代码
// 报表导出任务
public class ReportExportJob
{
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly IFileStorageService _fileStorage;
    private readonly IHubContext<ReportHub> _hub;
    private readonly ILogger<ReportExportJob> _logger;

    public async Task ExportAsync(
        Guid exportId,
        PerformContext context)
    {
        using var scope = _scopeFactory.CreateScope();
        var dbContext = scope.ServiceProvider
            .GetRequiredService<PmsDbContext>();

        var exportRecord = await dbContext.ReportExports
            .FindAsync(exportId);
        if (exportRecord == null) return;

        exportRecord.Status = ExportStatus.Processing;
        exportRecord.StartedAt = DateTime.UtcNow;
        await dbContext.SaveChangesAsync();

        // 通知前端
        await _hub.Clients.User(exportRecord.UserId)
            .SendAsync("ExportProgress", new
            {
                exportId,
                progress = 0,
                status = "processing"
            });

        try
        {
            // 分批查询,避免大结果集内存溢出
            var batchSize = 5000;
            var totalCount = await GetTotalCountAsync(
                dbContext, exportRecord);
            var batches = (int)Math.Ceiling(
                (double)totalCount / batchSize);

            var filePath = Path.Combine(
                Path.GetTempPath(),
                $"report_{exportId}_{Guid.NewGuid():N}.xlsx");

            using var package = new ExcelPackage(new FileInfo(filePath));
            var worksheet = package.Workbook.Worksheets
                .Add(exportRecord.ReportName);

            // 写表头
            WriteHeader(worksheet, exportRecord.ReportType);

            var row = 2;
            for (int batch = 0; batch < batches; batch++)
            {
                context.CancellationToken.ThrowIfCancellationRequested();

                var data = await GetBatchAsync(
                    dbContext, exportRecord,
                    batch * batchSize, batchSize);

                foreach (var item in data)
                {
                    WriteRow(worksheet, row++, item);
                }

                var progress = (int)((double)(batch + 1) / batches * 100);
                context.WriteLine($"已导出 {row - 2}/{totalCount} 行");

                await _hub.Clients.User(exportRecord.UserId)
                    .SendAsync("ExportProgress", new
                    {
                        exportId,
                        progress,
                        status = "processing",
                        processed = row - 2,
                        total = totalCount
                    });
            }

            await package.SaveAsync();

            // 上传到文件存储
            var downloadUrl = await _fileStorage.UploadAsync(
                filePath, $"reports/{exportRecord.Id}.xlsx");

            exportRecord.Status = ExportStatus.Completed;
            exportRecord.CompletedAt = DateTime.UtcNow;
            exportRecord.DownloadUrl = downloadUrl;
            exportRecord.FileSize = new FileInfo(filePath).Length;
            await dbContext.SaveChangesAsync();

            await _hub.Clients.User(exportRecord.UserId)
                .SendAsync("ExportCompleted", new
                {
                    exportId,
                    progress = 100,
                    downloadUrl,
                    status = "completed"
                });

            // 清理临时文件
            File.Delete(filePath);
        }
        catch (OperationCanceledException)
        {
            exportRecord.Status = ExportStatus.Cancelled;
            await dbContext.SaveChangesAsync();
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "报表导出失败: {ExportId}", exportId);
            exportRecord.Status = ExportStatus.Failed;
            exportRecord.ErrorMessage = ex.Message;
            await dbContext.SaveChangesAsync();

            await _hub.Clients.User(exportRecord.UserId)
                .SendAsync("ExportFailed", new
                {
                    exportId,
                    status = "failed",
                    error = ex.Message
                });

            throw;  // 让 Hangfire 知道失败,可以重试
        }
    }
}

10.3 API 接口

csharp 复制代码
[HttpPost("export")]
public async Task<IActionResult> Export(
    [FromBody] ExportRequest request,
    [FromServices] IBackgroundJobClient backgroundJobs,
    CancellationToken ct)
{
    var exportRecord = new ReportExport
    {
        Id = Guid.NewGuid(),
        UserId = User.GetUserId(),
        ReportType = request.ReportType,
        Parameters = JsonSerializer.Serialize(request.Parameters),
        Status = ExportStatus.Pending,
        CreatedAt = DateTime.UtcNow
    };

    await _dbContext.ReportExports.AddAsync(exportRecord, ct);
    await _dbContext.SaveChangesAsync(ct);

    var jobId = backgroundJobs.Enqueue<ReportExportJob>(job =>
        job.ExportAsync(exportRecord.Id, null!));

    exportRecord.JobId = jobId;
    await _dbContext.SaveChangesAsync(ct);

    return Accepted(new
    {
        exportId = exportRecord.Id,
        jobId,
        status = "pending",
        message = "报表正在后台生成,请稍候..."
    });
}

[HttpGet("export/{id:guid}/status")]
public async Task<IActionResult> GetStatus(Guid id, CancellationToken ct)
{
    var record = await _dbContext.ReportExports
        .AsNoTracking()
        .FirstOrDefaultAsync(r => r.Id == id, ct);

    if (record == null) return NotFound();

    return Ok(new
    {
        record.Id,
        record.Status,
        record.Progress,
        record.DownloadUrl,
        record.ErrorMessage,
        record.CreatedAt,
        record.CompletedAt
    });
}

11. PMS 场景实战:船岸同步调度器

11.1 场景分析

船端PMS需要定期将本地数据同步到岸端:

  • 设备状态变更实时上报(高优先级)
  • 库存数据每15分钟同步(中优先级)
  • 日志数据每天同步一次(低优先级)
  • 网络不可用时自动等待,恢复后自动重试
  • 同步失败需要指数退避重试

11.2 实现

csharp 复制代码
// 船端同步调度器
public class SyncScheduler : BackgroundService
{
    private readonly IBackgroundJobClient _backgroundJobs;
    private readonly IRecurringJobManager _recurringJobs;
    private readonly INetworkMonitor _networkMonitor;
    private readonly ILogger<SyncScheduler> _logger;

    protected override Task ExecuteAsync(CancellationToken stoppingToken)
    {
        // 实时变更:事件触发,立即入队
        _recurringJobs.AddOrUpdate<SyncJob>(
            "sync-realtime-changes",
            job => job.SyncRealtimeChangesAsync(
                CancellationToken.None),
            "*/2 * * * *",  // 每2分钟检查一次待同步变更
            TimeZoneInfo.Local);

        // 库存同步:每15分钟
        _recurringJobs.AddOrUpdate<SyncJob>(
            "sync-inventory",
            job => job.SyncInventoryAsync(CancellationToken.None),
            "*/15 * * * *",
            TimeZoneInfo.Local);

        // 日志同步:每天凌晨3点(低峰期)
        _recurringJobs.AddOrUpdate<SyncJob>(
            "sync-logs",
            job => job.SyncLogsAsync(CancellationToken.None),
            "0 3 * * *",
            TimeZoneInfo.Local,
            queue: "low-priority");

        // 网络状态监控
        _ = MonitorNetworkAsync(stoppingToken);

        return Task.CompletedTask;
    }

    private async Task MonitorNetworkAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            if (await _networkMonitor.IsShoreReachableAsync())
            {
                // 网络恢复:触发一次全量同步
                _backgroundJobs.Enqueue<SyncJob>(job =>
                    job.SyncPendingChangesAsync(CancellationToken.None));
            }

            await Task.Delay(TimeSpan.FromSeconds(30), ct);
        }
    }
}

// 同步任务
public class SyncJob
{
    private readonly ILocalDataQueue _localQueue;
    private readonly IShoreSyncClient _shoreClient;
    private readonly ILogger<SyncJob> _logger;

    [AutomaticRetry(Attempts = 5,
        DelaysInSeconds = new[] { 30, 60, 180, 600, 1800 })]
    public async Task SyncRealtimeChangesAsync(CancellationToken ct)
    {
        var pending = await _localQueue
            .GetPendingChangesAsync(batchSize: 100, ct);

        if (!pending.Any()) return;

        var synced = 0;
        foreach (var change in pending)
        {
            ct.ThrowIfCancellationRequested();

            try
            {
                await _shoreClient.UploadChangeAsync(change, ct);
                await _localQueue.MarkSyncedAsync(change.Id);
                synced++;
            }
            catch (HttpRequestException ex)
            {
                _logger.LogWarning(ex,
                    "同步失败,将由Hangfire重试: {ChangeId}", change.Id);
                throw;  // 触发重试
            }
        }

        _logger.LogInformation("实时同步完成: {Count} 条", synced);
    }

    public async Task SyncInventoryAsync(CancellationToken ct)
    {
        var snapshot = await _localQueue.GetInventorySnapshotAsync(ct);
        await _shoreClient.UploadInventoryAsync(snapshot, ct);
    }

    public async Task SyncLogsAsync(CancellationToken ct)
    {
        // 压缩后上传,节省卫星带宽
        var logs = await _localQueue.GetLogBatchAsync(
            DateTime.Today.AddDays(-1), ct);

        if (!logs.Any()) return;

        var compressed = CompressLogs(logs);
        await _shoreClient.UploadLogsCompressedAsync(compressed, ct);
    }
}

12. PMS 场景实战:定时维保提醒

csharp 复制代码
// 维保提醒服务:每天早上8点检查即将到期的维保任务
public class MaintenanceReminderJob
{
    private readonly IServiceScopeFactory _scopeFactory;
    private readonly IHubContext<NotificationHub> _hub;
    private readonly IEmailService _email;
    private readonly ILogger<MaintenanceReminderJob> _logger;

    public async Task CheckUpcomingMaintenanceAsync(PerformContext context)
    {
        using var scope = _scopeFactory.CreateScope();
        var dbContext = scope.ServiceProvider
            .GetRequiredService<PmsDbContext>();

        var today = DateTime.UtcNow.Date;
        var warningDate = today.AddDays(7);
        var overdueDate = today;

        // 查询7天内到期和已逾期的维保计划
        var upcomingPlans = await dbContext.MaintenancePlans
            .AsNoTracking()
            .Include(p => p.Equipment)
            .Where(p => p.IsActive
                && p.NextDueDate <= warningDate
                && p.Status != MaintenanceStatus.Completed)
            .ToListAsync();

        context.WriteLine($"找到 {upcomingPlans.Count} 条待提醒维保计划");

        var groupedByShip = upcomingPlans
            .GroupBy(p => p.Equipment.ShipId);

        foreach (var group in groupedByShip)
        {
            var shipId = group.Key;
            var shipName = group.First().Equipment.ShipName;
            var overdue = group
                .Where(p => p.NextDueDate < overdueDate)
                .ToList();
            var upcoming = group
                .Where(p => p.NextDueDate >= overdueDate)
                .ToList();

            // 实时通知
            await _hub.Clients
                .Group($"ship-{shipId}")
                .SendAsync("MaintenanceReminder", new
                {
                    shipId,
                    shipName,
                    overdueCount = overdue.Count,
                    upcomingCount = upcoming.Count,
                    overdueItems = overdue.Select(p => new
                    {
                        p.Id,
                        p.Equipment.EquipmentName,
                        p.MaintenanceType,
                        p.NextDueDate,
                        daysOverdue = (today - p.NextDueDate).Days
                    }),
                    upcomingItems = upcoming.Select(p => new
                    {
                        p.Id,
                        p.Equipment.EquipmentName,
                        p.MaintenanceType,
                        p.NextDueDate,
                        daysRemaining = (p.NextDueDate - today).Days
                    })
                });

            // 逾期项发送邮件给轮机长
            if (overdue.Any())
            {
                var chiefEngineer = await dbContext.Users
                    .FirstOrDefaultAsync(u =>
                        u.ShipId == shipId &&
                        u.Role == "ChiefEngineer");

                if (chiefEngineer != null)
                {
                    await _email.SendMaintenanceOverdueAsync(
                        chiefEngineer.Email,
                        shipName,
                        overdue);
                }
            }

            context.WriteLine(
                $"船舶 {shipName}: 逾期 {overdue.Count} 项, " +
                $"即将到期 {upcoming.Count} 项");
        }
    }
}

// 注册为每天早上8点执行(船端本地时间)
RecurringJob.AddOrUpdate<MaintenanceReminderJob>(
    "maintenance-reminder-daily",
    job => job.CheckUpcomingMaintenanceAsync(null!),
    "0 8 * * *",
    TimeZoneInfo.FindSystemTimeZoneById("China Standard Time"));

13. 性能调优与生产部署

13.1 Worker 数量

csharp 复制代码
builder.Services.AddHangfireServer(options =>
{
    // CPU 密集型:ProcessorCount
    // IO 密集型(API调用/数据库):ProcessorCount * 2~4
    options.WorkerCount = Environment.ProcessorCount * 3;

    // 队列优先级顺序:先 critical,再 default,最后 batch
    options.Queues = new[] { "critical", "default", "batch" };

    // 停止时等待 Job 完成的时间
    options.StopTimeout = TimeSpan.FromMinutes(5);

    // 关闭时不再接收新 Job
    options.ShutdownTimeout = TimeSpan.FromMinutes(1);
});

13.2 数据库优化

csharp 复制代码
config.UseSqlServerStorage(connectionString,
    new SqlServerStorageOptions
    {
        // 推荐隔离级别,减少锁
        UseRecommendedIsolationLevel = true,

        // 禁用全局锁(需要数据库支持)
        DisableGlobalLocks = true,

        // 命令批处理超时
        CommandBatchMaxTimeout = TimeSpan.FromMinutes(5),

        // Worker 获取 Job 后不可见时间
        SlidingInvisibilityTimeout = TimeSpan.FromMinutes(5),

        // 队列轮询间隔
        QueuePollInterval = TimeSpan.FromSeconds(15),

        // Job 过期时间(成功的 Job 保留多久)
        JobExpirationCheckInterval = TimeSpan.FromHours(1),

        // 自动清理旧 Job
        CountersAggregateInterval = TimeSpan.FromMinutes(5)
    });

13.3 定期清理

csharp 复制代码
// 每天凌晨4点清理30天前的成功Job
RecurringJob.AddOrUpdate(
    "hangfire-cleanup",
    () => CleanupOldJobs(),
    "0 4 * * *");

public void CleanupOldJobs()
{
    var cutoff = DateTime.UtcNow.AddDays(-30);
    var storage = JobStorage.Current;

    using var connection = storage.GetConnection();
    // Hangfire 会自动按 JobExpirationCheckInterval 清理
    // 但可以手动删除特别老的记录
}

13.4 容器化部署

dockerfile 复制代码
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
WORKDIR /app

FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
# ... build ...

FROM base AS final
WORKDIR /app
COPY --from=build /app/out .

# Hangfire Server 模式(不暴露HTTP端口)
ENV ASPNETCORE_URLS=""
ENTRYPOINT ["dotnet", "Pms.Worker.dll"]

K8s 部署建议:

  • API 进程:启用 Dashboard + Server
  • 独立 Worker 进程:只运行 Hangfire Server,不暴露端口
  • 多副本:至少2个 Worker 副本保证高可用
  • HPA:根据队列长度自动扩缩容
yaml 复制代码
apiVersion: apps/v1
kind: Deployment
metadata:
  name: pms-hangfire-worker
spec:
  replicas: 2
  template:
    spec:
      containers:
      - name: worker
        image: pms-worker:latest
        env:
        - name: Hangfire__WorkerCount
          value: "8"
        resources:
          requests:
            cpu: 500m
            memory: 1Gi
          limits:
            cpu: 2000m
            memory: 2Gi
        livenessProbe:
          exec:
            command: ["dotnet", "healthcheck.dll"]
          initialDelaySeconds: 30
          periodSeconds: 60

13.5 队列长度告警

csharp 复制代码
// 监控队列长度,超过阈值告警
RecurringJob.AddOrUpdate(
    "queue-length-monitor",
    () => MonitorQueueLength(),
    "*/5 * * * *");

public void MonitorQueueLength()
{
    var api = JobStorage.Current.GetMonitoringApi();
    var queues = api.Queues();

    foreach (var queue in queues)
    {
        if (queue.Length > 100)
        {
            // 队列积压,告警
            Log.Warning("Hangfire队列 {Queue} 积压: {Count} 个任务",
                queue.Name, queue.Length);
        }
    }
}

14. 测试策略

14.1 Job 逻辑单元测试

Job 本身只是普通类,可以直接单元测试:

csharp 复制代码
public class ReportExportJobTests
{
    private readonly Mock<IServiceScopeFactory> _scopeFactory;
    private readonly Mock<IFileStorageService> _fileStorage;
    private readonly Mock<IHubContext<ReportHub>> _hub;
    private readonly ReportExportJob _job;

    [Fact]
    public async Task Export_WithValidData_CompletesSuccessfully()
    {
        // Arrange
        var scope = new Mock<IServiceScope>();
        var dbContext = CreateTestDbContext();
        scope.Setup(s => s.ServiceProvider
            .GetService(typeof(PmsDbContext)))
            .Returns(dbContext);
        _scopeFactory.Setup(f => f.CreateScope())
            .Returns(scope.Object);

        // 插入测试数据
        await dbContext.ReportExports.AddAsync(new ReportExport
        {
            Id = Guid.NewGuid(),
            ReportType = "SparePartInventory",
            UserId = "test-user",
            Status = ExportStatus.Pending
        });
        await dbContext.SaveChangesAsync();

        // Act
        await _job.ExportAsync(exportId, MockPerformContext());

        // Assert
        var record = await dbContext.ReportExports
            .FindAsync(exportId);
        record.Status.Should().Be(ExportStatus.Completed);
        record.DownloadUrl.Should().NotBeNullOrEmpty();
    }
}

14.2 集成测试:验证 Job 入队

csharp 复制代码
public class ApplyApprovalIntegrationTests
    : IClassFixture<WebApplicationFactory<Program>>
{
    [Fact]
    public async Task Approve_EnqueuesNotificationJob()
    {
        var client = _factory.CreateClient();

        // 审批操作
        var response = await client.PostAsJsonAsync(
            $"/api/applies/{applyId}/approve",
            new { level = 1, opinion = "同意" });

        response.EnsureSuccessStatusCode();

        // 验证 Job 已创建(通过 JobStorage 检查)
        var monitor = JobStorage.Current.GetMonitoringApi();
        var enqueued = monitor.EnqueuedJobs("default", 0, 10);

        enqueued.Should().Contain(j =>
            j.Value.Job.Method.Name
                .Contains("NotifyApprovalAsync"));
    }
}

15. 常见陷阱与最佳实践

陷阱1:Job 方法捕获局部变量

csharp 复制代码
// ❌ 错误:闭包捕获 DbContext
foreach (var item in items)
{
    BackgroundJob.Enqueue(() => ProcessItem(item.Id, dbContext));
    // dbContext 在 Job 执行时可能已被释放!
}

// ✅ 正确:只传 ID,Job 内部创建 Scope
foreach (var item in items)
{
    var itemId = item.Id;  // 必须创建副本
    BackgroundJob.Enqueue<ItemProcessor>(p =>
        p.ProcessAsync(itemId, CancellationToken.None));
}

陷阱2:foreach 中的闭包陷阱

csharp 复制代码
// ❌ 错误:所有 Job 拿到的都是最后一个值
foreach (var shipId in shipIds)
{
    BackgroundJob.Enqueue(() => Sync(shipId));
}

// ✅ 正确:创建局部副本
foreach (var id in shipIds)
{
    var shipId = id;
    BackgroundJob.Enqueue(() => Sync(shipId));
}

陷阱3:Job 太大或执行时间过长

单个 Job 执行时间建议控制在 30分钟以内。超过的应该拆分为多个小 Job。

陷阱4:在 Job 中使用 DateTime.Now

使用 DateTime.UtcNow 或注入 IClock,避免时区问题。周期任务的 Cron 表达式指定时区。

陷阱5:Job 参数过大

Hangfire 将参数序列化到数据库。参数应该是简单类型(ID、字符串),不要传大对象:

csharp 复制代码
// ❌ 传大对象
BackgroundJob.Enqueue<ReportJob>(j =>
    j.GenerateAsync(largeDataTable, CancellationToken.None));

// ✅ 传 ID,Job 内部加载
BackgroundJob.Enqueue<ReportJob>(j =>
    j.GenerateAsync(reportId, CancellationToken.None));

陷阱6:忽略 CancellationToken

长时间运行的 Job 必须检查 PerformContext.CancellationToken,以便在 Server 关闭时优雅退出。

陷阱7:Job 不是事务的

Job 执行和状态更新不在同一事务中。Job 内的数据库操作需要自己管理事务。如果 Job 部分成功后崩溃,重试可能导致重复操作。必须保证幂等

陷阱8:RecurringJob 不会自动在多个实例间只执行一次

Hangfire 使用分布式锁保证 RecurringJob 在多实例环境中只触发一次,但要确保所有实例使用相同的 Storage相同的 JobId

最佳实践

  1. Job 方法参数只传简单类型/ID,不传大对象
  2. Job 内部创建 DI Scope,不依赖外部 Scope
  3. 长时间 Job 检查 CancellationToken
  4. Job 逻辑必须幂等,重试不会产生副作用
  5. 使用队列区分优先级:critical/default/batch
  6. 合理设置重试次数,网络异常重试,业务异常不重试
  7. 配置独立数据库,不和业务库混用
  8. 生产环境启用 Dashboard 认证
  9. 监控队列长度和失败率,设置告警
  10. 定期清理历史 Job,避免数据库膨胀
  11. Worker 数量根据负载调整,IO密集型可多一些
  12. 多实例部署保证高可用,至少2个 Worker
  13. 周期任务指定时区,避免 UTC 偏差
  14. 用 ContinueJobWith 编排工作流,不要在一个 Job 中做所有事
  15. Job 中用 try-catch 区分可重试和不可重试异常
  16. foreach 中创建局部变量副本,避免闭包陷阱
  17. 船端使用 SQLite 存储 + 本地 Worker,岸端使用 PostgreSQL/Redis
  18. Job 失败不影响主流程,但要可观测可告警

16. Checklist

  • 安装 Hangfire.AspNetCore 和对应存储包
  • 在 Program.cs 中 AddHangfire + AddHangfireServer
  • 配置独立数据库连接
  • Dashboard 启用认证授权
  • 配置合理的 WorkerCount 和 Queues
  • 全局/Job 级别配置重试策略
  • Job 方法参数只传 ID 和简单类型
  • Job 内部使用 CreateScope 获取 Scoped 服务
  • 长时间 Job 检查 CancellationToken
  • Job 逻辑保证幂等
  • 网络异常自动重试,业务异常不重试
  • 周期任务指定时区
  • 关键任务失败有告警通知
  • 监控队列长度,设置积压告警
  • 配置定期清理历史 Job
  • 生产环境多实例部署
  • foreach 闭包变量使用局部副本
  • 报表等长任务有进度通知(SignalR)
  • 船端使用 SQLite 存储
  • 容器化部署配置资源限制和健康检查
相关推荐
A_nanda1 天前
拆解ASP.NET Core 底层源码!从零手写
后端·asp.net
Lost of 程序猿2 天前
ASP.NET Core SignalR 实战:从实时推送到底层协议与高可用部署
后端·asp.net
Lost of 程序猿2 天前
ASP.NET Core 测试实战:从单元测试到集成测试的全链路质量保障
单元测试·asp.net·集成测试
七仔啊3 天前
C#、.NET 、ASP.NET Core、Visual Studio全系列快速入门
c#·asp.net·.net
Lost of 程序猿6 天前
AOP 实战:面向切面编程从理论到落地
后端·asp.net·aop·面向切片变成
Lost of 程序猿6 天前
ASP.NET Core 认证授权实战:从 JWT 到 Policy,设计一套企业级 RBAC 权限系统
后端·asp.net
Lost of 程序猿6 天前
ASP.NET Core 后台任务全景:从 BackgroundService 到 Channel 队列,再到分布式调度
后端·asp.net·.netcore
宝桥南山7 天前
Blazor Web Assembly - 体验一下Authentication State从Server共享给Client
microsoft·微软·c#·asp.net·.net·.netcore
张宇Joaquin11 天前
高性能计算之MPI——集合通信漫谈
服务器·microsoft·asp.net