
做自动化这些年我手上跑过的脚本不下三四十个有线上稳定运行一年多的也有上线两天就被打回原形的。以前我总爱把失败归因于“环境不稳定”“第三方系统太烂”“需求又改了”直到有一天我把所有失败项目拉出来统一复盘才发现一个让人后背发凉的事实大部分失败的自动化最后都栽在同一个环节上。不是框架选得不对不是环境维护得不好也不是代码写得不够优雅而是——步骤与步骤之间缺少了对“前置条件是否就绪”的等待和确认。更直白一点说是把“我不确定别人准备好没有”当成了“它一定准备好了”。这篇文章我会把这个环节彻底拆开讲清楚它为什么杀伤力这么大再把我实际踩坑、排查、修复的过程完整过一遍。不管你在做浏览器自动化、接口自动化、RPA流程还是写批量脚本这套思路都能直接套用。1. 先定位复盘到最后失败项目都绕不开同一个环节1.1 三个失败项目的现场回顾先说三个我亲手做过、最后都失败的项目大家感受一下。第一个是一个活动页自动抢券脚本。页面每天上午十点放券脚本到点就进去找券按钮找到就点。肉眼看起来很简单可跑起来隔三差五就会报“找不到元素”有时候又报“点击被拦截”。我把等待时间从2秒加到5秒、8秒问题依然存在。第二个是一个数据同步任务。脚本调用上游系统的异步任务接口提交一份数据清洗需求然后读取结果表写入自己的库。代码写得很直白提交请求拿到任务ID立刻查结果。结果就是经常查不到数据日志里一片空白再手动重跑一次又好了。第三个是一个文件批处理流程。业务系统每天凌晨导出一份报表脚本在导出动作完成后马上打开文件解析然后写入分析结果。刚开始一直报“文件为空”后来我把“没有数据”的判断去掉又开始报“数据不完整”最后发现是文件还在写入过程中就被读了。这三个项目看起来完全不相干但复盘到最后问题都出在同一处上一个动作完成后我没有确认下一个动作的前置条件已经成立就直接往下走了。抢券脚本没等按钮真正可点同步脚本没等任务真正执行完文件脚本没等文件真正写完。1.2 这个环节到底该叫什么我给它起了个名字前置就绪确认。在自动化领域它对应的就是“等待与重试机制”。你可能听过“显式等待”“轮询查询”“超时重试”这些词本质上说的都是同一件事——动作之间留出窗口并且主动确认状态而不是盲目依赖时间凑合。为什么这个环节这么容易被忽略因为写自动化的时候我们的大脑默认把整个流程想象成一条直线A完成就是A完成B可以马上开始。可现实世界的外围系统根本不是这样工作的页面加载要时间接口处理要排队文件落盘要刷缓存任何一个环节抖一下你的“直线流程”就会断掉。2. 为什么所有失败最终都会汇到同一个坑2.1 自动化是用同步思维驱动异步世界做过的朋友应该都有体会代码是我们一行一行写出来的它在我们的认知里天然是顺序执行的第一行跑完跑第二行第二行跑完跑第三行。可问题在于你指挥的那些外部系统——浏览器、接口服务、报表引擎、消息队列——没有一个会老老实实等你。它们本质上都是异步系统。页面元素要经过网络请求、DOM解析、渲染管线才会出现异步任务要经过队列排队、分片计算、结果回写才完成文件要经过内存缓冲、磁盘同步、进程释放才稳定。我们写自动化时却全程用同步思维去驱动它们还理所当然地认为“我执行完了”等于“它干完了”。我之前带过一个新人写自动化从来不用等待函数全凭time.sleep(3)走天下。问他为什么是3秒他说“试过2秒不行4秒太慢”。这种代码跑通的时候你好我好一旦系统慢了一拍所有的偶发问题全冒出来了。固定睡3秒本质上就是在赌外部系统一定能在3秒内完成这个赌注赢面能有多大2.2 那些常见的“假等待”是怎么骗人的很多人说自己写了等待等我一看代码发现写的是“假等待”。我把它们列出来你对照一下自己有没有写过等待方式能确认什么不能确认什么固定time.sleep(3)只能确认时间流逝了3秒不能确认页面、接口、文件是否就绪查找元素成功元素在DOM里存在元素是否可见、可点击、不被遮挡接口返回 HTTP 200请求被服务端接收处理了业务是否成功数据是否完整落库文件路径存在文件已被创建文件是否写完内容是否完整数据库能查到记录表里有数据数据是否就是本次操作的最新结果看清楚了吗很多“等待”只验证了表象根本没有验证真实的业务就绪状态。我在抢券脚本里试过time.sleep(5)然后立刻去点按钮表面上看元素已经能找到了但其实按钮上方还浮着一个半透明的营销弹层点击被它拦住了。这就是典型的“表面就绪业务未就绪”。2.3 失败为什么总是偶发且难以复现这个环节导致的失败最大的特点就是偶发性强复现难度大。因为外部系统的处理速度本身是个概率分布快的时候秒回慢的时候要等好几秒卡顿的时候十几秒也不稀奇。“偶发”意味着你重跑一次可能就成功了于是你误以为是运气问题或者干脆归结为“网络抖动”。但真相是你写了一个没有就绪确认的流程它在外部系统响应快的时候假装正常在响应稍慢的时候就悄悄崩溃。这类问题最折磨人因为它不给你一个稳定的报错现场让你反复怀疑代码本身。2.4 不修复这个环节的代价有多大不修复的代价分两层。第一层是脚本本身的失败率居高不下。我那个文件批处理项目上线第一周成功率只有百分之七十多每天都要人工去补跑失败的记录自动化不但没省人力反而多了一个盯日志的活儿。第二层是失败会沿着流程向下游传播。一个环节因为前置未就绪挂了后面的步骤都停在那里如果中间还有“失败重跑整个流程”的逻辑又可能造成重复写入、重复扣减。处理不好自动化的偶发失败会变成一个更大的脏数据来源。这也是为什么我后来坚定不移地在每个动作之间加上“等待—确认—重试”的闭环。宁可慢一点也不赌运气。3. 把“等待、确认、重试”变成标准动作3.1 核心思路每一步都拆成四个阶段我以前写自动化是这样的执行动作A执行动作B执行动作C。后来我改成发起动作等待就绪确认业务条件执行下一步并校验结果翻译成大白话就是每走一步之前先问一句“好了吗”确认对方回答“好了”再走下一步。如果长时间没回应就按策略重试或失败退出。我封装过一个通用的等待函数几乎每种自动化场景都能用。核心逻辑就是轮询一个条件直到它成立或超时import time def wait_until(condition, timeout10, interval0.5, desc目标条件): 轮询等待某个条件成立超时则抛出异常。 condition 是一个无参函数返回 True 表示就绪。 deadline time.time() timeout attempts 0 while time.time() deadline: try: if condition(): return { ok: True, attempts: attempts, elapsed: round(timeout - (deadline - time.time()), 2), } except Exception: # 条件函数内部抛异常按“未就绪”处理 pass attempts 1 time.sleep(interval) raise TimeoutError(f{desc} 等待超时最长 {timeout} 秒尝试 {attempts} 次)别小看这个函数它把“等待”这个动作从业务代码里抽了出来。之后任何地方需要等就绪只需要传一个能返回布尔值的条件就行。3.2 “就绪条件”到底该怎么设计很多人的条件写得太粗。我说几个场景的“正确姿势”浏览器自动化不要只判断元素存在要判断元素可见、可点击、且没有任何透明遮罩层盖住它。条件函数里可以再检查一下当前页面必需的文本或状态标志是否出现。接口自动化不要只看 HTTP 状态码要看业务返回码是否符合预期还要看关键字段是否存在、数据是否满足后续使用要求。文件处理要同时判断文件存在、文件大小大于某个阈值、以及最近修改时间保持稳定说明没有进程在持续写它。数据库任务不要只判断“有记录”要判断本次任务对应的记录、对应的状态字段是否已更新为终态。这些条件一旦写对你几乎不会再用到time.sleep去做“碰运气式”等待。我拿文件场景举个例子def file_ready(path, min_size_kb1, stable_seconds2): 判断文件是否写完存在、非空、大小在 stable_seconds 内不再变化。 if not os.path.exists(path): return False size os.path.getsize(path) if size min_size_kb * 1024: return False mtime os.path.getmtime(path) time.sleep(stable_seconds) if os.path.getmtime(path) ! mtime: return False return True这个函数我一直在用它比单纯判断“文件存在”靠谱太多。3.3 重试与退避别用蛮力重跑等待超时之后怎么办很多人会写成“一旦报错立刻重跑整个流程”。这是一个非常危险的冲动。重试本身没问题但要讲究策略。我的经验是分两点第一必须区分“可重试”和“不可重试”的错误。超时、连接中断、服务端繁忙这些是可重试的参数错误、权限不足、请求格式不对这些重试一万次也没用应该直接记录失败原因退出。第二重试间隔要用退避策略不要狂暴式重试。如果服务端已经繁忙你立刻再打一次只会雪上加霜。我常用的是指数退避加一个随机抖动import time import random def retry_call(func, max_retries3, base_delay1): for attempt in range(max_retries 1): try: return func() except Exception as exc: if attempt max_retries: raise delay base_delay * (2 ** attempt) random.uniform(0, 1) print(f第 {attempt 1} 次重试等待 {delay:.2f} 秒) time.sleep(delay)指数退避的意思很简单第一次失败等1秒重试第二次失败等2秒第三次等4秒每次间隔都翻倍再加一点随机量避免多个脚本在同一时刻打向同一个服务端。这是处理外部依赖抖动的基本修养。3.4 超时参数不能拍脑袋还有一个常犯的错超时时间随便填一个数10秒、30秒、60秒全凭感觉。我的经验是拿数据说话。你可以先跑几轮正常流程把每个动作的耗时记录下来算一下平均值和最大耗时。超时时间建议设为历史最大耗时的1.5到2倍再留出缓冲。比如正常耗时峰值是6秒超时就设12秒左右如果偶发能到30秒那超时就要往60秒以上走否则你的自动化会在一个“其实能成功只是慢”的任务上反复夭折。重试次数也一样别设成“无限重试”。我的默认值是3次最多不超过5次。超过这个次数说明不是简单抖动而是出了结构性故障继续重试只是白白消耗资源。4. 三个失败项目的完整修复记录4.1 案例一页面自动化点击被“幽灵遮罩层”拦截那个抢券脚本一开始的定位方式是找到按钮点击。我以为是定位不够准换了好几种选择器都没用。后来我把点击前的页面状态截图一看发现元素确实能找到但按钮上方浮动着一个半透明的活动浮层WebDriver模拟点击时点的是这个浮层而不是按钮。修复方案分三步把等待条件从“元素存在”改成“元素可见且可点击”轮询检查是否有透明遮罩层仍然盖住按钮如果有等待它消失点击之后增加结果校验——检查是否进入领券成功状态而不是无脑执行下一步。这里给一个简化版的条件函数核心是在等待阶段就把“能否点击”一起确认掉def btn_ready(driver, btn_locator, mask_locator): def _ready(): btn driver.find_element(*btn_locator) if not btn.is_displayed() or not btn.is_enabled(): return False # 如果还存在遮罩层则视为未就绪 if mask_locator: masks driver.find_elements(*mask_locator) for m in masks: if m.is_displayed(): return False return True return _ready这个案例也让我彻底明白了一个道理“元素在 DOM 里”和“用户真的可以点”是两码事。自动化脚本模拟的应该是“用户可操作状态”而不是“程序可找到状态”。4.2 案例二异步任务接口轮询只查了一次同步脚本的问题是提交任务后立刻查结果等于是让一个需要几十秒的重型任务在提交后瞬间出结果。我刚开始只加了一个time.sleep(20)但任务忙的时候40秒也不一定跑完闲的时候15秒就结束了。固定等20秒两头都不讨好。修复方案就是标准轮询。提交任务拿到任务ID然后循环检查任务状态def wait_task_done(task_id, timeout120, interval3): def _query(): resp query_task_status(task_id) # 假设这个函数返回任务信息 if resp[status] in (SUCCESS, FAILED): return True if resp[status] RUNNING: return False return False wait_until(_query, timeouttimeout, intervalinterval, descf任务 {task_id} 完成)轮询间隔我设成3秒超时设成120秒。为什么是3秒而不是0.5秒因为任务状态查询本身也有频率限制太频繁地轮询会给服务端造成无谓压力。这个粒度问题要靠对业务耗时的观察来决定通常取任务最短耗时的三分之一到五分之一比较合适。修复完之后我特意在日志里记下了每次轮询的状态变化哪个任务等了多少秒才完成一目了然再也没出现过“查不到结果”的诡异报错。4.3 案例三导出的Excel文件是空壳这个项目让我印象最深因为它的现象特别有迷惑性。系统显示“导出完成”日志里也没有任何异常但脚本解析出来的文件永远是空的。排查过程是这样的我先手动去文件目录看发现文件确实存在但文件大小徘徊在几百字节。等了几秒再看大小变大了再过几秒又变大。这说明“导出完成”只是业务系统发出了导出指令文件还在后台持续写入中。修复方法就是我前面提到的“文件稳定判断法”轮询等待文件存在等文件大小超过预期最小值比如50KB再等文件的最后修改时间连续3秒内没有变化最后才打开文件解析。这里有个容易被忽略的细节文件刚创建时可能只有文件头看起来“存在”且“不是空文件”但内容没写完。只判断“大小大于0”是不够的最好结合业务预期值。如果你不知道文件应该多大就退而求其次用“修改时间稳定N秒”来判断。这个修复做完以后文件解析的成功率直接拉满再也没有读半截文件的情况。5. 排查这个环节的实用套路5.1 五分钟定位法如果某个自动化项目你在怀疑它是“偶发问题”我建议按下面这个顺序排查基本五分钟内能定位到是不是前置就绪这个环节出了问题看失败日志的时间点找到脚本最后一次成功输出和第一次异常的间隔往前推看异常前执行的最后一个外部交互是什么——是调用了接口、点击了页面还是读取了文件检查这次外部交互之后脚本有没有做任何“业务级就绪确认”如果没有大概率就是栽在这里了。我自己用这个流程复盘了三个失败项目每次都能准确命中。你不妨也试试。5.2 常见问题速查表我整理了一张速查表给自动化踩坑选手备用现象大概率原因修复方向脚本偶尔找不到页面元素页面还没渲染完就绪条件不对等待元素可见、可点击而不是只查存在点击按钮没反应被遮罩层或弹窗挡住等待遮罩消失点击后加结果校验提交任务后读不到结果异步任务没执行完轮询任务状态到终态加超时读取文件为空或不完整文件还在写入中判断文件大小和修改时间稳定后再读脚本一忙就报超时超时时间小于真实耗时峰值用历史耗时数据重新设置超时上限重试后出现重复数据重试没有幂等保护接口加请求ID文件先重命名业务侧去重5.3 几个容易忽略的细节最后补充几个我从实操中捡回来的细节多数常规文档不会写第一等待条件函数里不要包含副作用。有同事写过“等待时顺便调一下刷新接口”的逻辑结果把要等的状态给刷新没了。等待只做状态探测不做业务操作。第二重试之前要清理中间状态。如果第一次执行已经产生了部分数据直接重跑可能叠加出脏数据。我通常会在任务开始时生成一个批处理ID重试前把本批的临时数据清掉再跑。第三能用事件回调就不用轮询。某些框架支持“等到某个事件后再继续”这种比轮询更实时、更省资源。轮询是兜底方案不是首选方案。第四日志里要把每次等待的结果记录下来。不需要多么复杂至少记录三样等待目标是什么、等待了多少秒、尝试了几次。否则系统上线后出了问题你连它是“快崩的”还是“慢崩的”都分不清。最后再分享一个小技巧。我现在接到任何一个自动化项目都会用一周时间观察各个外部环节的真实耗时然后把等待策略和超时参数全部配置化放进项目配置中心。这个动作看着不起眼但让我的自动化项目存活率高了不少。前期多记录数据后面就少熬夜盯日志。自动化这件事七分靠流程设计三分靠代码实现而“前置就绪确认”就是那七分里最关键的一环。