11 次浏览0 条回复

长批处理若只用 OFFSET 分页,处理中新增或删除记录时容易跳过或重复;把页码写进日志也不能形成可靠恢复点。一个最小方案是:启动时固定输入高水位,把工作拆成独立分片,并让每项业务写入可重入。

以下示例假设源表 id 单调递增。运行开始时保存边界,后续查询始终要求 id <= cutoff_id

create table batch_runs (
  run_id uuid primary key,
  cutoff_id bigint not null,
  state text not null check (state in ('running', 'paused', 'done'))
);

create table batch_chunks (
  run_id uuid not null references batch_runs(run_id),
  chunk_no integer not null,
  id_from bigint not null,
  id_to bigint not null,
  state text not null check (state in ('ready', 'running', 'done')),
  lease_owner uuid,
  lease_until timestamptz,
  attempts integer not null default 0,
  primary key (run_id, chunk_no)
);

insert into batch_runs (run_id, cutoff_id, state)
select :run_id, coalesce(max(id), 0), 'running' from source_items;

高水位让运行期间的新记录留给下一轮,但它只固定成员边界,不提供字段值快照;若任务必须读取启动瞬间的值,应把输入投影到暂存表或使用版本化数据。

不要让多个进程共同推进一个 last_id。分片完成顺序不同,较大游标先提交会永久跳过较小的在途分片。工作进程应分别领取分片:

with picked as (
  select run_id, chunk_no
  from batch_chunks
  where run_id = :run_id
    and (state = 'ready'
      or (state = 'running' and lease_until < clock_timestamp()))
  order by chunk_no
  for update skip locked
  limit 1
)
update batch_chunks c
set state = 'running', lease_owner = :owner,
    lease_until = clock_timestamp() + interval '60 seconds',
    attempts = attempts + 1
from picked p
where (c.run_id, c.chunk_no) = (p.run_id, p.chunk_no)
returning c.*;

领取语句还应校验运行状态为 running。暂停只把运行改为 paused;恢复后无需重置已完成分片。分片完成时必须校验 lease_owner 与租约仍有效,避免失去租约的旧进程覆盖接管者。

租约本身不能消除已经在途的写入,因此结果表要有稳定唯一键,例如 (job_type, source_id, transform_version)。业务结果和逐项回执放在同一事务;重复键且输入摘要相同视为幂等重放,摘要不同则进入显式冲突,不能静默覆盖。接管者依据逐项回执只补齐缺项,全部成功后才把分片标记为 done。整轮完成条件是所有分片均完成且冲突队列为空,不是最大游标到达高水位。

可复现验收至少覆盖四种顺序:处理中插入高水位之后的数据,本轮不处理;两个进程乱序完成分片,不丢记录;结果提交后丢失响应再接管,不生成第二份结果;暂停后终止任一进程再恢复,最终每个输入都有且只有一个回执。监控可记录待处理分片数、租约接管数、单项重试数、最老未完成分片年龄和冲突数。

这套方案增加了运行表、分片表和回执存储,但把“从哪里继续”从日志中的页码变成了可查询、可接管、可验收的协议。