第31篇|后台任务重复执行:用唯一任务键和租约防止并发
摘要:后台任务重复执行,常见于定时任务、页面返回刷新、手动同步各自启动了一次同样的业务。用户看到的是同步两次、通知重复、数据覆盖,日志里却像每个入口都合理。稳定做法是为业务任务建立唯一任务键和租约,任何入口都先申请运行权,拿不到租约就复用、跳过或等待。
我处理过一次后台同步问题:应用回到前台会同步一次,定时任务也会同步一次,用户点"立即同步"又来一次。三条入口同时跑,最后远端版本被旧任务覆盖。修复时没有去删某个入口,而是让所有入口进入同一个任务注册表。

这篇文章解决四个实际问题:
- 如何把任务入口和任务执行分开。
- 如何用唯一任务键避免同一业务并发执行。
- 如何用租约处理异常退出后的恢复。
- 如何验证手动、定时、前台恢复不会重复跑。


先确认重复的是同一业务任务
| 来源 | 任务意图 | 是否应合并 |
|---|---|---|
| 定时同步 | 同步当前用户数据 | 是 |
| 前台恢复 | 补齐当前用户数据 | 是 |
| 手动点击 | 用户要求立刻同步 | 可复用正在执行的任务 |
| 切换账号后同步 | 新用户数据 | 不合并 |
判断标准不是入口,而是业务任务键。用户不同、数据域不同,就不能合并。
定义任务键和租约
ts
export interface BackgroundTaskKey {
userId: string
scope: 'course_sync' | 'message_sync' | 'backup'
}
export function toTaskKey(key: BackgroundTaskKey): string {
return `${key.userId}|${key.scope}`
}
export interface TaskLease {
key: string
owner: string
expiresAt: number
}
租约不是永久锁。它有过期时间,能处理应用异常退出后任务永远占用的问题。
TaskRegistry 决定能不能跑
ts
export interface TaskRegistryStore {
getLease(key: string): Promise<TaskLease | null>
saveLease(lease: TaskLease): Promise<void>
removeLease(key: string, owner: string): Promise<void>
}
export class TaskRegistry {
constructor(private readonly store: TaskRegistryStore) {}
async acquire(key: string, owner: string, ttlMs: number): Promise<boolean> {
const current = await this.store.getLease(key)
if (current && current.expiresAt > Date.now()) {
return false
}
await this.store.saveLease({ key, owner, expiresAt: Date.now() + ttlMs })
return true
}
}
所有入口都先经过 acquire()。拿不到租约,不代表失败,而是说明同一个任务已经在跑。
Worker 只执行业务步骤
ts
export class CourseSyncWorker {
constructor(
private readonly registry: TaskRegistry,
private readonly repository: CourseSyncRepository
) {}
async run(userId: string, owner: string): Promise<void> {
const key = toTaskKey({ userId, scope: 'course_sync' })
const acquired = await this.registry.acquire(key, owner, 60_000)
if (!acquired) {
return
}
try {
await this.repository.pullRemoteChanges(userId)
await this.repository.pushLocalChanges(userId)
} finally {
await this.repository.releaseTask(key, owner)
}
}
}
Worker 不关心任务来自定时还是手动。入口差异只体现在 owner 和触发日志里。
失败后按阶段重试
ts
export interface SyncCheckpoint {
key: string
stage: 'pulling' | 'pushing' | 'done'
updatedAt: number
}
export interface CourseSyncRepository {
pullRemoteChanges(userId: string): Promise<void>
pushLocalChanges(userId: string): Promise<void>
releaseTask(key: string, owner: string): Promise<void>
}
如果任务跨多个阶段,记录 checkpoint 比只记录成功失败更有用。下次重试时能知道卡在拉取还是推送。
验证清单
- 定时和手动同时触发,只执行一条同步。
- 前台恢复时已有同步在跑,不再启动第二条。
- 切换账号后,新账号任务不被旧账号租约挡住。
- 模拟异常退出,租约过期后能重新执行。
- 失败重试不会重复推送已经完成的阶段。
手动任务要能看到"已有任务在运行"
用户点"立即同步"时,如果同一个任务已经在跑,不应该什么都不提示。页面可以显示已有任务的阶段和开始时间。
ts
export interface RunningTaskView {
key: string
stage: string
startedAt: number
canCancel: boolean
}
export interface TaskQueryService {
getRunningTask(key: string): Promise<RunningTaskView | null>
}
这样用户知道不是按钮失效,而是同步已经在进行。对一些耗时任务,也可以提供取消入口,但取消仍然要回到 Worker 或 Registry,不要让页面直接改租约。
租约过期不是强制成功
租约过期只说明上一个 owner 可能失联,不说明业务已经完成。重新执行前要读 checkpoint。
ts
export class SyncRecoveryService {
constructor(private readonly checkpointStore: SyncCheckpointStore) {}
async chooseRecovery(key: string): Promise<'restart' | 'resume' | 'skip'> {
const checkpoint = await this.checkpointStore.load(key)
if (checkpoint === null) {
return 'restart'
}
if (checkpoint.stage === 'done') {
return 'skip'
}
return 'resume'
}
}
这段逻辑避免"锁过期后从头再跑"造成重复推送。能恢复就恢复,已经完成就跳过,完全没有记录才重启。
任务入口要留下来源
同一个后台任务可能来自定时、前台恢复、用户点击或失败重试。它们最终走同一个 Worker,但日志里要能看出来源,否则排查时只看到"同步开始"。
ts
export type TaskSource = 'schedule' | 'foreground' | 'manual' | 'retry'
export interface TaskStartRequest {
key: string
source: TaskSource
owner: string
requestedAt: number
}
source 不参与任务去重,去重仍然看 key;它只用于排障和统计。比如用户反馈"我没点同步也同步了",就能从来源看到是前台恢复触发还是定时触发。
后台任务的用户反馈不能空着
后台任务跑起来后,页面至少要告诉用户当前状态。手动点击"立即同步"时,如果已经有任务在跑,可以显示"正在同步,开始于 14:03";如果刚刚失败,可以显示"上次同步失败,可重试"。这类反馈能减少用户重复点击,也能帮助你发现任务是否真的被租约挡住。
页面反馈至少包含:
- 当前任务阶段,例如拉取、推送或等待重试。
- 任务开始时间,帮助判断是否卡住。
- 可执行动作,例如查看详情、取消或稍后重试。
发布前并发验证
- 手动同步和定时同步同秒触发,只获得一个租约。
- 前台恢复触发时,如果手动同步正在跑,页面显示已有任务。
- 模拟 Worker 中途崩溃,租约过期后按 checkpoint 恢复。
- 切账号后旧账号任务不能继续写新账号数据。
- 日志能按 task key 查到一次任务的来源、阶段和结果。
交付前验收表
| 验收项 | 通过标准 |
|---|---|
| 同 key 并发 | 只获得一个租约 |
| 租约过期 | 能按 checkpoint 恢复或跳过 |
| 切账号 | 旧账号任务不写新账号数据 |
| 用户反馈 | 手动同步时能看到已有任务状态 |
后台任务的质量不只看最终有没有同步成功,还要看重复触发时是否有明确合并策略。
小结:后台任务要先拿运行权
后台任务重复执行,不靠删除入口解决。入口只发任务意图,TaskRegistry 用唯一键和租约决定能不能跑,Worker 负责业务执行,checkpoint 记录阶段。这样定时、前台恢复和手动同步不会互相踩踏。
切账号 | 旧账号任务不写新账号数据 |
| 用户反馈 | 手动同步时能看到已有任务状态 |
后台任务的质量不只看最终有没有同步成功,还要看重复触发时是否有明确合并策略。
小结:后台任务要先拿运行权
后台任务重复执行,不靠删除入口解决。入口只发任务意图,TaskRegistry 用唯一键和租约决定能不能跑,Worker 负责业务执行,checkpoint 记录阶段。这样定时、前台恢复和手动同步不会互相踩踏。