Flink 重启变双开:一次部署引发的两个 CDC 任务并发消费

19:08 发版 MySQL → ClickHouse 实时同步任务,几分钟后下游业务报警:「同一条订单数据写了两次」。

上 Flink Dashboard 一看:两个 MySQL to ClickHouse Realtime Sync V2 任务并行 RUNNING,状态都"健康"。

我盯着屏幕沉默了 3 秒------Jenkins 流水线明明有 stop 旧任务的步骤,为什么老的没死、新的又起来了?

翻 stop 逻辑的代码,发现一行 break + 一个状态只匹配 RUNNING 的过滤,挖出来背后还藏着 3 个并发陷阱。这篇讲怎么把 Flink CDC 部署做到真正幂等。


如果你不熟悉 Flink CDC + Jenkins 这套架构,先看这一节理清上下文;已经熟的同学可以跳到第二章。

业务方需要把 MySQL 的订单变更实时同步到 ClickHouse(OLAP 数仓)跑分析查询。我们用 Flink 的 CDC(Change Data Capture)连接器订阅 MySQL binlog → 实时写入 ClickHouse:

复制代码
MySQL binlog ──→ Flink Job ──→ ClickHouse 订单表
   (订阅)         (转换/聚合)      (实时写入)

这个作业是长期运行的流式任务 ,名字 MySQL to ClickHouse Realtime Sync V2,跑在 docker-compose 起的 Flink 集群里(容器名 flink-jobmanager-1)。

如果你想从零搭一套类似的 Flink CDC 同步链路,参考:《Flink 1.20 实战:零代码配置实现 MySQL 百表到 ClickHouse 实时同步》。本篇假设你对 Flink 流式作业的 stop / start / savepoint 概念已经有基本理解。

1.2 为什么不能"kill 重启"

一句话:Flink 流式作业在内存里维护消费位点 (已经读到 binlog 哪一位)和业务状态 (窗口聚合等),直接 kill -9 会丢,新任务启动后从头消费 → 数据大量重复写入

正确流程是用 flink stop --savepointPath 优雅停止把状态落盘 ,新任务 -s <savepoint> 加载状态继续。本次事故的全部讨论都围绕这套 stop / start 流程。

1.3 出事时的 Jenkinsfile(关键段)

整个 Jenkins Pipeline 大致这几步:

复制代码
拉代码 → Maven 构建 → scp jar → 停旧作业(含 savepoint)→ 启动新作业(从 savepoint 恢复)

出事的就是"停旧作业 / 启动新作业"这两个 stage,简化伪代码:

groovy 复制代码
stage('停止旧作业') {
    sh """
        ssh ${REMOTE_USER}@${REMOTE_HOST} '
          # 通过 Flink REST API 找当前 RUNNING 的同名任务
          JOB_ID=\$(curl -s ${FLINK_REST}/jobs/overview | python3 -c "
import sys, json
data = json.load(sys.stdin)
for job in data.get(\"jobs\", []):
    if job[\"name\"] == \"${JOB_NAME}\" and job[\"state\"] == \"RUNNING\":
        print(job[\"jid\"]); break
")

          # 发 stop 命令(触发 savepoint)
          if [ -n \"\$JOB_ID\" ]; then
            docker exec ${JM} /opt/flink/bin/flink stop --savepointPath ${SP_PATH} \$JOB_ID
            sleep 5    # 等它停干净
          fi
        '
    """
}

stage('启动新作业') {
    sh """
        ssh ${REMOTE_USER}@${REMOTE_HOST} '
          docker exec ${JM} /opt/flink/bin/flink run -d -s <savepoint> ...
        '
    """
}

看起来人畜无害。但这段代码同时藏着 4 个并发陷阱------平时没事,赶上特定时序就会双开。


二、问题现象

业务侧反馈「同一条记录在 ClickHouse 里出现了两遍」,时间精确到 19:08:30 之后开始出现。

去 Flink Dashboard 看:

复制代码
Running Jobs (2)
─ MySQL to ClickHouse Realtime Sync V2  jid=a3f2... RUNNING  start=19:00:14
─ MySQL to ClickHouse Realtime Sync V2  jid=8b1e... RUNNING  start=19:08:42

同名两个任务,都在 RUNNING 状态,都在消费同一份 MySQL binlog,都在写同一张 ClickHouse 表 ------ 数据被双写。

时间线对一下:

  • 19:08:30 触发 Jenkins 部署 restart 操作
  • 19:08:42 新任务起来,jid=8b1e 进入 RUNNING
  • jid=a3f2(19:00 那个老任务)没被停掉

三、第一层排查:Jenkins 流水线日志

去翻 Jenkins 这次构建的 console output,看停止旧任务那段:

