31-后台任务重复执行-用唯一任务键和租约防止并发
第31篇后台任务重复执行用唯一任务键和租约防止并发摘要后台任务重复执行常见于定时任务、页面返回刷新、手动同步各自启动了一次同样的业务。用户看到的是同步两次、通知重复、数据覆盖日志里却像每个入口都合理。稳定做法是为业务任务建立唯一任务键和租约任何入口都先申请运行权拿不到租约就复用、跳过或等待。我处理过一次后台同步问题应用回到前台会同步一次定时任务也会同步一次用户点“立即同步”又来一次。三条入口同时跑最后远端版本被旧任务覆盖。修复时没有去删某个入口而是让所有入口进入同一个任务注册表。这篇文章解决四个实际问题如何把任务入口和任务执行分开。如何用唯一任务键避免同一业务并发执行。如何用租约处理异常退出后的恢复。如何验证手动、定时、前台恢复不会重复跑。先确认重复的是同一业务任务来源任务意图是否应合并定时同步同步当前用户数据是前台恢复补齐当前用户数据是手动点击用户要求立刻同步可复用正在执行的任务切换账号后同步新用户数据不合并判断标准不是入口而是业务任务键。用户不同、数据域不同就不能合并。定义任务键和租约exportinterfaceBackgroundTaskKey{userId:stringscope:course_sync|message_sync|backup}exportfunctiontoTaskKey(key:BackgroundTaskKey):string{return${key.userId}|${key.scope}}exportinterfaceTaskLease{key:stringowner:stringexpiresAt:number}租约不是永久锁。它有过期时间能处理应用异常退出后任务永远占用的问题。TaskRegistry 决定能不能跑exportinterfaceTaskRegistryStore{getLease(key:string):PromiseTaskLease|nullsaveLease(lease:TaskLease):PromisevoidremoveLease(key:string,owner:string):Promisevoid}exportclassTaskRegistry{constructor(privatereadonlystore:TaskRegistryStore){}asyncacquire(key:string,owner:string,ttlMs:number):Promiseboolean{constcurrentawaitthis.store.getLease(key)if(currentcurrent.expiresAtDate.now()){returnfalse}awaitthis.store.saveLease({key,owner,expiresAt:Date.now()ttlMs})returntrue}}所有入口都先经过acquire()。拿不到租约不代表失败而是说明同一个任务已经在跑。Worker 只执行业务步骤exportclassCourseSyncWorker{constructor(privatereadonlyregistry:TaskRegistry,privatereadonlyrepository:CourseSyncRepository){}asyncrun(userId:string,owner:string):Promisevoid{constkeytoTaskKey({userId,scope:course_sync})constacquiredawaitthis.registry.acquire(key,owner,60_000)if(!acquired){return}try{awaitthis.repository.pullRemoteChanges(userId)awaitthis.repository.pushLocalChanges(userId)}finally{awaitthis.repository.releaseTask(key,owner)}}}Worker 不关心任务来自定时还是手动。入口差异只体现在 owner 和触发日志里。失败后按阶段重试exportinterfaceSyncCheckpoint{key:stringstage:pulling|pushing|doneupdatedAt:number}exportinterfaceCourseSyncRepository{pullRemoteChanges(userId:string):PromisevoidpushLocalChanges(userId:string):PromisevoidreleaseTask(key:string,owner:string):Promisevoid}如果任务跨多个阶段记录 checkpoint 比只记录成功失败更有用。下次重试时能知道卡在拉取还是推送。验证清单定时和手动同时触发只执行一条同步。前台恢复时已有同步在跑不再启动第二条。切换账号后新账号任务不被旧账号租约挡住。模拟异常退出租约过期后能重新执行。失败重试不会重复推送已经完成的阶段。手动任务要能看到“已有任务在运行”用户点“立即同步”时如果同一个任务已经在跑不应该什么都不提示。页面可以显示已有任务的阶段和开始时间。exportinterfaceRunningTaskView{key:stringstage:stringstartedAt:numbercanCancel:boolean}exportinterfaceTaskQueryService{getRunningTask(key:string):PromiseRunningTaskView|null}这样用户知道不是按钮失效而是同步已经在进行。对一些耗时任务也可以提供取消入口但取消仍然要回到 Worker 或 Registry不要让页面直接改租约。租约过期不是强制成功租约过期只说明上一个 owner 可能失联不说明业务已经完成。重新执行前要读 checkpoint。exportclassSyncRecoveryService{constructor(privatereadonlycheckpointStore:SyncCheckpointStore){}asyncchooseRecovery(key:string):Promiserestart|resume|skip{constcheckpointawaitthis.checkpointStore.load(key)if(checkpointnull){returnrestart}if(checkpoint.stagedone){returnskip}returnresume}}这段逻辑避免“锁过期后从头再跑”造成重复推送。能恢复就恢复已经完成就跳过完全没有记录才重启。任务入口要留下来源同一个后台任务可能来自定时、前台恢复、用户点击或失败重试。它们最终走同一个 Worker但日志里要能看出来源否则排查时只看到“同步开始”。exporttypeTaskSourceschedule|foreground|manual|retryexportinterfaceTaskStartRequest{key:stringsource:TaskSource owner:stringrequestedAt:number}source不参与任务去重去重仍然看key它只用于排障和统计。比如用户反馈“我没点同步也同步了”就能从来源看到是前台恢复触发还是定时触发。后台任务的用户反馈不能空着后台任务跑起来后页面至少要告诉用户当前状态。手动点击“立即同步”时如果已经有任务在跑可以显示“正在同步开始于 14:03”如果刚刚失败可以显示“上次同步失败可重试”。这类反馈能减少用户重复点击也能帮助你发现任务是否真的被租约挡住。页面反馈至少包含当前任务阶段例如拉取、推送或等待重试。任务开始时间帮助判断是否卡住。可执行动作例如查看详情、取消或稍后重试。发布前并发验证手动同步和定时同步同秒触发只获得一个租约。前台恢复触发时如果手动同步正在跑页面显示已有任务。模拟 Worker 中途崩溃租约过期后按 checkpoint 恢复。切账号后旧账号任务不能继续写新账号数据。日志能按 task key 查到一次任务的来源、阶段和结果。交付前验收表验收项通过标准同 key 并发只获得一个租约租约过期能按 checkpoint 恢复或跳过切账号旧账号任务不写新账号数据用户反馈手动同步时能看到已有任务状态后台任务的质量不只看最终有没有同步成功还要看重复触发时是否有明确合并策略。小结后台任务要先拿运行权后台任务重复执行不靠删除入口解决。入口只发任务意图TaskRegistry 用唯一键和租约决定能不能跑Worker 负责业务执行checkpoint 记录阶段。这样定时、前台恢复和手动同步不会互相踩踏。切账号 | 旧账号任务不写新账号数据 || 用户反馈 | 手动同步时能看到已有任务状态 |后台任务的质量不只看最终有没有同步成功还要看重复触发时是否有明确合并策略。小结后台任务要先拿运行权后台任务重复执行不靠删除入口解决。入口只发任务意图TaskRegistry 用唯一键和租约决定能不能跑Worker 负责业务执行checkpoint 记录阶段。这样定时、前台恢复和手动同步不会互相踩踏。