
简介这是一套基于 .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做库存同步而不是自己写定时器很多人一听到“定时同步库存”第一反应是用BackgroundService加Timer或者干脆写个死循环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里采用增量同步策略从SyncRecord表查出最近一次同步时间lastSyncTime调用外部接口时带上updatedSincelastSyncTime参数只拉取这个时间点之后变更过的数据本地逐条比对如果外部数据的UpdatedAt比本地新就更新库存数量同步完成后把本次同步时间和拉取条数写入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 ActionResultListExternalInventoryDto 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(DbContextOptionsInventoryDbContext options) : base(options) { } public DbSetProductInventory ProductInventories SetProductInventory(); public DbSetSyncRecord SyncRecords SetSyncRecord(); protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.EntityProductInventory() .HasIndex(x new { x.Sku, x.WarehouseId }) .IsUnique(); } }3.5 编写库存同步服务这是整个Demo的核心直接在IInventorySyncService里实现同步逻辑public interface IInventorySyncService { TaskSyncResult SyncAsync(CancellationToken ct default); }实现类中同步的核心逻辑分四步public class InventorySyncService : IInventorySyncService { private readonly InventoryDbContext _db; private readonly HttpClient _httpClient; private readonly ILoggerInventorySyncService _logger; public InventorySyncService( InventoryDbContext db, HttpClient httpClient, ILoggerInventorySyncService logger) { _db db; _httpClient httpClient; _logger logger; } public async TaskSyncResult 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 .GetFromJsonAsyncListExternalInventoryDto(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.AddOrUpdateIInventorySyncService( 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/PostgreSQL4.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有几个实用技巧设置连接字符串的PoolingTrue让EF Core复用数据库连接减少连接切换开销。写操作放到HangFire任务里统一调度避免其他地方直接操作数据库。如果并发量确实上来了尽快切换到SQL Server或PostgreSQL。这也是HangFire的一个优势存储层是抽象好的换数据库只需要改一行AddHangfire配置业务代码一行不用动。注意InMemory存储虽然调试方便但重启进程后任务历史记录全部清空。生产环境一定不要用InMemory至少要换成SQL Server。这在Demo里问题不大但上线前必须改。4.3 外部接口慢导致任务超时真实场景中第三方库存接口的响应速度并不乐观尤其是大促期间经常出现几秒钟没返回的情况。如果HttpClient不设置超时任务会一直挂在那里最终HangFire判定任务超时失败。建议在注册HttpClient时设置合理的超时时间builder.Services.AddHttpClientIInventorySyncService, InventorySyncService(client { client.Timeout TimeSpan.FromSeconds(30); });注意HttpClient.Timeout是整体超时时间不是单次请求的超时。如果你用的是带Polly的AddHttpClient扩展还可以加上重试策略。不过HangFire本身就带自动重试对库存同步这种场景犯不着在HttpClient层面再做一层重试反而可能造成请求风暴。4.4 第一轮同步数据量过大首次运行时的全量同步是个特例。如果接口一次性返回十万条数据内存会直接爆炸EF Core逐条比对也要跑到天荒地老。真实项目中外部接口一般都会做分页[HttpGet(inventory)] public async TaskActionResultPagedResultExternalInventoryDto 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后面如果再扩展我可能会把多店铺、多仓库的同步策略加进去每个租户配一套独立的重试和调度规则那基本上就能直接接到真实项目里了。本文还有配套的精品资源点击获取