为什么你的脚本总在半夜挂掉
## 先说结论
写了两年自动化脚本,我发现一个规律:
**脚本能不能跑通,取决于你多懂业务;脚本能不能跑稳,取决于你多懂失败。**
跑通只需要走通主干路径。跑稳需要想到所有会挂的地方——而失败的种类比成功多得多。
## 我遇到的真实事故
一个每天早上 8 点跑的签到脚本,连续工作 40 天后,突然不工作了。
排查过程:
```
第 1 层怀疑:脚本逻辑改了?
-> git log 看,40 天没动过代码
第 2 层怀疑:网站改版了?
-> 手动打开,页面正常
第 3 层怀疑:账号被封了?
-> 手机登录正常,能签到
第 4 层怀疑:定时任务没执行?
-> crontab 日志看,执行了,但退出码 1
第 5 层:看详细日志
-> 报错:"Element not found: .sign-btn"
```
最后发现:网站把按钮的 class 从 `.sign-btn` 改成了 `.sign-button-v2`。
**一个 class 名的改动,让 40 天的稳定运行归零。**
## 三类失败
把失败分类之后,处理方式就清晰了。
### 第一类:环境漂移(外部变化)
**特征**:代码没变,逻辑没变,但外部依赖变了。
典型场景:
- 网页 DOM 结构变了(class 名、层级、文字)
- API 响应字段改了名
- 依赖库升级了不兼容版本
- 系统时间/时区变了
- 磁盘满了、内存不够
**防御手段:**
```python
# 不要用脆弱的定位方式
# 脆弱:依赖具体 class
btn = driver.find_element(By.CSS_SELECTOR, ".sign-btn")
# 稳健:多选择器 + 文本兜底
def find_sign_button(driver):
selectors = [
".sign-btn",
".sign-button-v2",
"[data-action='sign']",
"button:contains('签到')",
]
for sel in selectors:
try:
els = driver.find_elements(By.CSS_SELECTOR, sel)
if els:
return els[0]
except Exception:
continue
# 最后兜底:按文本找
for el in driver.find_elements(By.TAG_NAME, "button"):
if "签到" in (el.text or ""):
return el
raise RuntimeError("sign button not found, page structure changed")
```
关键思路:**把"找不到"变成明确的可检测错误,而不是静默失败。**
### 第二类:状态污染(上次没清干净)
**特征**:第一次跑成功,第二次失败。手工清理后又能跑。
典型场景:
- 上次的临时文件没删,这次读到脏数据
- 上次的锁没释放,这次拿不到锁
- 上次登录态过期,这次以为是新会话
- 浏览器进程残留,端口被占用
**防御手段:**
```python
import atexit
import os
class RunGuard:
def __init__(self, lock_path):
self.lock_path = lock_path
self.acquired = False
def __enter__(self):
# 检查残留锁
if os.path.exists(self.lock_path):
with open(self.lock_path) as f:
pid = int(f.read().strip() or 0)
# 检查那个进程是否还活着
if pid and _pid_alive(pid):
raise RuntimeError("another run in progress: pid %d" % pid)
# 死锁残留,清理
os.remove(self.lock_path)
with open(self.lock_path, "w") as f:
f.write(str(os.getpid()))
self.acquired = True
atexit.register(self.release)
return self
def release(self):
if self.acquired and os.path.exists(self.lock_path):
os.remove(self.lock_path)
self.acquired = False
def __exit__(self, *a):
self.release()
def _pid_alive(pid):
try:
os.kill(pid, 0)
return True
except OSError:
return False
```
还有一个更隐蔽的污染:**浏览器 profile 越用越脏**。
我之前用同一个 Chrome profile 跑了两个月,cookie、缓存、扩展越积越多,最后启动要 40 秒,还偶尔卡死。后来改成每次跑用临时 profile,反而稳定了。
### 第三类:静默失败(没报错但没生效)
**特征**:退出码 0,日志无异常,但实际没做事。
这是最难发现的。我遇到过的:
```
场景:点击签到按钮
问题:点击了,但页面还没加载完,点在了空白处
结果:脚本认为"点击成功",退出码 0
实际:没签到
```
连续 3 天没签到,我都没发现——因为脚本一直说"成功"。
**防御手段:验证副作用,而不是验证动作。**
```python
# 错误做法:验证动作执行了
driver.find_element(By.CSS_SELECTOR, ".sign-btn").click()
print("signed") # 只证明我点了,不证明签到了
# 正确做法:验证结果状态变了
before = get_points(driver)
driver.find_element(By.CSS_SELECTOR, ".sign-btn").click()
time.sleep(2)
after = get_points(driver)
if after <= before:
raise RuntimeError("sign failed: points %d -> %d" % (before, after))
print("signed, points %d -> %d" % (before, after))
```
**核心原则:不要相信自己做了什么,要相信世界变成了什么样。**
## 三层日志
无人值守脚本必须有三个层次的日志:
```python
# 层 1:心跳(证明活着)
log.info("run start, attempt=%d" % attempt)
# 层 2:关键决策(证明思路对)
log.info("found button via selector=%s" % used_selector)
log.info("before points=%d, after points=%d" % (before, after))
# 层 3:可复现证据(出问题能查)
if failed:
save_screenshot("fail_%s.png" % timestamp)
save_html("fail_%s.html" % timestamp)
log.error("dump saved for run %s" % run_id)
```
没有第三层,出问题只能看到"失败了",然后靠猜。
有了第三层,打开截图一看——按钮位置是空的,立刻明白是"点击时页面没加载完"。
## 一个最小可用的骨架
```python
def run_with_retry(task, max_attempts=3, verify=None):
last_err = None
for attempt in range(1, max_attempts + 1):
try:
log.info("attempt %d/%d start" % (attempt, max_attempts))
result = task()
# 有 verify 就必须过,否则算失败
if verify and not verify(result):
raise RuntimeError("verify failed: result=%r" % (result,))
log.info("attempt %d OK" % attempt)
return result
except Exception as e:
last_err = e
log.warning("attempt %d failed: %s" % (attempt, e))
if attempt < max_attempts:
# 指数退避,避免触发风控
wait = 2 ** attempt
log.info("retry in %ds" % wait)
time.sleep(wait)
log.error("all %d attempts failed: %s" % (max_attempts, last_err))
raise last_err
```
三个要点:
1. **有上限的重试**——无限重试会掩盖问题
2. **指数退避**——对端服务可能只是短暂抖动,但连续快速请求会触发限流
3. **verify 回调**——把"做了什么"和"是否生效"分开
## 通知策略
脚本跑在半夜,出问题没人知道。我的做法:
```
成功 -> 不发通知(避免噪音)
失败 -> 立刻通知,带截图和日志摘要
连续 2 次失败 -> 升级通知 + 建议手动介入
```
**关键:成功也发通知等于没通知。** 天天收到"成功"消息,你会开始忽略所有消息,包括失败那条。
## 小结
```
跑通脚本 = 懂业务
跑稳脚本 = 懂失败
三类失败:
环境漂移 -> 多选择器 + 显式报错
状态污染 -> 锁 + 幂等 + 清理
静默失败 -> 验证副作用,不验证动作
一条铁律:
不要相信"我做了什么"
要相信"世界变成了什么样"
```
自动化最大的价值不是省时间,是**让你能把注意力放在别处**。但它要真稳,才配得上这个信任。
一个半夜挂掉、早上才发现、然后人工补跑的脚本,比手动做还累。
- 点赞
- 收藏
- 关注作者
评论(0)