CaptchaAI tính phí theo thread đang chạy, không theo từng lần submit — nhưng nếu hai worker cùng gửi một CAPTCHA giống hệt nhau trong cùng một khoảng thời gian, bạn vẫn chiếm dụng hai thread cho một kết quả duy nhất.
Tình huống này quen thuộc với team scraping/QA outsourcing tại TP.HCM và Hà Nội chạy hàng chục worker song song theo dõi giá trên Shopee, Lazada, Tiki — ví dụ một team dùng gói ADVANCE ($90/tháng, 50 thread) rất dễ để nhiều worker vô tình nhắm cùng một sitekey/pageurl trong cùng một đợt crawl.
Ý tưởng cốt lõi: đặt một khóa duy nhất ứng với
method/sitekey/pageurltrước khi submit, để worker đến sau tái sử dụng kết quả của worker đến trước thay vì gửi thêm request mới.
Vì sao request giải CAPTCHA bị trùng lặp
Bốn nguyên nhân sau chiếm phần lớn request trùng lặp trong hệ thống nhiều worker:
- Retry trước khi có kết quả — logic retry quá tích cực gửi lại một CAPTCHA trong khi request trước còn đang chờ, tốn gấp 2–5 lần chi phí.
- Nhiều worker cùng nhắm một mục tiêu — không có cơ chế phối hợp nên các worker cùng giải song song một
sitekey/pageurl, lãng phí thread. - Frontend tự kích hoạt lại khi refresh — retry ở giao diện người dùng khi hết timeout khiến mỗi lần làm mới trang tốn thêm một lần giải.
- Message trong hàng đợi bị phát lại — cơ chế đảm bảo "at-least-once delivery" khiến cùng một message được xử lý và giải trùng nhiều lần.
Thiết kế khóa chống trùng lặp (dedup key)
Tạo một khóa duy nhất từ các tham số của request:
import hashlib
def dedup_key(method, sitekey, pageurl):
"""Generate a deduplication key for a CAPTCHA solve request."""
raw = f"{method}:{sitekey}:{pageurl}"
return f"captcha:dedup:{hashlib.sha256(raw.encode()).hexdigest()[:16]}"
Thành phần khóa thay đổi theo từng loại CAPTCHA:
- reCAPTCHA v2 —
method+sitekey+pageurl - reCAPTCHA v3 —
method+sitekey+pageurl+action(action ảnh hưởng điểm số nên phải nằm trong khóa) - hCaptcha —
method+sitekey+pageurl - Cloudflare Turnstile —
method+sitekey+pageurl - CAPTCHA hình ảnh —
method+ hàm băm củabody(nội dung ảnh), không dùngsitekey/pageurlvì ảnh không có hai trường này
Triển khai chống trùng lặp bằng Redis
Cách làm với Python
import os
import time
import json
import hashlib
import redis
import requests
r = redis.Redis(
host=os.environ.get("REDIS_HOST", "localhost"),
port=int(os.environ.get("REDIS_PORT", 6379)),
decode_responses=True
)
API_KEY = os.environ["CAPTCHAAI_API_KEY"]
# Dedup window: how long to consider a request "in progress"
DEDUP_TTL = 180 # seconds
def dedup_key(method, sitekey, pageurl, extra=""):
raw = f"{method}:{sitekey}:{pageurl}:{extra}"
return f"captcha:dedup:{hashlib.sha256(raw.encode()).hexdigest()[:16]}"
def solve_with_dedup(sitekey, pageurl, method="userrecaptcha"):
key = dedup_key(method, sitekey, pageurl)
# Check if this request is already being solved
existing = r.get(key)
if existing:
state = json.loads(existing)
if state["status"] == "solving":
# Wait for the result
return wait_for_result(key)
elif state["status"] == "solved":
return {"solution": state["solution"], "source": "dedup_cache"}
elif state["status"] == "error":
pass # Allow retry on error
# Mark as solving
r.set(key, json.dumps({"status": "solving", "started": time.time()}), ex=DEDUP_TTL)
# Submit to CaptchaAI
resp = requests.post("https://ocr.captchaai.com/in.php", data={
"key": API_KEY,
"method": method,
"googlekey": sitekey,
"pageurl": pageurl,
"json": 1
})
data = resp.json()
if data.get("status") != 1:
r.set(key, json.dumps({"status": "error", "error": data.get("request")}), ex=30)
return {"error": data.get("request")}
captcha_id = data["request"]
# Poll for result
for _ in range(60):
time.sleep(5)
result = requests.get("https://ocr.captchaai.com/res.php", params={
"key": API_KEY, "action": "get",
"id": captcha_id, "json": 1
}).json()
if result.get("status") == 1:
solution = result["request"]
# Cache the result for other workers (short TTL since tokens expire)
r.set(key, json.dumps({
"status": "solved",
"solution": solution,
"solved_at": time.time()
}), ex=60) # Cache result for 60 seconds
return {"solution": solution, "source": "api"}
if result.get("request") != "CAPCHA_NOT_READY":
r.set(key, json.dumps({
"status": "error", "error": result.get("request")
}), ex=30)
return {"error": result.get("request")}
r.set(key, json.dumps({"status": "error", "error": "TIMEOUT"}), ex=30)
return {"error": "TIMEOUT"}
def wait_for_result(key, timeout=120):
"""Wait for another worker to finish solving."""
start = time.time()
while time.time() - start < timeout:
data = r.get(key)
if data:
state = json.loads(data)
if state["status"] == "solved":
return {"solution": state["solution"], "source": "dedup_wait"}
if state["status"] == "error":
return {"error": state.get("error", "UNKNOWN")}
time.sleep(2)
return {"error": "DEDUP_WAIT_TIMEOUT"}
Cách làm với JavaScript
const Redis = require("ioredis");
const axios = require("axios");
const crypto = require("crypto");
const redis = new Redis(process.env.REDIS_URL || "redis://localhost:6379");
const API_KEY = process.env.CAPTCHAAI_API_KEY;
const DEDUP_TTL = 180;
function dedupKey(method, sitekey, pageurl) {
const raw = `${method}:${sitekey}:${pageurl}`;
const hash = crypto.createHash("sha256").update(raw).digest("hex").slice(0, 16);
return `captcha:dedup:${hash}`;
}
async function solveWithDedup(sitekey, pageurl, method = "userrecaptcha") {
const key = dedupKey(method, sitekey, pageurl);
// Check existing
const existing = await redis.get(key);
if (existing) {
const state = JSON.parse(existing);
if (state.status === "solving") return await waitForResult(key);
if (state.status === "solved") return { solution: state.solution, source: "dedup_cache" };
}
// Mark as solving
await redis.set(key, JSON.stringify({ status: "solving", started: Date.now() }), "EX", DEDUP_TTL);
// Submit
const submit = await axios.post("https://ocr.captchaai.com/in.php", null, {
params: { key: API_KEY, method, googlekey: sitekey, pageurl, json: 1 },
});
if (submit.data.status !== 1) {
await redis.set(key, JSON.stringify({ status: "error", error: submit.data.request }), "EX", 30);
return { error: submit.data.request };
}
const captchaId = submit.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) {
await redis.set(key, JSON.stringify({ status: "solved", solution: poll.data.request }), "EX", 60);
return { solution: poll.data.request, source: "api" };
}
if (poll.data.request !== "CAPCHA_NOT_READY") {
await redis.set(key, JSON.stringify({ status: "error", error: poll.data.request }), "EX", 30);
return { error: poll.data.request };
}
}
await redis.set(key, JSON.stringify({ status: "error", error: "TIMEOUT" }), "EX", 30);
return { error: "TIMEOUT" };
}
async function waitForResult(key, timeout = 120000) {
const start = Date.now();
while (Date.now() - start < timeout) {
const data = await redis.get(key);
if (data) {
const state = JSON.parse(data);
if (state.status === "solved") return { solution: state.solution, source: "dedup_wait" };
if (state.status === "error") return { error: state.error };
}
await new Promise((r) => setTimeout(r, 2000));
}
return { error: "DEDUP_WAIT_TIMEOUT" };
}
Dùng khóa PostgreSQL khi không có Redis
Nếu stack scraping/QA của bạn chạy trên PostgreSQL và không muốn thêm Redis chỉ để dedup, advisory lock của Postgres làm được việc tương tự:
import psycopg2
def solve_with_pg_dedup(conn, sitekey, pageurl):
"""Use PostgreSQL advisory locks for deduplication."""
# Generate a numeric lock key from the dedup key
lock_id = hash(f"{sitekey}:{pageurl}") & 0x7FFFFFFF
cursor = conn.cursor()
# Try to acquire advisory lock (non-blocking)
cursor.execute("SELECT pg_try_advisory_lock(%s)", (lock_id,))
acquired = cursor.fetchone()[0]
if not acquired:
# Another worker is solving — wait for result
cursor.execute("SELECT pg_advisory_lock(%s)", (lock_id,))
# Lock acquired means other worker finished — check cache
cursor.execute(
"SELECT solution FROM captcha_cache "
"WHERE sitekey = %s AND pageurl = %s "
"AND created_at > NOW() - INTERVAL '60 seconds'",
(sitekey, pageurl)
)
row = cursor.fetchone()
cursor.execute("SELECT pg_advisory_unlock(%s)", (lock_id,))
if row:
return {"solution": row[0], "source": "pg_cache"}
return {"error": "NO_CACHED_RESULT"}
try:
# Solve the CAPTCHA
solution = solve_via_api(sitekey, pageurl)
if solution:
cursor.execute(
"INSERT INTO captcha_cache (sitekey, pageurl, solution) "
"VALUES (%s, %s, %s)",
(sitekey, pageurl, solution)
)
conn.commit()
return {"solution": solution} if solution else {"error": "SOLVE_FAILED"}
finally:
cursor.execute("SELECT pg_advisory_unlock(%s)", (lock_id,))
Đo lường mức tiết kiệm nhờ dedup
Ghi lại số liệu để chứng minh khóa dedup đáng công triển khai:
def track_dedup_stats(source):
"""Increment counters for dedup tracking."""
today = time.strftime("%Y-%m-%d")
r.hincrby(f"dedup:stats:{today}", source, 1)
r.expire(f"dedup:stats:{today}", 7 * 86400)
def get_dedup_report():
today = time.strftime("%Y-%m-%d")
stats = r.hgetall(f"dedup:stats:{today}")
total = sum(int(v) for v in stats.values())
saved = int(stats.get("dedup_cache", 0)) + int(stats.get("dedup_wait", 0))
return {
"total_requests": total,
"deduplicated": saved,
"savings_pct": f"{saved / total * 100:.1f}%" if total else "0%",
"breakdown": stats
}
Xử lý sự cố thường gặp
Khóa dedup bị đụng độ (collision)
Tham số băm quá ngắn hoặc thiếu trường. Đưa đủ tham số đặc thù của loại CAPTCHA vào khóa và tăng độ dài chuỗi băm.
Worker chờ bị timeout
Worker đang giải bị crash giữa chừng nên không cập nhật trạng thái. TTL của solving tự hết hạn sau 180 giây, worker chờ sẽ không kẹt mãi.
Kết quả cache đã cũ
Token hết hạn nhưng bản cache vẫn còn hiệu lực. Đặt TTL cache kết quả ngắn hơn vòng đời token — 60 giây là mức an toàn với reCAPTCHA.
Race condition khi set khóa
Hai worker kiểm tra khóa cùng lúc. Dùng SET NX (set-if-not-exists) để lấy khóa một cách nguyên tử (atomic).
Câu hỏi thường gặp
TTL của khóa chống trùng lặp nên đặt bao lâu?
Đặt TTL trạng thái solving bằng thời gian giải tối đa cộng thêm biên an toàn — 180 giây là mức hợp lý cho phần lớn loại CAPTCHA. TTL cache kết quả nên ngắn hơn nhiều, vì token hết hạn nhanh hơn nhiều so với thời gian giải.
Đặt khóa trước khi submit có làm chậm request đầu tiên không?
Không đáng kể. SET NX trên Redis hoặc pg_try_advisory_lock mất vài mili giây, trong khi giải một CAPTCHA mất từ vài giây đến hàng chục giây.
CAPTCHA dạng ảnh có dedup được không?
Có — dùng hàm băm của nội dung ảnh làm một phần của khóa, chứ không chỉ sitekey/pageurl. Hai ảnh giống hệt nhau luôn trả về cùng một văn bản.
Proxy khác nhau cho cùng một CAPTCHA có ảnh hưởng đến dedup không?
Không nên đưa proxy vào khóa dedup. Token kết quả hoạt động bất kể proxy nào đã giải nó — đưa proxy vào khóa chỉ khiến hai worker bị coi là hai request riêng biệt.
Chỉ chạy một worker duy nhất thì có cần dedup không?
Ít cần cho phần điều phối, nhưng khóa dedup vẫn chặn được retry trùng do timeout hoặc do frontend gửi lại request khi người dùng refresh.
Triển khai ngay
Ngừng trả tiền cho các lần giải CAPTCHA trùng lặp — lấy API key CaptchaAI và thêm lớp khóa dedup vào pipeline của bạn ngay hôm nay.
Hướng dẫn liên quan:
- Quản lý TTL của token trong Redis
- Đồng bộ trạng thái phiên giữa các worker phân tán
- Khôi phục lỗi khi giải theo lô (batch)