Ghi log giải CAPTCHA trên Lambda bằng RDS thường dính lỗi connection pool — mỗi cold start mở thêm kết nối SQL. DynamoDB né hẳn vấn đề này: không giới hạn kết nối, TTL tự dọn dữ liệu, độ trễ ổn định dù ghi 10 hay 10.000 lượt/ngày.
Một đội QA outsource ở TP.HCM chạy hàng chục Lambda theo dõi giá trên Shopee và Tiki, dùng gói ADVANCE của CaptchaAI ($90/tháng, 50 thread) để giải reCAPTCHA v2 và Turnstile, rồi ghi mọi lượt giải vào bảng dưới đây để đối chiếu SLA.
Vì sao chọn DynamoDB thay vì RDS hoặc Redis
RDS cần RDS Proxy vì mỗi cold start mở kết nối SQL mới. Redis nhanh hơn cho token ngắn hạn, kém cho lịch sử dài hạn.
- Không giới hạn kết nối — ghi thẳng, không cần proxy.
- TTL tự dọn — không cần cron job riêng.
- Độ trễ ổn định — không đổi khi traffic tăng vọt.
Thiết kế bảng DynamoDB cho theo dõi CAPTCHA
Một bảng, nhiều loại dữ liệu (single-table pattern)
Một bảng DynamoDB xử lý cả lịch sử giải, tác vụ đang chạy và số liệu tổng hợp:
SOLVE#{captcha_id}/META— bản ghi kết quả giảiSITE#{sitekey}/SOLVE#{timestamp}— lịch sử giải theo siteSTATS#{date}/TYPE#{captcha_type}— số liệu tổng hợp theo ngàyACTIVE#{captcha_id}/TASK— tác vụ đang xử lý (in-flight)
Áp dụng thành khai báo AWS thực tế:
Khai báo cấu trúc bảng
{
"TableName": "CaptchaSolves",
"KeySchema": [
{ "AttributeName": "PK", "KeyType": "HASH" },
{ "AttributeName": "SK", "KeyType": "RANGE" }
],
"AttributeDefinitions": [
{ "AttributeName": "PK", "KeyType": "S" },
{ "AttributeName": "SK", "KeyType": "S" },
{ "AttributeName": "GSI1PK", "KeyType": "S" },
{ "AttributeName": "GSI1SK", "KeyType": "S" }
],
"GlobalSecondaryIndexes": [
{
"IndexName": "GSI1",
"KeySchema": [
{ "AttributeName": "GSI1PK", "KeyType": "HASH" },
{ "AttributeName": "GSI1SK", "KeyType": "RANGE" }
],
"Projection": { "ProjectionType": "ALL" }
}
],
"BillingMode": "PAY_PER_REQUEST",
"TimeToLiveSpecification": {
"AttributeName": "ttl",
"Enabled": true
}
}
BillingMode: PAY_PER_REQUEST phù hợp vì traffic tăng giảm thất thường theo giờ.
Cài đặt và triển khai bằng Python
Cấu hình client và bảng
import os
import time
from datetime import datetime, timezone
import boto3
import requests
dynamodb = boto3.resource("dynamodb")
table = dynamodb.Table(os.environ.get("DYNAMODB_TABLE", "CaptchaSolves"))
API_KEY = os.environ["CAPTCHAAI_API_KEY"]
Giải CAPTCHA và ghi log kết quả
Hàm solve_and_track xử lý toàn bộ vòng đời một lượt giải:
- Gửi task tới
in.php, ghi lỗi vàoSITE#{sitekey}nếu thất bại - Ghi task vào
ACTIVE#{captcha_id}, TTL 10 phút - Polling
res.phptối đa 60 lần / 5 giây - Ghi kết quả vào
SOLVE#{id}, cập nhật site + thống kê ngày
def solve_and_track(sitekey, pageurl, captcha_type="recaptcha_v2", project=None):
now = datetime.now(timezone.utc)
timestamp = now.isoformat()
ttl_90_days = int(now.timestamp()) + (90 * 24 * 3600)
# Submit to CaptchaAI
resp = requests.post("https://ocr.captchaai.com/in.php", data={
"key": API_KEY,
"method": "userrecaptcha",
"googlekey": sitekey,
"pageurl": pageurl,
"json": 1
})
data = resp.json()
if data.get("status") != 1:
# Store error record
table.put_item(Item={
"PK": f"SITE#{sitekey}",
"SK": f"SOLVE#{timestamp}",
"captcha_type": captcha_type,
"pageurl": pageurl,
"status": "error",
"error": data.get("request"),
"submitted_at": timestamp,
"project": project or "default",
"ttl": ttl_90_days,
"GSI1PK": f"STATUS#error",
"GSI1SK": timestamp
})
return {"error": data.get("request")}
captcha_id = data["request"]
# Track active task
table.put_item(Item={
"PK": f"ACTIVE#{captcha_id}",
"SK": "TASK",
"sitekey": sitekey,
"pageurl": pageurl,
"captcha_type": captcha_type,
"submitted_at": timestamp,
"ttl": int(now.timestamp()) + 600 # Auto-clean in 10 min
})
# Poll for result
polls = 0
for _ in range(60):
time.sleep(5)
polls += 1
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:
solved_at = datetime.now(timezone.utc).isoformat()
elapsed_ms = int(
(datetime.now(timezone.utc) - now).total_seconds() * 1000
)
# Store success record
table.put_item(Item={
"PK": f"SOLVE#{captcha_id}",
"SK": "META",
"captcha_type": captcha_type,
"sitekey": sitekey,
"pageurl": pageurl,
"status": "solved",
"submitted_at": timestamp,
"solved_at": solved_at,
"elapsed_ms": elapsed_ms,
"polls": polls,
"project": project or "default",
"ttl": ttl_90_days,
"GSI1PK": f"STATUS#solved",
"GSI1SK": timestamp
})
# Also store in site history
table.put_item(Item={
"PK": f"SITE#{sitekey}",
"SK": f"SOLVE#{timestamp}",
"captcha_id": captcha_id,
"status": "solved",
"elapsed_ms": elapsed_ms,
"ttl": ttl_90_days
})
# Remove active task
table.delete_item(Key={
"PK": f"ACTIVE#{captcha_id}", "SK": "TASK"
})
# Update daily stats
update_daily_stats(captcha_type, True, elapsed_ms)
return {"solution": result["request"]}
if result.get("request") != "CAPCHA_NOT_READY":
table.put_item(Item={
"PK": f"SITE#{sitekey}",
"SK": f"SOLVE#{timestamp}",
"captcha_id": captcha_id,
"status": "error",
"error": result.get("request"),
"ttl": ttl_90_days
})
table.delete_item(Key={
"PK": f"ACTIVE#{captcha_id}", "SK": "TASK"
})
update_daily_stats(captcha_type, False, 0)
return {"error": result.get("request")}
table.delete_item(Key={"PK": f"ACTIVE#{captcha_id}", "SK": "TASK"})
update_daily_stats(captcha_type, False, 0)
return {"error": "TIMEOUT"}
def update_daily_stats(captcha_type, success, elapsed_ms):
date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
update_expr = "SET total_solves = if_not_exists(total_solves, :zero) + :one"
expr_values = {":zero": 0, ":one": 1}
if success:
update_expr += ", successful = if_not_exists(successful, :zero) + :one"
update_expr += ", total_elapsed = if_not_exists(total_elapsed, :zero) + :elapsed"
expr_values[":elapsed"] = elapsed_ms
else:
update_expr += ", failed = if_not_exists(failed, :zero) + :one"
table.update_item(
Key={"PK": f"STATS#{date_str}", "SK": f"TYPE#{captcha_type}"},
UpdateExpression=update_expr,
ExpressionAttributeValues=expr_values
)
Truy vấn dữ liệu đã lưu
Ba truy vấn dùng nhiều nhất:
- Lịch sử theo site
- Thống kê theo ngày
- Task đang chạy
def get_site_history(sitekey, limit=50):
"""Get recent solves for a specific site key."""
response = table.query(
KeyConditionExpression="PK = :pk",
ExpressionAttributeValues={":pk": f"SITE#{sitekey}"},
ScanIndexForward=False,
Limit=limit
)
return response["Items"]
def get_daily_stats(date_str=None):
"""Get stats for a specific date (default: today)."""
if not date_str:
date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d")
response = table.query(
KeyConditionExpression="PK = :pk",
ExpressionAttributeValues={":pk": f"STATS#{date_str}"}
)
return response["Items"]
def get_active_tasks():
"""List all currently active CAPTCHA tasks."""
response = table.query(
IndexName="GSI1",
KeyConditionExpression="GSI1PK = :pk",
ExpressionAttributeValues={":pk": "STATUS#polling"}
)
return response["Items"]
Triển khai bằng Node.js
Cùng logic, viết bằng AWS SDK v3 cho Node.js:
const { DynamoDBClient } = require("@aws-sdk/client-dynamodb");
const { DynamoDBDocumentClient, PutCommand, QueryCommand, UpdateCommand } = require("@aws-sdk/lib-dynamodb");
const axios = require("axios");
const client = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const TABLE = process.env.DYNAMODB_TABLE || "CaptchaSolves";
const API_KEY = process.env.CAPTCHAAI_API_KEY;
async function solveAndTrack(sitekey, pageurl, type = "recaptcha_v2") {
const now = new Date();
const timestamp = now.toISOString();
const ttl = Math.floor(now.getTime() / 1000) + 90 * 24 * 3600;
const submit = await axios.post("https://ocr.captchaai.com/in.php", null, {
params: { key: API_KEY, method: "userrecaptcha", googlekey: sitekey, pageurl, json: 1 },
});
if (submit.data.status !== 1) {
await client.send(new PutCommand({
TableName: TABLE,
Item: { PK: `SITE#${sitekey}`, SK: `SOLVE#${timestamp}`, status: "error", error: submit.data.request, ttl },
}));
return { error: submit.data.request };
}
const captchaId = submit.data.request;
let polls = 0;
for (let i = 0; i < 60; i++) {
await new Promise((r) => setTimeout(r, 5000));
polls++;
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) {
const elapsed = Date.now() - now.getTime();
await client.send(new PutCommand({
TableName: TABLE,
Item: {
PK: `SOLVE#${captchaId}`, SK: "META", captcha_type: type,
sitekey, pageurl, status: "solved", submitted_at: timestamp,
solved_at: new Date().toISOString(), elapsed_ms: elapsed, polls, ttl,
},
}));
return { solution: poll.data.request };
}
if (poll.data.request !== "CAPCHA_NOT_READY") {
return { error: poll.data.request };
}
}
return { error: "TIMEOUT" };
}
async function getSiteHistory(sitekey, limit = 50) {
const result = await client.send(new QueryCommand({
TableName: TABLE,
KeyConditionExpression: "PK = :pk",
ExpressionAttributeValues: { ":pk": `SITE#${sitekey}` },
ScanIndexForward: false,
Limit: limit,
}));
return result.Items;
}
Các lỗi thường gặp khi theo dõi CAPTCHA trên DynamoDB
Bốn lỗi thường gặp:
ProvisionedThroughputExceededException— quá nhiều lượt ghi mỗi giây; chuyển sang on-demand hoặc tăng WCU- TTL không xóa ngay — DynamoDB xóa theo kiểu eventual (~48 giờ); lọc mục hết hạn trong truy vấn, đừng dựa vào TTL để dọn real-time
- Hot partition trên
STATS#{date}— tất cả worker ghi cùng phân vùng; thêm hậu tố ngẫu nhiênSTATS#{date}#shard{0-9} - Truy vấn trả về quá nhiều mục — khóa phân vùng quá rộng; thêm điều kiện SK để thu hẹp kết quả
Tối ưu chi phí vận hành
- On-demand billing cho khối lượng dao động — không dự trù công suất thừa
- Bật TTL để tự dọn bản ghi cũ — giảm chi phí lưu trữ
- Chỉ lấy thuộc tính cần thiết trong truy vấn — giảm read unit tiêu thụ
- Ghi hàng loạt bằng
BatchWriteItem— ít lệnh gọi API hơn - DynamoDB Streams để tổng hợp — giảm tải cho Lambda chính
Câu hỏi thường gặp
Theo dõi 10.000 lượt giải CAPTCHA mỗi ngày trên DynamoDB tốn bao nhiêu?
Với on-demand billing: khoảng $1,25/triệu lượt ghi và $0,25/triệu lượt đọc. Ở mức 10.000 lượt giải/ngày, chi phí DynamoDB thường dưới $1/tháng.
Vì sao mục TTL không biến mất ngay sau khi hết hạn?
DynamoDB xóa item TTL theo cơ chế nền, thường trong vòng 48 giờ. Nếu cần loại bỏ mục hết hạn theo thời gian thực, hãy lọc thêm bằng điều kiện ttl > :now.
Làm sao tránh hot partition khi nhiều Lambda cùng ghi vào STATS#{date}?
Thêm hậu tố ngẫu nhiên vào khóa phân vùng, ví dụ STATS#{date}#shard{0-9}, để rải ghi ra nhiều phân vùng, rồi gộp kết quả bằng BatchGetItem.
Có cần dọn log giải CAPTCHA để tuân thủ Nghị định 13/2023/NĐ-CP không?
Có, nếu bản ghi chứa dữ liệu định danh người dùng. TTL 90 ngày vừa phục vụ audit vừa tự xóa dữ liệu quá hạn — không phải tư vấn pháp lý, hãy đối chiếu bộ phận pháp chế.
Bắt đầu theo dõi
Thiết kế trên đủ chạy production cho hầu hết khối lượng giải CAPTCHA trên Lambda — lấy API key CaptchaAI và cắm thẳng vào hàm solve_and_track ở trên.
Hướng dẫn liên quan:
- Giải CAPTCHA serverless với AWS Lambda
- Lưu lịch sử giải CAPTCHA bằng MongoDB
- Quản lý TTL token bằng Redis