text 复制代码
[停止旧作业]
[INFO] 找到 RUNNING 作业: a3f2...
[INFO] 停止 job a3f2...(含 savepoint)
docker exec flink-jobmanager-1 flink stop --savepointPath ... a3f2
[INFO] 等待 5 秒...
[INFO] 停止操作完成

[启动新作业]
[INFO] 提交作业: MySQL to ClickHouse Realtime Sync V2
[INFO] 作业已提交

乍一看正常------找到了任务、发了 stop 命令、等了 5 秒、然后启动新任务。但实际上事故就发生在这里。


四、根因:4 个并发陷阱叠加

回到第一章贴的那段代码,短短十几行,藏了 4 个独立陷阱:

陷阱 一句话
1 状态过滤只匹配 RUNNING,漏掉过渡态
2 break 取第一个匹配,漏掉多个同名实例
3 flink stop 是异步操作,命令返回 ≠ 任务真停
4 sleep 5 是侥幸,不是工程

下面一个个拆。

4.1 陷阱 1:状态过滤只匹配 RUNNING

if job['state'] == 'RUNNING' ------ Flink 任务的状态远不止 RUNNING:

状态分组 状态值
非终态(仍在运行 / 即将运行 / 即将退出) CREATEDRUNNINGRESTARTINGINITIALIZINGFAILINGRECONCILINGCANCELLING
终态 FINISHEDCANCELEDFAILEDSUSPENDED

老任务 a3f2 在 19:08:30 被发了 stop 信号后,实际上是先进入 CANCELLING,savepoint 持续中 。这个状态不是 RUNNING,state == 'RUNNING' 过滤就找不到它

修复:匹配整个非终态集合:

python 复制代码
NON_TERMINAL = {'CREATED','RUNNING','RESTARTING','INITIALIZING',
                'FAILING','RECONCILING','CANCELLING'}
ids = [j['jid'] for j in data.get('jobs', [])
       if j['name'] == JOB_NAME and j['state'] in NON_TERMINAL]
print(' '.join(ids))                  # 一次性返回所有 jid

4.2 陷阱 2:break 取第一个匹配的任务

事故现场更隐蔽的真相:Flink Dashboard 上同名任务可能不止一个

为啥?看陷阱 4 ------ 前几次部署如果 stop 没真的成功,每次"重启"都给你留下一个老任务。几次失败叠加,就是一堆同名 RUNNING 任务并存 。然后你这次 stop 又只 break 第一个 → 剩下的继续活着。

修复:去掉 break,循环停止所有匹配项:

bash 复制代码
for JOB_ID in $ALL_JOB_IDS; do
    docker exec ${JM} /opt/flink/bin/flink stop --savepointPath ... $JOB_ID
done

4.3 陷阱 3:发了 stop 命令 ≠ 任务真停了

flink stop异步操作

复制代码
flink stop $JOB_ID
   ↓
JobManager 收到信号
   ↓
触发 savepoint(CDC 任务可能要 savepoint 几十秒)
   ↓
状态: RUNNING → CANCELLING → FINISHED

整个过程几十秒到几分钟 (取决于 state 大小、checkpoint 速度)。flink stop 命令本身几秒就返回了,但任务还在 CANCELLING 中。

4.4 陷阱 4:sleep 5 是侥幸不是工程

bash 复制代码
sleep 5
# 紧接着启动新任务

写下这行的时候,工程师的心理大概是:「stop 一下,缓个 5 秒应该够了吧」。这是侥幸,不是工程------任何 magic number 在异常情况下都会被穿透:网络抖一下、JM 高负载、state 大、savepoint 慢,都可能让 5 秒不够。

陷阱 3 + 4 的合并修复 :发完 stop 后 poll 等真正的终态,不要时间等待:

bash 复制代码
for JOB_ID in $ALL_JOB_IDS; do
    docker exec ${JM} /opt/flink/bin/flink stop --savepointPath ... $JOB_ID

    # poll 最多 120 秒等终态
    for i in $(seq 1 60); do
        STATE=$(curl -s ${FLINK_REST}/jobs/$JOB_ID | python3 -c "
import sys,json
print(json.load(sys.stdin).get('state',''))")

        if [ "$STATE" = "FINISHED" ] || [ "$STATE" = "CANCELED" ] || [ "$STATE" = "FAILED" ]; then
            echo "[OK] job $JOB_ID 已进入终态: $STATE"
            break
        fi
        sleep 2
    done
done

五、兜底:启动前的二次校验

修完上面 4 个陷阱还不够。任何 poll 都得设个时间上限防止流水线 hang 死(当前配的 120 秒,也可以调到 180s、240s,但总会有一个值 ),万一超时了任务还没进入终态 (比如 state 太大 savepoint 死活做不完),上面的循环会"沉默地放弃"继续往下走启动新任务------双开又回来了

最后一道防线:启动新任务之前,再查一次有没有同名非终态任务,有就直接 fail,不启动

groovy 复制代码
def remaining = sshOutput("""
    curl -s ${FLINK_REST}/jobs/overview | python3 -c "
import sys, json
NON_TERMINAL = {'CREATED','RUNNING','RESTARTING','INITIALIZING',
                'FAILING','RECONCILING','CANCELLING'}
data = json.load(sys.stdin)
ids = [j['jid'] for j in data.get('jobs', [])
       if j['name'] == '${JOB_NAME}' and j['state'] in NON_TERMINAL]
print(' '.join(ids))
"
""").trim()

if (remaining) {
    error("启动前校验失败:仍有未停止的同名作业 [${remaining}],中止启动避免双开。")
}

这段看似冗余------前面 stop + poll 流程都跑过了,为什么还要再查?

可靠性是乘,不是加

4 个陷阱单独看每个都很小概率,但任何一个翻车就炸 。加上二次校验,需要"前 4 个陷阱漏 + 二次校验也漏"两件事同时发生才能炸,事故概率几乎归零。


六、还有一个隐藏陷阱:状态查询的"薛定谔"现象

修复完上面所有点之后,新任务启动时又出了个怪事------

text 复制代码
22:15:30  [INFO] 第 1 次查询,当前状态: FINISHED,5s 后重试...
22:15:30  [OK] 作业已 RUNNING(用时 10s)
22:15:30  FINAL_STATE: FINISHED                      ← 怎么又变 FINISHED 了?
ERROR: 启动后 120s 内作业未进入 RUNNING 状态

第 1 次查询返回 FINISHED,第 2 次查询返回 RUNNING,最后又抓到 FINISHED。同一行 curl,3 次返回 3 个状态?

6.1 真相:/jobs/overview 返回老任务 + 新任务

刚 stop 的老任务进入 FINISHED 后还在 /jobs/overview 列表里,新提交的任务也在列表里。Python 脚本是这样写的:

python 复制代码
# ❌ 取第一个匹配名字的,不管状态
for job in data.get('jobs', []):
    if job['name'] == JOB_NAME:
        print(job['state'])
        break

for ... break 取第一个匹配的------但 list 顺序不是稳定的

  • 第 1 次查询:list 里老任务 (FINISHED) 在前 → 返回 FINISHED
  • 第 2 次查询:list 顺序变了,新任务 (RUNNING) 在前 → 返回 RUNNING
  • 最后一次:又是老任务在前 → 返回 FINISHED

6.2 修复:按状态优先级筛选

不再"取第一个匹配名字的",而是"优先取 RUNNING,没有再看非终态过渡,最后才认 NO_ACTIVE":

python 复制代码
NON_TERMINAL = {'CREATED','RUNNING','RESTARTING','INITIALIZING',
                'FAILING','RECONCILING','CANCELLING'}
data = json.load(sys.stdin)
states = [j['state'] for j in data.get('jobs', []) if j['name'] == JOB_NAME]
nt = [s for s in states if s in NON_TERMINAL]

if 'RUNNING' in nt:
    print('RUNNING')
elif nt:
    print(nt[0])               # 过渡态如 INITIALIZING
elif states:
    print('NO_ACTIVE')         # 全是终态老任务(这才是真异常)

老任务 FINISHED 不再干扰判断,新任务 RUNNING 立即被识别。


七、完整治理后的部署流程

text 复制代码
┌─ Jenkins Pipeline ──────────────────────────────────────┐
│                                                         │
│  1. 找出所有同名非终态作业(CREATED/RUNNING/...等 7 态)     │
│         ↓                                               │
│  2. for 每个 JOB_ID:                                     │
│       ├─ flink stop --savepointPath ...                 │
│       ├─ poll 60×2s=120s 等真正终态                       │
│       └─ (超时也继续,下面二次校验兜底)                      │
│         ↓                                               │
│  3. 抓所有 "Savepoint completed. Path: ..."              │
│     取最后一个作为新任务恢复点                              │
│         ↓                                               │
│  4. 启动前二次校验:仍有非终态任务 → fail,不启动             │
│         ↓                                               │
│  5. flink run -s <savepoint> 启动新任务                   │
│         ↓                                               │
│  6. poll 24×5s=120s 等新任务进入 RUNNING                  │
│     状态查询用"优先 RUNNING > 非终态 > NO_ACTIVE"          │
│         ↓                                               │
│  7. 仍非 RUNNING 则 fail 通知                             │
│                                                         │
└─────────────────────────────────────────────────────────┘

每一步都对应一个之前踩过的坑:

步骤 解决了哪个陷阱
1 状态过滤只匹配 RUNNING(陷阱 1)
2(循环) break 漏掉多个同名实例(陷阱 2)
2(poll) stop 异步、sleep 5s 不够(陷阱 3 / 4)
4 poll 超时仍可能双开(兜底)
6(优先 RUNNING) 同名老任务终态干扰判断(薛定谔陷阱)

八、举一反三

8.1 异步操作 + 时间等待 = 慢性毒药

复制代码
sleep 5            ← 经验主义
等真正状态        ← 工程

flink stop / docker stop / kill -SIGTERM / kubectl delete pod

所有异步操作的"等"都应该是状态等待,不是时间等待。规则上限要有(防 hang),但主路径必须看状态。

8.2 list API 的查询不是幂等的,状态可能"漂移"

/jobs/overview 这种"返回当前所有 X"的 API,list 中的元素顺序通常不保证稳定 。靠 for ... break 拿第一个的代码大概率是 bug:

python 复制代码
# ❌ 不稳定
for x in list:
    if matches(x):
        return x

# ✓ 稳定(按业务语义筛 + 排序)
candidates = [x for x in list if matches(x)]
return prefer(candidates)        # prefer = 业务定义的优先级

凡是数据源有"老的 + 新的混在一起"的场景,都要按业务语义的优先级筛,不能取"第一个能匹配的"。

8.3 部署流水线的幂等性必须有"二次校验"

Jenkins/CI 流水线里发命令之后,永远要假设命令可能失败、超时、漏处理。重要的下一步前面要加一道二次校验:

复制代码
stop → poll → (启动前再 grep 一遍) → start

事故概率从"4 个陷阱中任何一个翻车都炸"降到"4 个陷阱 + 二次校验同时翻车才炸"------可靠性是 不是

很多人写 Flink 部署脚本只判 RUNNING,但完整的非终态集合是:

复制代码
CREATED → INITIALIZING → RESTARTING → RUNNING → 
   ├─ FAILING → FAILED (终态)
   ├─ CANCELLING → CANCELED (终态)
   ├─ RECONCILING → RUNNING (兜回去)
   └─ → FINISHED (终态)

任何状态判断如果不覆盖 7 个非终态,就有漏处理的风险。把这个集合贴在你 Flink 部署脚本顶部当注释,避免下次再踩。


九、总结

维度 改前 改后
状态过滤 只匹配 RUNNING 7 个非终态全覆盖
多任务处理 break 取第一个 循环停止所有匹配
等终态 sleep 5 poll 60×2s + 超时上限
启动前防御 二次校验,有非终态直接 fail
启动后状态查询 for/break 取第一个 优先 RUNNING > 非终态 > NO_ACTIVE
结果 重启偶发双开 多次部署稳定,再没出现过双开

最大的教训只有一句

异步系统的"等",永远要等状态,不要等时间。

5 秒 / 30 秒 / 5 分钟 ------ 任何 magic number 在异常情况下都会被穿透。Flink savepoint 慢 1 分钟、网络抖一下、JM 高负载抢锁------任何一个变量都可能让 sleep 等到的不是终态而是空气。


给读者的小问题:你们 CI/CD 流水线里,有几处用了 sleep N 来"等"异步操作?哪一处最有可能在压力大的时候出事? 评论区聊。

顺便如果你也在做 Flink CDC 这类需要保证 Exactly-Once 的部署,欢迎对比下你们的 stop / start 逻辑------双开 + 双写比想象中更容易发生。


延伸阅读

相关推荐
A15362558 小时前
自动化仓储物流管理系统有哪些?2026年深度测评与技术解析
大数据·人工智能·自动化
二宝哥8 小时前
大数据之安装Hadoop3.1.4
大数据·hadoop
金融小师妹8 小时前
基于AI宏观因子识别系统的贵金属波动分析:美元回落提振黄金反弹,能源飙升压制上行空间的机制分析
大数据·深度学习·逻辑回归·线性回归
城事漫游Molly8 小时前
方差分析(ANOVA)入门——比较三组或更多组均值的利器
大数据·算法·均值算法·论文笔记·科研统计
逸Y 仙X8 小时前
文章一:深度掌握Elasticsearch集群组建和集群设置
大数据·elasticsearch·搜索引擎·全文检索
阿乔外贸日记8 小时前
霍尔木兹通行规则调整,影响卡塔尔LNG出口恢复
大数据·人工智能·云计算
二宝哥8 小时前
大数据之安装zookeeper
大数据·分布式·zookeeper
财经资讯数据_灵砚智能8 小时前
基于全球经济类多源新闻的NLP情感分析与数据可视化(日间)2026年5月19日
大数据·人工智能·python·信息可视化·自然语言处理·灵砚智能