.NET 8 中使用 HangFire 实现库存同步定时任务方案
2026/9/20 19:22:43 网站建设 项目流程

简介:这是一套基于 .net 8 与 HangFire 的电商库存同步 Demo,面向 .NET 后端开发者和电商系统实施人员,演示如何借助 HangFire 后台任务调度、Redis 缓存及 SqlSugar 轻量 ORM,实现京东、天猫、抖音 O2O/B2C 等多平台 SKU 库存的定时同步与更新。项目采用分层架构,涵盖 HangfireServer 任务配置、StockServers 同步逻辑、SkuServers 商品处理及独立测试项目,便于对照学习任务调度、缓存加速与数据库操作的整合方式。压缩包共 2000 个文件,约 139.71MB,以 C# 源码(638 个 cs)、编译程序集(571 个 dll)为主,并包含 XML 注释文档、JSON 配置、CSS/JS(部分前端管理界面)等资源,整体目录结构清晰,可按模块检索。已有 165 人浏览学习。对于需要理解 HangFire 后台任务、Redis 缓存同步细节,以及 SqlSugar 数据访问实践的开发者,这份资源提供了可直接运行与扩展的完整示例。 写库存系统的人大概都有过这种经历:电商平台上超卖、仓库里积压、各个渠道的库存数字永远对不上,运营一催就头大。我最早做这块的时候,用的是控制台程序加Windows计划任务,后来换到.NET 8之后,发现HangFire这套后台任务框架是真方便——定时调度、失败重试、任务仪表盘全都内置了,配合库存同步这种典型的“周期性拉取”场景,简直量身定做。这篇文章就把我这个Demo的完整实现思路和踩坑过程记录下来,从架构设计到代码实现再到问题排查,一步不落。正在选型定时任务方案、或者刚开始接触HangFire的朋友,可以直接照着抄。

1. 为什么选HangFire做库存同步,而不是自己写定时器

很多人一听到“定时同步库存”,第一反应是用BackgroundServiceTimer,或者干脆写个死循环while(true) + Thread.Sleep。这两种做法在小项目里确实能跑,但一旦涉及到“任务挂了怎么办”“重复执行了怎么办”“我想看历史执行记录”这些问题,自己写一套成本很高,排查问题更是想哭。

HangFire把后台任务最常用的几块能力都封装好了:任务持久化、自动重试、调度管理、执行日志、可视化Dashboard。这些正是库存同步这类定时任务最需要的东西。打个比方,自己写定时器就像手工记账,偶尔记几笔还行,但要做成一套能审计、能复盘的系统,还是得用现成的账本框架。

从选型角度,我对比过几个主流方案,区别还是很明显的:

方案持久化重试机制可视化分布式支持上手成本
BackgroundService + Timer无,重启即丢需自己写不支持
Quartz.NET可配置需自己搭需额外配置
HangFire内置内置,策略可调内置Dashboard基于存储天然支持

HangFire还有一个很实在的优势:任务状态和执行记录会持久化到存储里(Demo里用InMemory,生产环境可以换SQL Server或PostgreSQL)。这意味着就算进程崩溃重启,任务也会按计划继续执行,不会无声无息地丢任务。对于库存同步这种业务来说,“漏跑一次”可能就意味着线上库存数据滞后,影响的是真金白银。

另一个关键点是,HangFire支持CRON表达式调度,这点和库存同步的需求非常吻合。库存同步通常不需要实时触发,而是每隔几分钟或者每天固定时间点做一轮增量拉取,CRON表达式可以精确表达这类调度规则,而且改规则不用改代码,直接改表达式就行。

2. Demo整体设计:模拟了一个什么业务场景

这个Demo模拟了一个很常见的业务:内部ERP系统需要定时从外部电商平台的库存接口拉取最新库存,然后更新到本地数据库,保证两边数据一致。

为什么选“库存同步”而不是别的案例?因为库存同步基本覆盖了定时任务开发会遇到的所有核心问题:数据怎么增量拉取、怎么避免重复更新、并发执行时怎么保证一致性、第三方接口不稳定时怎么处理。把这些问题跑通一遍,HangFire的核心用法也就掌握得差不多了。

2.1 模拟两个系统

