worker 进程被 OOM Killer 杀掉时,内存队列里还没解决完的验证码任务会随之消失,重启后无法找回。
灾难恢复(DR)规划解决的正是这类问题:基础设施故障、API 中断、配置出错时,如何用最短时间、最少数据损失把管道拉回正常。
恢复目标:先定 RPO、RTO、MTTR,再谈方案
| 指标 | 定义 | 验证码管道目标 |
|---|---|---|
| RPO(恢复点目标) | 最多能容忍丢多少数据 | 排队任务丢失 < 5 分钟 |
| RTO(恢复时间目标) | 服务最多允许中断多久 | < 15 分钟 |
| MTTR(平均恢复时间) | 历史故障的平均恢复耗时 | < 10 分钟 |
恢复目标映射
- 先分清哪些数据可事后重放、哪些链路须立刻恢复,别把整个系统当一块定目标。
- 队列、worker、API 客户端分别定 RPO/RTO,而非套一个笼统数字。
- 写清楚谁能触发故障转移、谁验证结果——没有 owner,演练时就会互相等对方拍板。
- reCAPTCHA 依赖 Google 托管脚本,国内访问不稳,"网络分区"要提前规划双线路。
常见故障排查
| 问题 | 原因 | 处理方式 |
|---|---|---|
| 崩溃期间任务丢失 | 只用了内存队列 | 换成持久化队列(SQLite、带 AOF 的 Redis) |
| 恢复后出现重复求解 | 过期任务未去重就被重新处理 | 加幂等键;恢复前先检查任务是否已解决 |
| 恢复耗时超过 RTO 目标 | 数据库备份周期太长 | 提高检查点频率 |
| 故障转移切到了错误区域 | DNS TTL 设置过高 | 计划内故障转移前把 TTL 降到 60 秒 |
大促(双 11、618)前,建议单独演练一次。
常见故障场景
Scenario 1: Worker crash → Restart workers, replay queue
Scenario 2: Queue data loss → Restore from persistent backup
Scenario 3: Network partition → Failover to secondary region
Scenario 4: API key compromised → Rotate key, update workers
Scenario 5: Config corruption → Rollback to last known good
用持久化任务队列扛住重启
- 验证码任务不能只放在内存队列里
- 任务必须落盘,才能在进程崩溃后原样恢复
Python —— 持久化任务队列
import os
import json
import time
import sqlite3
import threading
import requests
from datetime import datetime
API_KEY = os.environ["CAPTCHAAI_API_KEY"]
class PersistentTaskQueue:
"""SQLite-backed task queue that survives crashes."""
def __init__(self, db_path="captcha_tasks.db"):
self.db_path = db_path
self.conn = sqlite3.connect(db_path, check_same_thread=False)
self.lock = threading.Lock()
self._init_db()
def _init_db(self):
self.conn.execute("""
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
payload TEXT NOT NULL,
status TEXT DEFAULT 'pending',
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
started_at TEXT,
completed_at TEXT,
result TEXT,
attempts INTEGER DEFAULT 0
)
""")
self.conn.commit()
def enqueue(self, task_id, payload):
with self.lock:
self.conn.execute(
"INSERT INTO tasks (id, payload) VALUES (?, ?)",
(task_id, json.dumps(payload))
)
self.conn.commit()
def dequeue(self):
with self.lock:
cursor = self.conn.execute(
"SELECT id, payload FROM tasks "
"WHERE status = 'pending' ORDER BY created_at LIMIT 1"
)
row = cursor.fetchone()
if not row:
return None
task_id, payload = row
self.conn.execute(
"UPDATE tasks SET status = 'processing', "
"started_at = ?, attempts = attempts + 1 WHERE id = ?",
(datetime.utcnow().isoformat(), task_id)
)
self.conn.commit()
return {"id": task_id, "payload": json.loads(payload)}
def complete(self, task_id, result):
with self.lock:
self.conn.execute(
"UPDATE tasks SET status = 'completed', "
"completed_at = ?, result = ? WHERE id = ?",
(datetime.utcnow().isoformat(), json.dumps(result), task_id)
)
self.conn.commit()
def fail(self, task_id, error):
with self.lock:
# Requeue if under retry limit
cursor = self.conn.execute(
"SELECT attempts FROM tasks WHERE id = ?", (task_id,)
)
row = cursor.fetchone()
if row and row[0] < 3:
self.conn.execute(
"UPDATE tasks SET status = 'pending' WHERE id = ?",
(task_id,)
)
else:
self.conn.execute(
"UPDATE tasks SET status = 'failed', "
"result = ? WHERE id = ?",
(json.dumps({"error": error}), task_id)
)
self.conn.commit()
def recover_stale(self, timeout_seconds=600):
"""Reset tasks stuck in 'processing' after a crash."""
with self.lock:
cutoff = datetime.utcnow().timestamp() - timeout_seconds
self.conn.execute(
"UPDATE tasks SET status = 'pending' "
"WHERE status = 'processing' "
"AND started_at < datetime(?, 'unixepoch')",
(cutoff,)
)
count = self.conn.total_changes
self.conn.commit()
return count
@property
def stats(self):
cursor = self.conn.execute(
"SELECT status, COUNT(*) FROM tasks GROUP BY status"
)
return dict(cursor.fetchall())
# On startup: recover tasks that were processing during a crash
queue = PersistentTaskQueue()
recovered = queue.recover_stale(timeout_seconds=600)
print(f"Recovered {recovered} stale tasks after restart")
JavaScript —— 灾难恢复管理器
const axios = require("axios");
const fs = require("fs");
const API_KEY = process.env.CAPTCHAAI_API_KEY;
class DisasterRecoveryManager {
constructor(checkpointDir = "./dr-checkpoints") {
this.checkpointDir = checkpointDir;
if (!fs.existsSync(checkpointDir)) {
fs.mkdirSync(checkpointDir, { recursive: true });
}
}
checkpoint(label, data) {
const filename = `${this.checkpointDir}/${label}-${Date.now()}.json`;
fs.writeFileSync(filename, JSON.stringify(data, null, 2));
this.pruneOldCheckpoints(label, 10); // Keep last 10
return filename;
}
restore(label) {
const files = fs.readdirSync(this.checkpointDir)
.filter((f) => f.startsWith(label) && f.endsWith(".json"))
.sort()
.reverse();
if (files.length === 0) return null;
const latest = fs.readFileSync(
`${this.checkpointDir}/${files[0]}`, "utf8"
);
return JSON.parse(latest);
}
pruneOldCheckpoints(label, keep) {
const files = fs.readdirSync(this.checkpointDir)
.filter((f) => f.startsWith(label) && f.endsWith(".json"))
.sort();
while (files.length > keep) {
const old = files.shift();
fs.unlinkSync(`${this.checkpointDir}/${old}`);
}
}
async healthCheck() {
try {
const resp = await axios.get("https://ocr.captchaai.com/res.php", {
params: { key: API_KEY, action: "getbalance", json: 1 },
timeout: 10000,
});
return {
healthy: resp.data.status === 1,
balance: parseFloat(resp.data.request || 0),
};
} catch (err) {
return { healthy: false, error: err.message };
}
}
}
class ResilientSolver {
constructor() {
this.dr = new DisasterRecoveryManager();
this.pendingTasks = [];
}
async solveBatch(tasks) {
// Checkpoint before starting
this.dr.checkpoint("batch-pending", {
tasks,
startedAt: new Date().toISOString(),
});
const results = [];
for (const task of tasks) {
try {
const result = await this.solveSingle(task);
results.push({ taskId: task.id, ...result });
} catch (err) {
results.push({ taskId: task.id, error: err.message });
}
// Checkpoint progress periodically
if (results.length % 10 === 0) {
this.dr.checkpoint("batch-progress", { results, remaining: tasks.length - results.length });
}
}
// Final checkpoint
this.dr.checkpoint("batch-complete", { results });
return results;
}
async recover() {
// Check for incomplete batch
const progress = this.dr.restore("batch-progress");
const pending = this.dr.restore("batch-pending");
if (progress) {
const completedIds = new Set(progress.results.map((r) => r.taskId));
const remaining = pending?.tasks.filter((t) => !completedIds.has(t.id));
console.log(
`Recovering: ${progress.results.length} done, ${remaining?.length || 0} remaining`
);
return remaining || [];
}
if (pending) {
console.log(`Recovering full batch: ${pending.tasks.length} tasks`);
return pending.tasks;
}
return [];
}
async solveSingle(task) {
const resp = await axios.post("https://ocr.captchaai.com/in.php", null, {
params: {
key: API_KEY,
method: "userrecaptcha",
googlekey: task.sitekey,
pageurl: task.pageurl,
json: 1,
},
});
if (resp.data.status !== 1) throw new Error(resp.data.request);
const captchaId = resp.data.request;
for (let i = 0; i < 60; i++) {
await new Promise((r) => setTimeout(r, 5000));
const poll = await axios.get("https://ocr.captchaai.com/res.php", {
params: { key: API_KEY, action: "get", id: captchaId, json: 1 },
});
if (poll.data.status === 1) return { solution: poll.data.request };
if (poll.data.request !== "CAPCHA_NOT_READY")
throw new Error(poll.data.request);
}
throw new Error("TIMEOUT");
}
}
// Start with recovery check
const solver = new ResilientSolver();
solver.recover().then((remaining) => {
if (remaining.length > 0) {
console.log(`Resuming ${remaining.length} tasks from checkpoint`);
solver.solveBatch(remaining);
}
});
灾难恢复运行手册模板
RUNBOOK: CAPTCHA Pipeline Recovery
====================================
1. DETECT
- Alert fires: [PagerDuty / Slack / Email]
- Symptom: [Queue growing / Workers offline / Error spike]
2. ASSESS
- Check worker health: curl http://workers/health
- Check API status: GET /res.php?action=getbalance
- Check queue depth: SELECT COUNT(*) FROM tasks WHERE status='pending'
3. RECOVER
If: Workers crashed
→ Restart worker containers: docker-compose up -d workers
→ Run stale task recovery: recovery.py --recover-stale
If: Network partition
→ Failover to secondary region
→ Update DNS or load balancer routing
If: API key compromised
→ Generate new key at captchaai.com
→ Update secret store
→ Rolling restart workers
4. VERIFY
- Confirm solve rate > 90%
- Confirm queue draining
- Confirm no duplicate solves
5. POST-MORTEM
- Document root cause
- Update runbook if needed
常见问题
验证码管道多久做一次检查点比较合适?
每完成 5-10 个任务,或每隔 30 秒——以先到者为准。检查点越频繁 RPO 越小,但 I/O 开销也越高。
只有一台服务器,团队规模很小,还有必要做灾难恢复吗?
- 有必要,但不用照搬多区域方案
- 最低成本 DR:任务落盘(SQLite 即可)
- 定期备份数据库
- 准备一份 15 分钟内能跑通的重启脚本
如果 CaptchaAI 本身出现故障,我的自动化脚本会怎样?
- 把任务在本地排队,等 API 恢复后重试
- 管道侧应加断路器和重试逻辑,优雅处理短暂不可用
灾难恢复演练要多久做一次,怎么算通过?
- 至少每季度演练一次,模拟 worker 崩溃、队列数据丢失
- 恢复时间是否落在 RTO 内、数据丢失是否落在 RPO 内,就是通过标准
下一步
按最坏情况做准备——获取 CaptchaAI API 密钥,从第一天起把灾难恢复方案写进验证码管道里。
相关指南: