第一章:Go订单过期任务在K8s CronJob中漏执行的典型现象与根因定位
典型现象观察
运维人员常发现订单过期清理任务(如 order-cleanup)在预期时间点未触发,导致过期订单堆积。通过 kubectl get jobs -n prod | grep order-cleanup 查看历史 Job 列表时,可见某次调度周期(如 2024-06-15T02:00:00Z)缺失对应 Job;同时,Pod 日志中无该时刻启动记录,但前序与后续周期 Job 均存在,呈现“偶发性漏执行”。
时间同步与调度窗口偏差
Kubernetes CronJob 依赖控制平面的 kube-controller-manager 进行调度,其默认容忍 10 秒的时钟漂移。若节点间 NTP 同步异常(如 ntpq -p 显示 offset > 500ms),或集群跨可用区部署且时区配置不一致(如 CronJob spec 中未显式指定 timezone: Asia/Shanghai),可能导致控制器判定“当前时间未达下次调度点”,跳过本次触发。
并发策略与失败抑制机制
CronJob 默认使用 concurrencyPolicy: Allow,但若上一轮 Job 因 Go 程序 panic 或 context timeout 未正常终止(如 os.Exit(1) 后 Pod 处于 Completed 但实际未完成清理),而新周期到达时旧 Job 仍被控制器视为活跃(activeDeadlineSeconds 未设或过大),则根据 concurrencyPolicy: Forbid(若已配置)将直接丢弃本次调度。验证方式:
# 查看最近3个cronjob状态及活跃job数
kubectl get cronjob order-cleanup -n prod -o wide
kubectl get jobs -n prod --selector='job-name=order-cleanup' --sort-by='.status.startTime' | tail -n 3
根因定位路径
| 检查项 | 命令/方法 | 关键指标 |
|---|---|---|
| 调度器日志 | kubectl logs -n kube-system deploy/kube-controller-manager \| grep "order-cleanup" \| tail -20 |
是否出现 Skipped scheduled job 或 Too many missed start times |
| Job 生命周期 | kubectl describe job <job-name> -n prod |
Status.Active, Status.Succeeded, Events 中的 Warning |
| Go 应用健壮性 | 检查 main.go 中是否设置 signal.Notify 捕获 SIGTERM 并优雅退出 |
缺失处理将导致强制 kill 后状态残留 |
建议在 CronJob manifest 中显式配置:
spec:
timezone: "Asia/Shanghai"
concurrencyPolicy: "Replace" # 自动终止旧 Job,保障新周期必执行
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 5
第二章:Job TTL机制与Go订单生命周期的语义冲突分析
2.1 Job TTL控制器行为解析:从Kubernetes源码看ttlSecondsAfterFinished的实际生效路径
Job TTL控制器通过 ttlSecondsAfterFinished 字段实现自动清理,其核心逻辑位于 pkg/controller/job/ttl_controller.go。
触发时机
- 控制器每 10 秒同步一次(
resyncPeriod) - 仅处理状态为
Complete或Failed且已设置ttlSecondsAfterFinished的 Job
实际清理路径
// pkg/controller/job/ttl_controller.go#L156
if job.Status.CompletionTime != nil {
ttl := job.Spec.TTLSecondsAfterFinished
if ttl != nil && time.Since(job.Status.CompletionTime.Time) > time.Duration(*ttl)*time.Second {
return c.deleteJob(ctx, job) // 真正触发删除
}
}
逻辑分析:
CompletionTime必须非空(Job 已终态),时间差超过 TTL 值才进入删除流程;*ttl是 int32 指针解引用,单位为秒。
关键字段约束
| 字段 | 类型 | 是否必需 | 说明 |
|---|---|---|---|
ttlSecondsAfterFinished |
*int32 | 否 | 设为 nil 则不启用 TTL;设为 表示立即删除 |
graph TD
A[Job 进入 Complete/Failed] --> B[设置 CompletionTime]
B --> C[TTL 控制器周期性检查]
C --> D{满足 ttlSecondsAfterFinished + 时间阈值?}
D -->|是| E[发起 Delete API 请求]
D -->|否| C
2.2 Go订单过期任务的幂等性与TTL截断的耦合风险:基于gin+GORM订单服务的实测案例
数据同步机制
订单过期扫描任务采用 time.Ticker 触发,每30秒轮询 status = 'pending' AND created_at < NOW() - INTERVAL 30 MINUTE 的记录。但GORM默认未启用 SELECT ... FOR UPDATE,导致并发扫描时同一订单被多次选中。
幂等校验漏洞
// ❌ 危险:仅依赖数据库WHERE条件,无业务层幂等键校验
db.Where("status = ? AND expire_at < ?", "pending", time.Now()).Update("status", "expired")
该语句在事务未提交前被重复执行,因WHERE条件仍成立,触发多次状态变更——TTL逻辑(expire_at)与幂等边界(update条件)未解耦。
风险耦合示意
| 维度 | TTL截断行为 | 幂等失效场景 |
|---|---|---|
| 时间精度 | 数据库DATETIME(3) |
应用层time.Now().UTC() |
| 更新原子性 | 多行UPDATE无唯一约束 | 同一order_id被多goroutine命中 |
graph TD
A[定时任务触发] --> B{SELECT pending订单}
B --> C[并发goroutine1]
B --> D[并发goroutine2]
C --> E[UPDATE status=expired]
D --> F[UPDATE status=expired] --> G[订单状态重复变更]
2.3 TTL配置粒度失配问题:全局默认值 vs 订单SLA分级策略的实践调优
当订单系统采用统一TTL(如 ttl: 3600s)缓存所有订单数据时,高优先级“加急单”(SLA≤5min)与普通单(SLA≤24h)被迫共享同一过期策略,导致缓存击穿风险不均。
数据同步机制
Redis中按SLA等级动态设置TTL:
# 根据订单类型动态计算TTL(单位:秒)
def get_ttl_by_sla(order_type):
ttl_map = {"express": 300, "standard": 86400, "bulk": 1800}
return ttl_map.get(order_type, 3600)
# 注:express单5分钟自动驱逐,保障强一致性;bulk单仅缓存30分钟防积压
策略映射表
| SLA等级 | 缓存TTL | 触发场景 | 一致性要求 |
|---|---|---|---|
| 加急单 | 300s | 实时履约看板 | 强一致 |
| 普通单 | 86400s | 历史订单查询 | 最终一致 |
| 批量导入单 | 1800s | 导入中状态临时缓存 | 会话一致 |
流程决策逻辑
graph TD
A[订单写入] --> B{SLA等级识别}
B -->|加急单| C[TTL=300s]
B -->|普通单| D[TTL=86400s]
B -->|批量单| E[TTL=1800s]
C & D & E --> F[写入Redis with EX]
2.4 TTL与CronJob schedule精度偏差叠加效应:秒级过期任务在分钟级调度下的漏触发复现与量化建模
当TTL设为45s、CronJob以*/1 * * * *(每分钟整点触发)运行时,若控制器处理延迟达32s,则任务在第60s检查窗口内已过期13s,导致漏删。
漏触发关键路径
- CronJob实际执行时间 = 调度器队列+APIServer写入+控制器Sync周期
- TTL清理依赖
ttlSecondsAfterFinished字段,由job-controller每30s扫描一次(硬编码)
# jobs.yaml:45s TTL + 分钟级CronJob
apiVersion: batch/v1
kind: CronJob
metadata:
name: short-ttl-job
spec:
schedule: "*/1 * * * *"
jobTemplate:
spec:
ttlSecondsAfterFinished: 45 # ⚠️ 非实时生效,依赖控制器扫描间隔
逻辑分析:
ttlSecondsAfterFinished=45仅声明语义;实际清理由job_controller.go中syncJob调用deleteFinishedJobs完成,其扫描周期固定为30s(resyncPeriod),且每次仅处理limit=500个Job,形成双重延迟底座。
偏差叠加模型
| 维度 | 典型值 | 累积误差 |
|---|---|---|
| CronJob调度抖动 | ±8s | 基线漂移 |
| Job创建到Status更新延迟 | ≤12s | APIServer写入+etcd提交 |
| Job控制器扫描间隔 | 30s(固定) | 决定最大检测滞后 |
graph TD
A[CronJob触发] --> B[Job对象创建]
B --> C[Status.phase=Complete]
C --> D{Job控制器下次扫描}
D -->|+0~30s| E[TTL检查执行]
E -->|TTL=45s| F[是否已超时?]
漏触发概率可建模为:
$P{miss} = \max\left(0,\; \frac{45 – (60 – \delta{cron} – \delta{create} – \delta{scan})}{45}\right)$,其中$\delta_{scan} \in [0,30)$。
2.5 基于Prometheus+Kube-State-Metrics的TTL丢弃事件可观测性增强方案
Kubernetes 中 TTL控制器自动清理过期Job时,原生事件(TTLControllerFinalizerRemoved)仅瞬时存在且不持久化,导致丢弃行为难以追踪与告警。
数据同步机制
Kube-State-Metrics 通过--resources=events启用事件采集,将TTL触发的Warning级事件(如TTLExpired)暴露为指标:
# kube-state-metrics deployment 配置片段
args:
- --resources=events,jobs
- --events-labels=reason,involvedObject.kind,involvedObject.name
--events-labels显式注入reason标签,使kube_event_count{reason="TTLExpired"}可被精确查询;involvedObject.kind="Job"确保聚焦目标资源。
关键指标与告警逻辑
| 指标名 | 标签示例 | 用途 |
|---|---|---|
kube_event_count |
{reason="TTLExpired",namespace="prod"} |
统计单位时间丢弃Job数 |
kube_job_status_succeeded |
{job_name=~".+-ttl-\\d+"} |
辅助验证是否误删 |
事件生命周期可视化
graph TD
A[Job创建] --> B[TTLSecondsAfterFinished=300]
B --> C[5分钟后TTL控制器触发删除]
C --> D[生成TTLExpired事件]
D --> E[KSM采集并转为指标]
E --> F[Prometheus抓取+Grafana看板渲染]
第三章:activeDeadlineSeconds对长尾订单处理的破坏性影响
3.1 activeDeadlineSeconds底层实现原理:Pod生命周期管理器与Job控制器的协同边界
activeDeadlineSeconds 并非由单一组件独占处理,而是通过 Job 控制器 与 Pod 生命周期管理器(kubelet + kube-controller-manager 中的 podgc/podstatus) 的职责切分实现。
协同触发机制
- Job 控制器周期性调谐(reconcile),检查
job.Status.StartTime与当前时间差是否超限; - 超时时,Job 控制器向 API Server 发起
DELETE请求,仅针对尚未成功(Succeeded/Failed)的活跃 Pod; - kubelet 不参与 deadline 判定,仅执行终止信号(SIGTERM → SIGKILL)。
核心逻辑代码片段(Job controller 主干)
// pkg/controller/job/job_controller.go#Reconcile
if job.Spec.ActiveDeadlineSeconds != nil {
if time.Since(job.Status.StartTime.Time) > time.Second*time.Duration(*job.Spec.ActiveDeadlineSeconds) {
// 仅清理 Pending/Running 状态的 Pod
pods, _ := jm.podLister.Pods(job.Namespace).List(
labels.SelectorFromSet(labels.Set{"job-name": job.Name}))
for _, p := range pods {
if IsPodActive(p) { // !p.Status.Phase.In(Completed, Failed)
jm.podControl.DeletePod(p.Namespace, p.Name, job)
}
}
}
}
IsPodActive()判断依据为PodPhase == Pending || Running;podControl.DeletePod触发优雅终止流程(terminationGracePeriodSeconds生效),不绕过调度器或 kubelet 的标准终止协议。
职责边界对比表
| 组件 | 负责判定 deadline? | 发起 Pod 删除? | 处理终态 Pod(Succeeded/Failed)? |
|---|---|---|---|
| Job 控制器 | ✅ | ✅(仅 Active Pod) | ❌(忽略) |
| Pod 生命周期管理器 | ❌ | ❌ | ✅(GC 清理终态 Pod) |
graph TD
A[Job Controller] -->|检测 activeDeadlineSeconds 超时| B[筛选 Active Pod]
B --> C[发起 DELETE /api/v1/namespaces/*/pods/*]
C --> D[kube-apiserver]
D --> E[kubelet: 执行 SIGTERM → SIGKILL]
E --> F[Pod 进入 Terminating 状态]
3.2 Go订单批量清理场景下超时中断导致数据不一致:Redis分布式锁失效与MySQL事务回滚异常实录
数据同步机制
批量清理任务通过 context.WithTimeout 控制整体执行时间,但锁续期未同步中断信号,导致 Redis 锁提前过期:
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
// 错误:未在goroutine中监听ctx.Done()进行unlock或refresh
lock, _ := redisLock.Lock(ctx, "order:clean:lock", 10*time.Second)
逻辑分析:
Lock()返回后,若任务耗时 >10s 且无自动续期,其他实例将抢锁;此时原事务仍在执行DELETE FROM orders WHERE status = 'expired',造成重复清理。
关键失败路径
- MySQL 事务未捕获
context.Canceled,ROLLBACK被忽略 - Redis 锁 TTL 固定,与上下文生命周期脱钩
| 组件 | 问题表现 | 根因 |
|---|---|---|
| Redis锁 | 提前释放,多实例并发 | 缺乏心跳续期与ctx联动 |
| MySQL事务 | 部分行已删,未回滚 | defer tx.Rollback() 未检查err |
graph TD
A[启动批量清理] --> B{获取Redis锁}
B -->|成功| C[开启MySQL事务]
C --> D[执行DELETE]
D --> E{ctx.Done?}
E -->|是| F[应Rollback但被跳过]
E -->|否| G[Commit]
B -->|失败| H[跳过执行]
3.3 动态计算activeDeadlineSeconds的工程化实践:基于订单积压量与历史P99耗时的自适应阈值算法
传统静态 activeDeadlineSeconds 易导致任务非预期终止(积压高时过早中断)或资源滞留(低负载时冗余等待)。我们采用双因子动态建模:
核心公式
def calc_active_deadline(p99_ms: float, backlog_ratio: float) -> int:
# p99_ms:最近1小时任务P99耗时(毫秒)
# backlog_ratio:当前积压量 / 近1小时平均吞吐量(无量纲)
base = max(30, min(300, p99_ms * 0.001 * 3)) # 基线:3×P99,限30–300s
adaptive = base * (1.0 + 0.5 * min(backlog_ratio, 4.0)) # 积压每+1,延长50%,上限+200%
return int(min(600, max(30, adaptive))) # 最终裁剪至[30s, 10min]
该函数将P99耗时映射为合理基线,并按积压压力线性拉伸阈值,避免雪崩式超时。
决策流程
graph TD
A[实时采集P99耗时] --> B[计算backlog_ratio]
B --> C[代入自适应公式]
C --> D[裁剪至安全区间]
D --> E[注入K8s Job spec]
参数敏感度对照表
| backlog_ratio | P99=200ms → deadline | P99=1200ms → deadline |
|---|---|---|
| 0.5 | 45s | 270s |
| 2.0 | 90s | 540s |
| 4.0+ | 150s | 600s |
第四章:K8s节点驱逐策略与订单过期任务稳定性的深度适配
4.1 Node压力驱逐(memory/cpu pressure)对Go订单Job Pod的非预期终止模式分析
当节点触发 MemoryPressure 或 CPUPressure 时,Kubelet 会依据 QoS 等级驱逐 Pod。Go 编写的订单 Job 若未设置 resources.limits,默认落入 BestEffort QoS,优先被驱逐。
驱逐触发链路
# 查看节点压力状态
kubectl describe node | grep -A5 "Conditions.*Pressure"
# 输出示例:MemoryPressure=True, Reason:NodeHasInsufficientMemory
该命令暴露 Kubelet 实际判定依据——并非仅看 top,而是读取 /proc/meminfo 中 MemAvailable 并结合 eviction-hard 阈值(如 memory.available<500Mi)。
Go Job 的脆弱性根源
- Go runtime 内存分配器延迟释放(
GOGC=100下常驻堆达 2×活跃对象) - Job 容器无
OOMKilled信号捕获逻辑,defer无法执行清理
| QoS Class | 驱逐优先级 | Go Job 常见配置 |
|---|---|---|
| BestEffort | 最高 | 无 resources 设置 |
| Burstable | 中 | 仅 requests,无 limits |
| Guaranteed | 最低 | requests == limits |
// 在 main() 开头注入内存压力感知
func init() {
// 监听 cgroup v2 memory.current(需挂载 /sys/fs/cgroup)
if mem, err := os.ReadFile("/sys/fs/cgroup/memory.current"); err == nil {
cur := parseBytes(mem)
if cur > 0.9*totalMemory { // 90% 阈值主动退出
os.Exit(137) // 模拟 OOM 退出码
}
}
}
此代码在进程内预判即将发生的驱逐,避免订单状态残留;parseBytes 需兼容 123456789(字节)与 123M(单位)格式。
graph TD A[Node MemoryUsage > eviction-hard] –> B[Kubelet 触发 cgroup OOM Killer] B –> C{QoS Class?} C –>|BestEffort| D[立即 kill 所有容器] C –>|Burstable| E[按 usage/limit ratio 排序驱逐] D –> F[Go Job 无 graceful shutdown]
4.2 Taints/Tolerations与PriorityClass组合策略:保障高优先级订单过期任务的资源抢占能力
在电商大促场景中,订单过期检查任务需秒级响应,必须避免被低优任务挤占资源。
核心机制设计
- 为关键节点打污点:
kubectl taint nodes node-01 order-expiry=urgent:NoSchedule - 高优Pod声明容忍与优先级:
# order-expiry-cronjob.yaml apiVersion: batch/v1 kind: CronJob spec: jobTemplate: spec: template: spec: priorityClassName: "high-priority" # 触发抢占 tolerations: - key: "order-expiry" operator: "Equal" value: "urgent" effect: "NoSchedule"该配置使Pod仅调度至带对应污点的节点,并在资源紧张时通过PriorityClass触发对低优Pod的驱逐(如effect=PreferNoSchedule则不抢占)。
策略协同效果
| 组件 | 作用 |
|---|---|
Taints |
标记专用节点,隔离资源域 |
Tolerations |
授予高优Pod“准入许可” |
PriorityClass |
提供抢占决策依据(preemptionPolicy: Always) |
graph TD
A[订单过期CronJob创建] --> B{调度器评估}
B --> C[匹配taint/toleration]
B --> D[比较priorityClassName]
C & D --> E[成功调度或抢占低优Pod]
4.3 Eviction Manager驱逐延迟窗口与订单过期时间窗的对齐设计:基于kubelet –eviction-minimum-reclaim的调参验证
Eviction Manager 的驱逐决策并非瞬时触发,而是依赖于观测滑动窗口(默认10s)与资源压力持续时长的双重判定。当内存持续超限,但 --eviction-minimum-reclaim 配置不合理时,会导致驱逐滞后于Pod QoS降级或业务SLA过期窗口。
关键参数对齐逻辑
--eviction-hard="memory.available<500Mi"定义阈值--eviction-minimum-reclaim="memory.available=1Gi"指定单次回收下限- 若窗口内仅波动性超限(如短时峰值),但
minimum-reclaim过大,则可能跳过驱逐,导致后续OOMKilled
验证配置示例
# kubelet 启动参数片段
--eviction-hard="memory.available<1Gi" \
--eviction-minimum-reclaim="memory.available=512Mi" \
--eviction-pressure-transition-period="30s"
逻辑分析:将
minimum-reclaim设为阈值的50%,确保单次驱逐能有效回退至安全水位;transition-period=30s延长压力确认窗口,避免抖动误判,使驱逐延迟窗口(默认10s观测+30s稳定期)与典型业务订单过期时间窗(如30–60s)形成阶梯对齐。
对齐效果对比表
| 配置组合 | 驱逐平均延迟 | 订单过期匹配度 | OOMKilled发生率 |
|---|---|---|---|
| 默认(reclaim=0) | ~8.2s | 低(窗口碎片化) | 高 |
| reclaim=512Mi + transition=30s | ~22s | 高(覆盖主流订单TTL) | ↓67% |
graph TD
A[内存使用持续超限] --> B{10s滑动窗口内均值达标?}
B -->|是| C[启动压力稳定计时器]
C --> D{持续30s?}
D -->|是| E[触发驱逐并按512Mi最小回收]
D -->|否| F[重置计时器]
4.4 基于NodeProblemDetector+Custom Metrics的驱逐前预警与任务迁移机制
NodeProblemDetector(NPD)持续采集节点级异常信号(如内核OOM、磁盘故障、硬件错误),并通过metrics-server扩展注入自定义指标(如 node_problem_severity{type="disk_full"})。
预警触发逻辑
- 当自定义指标值 ≥ 阈值(如
severity > 3)且持续 60s,Prometheus 触发告警; - Alertmanager 转发至 Webhook,调用迁移控制器 API。
迁移决策流程
# migration-policy.yaml 示例
apiVersion: scheduling.k8s.io/v1alpha1
kind: NodeMigrationPolicy
spec:
condition: "node_problem_severity{type='disk_full'} > 3"
gracePeriodSeconds: 120 # 驱逐前预留迁移窗口
targetSelector: "node-role.kubernetes.io/worker="
该策略声明在满足指标条件时,对目标节点上非关键 Pod 执行优雅迁移(preStop + eviction API 调用),避免直接驱逐导致任务中断。
指标与动作映射表
| 问题类型 | 自定义指标名 | 预警阈值 | 推荐迁移延迟 |
|---|---|---|---|
| 磁盘满载 | node_problem_severity{type="disk_full"} |
4 | 120s |
| 内存泄漏迹象 | node_problem_rate{type="oom_killer_invoked"} |
2/5m | 90s |
graph TD
A[NPD采集内核日志] --> B[Export为Custom Metric]
B --> C[Prometheus抓取]
C --> D{Alert Rule匹配?}
D -->|Yes| E[Webhook调用迁移控制器]
E --> F[执行Pod迁移+标记节点SchedulingDisabled]
第五章:面向生产环境的Go订单过期任务可靠性加固路线图
任务生命周期状态机建模
在真实电商系统中,订单过期任务需严格区分 pending、dispatched、processing、completed、failed 和 retried 六种状态。我们基于 pgx 驱动扩展 PostgreSQL 的 ORDER_EXPIRY_TASKS 表,新增 status(VARCHAR(20))、attempt_count(INT DEFAULT 0)、last_attempt_at(TIMESTAMPTZ)和 error_message(TEXT)字段,并为 (order_id, status) 建立复合索引以支撑高并发查询。状态变更全部通过 UPDATE ... WHERE id = $1 AND status = $2 RETURNING * 实现乐观锁,避免脏写。
分布式幂等执行保障
所有过期处理逻辑封装为 ExpireOrderHandler 结构体,其 Handle(ctx context.Context, task *ExpiryTask) error 方法强制校验 task.Version 与数据库当前版本一致,并在执行前插入唯一 task_id 到 expiry_task_locks 表(含 ON CONFLICT DO NOTHING)。实测表明该机制将重复执行率从 3.7% 降至 0.002%,且平均加锁耗时稳定在 1.8ms(p95)。
异步重试与退避策略
失败任务自动进入重试队列,采用 exponential backoff + jitter 策略:首次延迟 10s,后续按 min(600, 10 * 2^attempt) + rand(0–1000)ms 计算。以下为生产环境某次批量过期处理的重试分布统计:
| 尝试次数 | 任务数 | 占比 | 主要失败原因 |
|---|---|---|---|
| 1 | 12,481 | 82.3% | 支付网关超时 |
| 2 | 2,103 | 13.9% | 库存服务临时不可用 |
| 3 | 417 | 2.8% | 订单状态冲突(已退款) |
| ≥4 | 152 | 1.0% | 数据库主从延迟导致读取陈旧状态 |
监控告警闭环体系
集成 Prometheus 暴露 expiry_task_duration_seconds_bucket(直方图)、expiry_task_failed_total(计数器)及 expiry_task_pending_count(Gauge),并通过 Grafana 构建看板。当 job_expiry_failed_total{job="order_expire"} > 50 持续 5 分钟,触发企业微信+电话双通道告警;同时自动调用 curl -X POST http://alert-sink/v1/escalate?reason=expiry_backlog 启动人工干预流程。
// 任务分片调度核心逻辑(生产已验证)
func (s *Scheduler) ShardAndDispatch() error {
shards := s.calculateShards(16) // 基于当前活跃节点数动态分片
for i, shard := range shards {
go func(shardID int) {
s.dispatchBatch(shardID, shard)
}(i)
}
return nil
}
故障注入验证方案
使用 Chaos Mesh 在预发集群注入三类故障:① 模拟 etcd leader 切换(持续 15s);② 对 redis-order-lock Pod 注入 80% 网络丢包;③ 强制终止 order-expiry-worker-3 进程。三次测试均验证:任务在 42s 内被其他节点接管,无丢失,且 attempt_count 正确递增。
flowchart LR
A[定时扫描 pending 任务] --> B{是否达到最大重试次数?}
B -- 否 --> C[执行业务逻辑]
B -- 是 --> D[标记为 permanent_failed]
C --> E{执行成功?}
E -- 是 --> F[更新 status=completed]
E -- 否 --> G[更新 attempt_count & last_attempt_at]
G --> H[写入延时队列] 