为了贴近真实场景,我把整个Demo拆成了两个“系统”:

  • 外部库存系统:用一个ExternalInventoryController模拟,对外暴露GET /api/external/inventory接口,返回一批商品的SKU、仓库、库存数量、最后更新时间。每次调用数据都会随机变化,模拟真实系统里库存不断波动的状态。
  • 本地库存系统:一个SQLite数据库,用EF Core管理,里面有一张ProductInventory表存放商品库存,一张SyncRecord表记录每次同步的日志。

这样设计的好处是,边界清晰。同步服务只面向外部接口,不直接操作外部数据源,和真实项目中对接第三方渠道的场景非常接近。

2.2 增量同步策略

库存同步最忌讳的是全量覆盖。假设你有十万个SKU,每次同步都全量拉一遍,接口压力大、数据库更新量大,而且很容易把本地新写入的数据覆盖掉。所以Demo里采用增量同步策略:

  1. SyncRecord表查出最近一次同步时间lastSyncTime
  2. 调用外部接口时带上updatedSince=lastSyncTime参数,只拉取这个时间点之后变更过的数据
  3. 本地逐条比对,如果外部数据的UpdatedAt比本地新,就更新库存数量
  4. 同步完成后,把本次同步时间和拉取条数写入SyncRecord

首次运行时没有同步记录,默认从DateTime.MinValue开始,也就是拉全量作为基础数据。这个逻辑和很多真实系统的同步方案是一致的:首次初始化拉全量,之后增量迭代。

2.3 并发与幂等设计

库存数据本身有状态,同步任务最怕两件事:一是两个同步任务同时跑,互相覆盖数据;二是在网路波动或者任务重试时,同一条数据被重复处理,把库存加了两遍。

针对第一点,Demo里做了一个简单但有效的处理:通过HangFire的DisableConcurrentExecution特性给任务加互斥锁。这个特性会保证同一个任务在同一个实例上不会并发执行,如果上一个任务还没跑完,下一个任务会直接跳过。对于库存同步这种周期任务来说,跳过一轮问题不大,下一轮会补回来。

针对第二点,更新的关键不是“看到外部数据就覆盖”,而是“只有当外部数据的UpdatedAt时间戳比本地新时才更新”。同时,ProductInventory表为SKU + WarehouseId设置了唯一索引,保证同一条商品记录不会出现两份,从根源上杜绝重复数据。

3. 从零搭建Demo:核心代码与配置

接下来是实操环节,整个项目基于.NET 8 Web API,开发工具用Visual Studio 2022或者Rider都行。核心依赖只有三个:HangFire、EF Core Sqlite、HttpClient。

3.1 创建项目并安装NuGet包

命令行操作,干净利落:

dotnet new webapi -n InventorySyncDemo cd InventorySyncDemo dotnet add package HangFire.AspNetCore dotnet add package HangFire.InMemory dotnet add package Microsoft.EntityFrameworkCore.Sqlite dotnet add package Microsoft.EntityFrameworkCore.Design

说明一下,HangFire.AspNetCore是主包,包含了HangFire Core的所有功能,并且提供了ASP.NET Core的集成扩展;HangFire.InMemory是内存存储实现,Demo级别用它完全够,不需要额外装数据库。如果你要上生产,把这行换成HangFire.SqlServer或者HangFire.PostgreSql就行,代码层面基本不需要改动。

3.2 配置HangFire服务

打开Program.cs,先注册HangFire核心服务:

builder.Services.AddHangfire(config => { config.SetDataCompatibilityLevel(CompatibilityLevel.Version_180); config.UseSimpleAssemblyNameTypeSerializer(); config.UseRecommendedSerializerSettings(); config.UseInMemoryStorage(); }); builder.Services.AddHangfireServer();

这里有几个配置值得解释一下。SetDataCompatibilityLevel(CompatibilityLevel.Version_180)是设置HangFire的数据兼容级别,让它能兼容新版序列化格式;UseRecommendedSerializerSettings()是使用推荐的JSON序列化配置,避免中文乱码和类型信息丢失;UseInMemoryStorage()就是我们选的存储方案。

接着在管道里启用Dashboard中间件:

app.UseRouting(); app.UseHangfireDashboard("/hangfire"); app.MapControllers();

注意中间件的注册顺序,UseHangfireDashboard必须在UseRouting之后、MapControllers附近。如果顺序不对,访问/hangfire会直接404,而且没有任何报错信息,这个坑我后面还会细说。

3.3 模拟外部库存接口

新建一个ExternalInventoryController,模拟第三方系统的库存接口。核心逻辑是每次请求都生成随机变化的库存数据:

[ApiController] [Route("api/external")] public class ExternalInventoryController : ControllerBase { [HttpGet("inventory")] public ActionResult<List<ExternalInventoryDto>> GetInventory( [FromQuery] DateTime? updatedSince) { var random = new Random(); var items = Enumerable.Range(1, 10).Select(i => new ExternalInventoryDto { Sku = $"SKU-{i:000}", WarehouseId = 1, Quantity = random.Next(0, 500), UpdatedAt = DateTime.UtcNow.AddMinutes(-random.Next(0, 60)) }) .Where(x => x.UpdatedAt >= (updatedSince ?? DateTime.MinValue)) .ToList(); return Ok(items); } }

updatedSince参数就是增量同步的“水位线”。如果外部传入这个时间,接口就只返回这个时间点之后变更过的数据。真实系统里这个逻辑通常对应SQL里的WHERE updated_at > @watermark,这里为了省事直接在内存里过滤了,原理是一样的。

3.4 库存数据实体与DbContext

定义两个核心实体类:

public class ProductInventory { public int Id { get; set; } public string Sku { get; set; } = string.Empty; public int WarehouseId { get; set; } public int Quantity { get; set; } public DateTime UpdatedAt { get; set; } } public class SyncRecord { public int Id { get; set; } public DateTime SyncedAt { get; set; } public int ItemCount { get; set; } }

DbContext的配置,重点在设置SKU + WarehouseId的唯一索引,这是防止重复数据的关键:

public class InventoryDbContext : DbContext { public InventoryDbContext(DbContextOptions<InventoryDbContext> options) : base(options) { } public DbSet<ProductInventory> ProductInventories => Set<ProductInventory>(); public DbSet<SyncRecord> SyncRecords => Set<SyncRecord>(); protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity<ProductInventory>() .HasIndex(x => new { x.Sku, x.WarehouseId }) .IsUnique(); } }

3.5 编写库存同步服务

这是整个Demo的核心,直接在IInventorySyncService里实现同步逻辑:

public interface IInventorySyncService { Task<SyncResult> SyncAsync(CancellationToken ct = default); }

实现类中,同步的核心逻辑分四步:

