ASP.NET Core 6中HangFire每日2点定时任务未运行排查
ASP.NET Core 6 Hangfire 周期性任务未执行问题排查与修复
问题场景
- 技术栈:ASP.NET Core 6 + Hangfire 实现每日凌晨2点同步第三方分支机构数据的周期性任务
- 预期行为:每日2点调用第三方JSON API,拉取分支机构数据完成新增/更新写入数据库
- 实际故障:部署后3天定时任务从未运行,数据库无任何同步记录
现有实现参考
第三方API与基础配置
第三方API地址:
https://api.thirdpartycompany.com:2233/api/BranchDetail
appsettings.json 节点配置:
"Endpoints": { "branchUrl": "https://api.thirdpartycompany.com:2233/api/BranchDetail" }
API返回结构示例:
{ "Branches": [ { "BranchName": "Accra", "BranchNumber": 1, "Country": "Ghana" }, { "BranchName": "Kumasi", "BranchNumber": 2, "Country": "Ghana" } ] }
核心代码定义
实体与DTO
数据库实体类:
public class Branch { public int Id { get; set; } public string BranchName { get; set; } public int BranchNumber { get; set; } }
传输对象定义:
public class BranchCreateUpdateDto { public string BranchName { get; set; } public int BranchNumber { get; set; } } public class BranchResponse { public List<BranchCreateUpdateDto> Branches { get; set; } }
统一响应类:
public class BaseResponse { public bool Success { get; set; } = true; public string Message { get; set; } }
AutoMapper配置
public class AdminMapperProfile: Profile { public AdminMapperProfile() { CreateMap<BranchCreateUpdateDto, Branch>().ReverseMap(); } }
仓储层实现
泛型仓储基类:
public class GenericRepository<T> : IGenericRepository<T> where T : class { private readonly ApplicationDbContext _dbContext; private readonly DbSet<T> _dbSet; public GenericRepository(ApplicationDbContext dbContext) { _dbContext = dbContext; _dbSet = _dbContext.Set<T>(); } public async Task InsertAsync(T entity) { await _dbSet.AddAsync(entity); } public T GetById(string id) { return _dbSet.Find(id); } public void Update(T entity) { _dbSet.Set<T>().Update(entity); } }
分支机构仓储接口与实现:
public interface IAdminBranchRepository : IGenericRepository<Branch> { } public class AdminBranchRepository : GenericRepository<Branch>, IAdminBranchRepository { private readonly ApplicationDbContext _dbContext; private readonly DbSet<Branch> _branches; public AdminBranchRepository(ApplicationDbContext dbContext) : base(dbContext) { _dbContext = dbContext; _branches = _dbContext.Set<Branch>(); } }
工作单元接口与实现:
public interface IUnitOfWork : IDisposable { IAdminBranchRepository Branches { get; } Task Save(); } public class UnitOfWork : IUnitOfWork { private readonly ApplicationDbContext _dbContext; private IAdminBranchRepository _branches; public UnitOfWork(ApplicationDbContext dbContext) { _dbContext = dbContext; } public IAdminBranchRepository Branches => _branches ??= new AdminBranchRepository(_dbContext); public async Task Save() { await _dbContext.SaveChangesAsync(); } }
服务层实现
服务接口方法:
Task<BaseResponse> CreateBranchAsync();
服务实现类:
public class AdminBranchService : IAdminBranchService { private readonly ApplicationDbContext _dbContext; private readonly IMapper _mapper; private readonly IUnitOfWork _unitOfWork; private readonly ILogger _logger; private readonly IConfiguration _config; private readonly HttpClient _myClient; public AdminBranchService( ApplicationDbContext dbContext, IUnitOfWork unitOfWork, ILogger logger, IMapper mapper, IConfiguration config, HttpClient myClient ) { _dbContext = dbContext; _mapper = mapper; _unitOfWork = unitOfWork; _logger = logger; _config = config; _myClient = myClient; } public async Task<BaseResponse> CreateBranchAsync() { var branchResponse = new BaseResponse(); var branches = new List<Branch>(); try { string branchUrl = _config.GetSection("Endpoints").GetValue<string>("branchUrl"); _myClient.DefaultRequestHeaders.Accept.Clear(); _myClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); HttpResponseMessage response = _myClient.GetAsync(branchUrl).Result; var stringResult = response.Content.ReadAsStringAsync().Result; BranchResponse list = JsonConvert.DeserializeObject<BranchResponse>(stringResult); foreach (var singleBranch in list.Branches) { Branch res = new Branch(); if (_dbContext.Branches.Any(x => x.BranchName == singleBranch.BranchName)) { res.BranchNumber = singleBranch.BranchNumber; _unitOfWork.Branches.Update(res); } else { res.BranchName = singleBranch.BranchName; res.BranchNumber = singleBranch.BranchNumber; await _unitOfWork.Branches.InsertAsync(res); } await _unitOfWork.Save(); } _logger.Information("Branches Added Successfully"); } catch (Exception ex) { _logger.Error("An Error occured " + ex.ToString()); } return branchResponse; } }
基础配置代码
数据库连接扩展:
public static class ConnectionConfiguration { public static void AddDbContextAndConfigurations(this IServiceCollection services, IWebHostEnvironment env, IConfiguration config) { services.AddDbContextPool<ApplicationDbContext>(options => { string connStr; connStr = config.GetConnectionString("DefaultConnection"); options.UseSqlServer(connStr); }); } }
Hangfire配置扩展:
public static class HangFireServiceExtension { public static void AddHangFireConfigurations(this IServiceCollection services, IConfiguration config) { string connStr; connStr = config.GetConnectionString("DefaultConnection"); services.AddHangfire(configuration => configuration .SetDataCompatibilityLevel(CompatibilityLevel.Version_170) .UseSimpleAssemblyNameTypeSerializer() .UseRecommendedSerializerSettings() .UseSqlServerStorage(connStr, new SqlServerStorageOptions { CommandBatchMaxTimeout = TimeSpan.FromMinutes(5), SlidingInvisibilityTimeout = TimeSpan.FromMinutes(5), QueuePollInterval = TimeSpan.Zero, UseRecommendedIsolationLevel = true, DisableGlobalLocks = true })); services.AddHangfireServer(); } }
Program.cs 配置片段:
builder.Services.ConfigureAutoMappers(); // Db Injection builder.Services.AddDbContextAndConfigurations(environment, configuration); // HangFire builder.Services.AddHangFireConfigurations(configuration); app.UseHangfireDashboard(); RecurringJob.AddOrUpdate<IAdminBranchService>("Post_All_Branches", service => service.CreateBranchAsync(), Cron.Daily(2, 0), TimeZoneInfo.Local);
实现疏漏点
- 依赖注入缺失:代码中没有将
IAdminBranchService、AdminBranchService、IUnitOfWork、UnitOfWork注册到DI容器,服务中注入的ILogger未指定泛型类型,直接注入原生HttpClient也未做注册,Hangfire无法解析IAdminBranchService实例,任务注册后根本无法触发执行。 - 异步代码同步阻塞:服务方法中使用
.Result同步等待HttpClient异步请求结果,在ASP.NET Core同步上下文中极易造成死锁,即使任务触发也会卡死无法执行完成。 - 更新逻辑完全错误:判断分支已存在时,直接new了一个空的
Branch对象,没有查询出数据库中现有实体的主键ID,直接调用Update会要么抛主键为空异常,要么插入新数据而非更新存量数据。 - 任务注册位置错误:
RecurringJob.AddOrUpdate写在中间件配置段,此时DI容器还未完全构建完成,应用启动时可能因为依赖未就绪导致任务注册失败,异常被启动流程掩盖后不会有任何报错提示。 - 时区配置风险:使用
TimeZoneInfo.Local在部署到非本地时区的服务器(比如云服务器默认UTC时区)时,实际执行时间会和预期的凌晨2点出现偏差,甚至因为时区ID不存在抛异常。 - 异常处理吞错:所有异常只打日志不向外抛出,Hangfire无法感知任务执行失败,不会触发重试,也不会在仪表盘标记失败状态,排查时看不到任何错误痕迹。
- 循环提交数据库性能差:每一条分支数据就调用一次
SaveChanges,数据量大时执行极慢,长事务容易导致锁表,任务超时被Hangfire判定为失败。
修复方案
补全依赖注入配置
在Program.cs中添加服务注册,使用IHttpClientFactory注册HttpClient避免套接字耗尽,同时修正日志注入类型:// 注册工作单元 builder.Services.AddScoped<IUnitOfWork, UnitOfWork>(); // 注册分支服务 builder.Services.AddScoped<IAdminBranchService, AdminBranchService>(); // 注册HttpClient builder.Services.AddHttpClient();服务构造函数中把
ILogger改为ILogger<AdminBranchService>,确保日志组件能正常解析。修正异步代码写法,移除同步阻塞
把服务中所有.Result替换为await异步等待:HttpResponseMessage response = await _myClient.GetAsync(branchUrl); var stringResult = await response.Content.ReadAsStringAsync();重写分支新增/更新逻辑
一次性查询所有现有分支,避免循环内每次查库,更新操作必须基于已查询到的存量实体:BranchResponse list = JsonConvert.DeserializeObject<BranchResponse>(stringResult); var existingBranches = _dbContext.Branches.ToList(); foreach (var singleBranch in list.Branches) { var existingBranch = existingBranches.FirstOrDefault(x => x.BranchName == singleBranch.BranchName); if (existingBranch != null) { existingBranch.BranchNumber = singleBranch.BranchNumber; _unitOfWork.Branches.Update(existingBranch); } else { var newBranch = new Branch { BranchName = singleBranch.BranchName, BranchNumber = singleBranch.BranchNumber }; await _unitOfWork.Branches.InsertAsync(newBranch); } } // 循环结束后一次性提交,减少数据库交互 await _unitOfWork.Save();修正任务注册位置与时区配置
把RecurringJob注册放到DI容器构建完成、应用启动的阶段,使用明确的时区配置:var app = builder.Build(); // 原有中间件配置(UseRouting、UseAuthorization、UseHangfireDashboard等)保留 // 容器构建完成后注册定时任务 using var scope = app.Services.CreateScope(); RecurringJob.AddOrUpdate<IAdminBranchService>( "Post_All_Branches", service => service.CreateBranchAsync(), Cron.Daily(2, 0), TimeZoneInfo.FindSystemTimeZoneById("China Standard Time") ); app.Run();如果部署在Linux服务器,时区ID替换为
Asia/Shanghai即可修正异常处理逻辑
记录日志后重新抛出异常,让Hangfire可以感知任务失败,在仪表盘展示错误详情:catch (Exception ex) { _logger.Error("分支同步任务执行失败:" + ex.ToString()); throw; }
内容的提问来源于stack exchange,提问作者Ayobamilaye
相关产品推荐
相关产品推荐

