最近在微服务项目里线上发现一个奇怪的现象:用AbpVnext框架写的AsyncBackgroundJob,明明是在A服务里注册和入队的,结果却被另一个服务B抢着执行了。更麻烦的是,B服务跑完之后,A服务还会再跑一遍,两边的后台任务日志互相穿插,数据库里出现重复数据和被覆盖的数据。排查两天后,根子出在后台作业调度机制上:AbpVnext默认的AsyncBackgroundJob队列是共享数据库,多服务实例在同一个持久化表里竞争任务,谁抢到算谁的,完全不带“归属感”。这篇文章就把我这次定位、排查、修复的过程完整记录下来,包括底层原理、几种可选方案和最终落地的代码,希望给同样被“抢占”折磨的朋友一点参考。
1. 问题现象与根因分析:先搞清楚“抢占”是怎么发生的
1.1 现象描述:重复执行、日志错乱、数据被覆盖
先说实际现象。项目部署了两个后端服务实例,A服务和B服务,它们连接同一个数据库,都基于AbpVnext框架。业务上有一个异步任务,比如“生成月度报表”,在A服务里通过AsyncBackgroundJob入队。当时我的预期很朴素:A服务入队的任务,应该由A服务自己消费,最多也就是A服务的多个实例之间做负载均衡。但实际日志显示,A服务入队后,过了一小会儿,B服务的日志里也开始处理同一个报表生成任务。两个服务都执行了同一套逻辑,各自生成了一份报表文件,最后写入同一个目录和数据库记录,互相覆盖。
更诡异的是,有时候A服务入队后,B服务抢先执行,A服务自己的后台作业管理器反而不执行;有时候两边几乎同时执行,日志里的上下文ID和异常堆栈混在一起,数据库里出现了双份记录。这在业务上特别危险,因为“生成报表”不是幂等操作,两次执行会产生两张不同的报表,而且由于并发操作数据库同一个记录,还出现了死锁和更新失败。
我当时第一反应是代码问题,以为自己在入队时不小心把BackgroundJobName传错了,或者把服务B也接入了同一个队列。但检查了半天,发现代码并没有明显错误,而且生产环境是上次发版之后才出现的,之前单服务部署时一切正常。
这个现象的本质,就是标题里说的“其他服务抢占AsyncBackgroundJob”。在AbpVnext里,AsyncBackgroundJob不是一个独立的消息队列,它默认是数据库轮询模型,所有服务实例共用一张后台作业表,任何实例的后台作业管理器都可以从表里取下一个待执行的任务。谁先查到,谁就拿走执行,根本没有“这个任务属于哪个服务”的隔离概念。
1.2 AbpVnext后台作业原理:存储、轮询、执行
要彻底理解抢占,必须把AbpVnext的后台作业工作机制梳理一遍。AbpVnext的BackgroundJobs模块由几个核心部分构成:
IBackgroundJobManager:后台作业管理器,负责入队和调度。IBackgroundJobStore:作业存储接口,默认实现基于数据库,即BackgroundJobStore,把作业信息保存到数据库表中。IBackgroundJobSerializer:作业参数序列化器,默认用JSON把JobArgs序列化存储。IBackgroundJobWorker:后台作业工作器,它只是一个后台线程,会周期性从存储中取出到期且未执行的任务。AsyncBackgroundJob<TArgs>:你定义的作业类型,实现ExecuteAsync方法即可。
整个执行时序是:你调用IBackgroundJobManager.EnqueueAsync<TJob, TArgs>(args),管理器会把Job类型全名、序列化后的参数、下次执行时间等写入数据库表;同时每个服务实例启动时会启动IBackgroundJobWorker,这个Worker内部有一个线程池,每秒钟(默认轮询间隔是1秒)执行一次任务拉取逻辑:从数据库里查一批当前时间达到执行时间、尚未完成的任务,然后逐条执行。
如果这项任务执行成功后,Worker会调用Store的AfterExecuteAsync把任务标记为完成或直接删除。执行失败时,会根据配置决定重试次数和重试时间。
这个机制本身在单服务单实例下没有任何问题,因为只有自己在查表,等价于一个单消费者队列。但多实例部署时,所有实例的Worker都在执行同一个查询,都试图从同一个表里取未执行任务。如果数据库查询没有加行锁,或者加了锁但锁粒度不够,很可能两个服务同时拿到同一条任务,然后各自执行。
AbpVnext官方确实控制过这个问题,在BackgroundJobWorker里实现了一个简单的IWaitStrategy和单实例抢占逻辑。仔细看源码会发现,BackgroundJobWorker在执行任务之前会调用IBackgroundJobStore.GetWaitingJobsAsync获取待执行任务,然后尝试用数据库更新的方式把任务状态变成“in process”。有些版本使用了TryLockAsync方法,但这里的锁是数据库级别的乐观锁或更新锁,在MySQL的InnoDB引擎下有一些效果,在SQL Server下则依赖表锁或行锁。但问题在于,查询出任务和锁定任务不是原子操作,多个并发请求可能同时查到同一条任务,然后各自尝试更新,即使最终只有一个更新成功,另一个在更新受影响行数为0时应该放弃,但实际某些版本AbpVnext没有做严格检查,或者数据库隔离级别较低,导致两边都认为更新成功。
这就是“抢占”的技术温床。
1.3 为什么会被其他服务抢占:共享数据库与分布式锁缺失
最根本的原因就是共享数据库。微服务架构中,两个服务如果连接同一个数据库,并且使用同一个后台作业表,那它们之间就没有隔离了。哪怕A服务入队时指定的作业类型是AJob,B服务的Worker也照样能查到这个任务,因为它不区分作业类型归属服务,只按照时间排序查待执行任务。源码里的查询条件大概是“NextTryTime <= now && IsAbandoned == false”,并没有“TryCount、AccountId、ServiceName”之类的维度。
如果仅仅共享一个MySQL数据库,B服务又没有禁用BackgroundJobWorker,那么B服务自然会把A服务入队的任务当成公共池里的任务抢来执行。这跟“谁入队谁执行”的直觉完全相反,很多第一次接触AbpVnext后台作业的人都会踩这个坑。
第二个原因是缺少分布式锁。AbpVnext的后台作业模块并没有默认引入Redis分布式锁,它只会用一个数据库更新尝试“抢”任务,但抢不到默认就放弃了,并没有重试或明确归属。在跨进程、跨实例场景下,这种基于数据库单行的存在性判断并不可靠。
第三个原因是多数据库迁移或初始化顺序。如果B服务的模块列表里也加入了AbpBackgroundJobsModule,它就会自动创建自己的后台作业表,但如果两个服务配置了同一个数据库连接字符串,且都执行了自动迁移,那么两张表其实是同一张表,B服务也会注册后台Worker。