public class InventorySyncService : IInventorySyncService { private readonly InventoryDbContext _db; private readonly HttpClient _httpClient; private readonly ILogger<InventorySyncService> _logger; public InventorySyncService( InventoryDbContext db, HttpClient httpClient, ILogger<InventorySyncService> logger) { _db = db; _httpClient = httpClient; _logger = logger; } public async Task<SyncResult> SyncAsync(CancellationToken ct = default) { // 第一步:获取上次同步时间 var lastSyncTime = await _db.SyncRecords .OrderByDescending(x => x.SyncedAt) .Select(x => x.SyncedAt) .FirstOrDefaultAsync(ct); var watermark = lastSyncTime == default ? DateTime.MinValue : lastSyncTime; // 第二步:调用外部接口,拉取增量数据 var url = $"/api/external/inventory?updatedSince={watermark:O}"; var externalItems = await _httpClient .GetFromJsonAsync<List<ExternalInventoryDto>>(url, ct) ?? new(); // 第三步:逐条比对,执行新增或更新 var updatedCount = 0; var newestTime = watermark; foreach (var item in externalItems) { var local = await _db.ProductInventories .FirstOrDefaultAsync(x => x.Sku == item.Sku && x.WarehouseId == item.WarehouseId, ct); if (local == null) { _db.ProductInventories.Add(new ProductInventory { Sku = item.Sku, WarehouseId = item.WarehouseId, Quantity = item.Quantity, UpdatedAt = item.UpdatedAt }); updatedCount++; } else if (item.UpdatedAt > local.UpdatedAt) { local.Quantity = item.Quantity; local.UpdatedAt = item.UpdatedAt; updatedCount++; } if (item.UpdatedAt > newestTime) newestTime = item.UpdatedAt; } // 第四步:写入同步日志 _db.SyncRecords.Add(new SyncRecord { SyncedAt = DateTime.UtcNow, ItemCount = externalItems.Count }); await _db.SaveChangesAsync(ct); _logger.LogInformation("库存同步完成,处理 {Count} 条数据", updatedCount); return new SyncResult { SyncedCount = updatedCount, NewestUpdate = newestTime }; } }

整套逻辑就是一个标准的状态比对流程:先查水位线,再拉增量,然后逐条比对时间戳,最后记录同步痕迹。为什么比对时间戳而不是直接覆盖?因为外部系统返回的数据可能包含旧记录,如果直接覆盖,会把本地更新的数据回退掉。只有“外部时间比本地新”才更新,这是库存同步这类场景最常见的幂等处理方式。

提示:Demo里逐条查询再更新是为了让逻辑更直观,真实项目数据量大时,建议把外部数据拉到内存后,用批量方式一次性比对,或者用EF Core 7+的ExecuteUpdate做批量更新,性能会好很多。关于这一点我放在第4节详聊。

3.6 注册定时任务和手动触发端点

Program.cs中,应用启动后注册一个每5分钟执行一次的定时任务:

using Hangfire; // ... 其他配置 ... var app = builder.Build(); app.UseRouting(); app.UseHangfireDashboard("/hangfire"); app.MapControllers(); RecurringJob.AddOrUpdate<IInventorySyncService>( "inventory-sync", service => service.SyncAsync(), "*/5 * * * *", new RecurringJobOptions { TimeZone = TimeZoneInfo.Local }); app.Run();

这里的CRON表达式*/5 * * * *表示每5分钟执行一次。我特意加了TimeZone = TimeZoneInfo.Local这行配置,如果你不加,HangFire默认使用UTC时间解析CRON,国内服务器就会差8个小时,任务在凌晨4点跑,怎么调都不对。这是HangFire新手最常踩的坑之一。

另外为了调试方便,我还加了一个手动触发端点:

app.MapGet("/trigger-sync", async (IInventorySyncService service) => { var result = await service.SyncAsync(); return Results.Ok(result); });

这个端点在调试阶段特别有用。定时任务要等5分钟才跑一次,调试的时候干等着很浪费时间,直接访问/trigger-sync就能立刻触发一轮同步,而且可以在返回结果里看到同步了几条数据、最新水位线是什么。

3.7 运行Demo并查看Dashboard

启动项目后,浏览器访问/hangfire就能看到HangFire的仪表盘。左侧菜单可以切到“周期性任务”,你会看到inventory-sync这个任务,状态是“已启用”。每次执行之后,可以在“任务”页面看到执行记录,成功的显示绿色对勾,失败的显示红色并进入重试队列。

这个Dashboard用起来类似于快递app的物流轨迹:任务什么时候触发、执行了多久、成功还是失败、失败后重试了几次、最终结果如何,全部一目了然。对于排查问题太重要了。

4. 常见问题与排查技巧实录

这个Demo我前前后后搭了好几遍,每次换机器或者换版本都会遇到一些奇奇怪怪的问题。整理一份速查表,给大家省点时间:

问题现象可能原因解决方案
访问 /hangfire 返回404中间件注册顺序不对确保UseHangfireDashboard放在UseRouting之后
定时任务不执行CRON时区问题注册时指定TimeZone = TimeZoneInfo.Local
任务在Dashboard显示为失败外部接口抛异常或超时查看异常详情,给HttpClient设置超时时间,调整重试策略
任务重复执行多实例部署时重复注册AddOrUpdate确保幂等注册,或把任务注册放到独立启动项目
同一条商品记录出现多条缺少唯一约束SKU + WarehouseId加唯一索引
数据库报database is lockedSQLite并发写入冲突Demo级别的临时方案是串行化写操作,生产环境换成SQL Server/PostgreSQL

4.1 HangFire任务重复执行的坑

这个坑我在真实项目里遇到过。多实例部署时,每个实例启动都会调用RecurringJob.AddOrUpdate,结果同一个任务被注册多次,多个实例同时执行,库存被重复扣减。

解决方案有几个层面:

