XXL-JOB 在 Consul Service Mesh 中的分片调度方案

摘要

问题背景是XXL-JOB 的分片广播依赖调度中心能看到多个执行器实例地址;如果执行器只注册一个 Kubernetes Service 或 Mesh 服务名,XXL-JOB Admin 只能看到一个逻辑地址,SHARDING_BROADCASTFAILOVERBUSYOVERROUNDRANDOMCONSISTENT_HASH 等实例级路由能力都会退化。

方案收敛为三种模式:普通任务走 ServiceName;XXL-JOB 原生分片走 Pod 级注册;核心批处理走独立任务编排服务。

官方依据

  • XXL-JOB 官方文档说明:执行器支持自动注册,调度中心可以发现注册任务并触发执行;执行器集群部署时支持多种路由策略,包括分片广播;分片广播会对执行器集群中的实例广播执行,并基于分片参数开发任务逻辑。参考:XXL-JOB 官方文档English Documentation
  • XXL-JOB 执行器配置中,xxl.job.executor.address 为空时使用内嵌服务 IP:PORT 注册;不为空时优先使用该配置作为注册地址。参考:执行器配置说明
  • Consul 官方文档说明:Consul Service Mesh 使用基于 SPIFFE X.509 标准的证书做服务身份和 mTLS;透明代理会把 Pod 的入站/出站流量重定向到 sidecar proxy;在 Kubernetes 中启用透明代理需要 consul.hashicorp.com/connect-injectconsul.hashicorp.com/transparent-proxy 等配置,并通过 Service Intentions 显式允许服务间通信。参考:Consul service meshEnable transparent proxy mode on KubernetesConsul on Kubernetes annotations

核心问题

XXL-JOB 的调度模型是:

XXL-JOB Admin
  -> 选择一个或多个 executor address
  -> 调用对应 Executor 实例

当执行器集群有 10 个 Pod 时,XXL-JOB 理想上应该看到 10 个地址:

10.244.1.21:9999
10.244.1.22:9999
10.244.1.23:9999
...

如果只注册一个服务名:

http://xxl-job-executor.default.svc.cluster.local:9999

XXL-JOB Admin 只会看到一个地址。Consul Envoy 或 Kubernetes Service 后面即使有 10 个 Pod,也只是网络层负载均衡,不会变成 XXL-JOB 语义里的 10 个分片。

因此分片广播下会退化为:

shardTotal = 1
shardIndex = 0

这就是 XXL-JOB 与 Service Mesh 默认抽象之间的冲突:XXL-JOB 要实例列表,Mesh 倾向隐藏实例列表。

模式一:ServiceName + Mesh 负载均衡

适用于普通定时任务,只要求任意一个实例执行一次。

XXL-JOB Admin
  -> xxl-job-executor.service.consul 或 Kubernetes Service
  -> Consul Envoy / K8s Service
  -> 任意 Executor Pod

执行器注册地址:

xxl.job.executor.address=http://xxl-job-executor.default.svc.cluster.local:9999

适用任务:

  • 缓存清理
  • 轻量同步
  • 日报生成
  • 巡检任务
  • 允许任意一个实例执行的补偿任务

约束:

  • 任务必须幂等。
  • 不依赖分片广播。
  • 不依赖指定实例。
  • 不依赖 XXL-JOB 的实例级失败转移或忙碌转移。
  • Mesh 层重试、超时、连接池策略不能改变任务语义。

这个模式最简单,但不适合真正的分片任务。

模式二:Pod 级注册,保留 XXL-JOB 原生分片

适用于继续使用 XXL-JOB 的 SHARDING_BROADCAST、实例级故障转移、忙碌转移、按实例查看日志等能力。

核心原则:

服务治理可以走 Mesh
任务调度注册必须保留实例级地址

推荐让每个执行器 Pod 注册自己的 PodIP:port

xxl.job.executor.appname=xxl-job-executor-mesh
xxl.job.executor.ip=${POD_IP}
xxl.job.executor.port=9999
xxl.job.executor.address=

注意:xxl.job.executor.address 必须保持为空,或者明确填入该 Pod 自己的可达地址。不要在分片模式下把它设置为普通 ServiceName。

Deployment 示例:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: xxl-job-executor
spec:
  replicas: 3
  selector:
    matchLabels:
      app: xxl-job-executor
  template:
    metadata:
      labels:
        app: xxl-job-executor
      annotations:
        consul.hashicorp.com/connect-inject: "true"
        consul.hashicorp.com/transparent-proxy: "true"
        consul.hashicorp.com/connect-service: "xxl-job-executor"
        consul.hashicorp.com/connect-service-port: "9999"
    spec:
      containers:
        - name: executor
          image: your-executor-image:latest
          env:
            - name: POD_IP
              valueFrom:
                fieldRef:
                  fieldPath: status.podIP
            - name: XXL_JOB_EXECUTOR_IP
              value: "$(POD_IP)"
            - name: XXL_JOB_EXECUTOR_PORT
              value: "9999"
          ports:
            - name: job
              containerPort: 9999
          readinessProbe:
            httpGet:
              path: /actuator/health/readiness
              port: 8080
          lifecycle:
            preStop:
              exec:
                command: ["sh", "-c", "sleep 20"]
      terminationGracePeriodSeconds: 60

Spring Boot 配置:

xxl:
  job:
    admin:
      addresses: http://xxl-job-admin.default.svc.cluster.local:8080/xxl-job-admin
    executor:
      appname: xxl-job-executor-mesh
      ip: ${XXL_JOB_EXECUTOR_IP:}
      port: ${XXL_JOB_EXECUTOR_PORT:9999}
      address:

Consul Intentions

如果 Consul Mesh 开启默认拒绝或严格 ACL,需要显式允许 Admin 调用 Executor:

apiVersion: consul.hashicorp.com/v1alpha1
kind: ServiceIntentions
metadata:
  name: xxl-job-executor
spec:
  destination:
    name: xxl-job-executor
  sources:
    - name: xxl-job-admin
      action: allow

ServiceDefaults

XXL-JOB executor 的调度端口可以按 TCP 治理,避免 L7 HTTP 路由、重试、重写影响 XXL-JOB 自己的失败判断:

apiVersion: consul.hashicorp.com/v1alpha1
kind: ServiceDefaults
metadata:
  name: xxl-job-executor
spec:
  protocol: tcp

如果团队明确需要 HTTP L7 可观测或 L7 intentions,再改为 http,但要同步确认 Consul 的重试、超时、路由规则不会破坏任务执行语义。

关于 Pod IP 直连与 mTLS

原草稿中“Pod IP 直连也能完美享受 mTLS”的判断需要加前提。更准确的说法是:

当 Admin 与 Executor 都被 Consul Connect 注入、透明代理生效、执行器端口被 Consul service 正确注册、Intentions 允许访问、且流量没有被排除出 transparent proxy 时,应用层访问 PodIP:9999 可以被本地 sidecar 拦截并转换为 Envoy 间 mTLS 流量。

mTLS 证书绑定的是服务身份,不是 Pod IP 本身。Consul 使用 SPIFFE X.509 证书表达服务身份,Envoy 之间完成双向证书校验和加密连接,Intentions 再按源服务与目标服务授权。

因此不能只凭“代码里访问的是 Pod IP”判断是否有 mTLS。必须验证流量是否真正经过 sidecar。

需要检查:

  • Pod 上存在 consul-dataplane 或 Envoy sidecar。
  • Pod 注解包含 consul.hashicorp.com/connect-inject: "true"
  • transparent proxy 对该 Pod 生效。
  • 没有用 transparent-proxy-exclude-inbound-portstransparent-proxy-exclude-outbound-ports 排除 9999
  • xxl-job-adminxxl-job-executor 的 Intentions 是 allow。
  • NetworkPolicy 允许 sidecar 和应用端口所需通信。

验证命令示例:

kubectl get pod <admin-pod> -o jsonpath='{.metadata.annotations}'
kubectl get pod <executor-pod> -o jsonpath='{.metadata.annotations}'
kubectl logs <admin-pod> -c consul-dataplane
kubectl logs <executor-pod> -c consul-dataplane
kubectl exec <admin-pod> -c consul-dataplane -- curl -s http://127.0.0.1:19000/clusters

如果平台开放 Envoy admin endpoint,可以在 clusters 或 listeners 中确认执行器服务、端点和 TLS transport socket。不同 Consul/Envoy 部署的 admin 端口可能不同,不能把 127.0.0.1:19000 视为固定值。

Headless Service 方案

Headless Service 可以用于实例级 DNS 发现:

apiVersion: v1
kind: Service
metadata:
  name: xxl-job-executor-headless
spec:
  clusterIP: None
  selector:
    app: xxl-job-executor
  ports:
    - name: job
      port: 9999
      targetPort: 9999

但要注意:

  • 普通 Deployment 的 Pod 名称不稳定,不适合作为长期实例身份。
  • Headless Service 返回多个 endpoint,并不代表 XXL-JOB Admin 会自动把这些 endpoint 当作多个执行器地址。
  • 如果要让 XXL-JOB 看到多个地址,仍然需要执行器逐个注册,或额外实现 endpoint 同步器,把 endpoint 列表同步到 XXL-JOB 执行器地址列表。
  • 如果要稳定实例名,可以使用 StatefulSet,让地址类似 xxl-job-executor-0.xxl-job-executor-headless.default.svc.cluster.local:9999

对 XXL-JOB 原生分片来说,最直接稳定的方式仍是执行器 Pod 自注册。

生命周期与脏地址治理

Pod 级注册的风险在生命周期:

Pod 重启、扩缩容、节点漂移
  -> Pod IP 变化
  -> Admin 可能短时间保留旧地址
  -> 分片广播可能触发失败、重试或重复执行

治理要求:

  • readiness 通过前不要对外接任务。
  • 停止前给执行中任务留出收尾时间。
  • preStopterminationGracePeriodSeconds 覆盖最长安全下线窗口。
  • 依赖 XXL-JOB 心跳剔除失效实例,同时监控注册表中旧地址滞留时间。
  • 任务必须幂等,按 jobId + triggerTime + shardIndex 或业务唯一键做重复保护。
  • 长任务需要 checkpoint,不能只依赖进程内状态。

建议在任务处理逻辑里记录:

job_id
trigger_time
shard_index
shard_total
executor_address
attempt
business_range
status

这样才能排查滚动升级、扩缩容和重试导致的重复或缺口。

模式三:XXL-JOB 只做触发,任务服务内部编排

对核心批处理、长任务、财务对账、库存重算、履约补偿等任务,不建议把全部分布式执行语义压在 XXL-JOB 的实例路由上。

更稳的长期架构是:

XXL-JOB Admin
  -> job-orchestrator service
  -> 创建任务实例
  -> 写任务表和分片表
  -> Worker 抢占分片
  -> 执行、checkpoint、重试、补偿、归档

任务分片可以由这些机制承担:

  • 数据库分片表 + 乐观锁
  • Redis/Consul lock
  • Kafka/Pulsar 分区
  • Kubernetes Job
  • Argo Workflows
  • 内部任务状态机

这种模式下,XXL-JOB 只需要调用一个服务名。Mesh 隐藏后端实例不是问题,因为真正的分片、锁、状态、重试都在任务服务内部完成。

推荐标准

任务等级调度方式注册方式适用场景
L1 Trigger JobXXL-JOB 触发一次ServiceName普通巡检、缓存清理、轻量同步
L2 Sharding JobXXL-JOB 分片广播Pod 级注册批量扫描、消息重推、可并行补偿
L3 Orchestrated JobXXL-JOB 只触发任务服务ServiceName核心长任务、可恢复批处理、财务/库存/履约类任务

默认选择:

  • 普通任务:ServiceName + Mesh LB。
  • 依赖 XXL-JOB 原生分片:PodIP 或稳定实例 DNS 注册。
  • 核心批处理:独立 Task Service 编排。

验收清单

分片模式上线前至少验证:

  • XXL-JOB Admin 的执行器列表中能看到多个不同的 executor address。
  • 新建任务选择 SHARDING_BROADCAST 后,每个执行器 Pod 都能收到一次调度。
  • 每个 Pod 日志能打印不同的 shardIndex,且 shardTotal 等于当前可用执行器数。
  • 扩容后,下一轮调度能看到新的 shardTotal
  • 滚动升级期间,旧 Pod 不再接收新任务,正在执行的任务能完成或被幂等重试。
  • kill、log、callback 行为能命中正确实例,或已接受其限制。
  • Consul sidecar 日志和 Envoy 配置能证明流量经过 Mesh。
  • Intentions、NetworkPolicy、readiness、preStop、terminationGracePeriodSeconds 均已配置。
  • 任务侧有重复触发保护和失败补偿策略。

最终判断

  1. 不需要分片的任务,注册 ServiceName,接受 XXL-JOB 实例级能力退化。
  2. 需要 XXL-JOB 原生分片广播的任务,执行器必须以 Pod 级地址或稳定实例地址注册,让 Admin 看到多个 executor address。
  3. 核心批处理和长任务,不把 XXL-JOB 当分布式执行引擎,而是让它只触发任务编排服务。
  4. Consul Mesh 下的 Pod IP 直连 mTLS 必须实测确认,不能用“访问 Pod IP”或“注入 sidecar”单点推断。