1. 那个被所有人低估的环节:异常处理
做自动化的人都有一个共同的幻觉:脚本跑通了第一次,就以为它能一直跑下去。我做了三年多的自动化流程,从数据抓取、文件批处理到跨系统同步,前前后后写了不下五十个脚本。真正让我栽跟头的,从来不是核心逻辑写不出来,而是那些“跑着跑着就挂了”的意外。后来我复盘了一下,所有失败的自动化项目,几乎都栽在同一个环节上——异常处理与失败恢复。
这个结论听起来像废话,但你仔细想想,你上一次写自动化脚本的时候,花了多少时间在设计“如果这一步失败了该怎么办”上面?大多数人的做法是:写一个主流程,跑通了,加个try...except包一下,打印个错误日志,完事。然后某天凌晨三点,脚本在某个环节卡住了,没有告警,没有重试,第二天你发现数据缺了一大块,或者更糟——脏数据已经写进了下游系统。
这篇文章我想聊的就是这个事。不是教你写某个具体的自动化工具,而是把“异常处理”这个环节拆开揉碎,讲清楚为什么它是自动化项目成败的分水岭,以及我在实际项目中总结出来的一套可复用的处理思路。适合所有写过自动化脚本、被“跑着跑着就挂了”折磨过的人。
2. 为什么异常处理是自动化项目的生死线
2.1 自动化脚本的本质是“无人值守的承诺”
你写一个自动化脚本,本质上是在做一个承诺:这件事以后不用人管了,它会自己跑。这个承诺的价值在于“无人值守”,而它的风险也恰恰在于“无人值守”。手动操作的时候,人本身就是异常处理器——页面加载慢了你会等,接口报错了你会重试,数据格式不对你会手动修。一旦自动化了,这些“人的判断”全部消失了,你必须提前把所有可能的意外都想到。
我见过太多这样的场景:一个数据同步脚本,平时跑得好好的,某天上游接口返回了一个空数组,脚本没做判断,直接把空数据写进了目标表,覆盖了原有数据。等发现的时候,已经过去三天了。这种问题的根因不是技术难度,而是设计时压根没考虑“如果上游返回空怎么办”。
2.2 失败不是概率问题,是时间问题
很多人对异常处理不上心,是因为存在一个侥幸心理:“这个接口很稳定,不会出问题。”但自动化的特点是高频重复执行。一个每天跑一次的脚本,一年要跑365次。哪怕单次失败概率只有0.1%,一年下来也有超过30%的概率至少失败一次。如果脚本是每小时跑一次,那失败几乎是必然事件。
我在实际项目中总结了一个经验:任何自动化流程,只要运行频率足够高、运行时间足够长,所有理论上可能出错的地方,最终都会出错。这不是悲观,这是工程现实。所以异常处理不是“锦上添花”,而是“必须品”。
2.3 异常处理缺失的代价远比你想象的大
一个没有异常处理的自动化脚本,失败后的代价可以分为三个层次:
| 代价层次 | 具体表现 | 修复成本 |
|---|---|---|
| 第一层:任务中断 | 脚本报错退出,任务没完成 | 低,重新跑一次就行 |
| 第二层:数据污染 | 部分数据写入,状态不一致 | 中,需要手动清理和修复 |
| 第三层:连锁故障 | 下游系统基于错误数据继续运行 | 高,可能影响多个系统 |
大多数人对异常处理的理解停留在第一层,觉得“挂了就挂了,重新跑一下”。但真正危险的是第二层和第三层。我踩过最惨的一次坑,是一个文件处理脚本在处理到一半时因为磁盘空间不足中断了,已经处理的文件被标记为“完成”,未处理的文件还留在原地。下次运行时,脚本跳过了“已完成”的文件,导致一部分数据永远丢失了。这个问题的修复成本,远远超过了当初花十分钟写一个状态检查逻辑的成本。
3. 我在实际项目中踩过的四个典型坑
3.1 坑一:把“重试”当成万能药
最开始做自动化的时候,我的异常处理策略非常简单粗暴:不管什么错,重试三次,还不行就放弃。这个策略在应对网络抖动时确实有效,但很快就暴露了问题。
有一次我写了一个批量提交表单的脚本,某个表单因为字段校验规则变了,提交时一直返回参数错误。脚本按照我的重试逻辑,连续重试了三次,每次都在日志里记了一条错误,然后跳过了这条记录继续处理下一条。表面上看没什么问题,但实际上,那个参数错误是因为我的请求体里少了一个必填字段,所有表单都会失败。脚本重试了三次之后,把所有记录都跳过了,最终结果是:整个批次的数据一条都没提交成功,但脚本正常退出,没有任何告警。
这个坑的本质是:重试只能解决“暂时性故障”,不能解决“永久性故障”。网络超时、服务暂时不可用,这些是暂时性的,重试有意义。但参数错误、权限不足、数据格式不对,这些是永久性的,重试一万次也没用,只会浪费时间并掩盖真正的问题。
后来我调整了策略,把异常分成两类:
- 可重试异常:网络超时、连接被拒、服务返回5xx错误。这类异常用指数退避策略重试,每次重试间隔逐渐拉长。
- 不可重试异常:参数校验失败、权限错误、数据格式错误。这类异常立即停止当前任务,记录详细上下文,触发告警。
判断标准很简单:如果同样的请求再发一次,结果可能不同,就是可重试的;如果结果一定相同,就是不可重试的。
3.2 坑二:日志写了等于没写
我曾经很得意自己的脚本日志写得很全,每个步骤都有print输出。直到有一次线上出问题,我去翻日志,发现日志里只有一行:“Error occurred at step 3”。没有请求参数,没有响应内容,没有当前处理的数据标识。我花了两个小时才定位到问题,而如果日志里记录了请求的完整上下文,可能五分钟就解决了。
写日志这件事,关键不在于“写了多少”,而在于“出了问题时能不能靠日志还原现场”。我现在写自动化脚本,日志里必须包含这几样东西:
- 时间戳:精确到毫秒,方便和其他系统日志对齐。
- 任务标识:当前处理的是哪条数据、哪个文件、哪个用户,没有这个就无法定位。
- 步骤标识:当前执行到哪一步,方便判断问题出在哪个环节。
- 异常上下文:请求参数、响应内容、堆栈信息,这些是定位问题的关键。
- 重试次数:如果是重试后的失败,要记录这是第几次重试。
一个实用的技巧:在日志里记录“预期结果”和“实际结果”的对比。比如“预期返回200,实际返回403”,这样一眼就能看出问题所在。
3.3 坑三:没有幂等性设计,重试变成灾难
这个问题比前两个更隐蔽,也更危险。所谓幂等性,就是同一个操作执行一次和执行多次,结果是一样的。很多自动化脚本在设计时没有考虑这一点,导致重试时产生了重复数据或重复操作。
我遇到过一个典型的场景:一个订单同步脚本,从A系统读取订单,写入B系统。脚本的逻辑是“读取未同步的订单,逐条写入B系统,写入成功后标记为已同步”。看起来没问题,但如果脚本在“写入B系统成功”和“标记为已同步”之间崩溃了,下次运行时这条订单会被再次读取并再次写入B系统,造成重复订单。
解决这个问题的标准做法是:把“写入”和“标记”合并成一个原子操作,或者让写入操作本身具备幂等性。具体来说,可以在B系统写入时使用唯一标识做去重,或者在A系统标记时使用“处理中”状态来防止重复读取。
3.4 坑四:告警发了没人看,等于没发
我有一段时间很迷信告警,脚本里到处埋告警点,邮件、短信、即时消息全上了。结果就是:告警太多了,我自己都懒得看。每天早上打开手机,几十条告警消息,大部分都是“重试后成功”的噪音。真正重要的告警被淹没在里面,等发现的时候已经晚了。
告警的设计原则应该是:少而精,每条告警都值得被立即处理。我现在只对以下几种情况发告警:
- 任务彻底失败,无法自动恢复。
- 数据一致性出现问题,需要人工介入。
- 任务执行时间超过预期阈值(可能是卡住了)。
- 连续多次重试后仍然失败。
其他情况,比如单次重试后成功、非关键步骤的轻微异常,只记日志,不发告警。这样告警通道保持干净,每条告警都能引起足够的重视。
4. 一套可复用的异常处理框架
4.1 分层处理:把异常挡在合适的层级
我在多个项目中总结出来的经验是:异常处理不应该是一层try...except包住整个脚本,而应该是分层的。每一层负责处理自己能处理的异常,处理不了的向上抛。
具体来说,我通常把自动化脚本分成三层:
- 执行层:负责具体的操作,比如发请求、读写文件、调用接口。这一层捕获具体的异常,判断是否可重试,记录详细日志。
- 任务层:负责编排一个完整任务的执行流程,比如“读取数据-处理数据-写入数据”。这一层负责重试逻辑、状态管理、幂等性保证。
- 调度层:负责触发任务、监控任务状态、发送告警。这一层负责整体健康检查、超时控制、告警通知。
这样分层的好处是:每一层的职责清晰,异常在合适的层级被处理,不会出现“底层异常把整个脚本搞崩”或者“顶层异常处理掩盖了底层细节”的问题。
4.2 状态机:让每一步都可追踪、可恢复
对于复杂的自动化流程,我强烈建议用状态机的方式来管理任务状态。简单来说,就是把任务的执行过程拆成若干个状态,每个状态有明确的进入条件和退出条件,状态之间的转换有明确的规则。
举个例子,一个数据同步任务的状态可以这样设计:
| 状态 | 含义 | 可转换到 |
|---|---|---|
| PENDING | 待处理 | PROCESSING |
| PROCESSING | 处理中 | SUCCESS, FAILED, RETRYING |
| RETRYING | 重试中 | PROCESSING, FAILED |
| SUCCESS | 成功 | 终态 |
| FAILED | 失败 | 终态 |
有了状态机,脚本在任何时候崩溃,重启后都能根据当前状态判断下一步该做什么。比如状态是 PROCESSING,说明上次处理到一半崩了,需要检查是否有部分数据已写入,然后决定是回滚还是继续。状态是 RETRYING,说明上次重试也失败了,需要判断是否超过最大重试次数。
4.3 断点续传:让失败不再是“从头再来”
断点续传是异常处理的高级形态。它的核心思想是:把大任务拆成小批次,每完成一个批次就记录进度,失败后从上次中断的地方继续,而不是从头开始。
我在处理大批量数据时,一定会做断点续传。具体做法是:
- 把数据分成固定大小的批次,比如每100条一批。
- 每处理完一批,把进度写入一个持久化的位置(文件、数据库、缓存都行)。
- 脚本启动时先读取进度,从上次中断的位置继续。
- 全部处理完后,清除进度记录。
这样做的好处是:即使脚本在跑到第5000条时失败了,重启后从第5001条继续,不需要重新处理前5000条。对于耗时长的任务,这个优化能节省大量时间。
4.4 兜底策略:当所有自动化都失效时
再完善的异常处理,也有覆盖不到的情况。所以每个自动化流程都需要一个兜底策略:当自动恢复失败时,如何保证数据不丢、状态不乱、有人知道。
我的兜底策略通常包括三个部分:
- 数据兜底:失败的数据写入一个“待处理”队列,不会丢失,等人工介入后可以重新处理。
- 状态兜底:任务状态标记为“需要人工介入”,避免被误认为已完成。
- 通知兜底:通过多种渠道发送告警,确保有人能看到。如果一种渠道发不出去,换另一种。
5. 几个容易被忽略的细节
5.1 超时设置:没有超时的等待就是死等
很多自动化脚本在发请求或执行操作时没有设置超时时间,默认就是“一直等”。这在网络正常时没问题,但一旦对方服务卡住,你的脚本就会永远挂在那里,既不报错也不退出,占用资源还发现不了。
我的习惯是:任何可能阻塞的操作,都必须设置超时。网络请求设连接超时和读取超时,文件操作设最大等待时间,数据库查询设查询超时。超时时间根据实际业务场景来定,一般网络请求设5到30秒,文件操作设1到5分钟,数据库查询设30秒到5分钟。
超时之后怎么处理?如果是可重试的操作,走重试逻辑;如果是不可重试的,记录日志并告警。
5.2 资源清理:别让失败留下烂摊子
脚本失败时,往往会留下一些“半成品”:临时文件没删、数据库连接没关、锁没释放。这些残留物在下次运行时可能导致新的问题。
我现在的做法是:用try...finally或者上下文管理器来保证资源清理。不管操作成功还是失败,临时文件都要删除,连接都要关闭,锁都要释放。如果清理本身也可能失败,那就把清理操作也纳入异常处理范围,确保不会因为清理失败导致更严重的问题。
5.3 版本兼容:上游变了,你的脚本知道吗
自动化脚本依赖的外部接口、文件格式、数据规范,都可能发生变化。上游系统升级了接口,返回的字段名变了;数据提供方调整了文件格式,多了一列少了一列。这些变化如果脚本没有感知,就会直接报错或者更糟——静默地处理错误数据。
我的做法是:在脚本里加入数据校验逻辑。每次处理数据前,先检查数据的结构是否符合预期。字段数量对不对、必填字段有没有、数据类型对不对。如果不符合预期,立即停止并告警,而不是硬着头皮往下跑。
5.4 测试异常路径:别只测“正常情况”
大多数人测试自动化脚本时,只测试正常流程:数据正常、网络正常、接口正常。但异常处理逻辑恰恰是在异常情况下才生效的,如果不测试异常路径,你根本不知道异常处理写对没写对。
我现在的习惯是:写完异常处理逻辑后,人为制造异常来验证。把网络断开,看重试逻辑是否生效;把接口返回改成错误码,看告警是否触发;把数据格式改错,看校验逻辑是否拦截。只有实际验证过的异常处理,才是可靠的异常处理。
6. 写在最后的一些个人体会
做了这么多自动化项目,我最大的体会是:自动化的价值不在于“能跑”,而在于“能一直跑”。一个只能跑一次的脚本,价值有限;一个能稳定跑一年的脚本,才是真正的自动化。
而决定一个脚本能不能一直跑的,不是它的核心逻辑有多精妙,而是它的异常处理有多完善。核心逻辑决定了脚本的上限,异常处理决定了脚本的下限。大多数时候,我们不需要追求上限有多高,而是要把下限做扎实。
如果你现在手上有一个自动化脚本,跑得还算稳定,我建议你花半个小时做一件事:把脚本里所有的try...except找出来,看看每个异常处理是否合理。是不是所有异常都被捕获了?捕获之后是重试还是放弃?放弃之后有没有告警?告警之后有没有人处理?把这几个问题回答清楚,你的脚本稳定性至少能提升一个档次。
还有一个很实用的建议:给每个自动化脚本写一个“故障手册”。记录这个脚本可能出现的故障、每种故障的表现、排查步骤、修复方法。这样即使不是你本人来处理问题,其他人也能快速上手。这个手册不需要多正式,一个 Markdown 文件就够了,但它的价值会在出问题的时候体现出来。
最后分享一个我用了很久的小技巧:在脚本启动时,先做一次“自检”。检查依赖的服务是否可达、需要的文件是否存在、配置是否正确。自检不通过就直接退出并告警,不要等到跑到一半才发现问题。这个自检逻辑花不了多少时间,但能避免很多“跑了一半才失败”的尴尬情况。