31-后台任务重复执行-用唯一任务键和租约防止并发

第31篇|后台任务重复执行:用唯一任务键和租约防止并发

摘要:后台任务重复执行,常见于定时任务、页面返回刷新、手动同步各自启动了一次同样的业务。用户看到的是同步两次、通知重复、数据覆盖,日志里却像每个入口都合理。稳定做法是为业务任务建立唯一任务键和租约,任何入口都先申请运行权,拿不到租约就复用、跳过或等待。

我处理过一次后台同步问题:应用回到前台会同步一次,定时任务也会同步一次,用户点"立即同步"又来一次。三条入口同时跑,最后远端版本被旧任务覆盖。修复时没有去删某个入口,而是让所有入口进入同一个任务注册表。

这篇文章解决四个实际问题:

  1. 如何把任务入口和任务执行分开。
  2. 如何用唯一任务键避免同一业务并发执行。
  3. 如何用租约处理异常退出后的恢复。
  4. 如何验证手动、定时、前台恢复不会重复跑。

先确认重复的是同一业务任务

来源 任务意图 是否应合并
定时同步 同步当前用户数据
前台恢复 补齐当前用户数据
手动点击 用户要求立刻同步 可复用正在执行的任务
切换账号后同步 新用户数据 不合并

判断标准不是入口,而是业务任务键。用户不同、数据域不同,就不能合并。

定义任务键和租约

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 比只记录成功失败更有用。下次重试时能知道卡在拉取还是推送。

验证清单

  1. 定时和手动同时触发,只执行一条同步。
  2. 前台恢复时已有同步在跑,不再启动第二条。
  3. 切换账号后,新账号任务不被旧账号租约挡住。
  4. 模拟异常退出,租约过期后能重新执行。
  5. 失败重试不会重复推送已经完成的阶段。

手动任务要能看到"已有任务在运行"

用户点"立即同步"时,如果同一个任务已经在跑,不应该什么都不提示。页面可以显示已有任务的阶段和开始时间。

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";如果刚刚失败,可以显示"上次同步失败,可重试"。这类反馈能减少用户重复点击,也能帮助你发现任务是否真的被租约挡住。

页面反馈至少包含:

  1. 当前任务阶段,例如拉取、推送或等待重试。
  2. 任务开始时间,帮助判断是否卡住。
  3. 可执行动作,例如查看详情、取消或稍后重试。

发布前并发验证

  1. 手动同步和定时同步同秒触发,只获得一个租约。
  2. 前台恢复触发时,如果手动同步正在跑,页面显示已有任务。
  3. 模拟 Worker 中途崩溃,租约过期后按 checkpoint 恢复。
  4. 切账号后旧账号任务不能继续写新账号数据。
  5. 日志能按 task key 查到一次任务的来源、阶段和结果。

交付前验收表

验收项 通过标准
同 key 并发 只获得一个租约
租约过期 能按 checkpoint 恢复或跳过
切账号 旧账号任务不写新账号数据
用户反馈 手动同步时能看到已有任务状态

后台任务的质量不只看最终有没有同步成功,还要看重复触发时是否有明确合并策略。

小结:后台任务要先拿运行权

后台任务重复执行,不靠删除入口解决。入口只发任务意图,TaskRegistry 用唯一键和租约决定能不能跑,Worker 负责业务执行,checkpoint 记录阶段。这样定时、前台恢复和手动同步不会互相踩踏。

切账号 | 旧账号任务不写新账号数据 |

| 用户反馈 | 手动同步时能看到已有任务状态 |

后台任务的质量不只看最终有没有同步成功,还要看重复触发时是否有明确合并策略。

小结:后台任务要先拿运行权

后台任务重复执行,不靠删除入口解决。入口只发任务意图,TaskRegistry 用唯一键和租约决定能不能跑,Worker 负责业务执行,checkpoint 记录阶段。这样定时、前台恢复和手动同步不会互相踩踏。

相关推荐
我是小a1 小时前
HarmonyOS API 24 List组件与数据渲染技术详解
华为·harmonyos·鸿蒙
我是小a2 小时前
HarmonyOS API 24 交互事件与动画效果技术详解
华为·harmonyos·鸿蒙
不肥嘟嘟右卫门2 小时前
鸿蒙原生ArkTS布局方式之List垂直列表布局深度解析
华为·list·harmonyos
listening7772 小时前
HarmonyOS 6.1 端云协同大模型实战:隐私不泄露的智能导购落地
华为·harmonyos
xd1855785553 小时前
SQL语句生成-基于鸿蒙的AI SQL语句生成应用开发实践
人工智能·sql·华为·harmonyos·鸿蒙
鸿蒙程序媛3 小时前
【工具汇总】鸿蒙 ArkWeb完整调试工具步骤
华为·harmonyos
解局易否结局6 小时前
鸿蒙人脸检测与活体识别实战:基于 @ohos.visionKit 构建安全身份验证
安全·华为·harmonyos
ldsweet16 小时前
《HarmonyOS技术精讲-Basic Services Kit》线程通信利器:Emitter实现高效线程间消息传递
华为·harmonyos
心中有国也有家16 小时前
AtomGit Flutter 鸿蒙客户端:Canvas 绘制进阶-路径、渐变与混合模式
android·javascript·flutter·华为·harmonyos