19:08 发版 MySQL → ClickHouse 实时同步任务,几分钟后下游业务报警:「同一条订单数据写了两次」。
上 Flink Dashboard 一看:两个
MySQL to ClickHouse Realtime Sync V2任务并行 RUNNING,状态都"健康"。我盯着屏幕沉默了 3 秒------Jenkins 流水线明明有 stop 旧任务的步骤,为什么老的没死、新的又起来了?
翻 stop 逻辑的代码,发现一行
break+ 一个状态只匹配RUNNING的过滤,挖出来背后还藏着 3 个并发陷阱。这篇讲怎么把 Flink CDC 部署做到真正幂等。
一、背景:这套 Flink 部署是怎么搭的
如果你不熟悉 Flink CDC + Jenkins 这套架构,先看这一节理清上下文;已经熟的同学可以跳到第二章。
1.1 这个 Flink 作业在做什么
业务方需要把 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:
| 状态分组 | 状态值 |
|---|---|
| 非终态(仍在运行 / 即将运行 / 即将退出) | CREATED、RUNNING、RESTARTING、INITIALIZING、FAILING、RECONCILING、CANCELLING |
| 终态 | FINISHED、CANCELED、FAILED、SUSPENDED |
老任务 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 个陷阱 + 二次校验同时翻车才炸"------可靠性是乘 不是加。
8.4 Flink 的 7 个非终态状态记下来
很多人写 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 逻辑------双开 + 双写比想象中更容易发生。
延伸阅读:
- 想看这次治理后的完整 Jenkinsfile demo(可直接抄走)→ 《Flink Jenkinsfile 怎么写不出 bug:10 条设计要点 + 完整 demo》
- 想看跨业务场景的通用 Jenkinsfile 反模式(K8s / Docker / DB / Flink 都覆盖)→ 《Jenkinsfile 反模式图鉴:10 个把 CI/CD 流水线写崩的姿势》