一个常见场景:应用因为 OOM 异常退出,重启后恢复正常,但有一批任务一直停留在“执行中”,既不成功,也不失败。
这里的两难不在人工处理环节。人介入后,可以查日志、看业务记录,再决定是重新执行还是作其他处理。难的是系统在自动处理时无法判断应该怎么做:
- 如果把这些任务的状态从
RUNNING改回PENDING并重新执行,风险是原任务可能并没有中断,只是执行时间较长,这样就会出现两个执行者同时处理同一个任务; - 如果保持
RUNNING不动,风险是原任务可能已经随应用退出而中断,这样它就会一直停留在RUNNING,无法自动恢复。
而从任务表上看,这两种情况的表现完全相同,都是 RUNNING。系统只根据这个状态,无法判断应该改回待执行,还是继续等待。
本文讨论的问题是:任务状态应该怎样设计,才能让系统既能避免同一个任务被并发重复执行,又能在任务中断后判断出它已经无人处理,并重新执行它?
下文先介绍两种状态更新方案。方案一是常见的未处理异常退出的方案,方案二可以避免方案一的主要问题,但它把恢复时间交给了底层组件的超时设置,实际使用中并不合适。因此最终仍采用方案一 + 异常处理机制。最后再从理论上论证,为什么这类问题无法做到完全自动处理,总有一些情况需要人工介入。
1. 问题说明
假设有一张最简单的任务表,核心字段只有一个 status,取值为 PENDING(待执行)、RUNNING(执行中)、SUCCESS(成功)、FAIL(失败)。
多个应用实例(甚至多个线程)都会来这张表里领取任务。领取任务时需要解决两个问题:
- 怎样让其他执行者知道这个任务已经有人在处理? 如果状态没有及时更新,或者更新后其他执行者看不到,就可能有多个执行者重复执行同一个任务。实际处理时,一般会用带条件的原子更新(CAS)来保证一个任务只被一个执行者领取到。
- 执行者在执行过程中异常退出后,怎样让其他执行者知道这个任务已经无人处理? 执行者异常退出时,通常没有机会再更新状态,任务会停留在
RUNNING。
两个问题需要同时解决,这是任务状态设计的难点所在。
2. 方案一:双事务,先提交 RUNNING 状态
方案一使用两个事务:先同步调用一个新事务,用乐观锁 CAS 把状态改成 RUNNING 并提交,抢占成功后,再执行任务。这里的新事务是指数据库事务上新开一个事务,程序进程内仍然是同步调用、同步等待结果。
流程如上图:
- 事务 1(主事务)先查询任务状态,判断任务是否满足执行条件;
- 如果满足,同步调用事务 2。事务 2 是一个新事务,在进程内同步执行:用乐观锁 CAS 更新任务状态,即
UPDATE ... SET status = 'RUNNING' WHERE id = ? AND status = 'PENDING',并提交事务; - 事务 1 同步等待事务 2 的返回结果。影响行数为 1 表示抢占成功,开始执行任务;影响行数为 0 说明任务已被其他执行者领取,放弃本次执行;
- 任务执行完成后,再把状态更新为
SUCCESS或FAIL。
这个方案可以解决任务重复领取的问题,但有一个致命缺点。
2.1 致命缺点:执行者异常退出后,任务一直停留在 RUNNING
事务 2 已经提交了 RUNNING,事务 1 正在执行任务,此时如果应用异常退出(例如 OOM、机器故障、强制终止进程、发布重启),就不会再有人修改任务状态,数据库中的状态会一直停留在 RUNNING。
其他执行者看到状态是 RUNNING,会认为任务仍在执行,从而跳过这个任务。结果是任务实际上已经中断,却一直无人继续处理。
3. 方案二:单事务,任务状态随业务事务一起提交或回滚
方案二只用一个事务贯穿任务执行的整个过程,状态更新先不提交,等任务全部执行完成后再一起提交。这样应用异常退出时事务会回滚,状态也会恢复。
流程如上图:
- 主事务查询任务状态,判断是否满足执行条件;
- 先把数据库的锁等待时间改得很短(图中为 0,实际可以使用 0 或 1 秒这类很短的时间),然后尝试用 CAS 把状态更新为
RUNNING,但不提交; - 如果更新失败,说明任务正在被其他执行者处理,直接回滚事务并结束;
- 如果更新成功,继续执行任务,全部完成后在同一个事务里把状态更新为
SUCCESS或FAIL,最后一起提交。
第 2 步修改锁等待时间的原因是:RUNNING 状态没有提交,其他执行者在查询时看到的仍是原来的状态,只能通过尝试更新时遇到行锁,才知道任务正在被处理。而 MySQL 默认的锁等待时间(innodb_lock_wait_timeout)为 50 秒,如果不修改,每个来领取任务的执行者最长可能要等 50 秒才能知道结果。把等待时间设得很短,遇到行锁时可以很快失败并放弃领取。
3.1 方案二的优点:应用异常退出时,状态随事务回滚
任务执行到一半时,如果应用异常退出,连接断开,未提交的事务会被回滚,那条把状态改为 RUNNING 的更新也会被撤销,任务状态恢复为原来的值,可以被重新领取执行。
执行者不需要在退出前主动更新状态,也不需要额外的程序来修改状态。从这一点看,方案二解决了方案一的主要缺点。
3.2 方案二的主要问题:恢复时间取决于底层组件的超时时间
方案二的问题在于,事务什么时候回滚,并不受业务控制。这个问题并不容易察觉,因为大多数情况下,应用挂掉,MySQL是能及时感知并回滚事务。
然而,不怕一万就怕万一,进程停止运行和连接被数据库判定失效并不是同一时间发生的:
- 如果进程被正常终止,操作系统会关闭连接,数据库可以较快发现;
- 如果进程没有退出,只是停止响应,例如程序进入无限循环、长时间 GC、机器长时间无响应、网络异常导致连接中断但双方未及时感知,那么进程还在,连接也还没有被关闭,数据库无法判断这个连接是否失效。
这种情况下只能等待超时。常见的几个超时设置如下(数值为常见环境下的经验值,具体数值随操作系统和数据库配置不同而不同,这里关注的是它们的数量级和作用):
| 机制 | 实际作用 | 常见量级 |
|---|---|---|
| TCP keepalive | 检测一条 TCP 连接的对端是否还存在 | 约 2 小时 |
MySQL 会话超时(wait_timeout) |
回收长时间没有活动的数据库会话 | 默认可达 8 小时 |
也就是说,方案二的自动恢复实际上依赖 TCP 和数据库的超时处理。客户端异常后,任务可能要等几个小时才能被重新执行,而且这个时间无法按任务调整。
更重要的是,这些超时判断的依据和业务需要判断的内容并不一致:
- 底层超时判断的是连接是否还存在,业务需要判断的是任务是否还在正常执行、是否还需要继续等待;
- 底层超时是全局统一的配置,任务的正常执行时间却差别很大:有的任务 30 秒没有完成就可以认为异常,有的任务正常执行就需要 2 个小时。用同一个连接超时时间去判断所有任务并不合理;
- 如果为了缩短恢复时间而调小这些底层超时,会影响到所有使用同一套配置的正常连接和长事务,改动的影响范围太大。
另外,单事务方案还有两个实际成本:一是任务执行时间有多长,事务就要保持多久,期间一直占用数据库连接和行锁,未提交数据产生的 undo 记录也会一直保留;二是事务回滚只能撤销数据库内的修改,任务执行过程中已经发出的接口调用、消息和文件写入不会随之撤销,这一点在第 5 节还会提到。
因此,方案二更适合执行时间短、处理内容主要在数据库内完成的任务。作为通用的任务状态设计,它把任务何时可以重新执行、何时重试、重试多少次这些本应由业务决定的事项,交给了底层组件的超时设置,灵活性和可控性都不足,实际中一般不会采用。
相比之下,方案一的问题是缺少异常退出后的处理机制,这个问题可以在方案一的基础上补充解决。因此实际方案仍采用方案一。
4. 实际方案:在方案一的基础上,用租约管理 RUNNING 状态
实际方案的核心是给 RUNNING 状态设置一个有效期,可以理解为租约(lease):
执行者领取任务后,需要在执行过程中定期更新心跳时间;超过约定时间没有更新,就认为这个执行者已经无法继续处理,任务可以被回收并重新分配。
状态也相应增加 RETRY_WAIT(等待重试)和 WAIT_MANUAL(等待人工处理)两个状态:
在这个基础上,异常任务分两种方式处理:
常规异常处理(自动处理)。 系统能够判断原来的执行已经不会再继续,就自动把状态从 RUNNING 改回 PENDING,让任务重新被领取。常见有三种:
- 心跳停止时间超过阈值。 定时巡检发现任务还是
RUNNING,但心跳已经超过设定时间没有更新,就把状态改回PENDING。这个机制能否自动化仍要看实际业务情况。 - 应用进程重启完成时。 进程启动后,把执行者标识等于当前 IP + 进程 ID、状态仍是
RUNNING的任务改回PENDING。这些任务是重启前由本进程领走的,进程已经重新启动,原来的执行不会再继续。 - 执行者自己判断可以重试。 执行过程中遇到明确可以重试的错误,例如数据库连接短暂失败,执行者还在运行,由它自己把状态改回
PENDING。
特殊异常处理(人工处理)。 系统看不出应该继续等、重新执行,还是做别的处理,就把任务改为 WAIT_MANUAL,停止自动重试,交给人工决定。常见有三种:
- 心跳正常,但运行时长明显超过预期。 心跳还在更新,说明进程还在;按这个任务平时的耗时,它早该结束了。常见原因是调用第三方接口一直不返回、任务进入无限循环、机器卡顿。这种情况下不能把它当成已经中断而自动重跑,也不适合一直等下去。
- 同一任务反复失败,或输入数据本身有问题。 再自动执行也不会得到不同的结果。应由人查看原因后,决定重新执行、标记失败,或修正数据后再执行。
4.1 三个方案的对比
| 对比项 | 方案二:单事务 | 方案一:无异常处理 | 方案一 + 租约和人工处理(实际采用) |
|---|---|---|---|
| 状态可见性 | 状态未提交,只能通过行锁判断任务是否被占用 | 提交后其他执行者可见 | 提交后其他执行者可见 |
| 执行者异常退出后 | 事务回滚,状态恢复 | 一直停留在 RUNNING | 心跳过期后由定时巡检回收 |
| 恢复时间由谁决定 | TCP 和数据库的超时时间,常见为小时级 | 没有恢复机制 | 由业务设置的租约时间决定,可以到秒级或分钟级 |
| 重试方式 | 基本由底层超时决定,难以按任务调整 | 可以自行设计,但没有程序去执行 | 重试次数、重试间隔、哪些错误不重试,都可以按任务设置 |
| 长事务与锁占用 | 任务执行期间一直保持事务和行锁 | 无 | 无 |
| 无法自动判断的情况 | 状态可以回滚,外部调用的结果不会随之撤销 | 无处理方式 | 转为人工处理,查看记录后再决定 |
| 代价 | 实现简单,但恢复时间不可控 | 实现最简单,但异常后无人处理 | 需要增加心跳、定时巡检和管理功能 |
方案一的不足是缺少配套的异常处理机制,补充后可以解决;方案二的问题是把恢复时间的决定权交给了底层组件,这个问题在方案本身之内无法调整。这也是最终仍采用方案一的原因。
5. 从理论上看:为什么无法做到完全自动处理
前面已经给出了自动处理和人工处理两种方式。一个自然的问题是:自动处理的范围能不能继续扩大,直到不再需要人工处理?
结论是不能。这不是实现得不够细致的问题,而是这类问题本身存在几个无法绕开的限制。
5.1 无法在有限时间内区分“已停止响应”和“只是执行较慢”
这是分布式系统中的一个基本结论:在异步系统中,无法做到完全准确的故障检测。
可以用两种情形来说明。假设定时巡检设定,任务的心跳超过 T 秒没有更新就回收任务:
- 情形 A:执行者已经停止运行,心跳不会再更新;
- 情形 B:执行者仍在运行,只是心跳更新因为网络延迟、GC 停顿或机器短时繁忙而迟迟没有写入,要等到 T 秒以后才会更新。
在 T 秒这个时间点上,巡检程序在两种情形中看到的都是同一结果:心跳已经超过 T 秒没有更新。同一个观察结果只能对应同一种处理决定,这个决定在两种情形中必然有一种不合适:在情形 B 中回收任务,会和原执行者同时执行同一个任务;在情形 A 中不回收,任务就会一直等待。
因此,任何超时时间都只是一种取舍:设得短,可以更快地恢复已中断的任务,但也更容易把执行较慢的任务误判为异常;设得长,误判较少,但已中断的任务要等更久才能恢复。不存在一个超时时间,可以同时做到完全不误判和立即恢复。租约机制并没有绕开这个限制,它只是把这个时间交给了业务,允许不同任务按各自的正常执行时间分别设置。
5.2 无法确认外部操作在中断时到底执行到了哪一步
任务执行通常不只是修改数据库,还会调用第三方接口、发送消息、写文件、扣减库存等。应用可能在执行过程中的任意一步异常退出。以下三种情形在任务表中看起来完全一样,都是 RUNNING 且心跳已经过期:
| 情形 | 中断前实际发生的情况 | 合适的处理 | 如果直接重新执行的后果 |
|---|---|---|---|
| A | 还没有调用第三方接口 | 重新执行 | 无,属于正常重试 |
| B | 已经调用第三方接口,对方处理成功,但返回结果在网络中丢失 | 不应重复调用,应先向对方查询结果 | 可能重复扣款、重复发货或重复发送通知 |
| C | 第三方只处理了一部分,处于中间状态 | 需要做补偿处理,或由人工核对后再决定 | 可能让中间状态的数据更加不一致 |
观察结果相同,合适的处理却不同,自动程序按任何一种固定方式处理,都至少会在其中一种情形下处理错误。这也是分布式系统中关于 exactly-once 的常见结论:在网络不可靠的情况下,对外部操作无法保证严格意义上的不多不少正好执行一次。实际中通常只能选择 at-least-once(至少执行一次,可能重复)再配合业务幂等,或者 at-most-once(最多执行一次,可能遗漏)再配合事后核对和补充处理,选择哪一种取决于业务对重复和遗漏的接受程度。
幂等键、下游状态查询、本地消息表、检查点、Saga 补偿、执行令牌校验等方法,可以处理其中一部分情形:让情形 B 可以查询和去重,让情形 C 可以做补偿。但这些方法都依赖前提条件,例如对方系统要支持幂等键或提供查询接口,已经产生的操作要能够补偿。前提条件都满足时,自动处理可以做得比较完整;只要有一个条件不满足,例如对方只提供了一个调用接口、无法查询处理结果,系统就仍然无法自动判断。对无法判断的情况保留人工处理,就是这个原因。
5.3 有些处理决定需要结合业务情况,无法只根据状态字段判断
前面两点说的是系统无法确认事实。还有一种情况是事实已经清楚,但应该怎样处理并不是单纯的技术判断。
例如,一个给一万名用户发送活动通知的任务,发送到六千人时中断。已发送和未发送的名单都是明确的,但后续应该怎样处理,还需要考虑:剩余用户是否还需要继续发送,活动是否已经变更或取消,通知内容是否已经修改,从其他渠道收到过通知的用户是否会重复收到。这些信息不在任务表中,需要结合当时的业务情况来决定。
自动程序只能执行事先设定好的规则,而实际运行中会出现事先没有考虑到的组合情况:新的失败原因、新的业务限制、新的外部系统。机器适合处理判断条件明确、会重复出现的问题;第一次出现、判断错了影响又较大的问题,需要人来结合具体情况决定。保留人工处理,处理的就是这一部分情况。
综合以上三点,实际系统只能根据任务特点设定超时和重试规则,并为无法自动判断的情况保留人工处理入口。
6. 总结
回到最初的问题:任务状态应该怎样设计?
- 方案一用双事务先提交
RUNNING再执行任务,状态和重试方式都由业务控制,但执行者异常退出后任务会一直停留在RUNNING; - 方案二用单事务让状态随业务事务一起回滚,看起来解决了上面的问题,但恢复时间取决于 TCP 和数据库的超时时间,通常是小时级,也无法按任务分别设置,因此实际中一般不会采用;
- 实际采用的方案仍是方案一,在它之上用租约和心跳判断任务是否仍在执行,用定时巡检和重试规则自动处理应用异常退出、发布重启、机器故障等常见异常,用人工处理解决外部操作结果无法确认、多次重试仍失败、需要结合业务情况决定等无法自动判断的情况。
从理论上看,这个安排不是临时折中,而是由问题本身的限制决定的:无法在有限时间内完全区分停止响应和执行较慢,无法确认外部操作在中断时执行到了哪一步,一部分处理决定还需要结合业务情况判断。这三点决定了,任务系统无法完全去掉人工处理,只能通过明确的超时、重试和状态记录,尽量扩大自动处理的范围,并把需要人工处理的情况及时识别出来,附上完整的执行记录,交给人来决定。
换句话说,设计任务状态时真正要做的,不是追求所有异常都能自动恢复,而是分清哪些情况可以自动处理、哪些情况不能。能自动处理的尽量自动处理,不能自动判断的及时改为人工处理,这样既不会让常见异常长时间无人处理,也不会在情况不明时自动做出可能错误的决定。
人工兜底从来不是设计的缺陷。没有兜底的自动化,才是。