流程跑到第47页时崩了,前46页的采集成果跟着灰飞烟灭。
这种痛,只有真正跑过几千条数据的人才能体会。
我们之前把浏览器池、环境隔离、监控告警、分布式执行和发布体系都搭起来了,系统的骨架越来越硬朗。但有一个问题始终像鱼刺一样卡在喉咙里——长时间运行的流程,一旦中断,所有进度归零。
店群运营里多的是这类流程:TEMU全店商品批量下架再上架、拼多多订单数据全量采集、TikTok Shop消息列表的逐条回复。这些流程跑一次动辄二三十分钟,甚至超过一小时。中间任何一个节点崩了——网页加载超时、元素意外消失、浏览器内存溢出——之前跑过的所有进度瞬间作废,重头再来。
这篇文章要讲的,就是我们怎么一步步把“断点续跑”从一个美好的愿望,变成生产环境里真正扛得住异常的工程能力。
一、长流程的致命脆弱性
一开始我们面对长流程中断,做法很原始:人工上去看跑到哪一步了,手动把前面的步骤补上,再重启流程。随着店铺增多,这种场景几乎每天都在发生,运营怨声载道。
后来我们试着在影刀流程内部加各种try-catch,失败了就重试当前步骤。确实减少了整体失败率,但没解决根本问题:进度还在浏览器内存里,换个浏览器实例、重启个流程,什么都丢了。
问题的本质很简单:RPA流程的状态——当前在第几页、处理到第几条、哪些已经完成——被锁死在浏览器进程和流程变量里。进程一挂,状态蒸发。没有任何外部化的持久存储,断点续跑无从谈起。
状态留在浏览器里,等于把钱放在一个随时可能死机的钱包里。
二、断点续跑不是“存个进度”那么简单
很多人直觉上觉得,断点续跑就是每执行几步把进度写到一个文件里,失败了下次从文件里读到哪了接着跑。理论上没错,但落到RPA流程里,要解决的问题远比想象中复杂:
- 步骤不是线性的:流程里有循环、条件分支、子流程调用,简单的行号或序号无法准确还原执行上下文。
- 页面状态需要恢复:重新打开浏览器后,不能只告诉流程“接着从第5页开始”,还得把页面还原到那个状态——翻页、筛选条件、滚动位置都得重新执行一遍才能到达断点。
- 部分操作不可重放:比如已经点击了“确认发货”,这个动作不能在恢复时再来一次,否则就是重复操作。
- 与环境的绑定关系:断点信息如果存进
user-data-dir,浏览器实例一旦销毁,断点就没了。这就要求断点存储必须独立于浏览器环境。
真正工程化的断点续跑,需要做到三点:状态外部化、步骤幂等化、恢复路径可重放。
三、状态外部化:把流程的灵魂从浏览器里抽出来
我们的核心设计是把流程运行过程中所有的业务状态,全部从影刀流程变量里搬到一个外部存储——Redis,部分关键信息同步落库。
这里的状态不是指“浏览器当前打开了哪个URL”这种技术状态,而是业务语义层面的进度:
- 当前采集任务处理到第几页、第几条
- 哪些商品ID已经处理过
- 循环的游标位置
- 已经完成的不可逆操作列表
在影刀流程内部,我们在关键步骤节点之后,通过Python插件把当前进度写入Redis。这个写入操作是同步的、即时的,每完成一个最小业务单元就写一次。
import redis
import json
import time
class FlowStateManager:
def __init__(self, redis_client, task_id: str):
self.redis = redis_client
self.task_id = task_id
self.state_key = f"flow_state:{task_id}"
def save_checkpoint(self, checkpoint: dict):
checkpoint["updated_at"] = time.time()
self.redis.set(self.state_key, json.dumps(checkpoint), ex=86400) # 24h过期
def load_checkpoint(self) -> dict | None:
data = self.redis.get(self.state_key)
if data:
return json.loads(data)
return None
def mark_step_completed(self, step_name: str, extra: dict = None):
current = self.load_checkpoint() or {"completed_steps": [], "cursor": {}}
if step_name not in current["completed_steps"]:
current["completed_steps"].append(step_name)
if extra:
current["cursor"].update(extra)
self.save_checkpoint(current)
def clear_checkpoint(self):
self.redis.delete(self.state_key)
这个 FlowStateManager 完全独立于浏览器进程存在。哪怕浏览器崩溃、执行机重启,只要Redis没丢,进度就在。
四、Checkpoint设计:粒度与成本的平衡
Checkpoint的粒度是个需要仔细权衡的东西。太粗了,恢复时浪费的时间多;太细了,写Redis的开销过高,可能反而拖慢流程。
我们的经验是,按业务操作的自然边界来切分Checkpoint,而不是按技术步骤。
举个例子,一个订单采集流程,自然边界是“每采集完一页订单,这一页就是一次Checkpoint”。而不是每采集一条订单写一次——那样I/O太频繁;也不是采集完全部才写一次——那样失去断点意义。
具体的Checkpoint数据结构示例:
{
"completed_steps": ["login", "navigate_to_order_page", "set_date_filter"],
"cursor": {
"current_page": 47,
"total_pages": 120,
"last_order_id": "PO20260518000321",
"processed_count": 940
},
"non_replayable_actions": [
{"action": "confirm_shipment", "order_id": "PO20260518000101"}
],
"updated_at": 1716001200.123
}
恢复时,流程先读取这个Checkpoint,跳过 completed_steps 里已完成的大步骤,直接通过游标里的 current_page 跳转到对应页数,然后根据 last_order_id 精准定位到断点处继续处理。对于 non_replayable_actions 里的动作,恢复逻辑会显式跳过,不重复执行。
五、恢复路径的重放与幂等性保证
有了Checkpoint,恢复时怎么把页面状态推回到断点位置?我们不能简单粗暴地让浏览器直接跳转到某个URL,因为很多页面的状态是靠一系列交互累积出来的——筛选条件、排序方式、列表滚动加载。
我们的做法是,恢复流程本身就是一条精简版的流程路径。
这条恢复路径只做三件事:
- 重现环境:导航到起始页,按顺序执行筛选、排序等前置操作,直至到达断点所在页。
- 定位断点:根据游标里的
last_order_id或current_page,将页面滚动或翻页到具体位置。 - 跳过已处理项:恢复执行逻辑中带一个判断,如果当前数据项的ID已经出现在
completed_steps相关记录里,则跳过。
这里最关键是幂等性。恢复路径可能被多次执行(比如流程在恢复过程中又崩了),所以每一步恢复操作都必须是幂等的——翻到第47页这个动作,多执行几次结果不变;根据 last_order_id 定位,定位两次也不会出错。
对于不可逆操作,我们在Checkpoint里记录的是已经成功执行的动作清单,恢复时直接对照这个清单来跳过。这比逆向操作回滚更安全。
六、与浏览器实例池的配合
断点续跑引入了一个新的复杂度:恢复流程时,能否分配到原来那个浏览器实例?
答案是不一定。如果实例因为内存泄漏被销毁了,或者分配给了其他任务,恢复时只能拿到一个新实例。这就要求断点续跑不能依赖浏览器实例的任何本地状态,只能依赖外部化存储的Checkpoint。
我们的做法是,恢复任务被调度中心重新下发后,调度Worker会走正常的浏览器实例获取流程,拿到一个干净的空闲实例(或新创建的),然后执行恢复路径,把环境推到断点位置。
这个过程中,user-data-dir 的绑定关系保证了登录态不丢,Checkpoint保证了业务进度不丢,浏览器池保证了执行资源可用。三者配合起来,断点续跑才能在生产环境里真正跑通。
七、那些只有落地才能碰到的麻烦
第一个麻烦:Checkpoint写入的原子性被破坏
有一次Redis网络抖动,save_checkpoint 执行了一半,JSON写了一半就断了,结果恢复时解析JSON失败,任务卡死。后来我们把写入逻辑改为:先写入一个临时Key,写入成功后再Rename到正式Key。利用Redis的单线程特性,Rename是原子的,要么全写入要么全不写入,杜绝了半截数据。
def save_checkpoint_atomic(self, checkpoint: dict):
temp_key = f"{self.state_key}_temp"
self.redis.set(temp_key, json.dumps(checkpoint), ex=86400)
self.redis.rename(temp_key, self.state_key)
第二个麻烦:进度漂移
流程在采集第47页时崩了,Checkpoint里记的也是47。恢复后从第47页开始重新采集,但第47页的数据因为上次崩的时候已经部分入库,导致入库了重复数据。问题在于Checkpoint记录的粒度——current_page 是页级别,但崩在页内,恢复时整页重采。
解决方案是把游标粒度从页面细化到业务主键。比如记录最后一个成功处理的订单ID,恢复时从该订单的下一条开始。对于不支持主键偏移的列表页,则改为每采集完一条就更新一次游标中的序号,粒度到条。
第三个麻烦:恢复过程中的再次失败
恢复流程本身也有可能失败。如果重试N次都失败,说明当前店铺环境可能出了某种严重问题(比如页面大改版)。这种情况我们设计了一个熔断机制:同一任务的恢复连续失败3次后,任务状态直接变为 aborted,并生成一份详细的失败报告和最后一次的页面截图,推送给人工处理队列,不再浪费计算资源死循环重试。
八、断点续跑如何改变团队的开发习惯
这套机制上线后,我们明显感觉到团队对长流程的态度变了。以前设计一个批量操作流程,大家会尽量把流程切短,拆成多个小流程——不是因为业务需要,而是怕崩。现在,流程可以按照业务逻辑的自然长度来设计,不用迁就稳定性。
同时,流程开发也形成了一些新规范:
- 每个新流程上线前,必须定义Checkpoint结构和恢复路径,写入流程设计文档
- 所有不可逆操作前,必须先在Checkpoint中标记“即将执行”,执行成功后再标记“已完成”,用两阶段提交的思想防止执行状态丢失
- 恢复路径必须作为流程的一部分一起开发和测试,不能用主流程代码临时拼凑
这些规范一开始让开发慢了下来,但很快大家就发现,调试和排错的效率大幅提升——因为任何中断都有迹可循,不再是黑盒。
九、最终我们追求的是什么
断点续跑不是为了让系统“永不失败”——失败是不可避免的,网络会断、页面会改、机器会崩。我们追求的是:失败之后,系统能用最小的代价、最短的时间、最少的脏数据,回到正轨。
这背后是一整套工程理念的转变:从把RPA流程看作一次性脚本,变成把它看作一个带有状态机语义的、可恢复的、可观测的自动化作业。
进度不是一种感觉,而是一种数据结构。
把它存下来,保护起来,你才真正拥有了它。
作者:林焱
一个坚信自动化流程也该有存档功能的工程师