  • 任务注册逻辑保证幂等。AddOrUpdate本身就是“存在就更新,不存在才新增”,所以同一个任务ID不会生成多个调度记录。
  • 多实例部署时,只让一个实例负责注册定时任务。可以通过配置开关控制,比如appsettings.json里的Hangfire:EnableRecurringJobs字段。
  • 就算有多个实例同时执行,也要在任务内部做好幂等。比如同步逻辑里比对UpdatedAt时间戳,重复执行也不会覆盖更新数据。

HangFire还提供了DisableConcurrentExecution特性,可以限制同一任务不并发执行:

[DisableConcurrentExecution(timeoutInSeconds: 60)] public async Task SyncAsync(CancellationToken ct) { // 同步逻辑 }

加了这行特性后,如果前一个任务还没执行完,后一个任务会直接跳过。这个机制对库存同步来说非常实用,宁可少跑一轮,也不要两轮同时写数据。

4.2 SQLite的并发写入问题

Demo里用的是SQLite,它的并发写入能力比较弱,多线程同时写时会报database is locked。出现这个问题的场景通常是:HangFire后台任务在执行写操作的同时,你自己手动访问/trigger-sync又触发一次,两边同时写数据库,冲突就来了。

针对SQLite,有几个实用技巧:

  • 设置连接字符串的Pooling=True,让EF Core复用数据库连接,减少连接切换开销。
  • 写操作放到HangFire任务里统一调度,避免其他地方直接操作数据库。
  • 如果并发量确实上来了,尽快切换到SQL Server或PostgreSQL。这也是HangFire的一个优势,存储层是抽象好的,换数据库只需要改一行AddHangfire配置,业务代码一行不用动。

注意:InMemory存储虽然调试方便,但重启进程后任务历史记录全部清空。生产环境一定不要用InMemory,至少要换成SQL Server。这在Demo里问题不大,但上线前必须改。

4.3 外部接口慢导致任务超时

真实场景中,第三方库存接口的响应速度并不乐观,尤其是大促期间,经常出现几秒钟没返回的情况。如果HttpClient不设置超时,任务会一直挂在那里,最终HangFire判定任务超时失败。

建议在注册HttpClient时设置合理的超时时间:

builder.Services.AddHttpClient<IInventorySyncService, InventorySyncService>(client => { client.Timeout = TimeSpan.FromSeconds(30); });

注意,HttpClient.Timeout是整体超时时间,不是单次请求的超时。如果你用的是带Polly的AddHttpClient扩展,还可以加上重试策略。不过HangFire本身就带自动重试,对库存同步这种场景,犯不着在HttpClient层面再做一层重试,反而可能造成请求风暴。

4.4 第一轮同步数据量过大

首次运行时的全量同步是个特例。如果接口一次性返回十万条数据,内存会直接爆炸,EF Core逐条比对也要跑到天荒地老。真实项目中,外部接口一般都会做分页:

[HttpGet("inventory")] public async Task<ActionResult<PagedResult<ExternalInventoryDto>>> GetInventory( [FromQuery] DateTime? updatedSince, [FromQuery] int page = 1, [FromQuery] int pageSize = 500) { // 按 updatedSince 过滤后分页返回 }

同步服务里就变成了一个循环拉取的过程,每次都传入当前页码直到拉完为止。对于同步任务的内部处理,也可以换成一次性把外部数据取回来后,在内存里构建字典,避免逐条查询数据库:

var externalDict = externalItems.ToDictionary(x => (x.Sku, x.WarehouseId)); var localSkus = await _db.ProductInventories .Where(x => externalDict.Keys.Select(k => k.Sku).Contains(x.Sku)) .ToListAsync();

这种方式把“每条数据都查一次数据库”变成了“一次查回本地所有关联数据,内存里比对”,性能提升是数量级的。数据量超过几千条时,这条优化几乎是必须的。

最后分享一点实际体会

这套Demo跑通之后,我在真实项目里接着做了几个扩展,这里一并分享一下。第一,定时任务能解决70%的库存同步需求,但遇到秒杀这类高并发场景,定时拉取就不够用了,得改成消息队列或者API主动推送,HangFire的BackgroundJob.Enqueue可以很方便地把收到的推送数据丢到后台队列里异步处理。第二,HangFire的Dashboard在生产环境一定要加访问权限,不然任何人访问/hangfire都能看到你的任务执行情况,甚至手动触发任务,很危险。第三,调试的时候手动触发端点比干等定时任务高效得多,但上生产前记得把/trigger-sync这种调试端点删掉或者加身份验证。

最后再分享一个小技巧:如果你不想用默认的CRON表达式调度,HangFire还支持在Dashboard后台手动触发任务、暂停任务、删除任务,生产环境排障时直接在页面上操作,比改代码重启服务方便一个量级。这个Demo后面如果再扩展,我可能会把多店铺、多仓库的同步策略加进去,每个租户配一套独立的重试和调度规则,那基本上就能直接接到真实项目里了。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询