1. 整体设计与思路拆解
接手过企业级脚本项目的人应该都有这种体会:一个脚本能跑起来,和能稳定跑半年不出事,完全是两个维度的事情。标题里提到的断言、日志、异常、重试,就是让脚本从“玩具级”走向“企业级”必须跨过的四道坎,也是脚本开发中最容易忽视、却最影响稳定性的四根支柱。
先说一个我自己的真实经历。早年做接口自动化测试脚本时,首次跑通脚本的兴奋感还没过去,就发现脚本隔三差五在深夜挂掉,第二天早上看任务平台一片红。最气的是脚本挂了没有任何痕迹,既不知道卡在哪一步,也不知道是数据问题还是网络问题,只能靠猜。后来我专门花了两天时间,把断言、日志、异常、重试这套体系从头到尾搭了一遍,那一周之后脚本稳定性直接上了一个台阶,再出问题也能在五分钟内定位到根因。
这就是我想要在这篇博文里分享的核心内容:怎么把四个看似基础的技术点,组合成一套真正能扛住生产环境考验的脚本基建。这里说的脚本,不局限某一种语言,Python、Shell、JavaScript写的自动化任务、数据采集、接口巡检、定时作业都在范围内,核心思路是通用的。适合谁看?正在写脚本但总觉得不够稳的工程师,被线上脚本半夜告警折磨过的运维,准备把自己的个人脚本升级成团队公共工具的开发者,都应该能从这里找到可以直接抄作业的方案。
1.1 四个优化点的职责边界
先理清楚这四个东西各自管什么。
断言,管的是“结果对不对”。脚本跑完不等于任务完成,跑完得到的结果是不是符合预期,这才是关键。没有断言,脚本跑完你可以说它“执行了”,但不能说它“成功了”。断言就是把不可见的执行结果变成可验证的是非判断。
日志,管的是“过程看得见”。脚本跑得好好的时候,日志好像没什么用,可真出了事,日志就是唯一的目击者。从“脚本挂了”到“脚本为什么挂”,中间隔着一条日志的长河。日志写得好不好,直接决定定位问题的时间是从十分钟变成一小时,还是从一小时变成十秒钟。
异常,管的是“出事别崩”。脚本运行环境千变万化,网络超时、服务端返回500、数据结构变了、磁盘写满了,任何一个意外都有可能导致脚本当场崩溃。异常处理的意义不是消灭错误,而是把错误控制在可控范围,让脚本在出错时知道自己该做什么。
重试,管的是“再来一次”。很多故障是瞬时的,网络抖一下、服务端临时过载、连接池刚好被占满,这些情况下直接判定失败太冤枉,稍微等一会儿再试试可能就成功了。重试机制也是四个优化点中,对稳定性提升立竿见影、又最容易被做过头的一项。
1.2 四个机制的协作关系
这四个点不是孤立的,它们之间是串联协作的关系。我的理解是:断言负责发现问题,异常负责处理问题,重试负责给异常一次补救机会,日志负责把整个过程记录下来给人看。
举个例子,一个定时拉取第三方接口数据的脚本,每天凌晨执行。某天凌晨服务商发布了一个新版本,接口响应结构发生了变化,脚本里解析返回数据的逻辑拿到了一个不存在的字段。这时候,异常处理会让脚本不因为KeyError直接崩溃,而是捕获这个异常并打印出堆栈;日志会把完整的响应体结构记录到文件里,方便第二天对比分析;如果脚本集成了健康检查断言,还会在解析之前先校验响应体的schema,提前发现结构变化;而重试机制会根据错误类型判断——数据结构变了属于不可恢复的业务性错误,不值得重试;但如果是超时、连接被重置这类瞬时错误,延迟几秒重试三次大概率能恢复正常。
这样一套组合下来,脚本对故障的感知、响应、恢复、追溯就形成了一个完整的闭环。这也是我坚定不移地认为,这四个技术点必须放在一起讲的原因——单拎出来任何一个,效果都要打折扣。
2. 断言机制:给脚本装上“正确答案”
断言是整个体系里我最喜欢的一块,因为它直接回答了一个灵魂问题:脚本到底怎么算跑成功了。很多脚本失败的场景,问题根源恰恰在于编写者从来没明确定义过“成功”。
2.1 断言的本质与误区
从本质上讲,断言就是“将执行结果与预期结果做比较,不相等则判失败”。企业级断言和平时写代码时随手加的assert有本质区别。随手加的断言更多是开发期自查,断言的表达式经常是“这里不应该为null”“这里大小不应该超过10”;企业级断言的关注点则是面向整个任务的验收——从用户视角看,这个任务的最终产物是否正确。
常见的误区是,把断言当成锦上添花,甚至完全依赖第三方框架自带的断言。我见过不少团队用JUnit、pytest这类测试框架主要看用例有没有通过,但对于测试脚本采集到的数据是否合理、接口响应是否符合业务语义,完全没有校验。这样的测试用例跑得再绿,也没有实际意义——测试脚本替你在验证,但验证的深度太浅,浅到等于没验证。
真正好的断言应该是“能挡得住需求变化的断言”。这一点很重要:需求变了,断言如果不变,脚本还是会按旧标准校验,导致大量误报或者漏报。企业里脚本需要长期维护,断言的更新频率和代码本身的迭代频率应该是同步的。
2.2 断言的分层设计与写法
做断言时,我习惯把校验目标分成三层:
数据完整性断言:校验返回的数据集是否完整,比如一个接口应该返回100条记录,实际只返回了98条,这就是数据缺失,应该用断言抓住。形如:assert_that(response.total).isEqualTo(100)。
数据有效性断言:校验每条记录里的关键字段是否有效。比如时间字段的格式、ID字段不为空、状态值是否在合法枚举内。这类断言考验编写者对业务的理解深度——你得知道哪些字段是无论如何都不能为空的。
业务规则断言:校验跨字段组合后是否符合业务规则。比如订单总金额=商品金额+运费-优惠,返回的每个订单都该满足这个恒等式。业务规则断言是最难写、也最有价值的一层,它能发现很多“数据没丢但算错了”的隐性问题。
写断言时还有一个细节我特别想强调:断言语句一定要带上下文。直接写assert result == expected,挂了之后你只知道不相等,不知道具体是哪个字段不相等、期望值是什么、实际值是什么。正确写法是带上可读的提示信息和关键变量值,例如:
assert response["order_status"] == "PAID", \ f"订单状态异常: expected=PAID, actual={response['order_status']}, order_id={order_id}"这样断言失败时,日志里直接能看到订单号和实际状态,不用再翻代码去猜。
2.3 断言策略中的“可执行化”
还有一个实践经验,我把它叫做“探针式断言”。普通的断言是一次性的——校验一次,过了就过了。但高稳定性脚本的场景是,同一段逻辑会被反复执行,比如每隔五分钟采集一次数据。我建议在脚本启动时做一次“环境探针”:先调用少量接口、解析少量数据,跑一遍轻量级断言,确认整个链路是通的,再正式进入批量处理逻辑。
这样做的好处很明显:如果环境有问题(鉴权失效、网络不通、接口变更),探针阶段就会触发告警并中止,不会让脚本带着故障跑完整轮任务,浪费大量时间和资源。
探针式断言的实现在逻辑上并不复杂,把启动阶段和数据解析阶段共用的校验逻辑抽成一个方法,启动时先在小样本上执行一遍,再决定是否继续。别小看了这个设计,它能让一批批量的任务省下很多不必要的无效重试。
2.4 断言失败后的动作设计
断言失败的后续动作也值得专门设计。我的经验是把断言失败分成两类,一类是可恢复的——比如数据量偏少,可能是上游产线还在写入,过几分钟再查可能就齐了;另一类是不可恢复的——比如数据结构根本变了、接口字段名都没了,这种情况再重试也没有意义。
对应的设计是:可恢复的断言失败,交给重试机制处理;不可恢复的断言失败,立刻中止脚本并记录详细的失败现场——包括请求参数、返回原始内容、当前时间、触发断言的代码位置。失败现场记录得越全,后面人排查问题越省事。这一块的日志设计,我会在下一节详细讲。
3. 日志体系:让每一行执行都留有足迹
日志在脚本里经常被当成“print”的近义词,这个观念必须改。企业级日志不是给开发者在终端里自己看的,而是给未来的环境、未来的排障人员(很可能是三个月后的你自己)看的。一份合格的日志,应该达到“远程看一眼就能定位问题方向”的水准。
3.1 日志级别要分,但不该滥用
日志分级听起来简单,实际操作中走样的情况特别多。最常见的问题有两个:一是全程只用一个级别,所有信息都打INFO,导致重要问题被淹没在流水账里;二是为了省事把大量中间态数据都打DEBUG,线上环境为了排查问题不得不开DEBUG,结果日志量爆炸,磁盘一夜写满。
我推荐的小队级标准是:正常执行步骤用INFO(每个关键阶段至少一条),变量展开、详细数据用DEBUG,异常和错误用ERROR并始终带堆栈,可预见的边界情况(比如某次请求超时但重试后成功)用WARN。守住了这个级别约定,生产环境的日志量基本可控,需要深入排查时再临时开DEBUG。
还有一个简单有效的约定:日志里必须能看出“这条日志是谁在哪产生的”。至少要包含模块名、函数名、行号。Python的logging模块默认格式化里加%(filename)s:%(lineno)s就能实现,成本为零,排障价值巨大。Shell脚本也一样,用echo打日志时手动加上函数名和行号。
3.2 日志规范里最能提效的字段
日志格式的具体规范,不同语言、不同日志框架会有差异,但核心字段是通用的:
| 字段 | 作用 |
|---|---|
| 时间戳 | 到毫秒,带时区,方便跨系统对齐 |
| 日志级别 | INFO / WARN / ERROR / DEBUG |
| 模块与函数 | 定位代码位置 |
| 请求ID / 任务ID | 串联一次完整调用链 |
| 关键上下文 | 业务参数、订单号、接口URL等,用于快速定位问题对象 |
| 错误摘要与堆栈 | 异常信息必须完整保留 |
这里最想重点说的是请求ID / 任务ID,也就是业界常说的TraceID。脚本往往是一个循环处理几千条数据,单条数据处理失败时,如果日志里没有这条数据对应的唯一ID,排查起来真的要命。正确的做法是:循环开始时生成一个trace_id或者直接用数据本身的业务主键,这一段的所有日志都带上这个ID。这样日志里grep "trace_id=AB123",整条链路就全部浮出水面了。
3.3 落盘策略与轮转配置
日志配置里最容易踩坑的是轮转和保留策略。没有轮转,日志文件会无限膨胀,最终把磁盘写满,脚本故障不说还可能拖垮同一台机器上的其他应用。轮转策略按体积和按时间两种都常见,我一般组合使用:单个文件达到50MB就轮转,保留最近10个文件或者最近7天。这个配置在Python logging的hander里几行就搞定,Shell下配合logrotate也很方便。
还有个很多人忽略的细节:日志目录要提前创建好,并确认脚本进程有写入权限。很多脚本上线后跑得好好的,突然某天日志就不写了,一查是运维清理目录时把权限改了,或者目录被误删。脚本启动时先检查日志目录是否存在、是否可写,不存在就尝试创建,这一步硬化能让后面的排障省很多时间。
3.4 避免日志拖慢脚本性能
日志写得越详细越安全,但性能开销也越大。高频率的日志写入会拖慢脚本执行速度,尤其在循环里写日志的场景。一个经验是:循环内部按需记录,不搞每一条都写;要记录循环进度时,用“每处理100条记一条”的节流策略。异步日志在Python里有QueueHandler配合后台线程消费的方案,日志IO从同步变成异步,吞吐量能提升不少。但异步日志也有代价——脚本进程非正常退出(比如被kill)时,堆积在队列里的日志可能来不及落盘,有丢失风险。权衡之下,普通脚本用同步日志加节流就够了,没必要强上异步。
注意:日志内容不要记敏感信息,尤其是密钥、Token、密码、个人隐私数据。日志文件本身也是会被拷贝、被备份、被传到日志平台的,里面的数据一旦泄露就收不回来了。必须记录凭证时,至少做脱敏处理。
4. 异常处理:不出轨,也要有应急预案
异常处理是脚本健壮性的地基。没有这层设计,前面说的断言、日志、重试全是空谈——脚本一崩,后面的机制根本没机会执行。异常处理的设计核心,是提前想清楚“哪些错误能继续跑,哪些错误必须停下来”。
4.1 异常分类:三桶模型
我习惯把脚本运行中可能出现的异常分成三类,分别放进三个“桶”:
基础设施异常:网络连不上、DNS解析失败、磁盘空间不足、数据库连接断开等。这类异常的共同特征是瞬时性强,可能与当前运行环境强相关,前一秒倒下后一秒可能就正常,是重试机制的优先候选。
业务规则异常:数据校验不通过、业务状态不在预期等。这类异常代表数据和逻辑本身有问题,重试大概率无效,应当直接记录下来,跳出当前数据处理单元(比如跳过当前记录),进入下一条。
未预期异常:类型错误、边界溢出、依赖库bug。这类异常说明代码本身存在缺陷或环境出现了意料之外的变化,必须完整保留现场,通常需要人工介入处理,不能默默吞掉。
分好了桶,异常处理逻辑就非常清晰了:基础设施异常进重试;业务规则异常跳出当前单元;未预期异常记录完整堆栈并置失败标志,等整轮结束时汇总报告。
4.2 捕获粒度与“圈养”策略
异常捕获的粒度是个大学问。一个常见错误是在main函数外面套一个巨大的try...except,出了任何错都逮住,然后打印个“过程失败”就结束。这种写法把异常处理变成了掩盖问题的工具,脚本永远在“看起来挂了但没完全挂”的状态运行,失去了告警的意义。
推荐的捕获粒度是“按处理单元捕获异常”。以数据处理脚本为例,每处理一条数据是一个独立的try块,单条数据的异常不应该影响整个批次的执行。块内捕获后先判断异常类型属于哪个桶,再走对应分支。中途如果遇到“未预期异常”,就把失败数据标记出来,等全部处理完后统一打报告。
这样处理的好处是最大化脚本的吞吐能力。一个批次1000条数据,其中3条格式异常,脚本应该处理完其余997条,最后告诉你“有3条数据异常,详见日志”。而不是跑到第50条就整体崩溃,剩下950条全部没处理。
4.3 finally与资源回收
异常处理里最容易被忽略的是资源回收。脚本打开文件、建立数据库连接、拿锁、申请临时目录,这些都是吃系统资源的操作,一旦在自己处理异常的逻辑里漏了释放,轻则文件句柄泄漏,重则数据库连接耗尽,故障范围从脚本本身蔓延到整个服务。
所有资源型的对象,都要用with语法(Python)或者对应的try/finally结构(Java、Go)来管理。这里有一个我自己曾经踩过的坑:脚本用Python连接了MySQL,处理数据时一个字段抛了未预期异常,我的异常分支做了记录,但忘了关连接。脚本循环一跑就是几个小时,连接池里的连接越积越多,最后把开发环境数据库的连接数打满了,整个组的同事都连不上库。后来排查了半天才定位到是脚本泄漏了连接。
所以异常处理代码的书写原则是:先用finally保证资源一定回收,再在里面思考异常的具体分支。顺序反了,经验教训就来了。
4.4 兜底异常和退出码
虽然按处理单元捕获了异常,但整脚本层面也需要一个兜底。最外层try...except捕获所有漏网之鱼,记录最完整的运行现场,然后带着非零退出码退出。退出码是企业级脚本之间协作的基础契约:0表示成功,非0表示失败。你写的脚本如果可能被其他系统(CI、调度平台、定时任务服务)调用,退出码就是它们判断脚本结果的唯一依据。
我见过太多脚本“成功失败都返回0”的案例,CI流程里脚本内部明明报错了,阶段还是绿的,问题被完美掩盖到发布上线才炸。这些问题在写脚本时多写一行sys.exit(1)就能避免。
5. 重试机制:弹性处理瞬时故障
重试是修复瞬时故障性价比最高的手段,但同时又是最容易写崩的一种机制。无脑重试不仅浪费资源,还可能放大故障面,甚至造成雪崩。重试机制的设计,核心是做好两个判断:哪些错误值得重试,重试的节奏怎么控制。
5.1 值得重试的错误清单
适合重试的错误类型:网络超时、连接被重置、HTTP 502/503/504、数据库连接池暂时无可用连接、分布式锁争抢失败、上游服务返回“正在限流请稍后再试”等。这些错误的共同点是“错不在你”,换一个时间窗口去请求,大概率能成功。
不应该重试的错误类型:认证失败(401/403)、参数校验失败(400)、数据结构错误、资源不存在(404)、幂等性无法保证的写操作(没有唯一ID无法去重时)。对这些错误做重试,除了浪费时间和流量外没有任何意义。
5.2 退避策略与抖动
重试节奏是重试机制最核心的参数。先看一个反面典型:固定间隔死循环重试。脚本每2秒重试一次,任务一直失败就一直重试,直到把日志灌满、把目标服务打挂。这在我见过的很多初版脚本里都有,危机感极强。
正确的姿势是指数退避。每次重试间隔翻倍,比如第一次重试等2秒、第二次等4秒、第三次等8秒、第四次等16秒,直到达到最大间隔(一般上限60秒)。
光有指数退避还不够,业界还会加一个“抖动”处理:在退避计算出的等待时间上加上一个随机偏移量。为什么要加随机性?因为如果上百个客户端同时触发瞬时故障,大家第一次重试的时间点也完全一致,重试请求会在同一秒内打向上游——这就是重试风暴。加抖动可以让重试请求在时间轴上散开,降低对上游的瞬时压力。典型代码示例:
import random import time def retry_with_backoff(func, max_retries=3, base_delay=1, max_delay=60): for attempt in range(max_retries): try: return func() except RetryableError as exc: if attempt == max_retries - 1: raise delay = min(max_delay, base_delay * (2 ** attempt)) jitter = random.uniform(0, delay) # 全抖动,也可以是delay的一半 time.sleep(delay + jitter) logger.warning("retry attempt=%s, delay=%s, error=%s", attempt + 1, round(delay + jitter, 2), exc)5.3 重试上限与总耗时的控制
重试次数不能无限大。经验值是最多3到5次,具体取决于重试成本和总耗时的预算。任何重试设计都要回答一个问题:整体重试上去的时间,加上单次处理的时间,不能超过任务的整体SLA。
举一个实际例子:一个脚本任务要求整体10分钟内完成,单次请求平均耗时1秒,那么如果最多重试5次,理论上最差情况会有约1分钟的时间消耗(5次请求本身加退避等待)。这个量级在SLA内,没问题。但如果单次请求耗时已经到30秒,重试5次就意味着最差情况要超过3分钟,如果任务对耗时敏感,就必须把重试次数往下调。
我的习惯是在重试时把总耗时也作为一个隐含终止条件:不管重试次数是否用完,只要重试的开始时间距离首次失败已经超过N分钟,立即放弃。
5.4 重试的幂等与重复副作用
重试最隐蔽的风险是重复请求带来的副作用。这个风险在PUT、POST这类可能改变服务端状态的接口上尤其严重。第一次请求其实已经到达服务端并执行成功了,但响应在途中丢失,客户端超时后出发重试,服务端又执行了一遍——最终数据被处理了两次。
解决这个问题的标准方案是幂等键:每次请求生成唯一的idempotency_key,服务端针对这个key做去重处理。如果上游不支持幂等键,那就要谨慎决定是否对写操作执行重试。
对于脚本内部的处理流程,最稳妥的设计是“先查后写”:数据写入前先查一下是否已经处理过,处理过就跳过。这个机制在很多数据采集脚本里叫“断点续传”,配合一个记录处理进度的状态表或文件,重试时从断点继续,而不是从零开始,效率和稳定性都大幅提升。
6. 实操案例:从一个脆弱脚本到企业级脚本改造全记录
理论说了不少,下面用一个完整的实战案例来演示整个改造过程。场景设定为一个定时数据采集脚本,每10分钟从第三方开放接口拉取当天的订单数据,清洗后写入数据库。初始版本跑在个人开发机时没什么问题,但部署到服务器上稳定运行后就不断出状况。
6.1 初始版本的脆弱之处
import requests import time def fetch_orders(): resp = requests.get("https://api.example.com/orders", timeout=5) data = resp.json()["data"] for order in data: # 清洗并入库 print(f"handle order {order['id']}") time.sleep(0.5) def main(): while True: fetch_orders() time.sleep(600) main()这段代码的问题列出来非常触目惊心:
- 没有断言,接口返回的数据结构变了,代码会在
resp.json()["data"]那句直接抛KeyError。 - 日志等于没有,只有一个
print,生产环境没人盯着终端看。 - 没有任何异常处理,任何一步出错都会导致整个脚本退出,调度平台也没法判断失败原因。
- 没有重试,网络抖一下或者服务端临时不可用,脚本就当场死亡。而且这个脚本还是单次任务设计,一旦中途失败,本次数据全部丢失。
6.2 改进后完整骨架
针对上面四个问题,我逐一加固,得到下面这个升级版骨架:
import logging import random import sys import time import requests from logging.handlers import RotatingFileHandler # ---- 日志配置 ---- logger = logging.getLogger("order_pipeline") logger.setLevel(logging.INFO) _fh = RotatingFileHandler("order_pipeline.log", maxBytes=50 * 1024 * 1024, backupCount=5) _fh.setFormatter(logging.Formatter( "%(asctime)s [%(levelname)s] %(filename)s:%(lineno)s %(trace_id)s - %(message)s" )) logger.addHandler(_fh) class RetryableError(Exception): """可重试异常:网络、超时、5xx等""" class BizError(Exception): """业务异常:数据格式不符等,跳过当前记录""" def fetch_orders(trace_id): resp = requests.get("https://api.example.com/orders", timeout=10) if resp.status_code != 200: raise RetryableError(f"HTTP {resp.status_code}") data = resp.json().get("data") # 断言:校验必要字段 assert data is not None, f"response data is None, raw={resp.text[:500]}" assert isinstance(data, list), f"data should be list, actual={type(data)}" return data def handle_order(order, trace_id): logger.info("handle order begin", extra={"trace_id": trace_id}) # 核心清洗入库逻辑 # ... if not order.get("id"): raise BizError(f"order missing id: {order}") def run_batch(trace_id, max_retries=3, base_delay=1): for attempt in range(max_retries): try: orders = fetch_orders(trace_id) for order in orders: try: handle_order(order, trace_id) except BizError as exc: logger.warning("skip order due biz error, %s", exc, extra={"trace_id": trace_id}) continue return True except RetryableError as exc: if attempt == max_retries - 1: logger.error("fetch orders exhausted all retries, %s", exc, extra={"trace_id": trace_id}) return False delay = min(60, base_delay * (2 ** attempt)) + random.uniform(0, 1) logger.warning("transient error, retry in %.2fs, %s", delay, exc, extra={"trace_id": trace_id}) time.sleep(delay) def main(): trace_id = f"task-{int(time.time())}-{random.randint(1000, 9999)}" logger.info("scheduler start", extra={"trace_id": trace_id}) try: if not run_batch(trace_id): sys.exit(1) except Exception: logger.exception("unexpected fatal error", extra={"trace_id": trace_id}) sys.exit(2) finally: logger.info("scheduler finish", extra={"trace_id": trace_id}) main()这段代码虽然不长,但四个优化点都落地了:断言在fetch_orders里,日志全程记录且带trace_id,异常按RetryableError/BizError/大兜底三分类处理,重试采用指数退避加抖动的3次重试策略。
6.3 改造后的效果验证
改造上线后,我做了几个验证实验:
构建一个模拟接口,每10次请求中随机返回两次503,观察改造后脚本在24小时内是否出现过任务失败。结果处理稳定性非常好:单次请求的失败都被重试消化了,整体任务没有一次失败。而改造前的脚本,在这种模拟故障下每次都会跑挂。
另一个实验是故意修改接口返回结构,把data字段改成items,观察脚本行为。断言在第一时间抓住了结构变化,打印出原始响应内容,任务快速失败退出并带有非零退出码。调度平台收到失败信号后告警,整个定位时间不超过1分钟。
从这两个实验来看,改造后的脚本在“抗瞬时故障”和“快速感知永久故障”上的表现都达到了预期,稳定性维度算得上是“稳如磐石”了。
7. 常见问题与排查技巧实录
最后分享一些在实践中反复出现过的问题和对应的处理思路。这些问题在团队里新人写脚本时几乎都踩过一遍,整理成速查表,方便随时查阅。
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 脚本运行产物正确,但返回码是1 | 逻辑执行成功但遗漏了退出码设置 | 检查每个分支是否有return/exit | 明确设置返回码,成功分支写exit(0) |
| 日志文件为空 | 目录权限不正确或日志级别过滤掉 | 检查日志目录权限;临时用--debug参数启动 | 启动时检测目录是否可写;把DEBUG级别单独控制 |
| 断言触发了大量误报 | 断言写成业务规则、数据合法但值与昨天不同 | 查看断言中的上下文信息,确认到底校验的是什么 | 划分数据完整性与业务规则,避免一刀切 |
| 重试导致接口被限流 | 重试风暴:多个任务同时失败同时重试 | 检查重试日志里是否大量集中在同一秒 | 指数退避加抖动;增加全局限流 |
| 捕获了所有异常但问题仍反复 | 异常被吞掉,只打了summary日志 | 查看ERROR日志是否有堆栈 | 在未知异常分支记录完整堆栈 |
| shell脚本重试逻辑写的乱 | 用sleep+goto实现的循环不直观 | 理顺逻辑改写成函数式重试 | 多语言下都能用“循环+计数器+break”实现 |
7.1 三个独家的排障技巧
技巧一:先看日志再动代码。调试任何脚本问题,第一步永远是打开日志文件,搜索对应的trace_id,把整条执行链路从头到尾过一遍。很多问题在日志里看一眼堆栈就能定位,直接翻代码反而会浪费大量时间。
技巧二:给重试加一个可视化的进度输出。重试过程中如果在日志里打一条从第几次重试、上次失败原因、等待什么时候再试,那么在运维侧看这些日志会非常安心——你知道脚本没有死,还在处理故障。这几行日志对于建立“脚本可靠”的信任感非常重要。
技巧三:给核心脚本做一次性的自诊断。脚本启动时用探针式断言检查一遍依赖项(数据库连接、目录权限、环境变量、网络连通性),一旦不符合预期立即失败并输出诊断报告。这样脚本出问题时,第一个提示往往就是根因,而不是需要人再去猜。
7.2 脚本维护中的长期治理
企业级脚本不是写完就完事了。我个人有一条经验:每隔一段时间给脚本做一次稳定性回放。把过去两周的日志打开,找出所有WARN和ERROR,分析这些预警有多少被重试消化、有多少靠人工介入才恢复、有无可以继续优化的点。每做一次回放,脚本的健壮性都会提升一个台阶,因为你会发现很多之前没意识到的边界情况。
另一个长期治理原则是:脚本要能“安静地成功”。一个设计良好的脚本,在运行一切正常时不应该打扰任何人,只在异常时发出告警。日志记录要带好上下文信息,告警要有关联的任务标识,这样运维者在收到告警的第一时间就能知道是哪个任务出了什么问题,而不用再去翻各种平台。
8. 写在最后的一些体会
做完这套企业级优化之后,我最深的一个体感是:脚本稳定性的提升,靠的不是某一个“大招”,而是把断言、日志、异常、重试这四件事都做到位之后产生的水桶效应。缺任何一块板子,水桶都装不满。
从投入产出比来看,这是脚本开发里性价比最高的一组投资。断言和日志的代码量可能只占总代码的10%到15%,但这10%决定了剩下85%的代码在出问题时是“可救”还是“不可救”;异常和重试的代码量可能有20%,但正是这20%决定了脚本在没有人类干预的情况下能坚持跑多久。
最后再分享一个具体的小建议。如果此刻你手头有一个已经跑了一段时间的脚本,但一直没做这四项加固,不妨先别急着写新功能,花一个下午专门给脚本加上这套“护甲”。先把重试的逻辑加上(效果立竿见影),再把日志从print换成结构化logger,然后抽零碎时间把关键路径上的断言补齐,最后一定要检查一遍异常处理的兜底和退出码。做完之后跑一周看看,晚上睡觉时收到告警电话的概率会小一个数量级。这件事,真的值。