В Lambda нет постоянного соединения с базой между вызовами, поэтому пул соединений PostgreSQL или MySQL скорее мешает. DynamoDB снимает проблему: HTTPS-подключение на каждый вызов, встроенный TTL и стабильная скорость при всплесках нагрузки.
Почему не реляционная база данных
В Lambda каждый вызов может открыть новое подключение, а PostgreSQL и MySQL ограничены числом одновременных соединений на инстанс. При резком всплеске трафика — например, когда десятки Lambda параллельно отправляют задачи в in.php и опрашивают res.php — этот лимит быстро исчерпывается, и приходится добавлять RDS Proxy: отдельный платный компонент и ещё одна точка отказа. DynamoDB обращается по HTTPS на каждый запрос, поэтому лимита соединений нет вовсе, а PAY_PER_REQUEST снимает вопрос заранее рассчитанной пропускной способности.
Проектирование таблицы
Паттерн «одна таблица на всё»
Одна таблица закрывает историю решений, активные задачи и дневную статистику — single-table design без join'ов:
| Ключ раздела (PK) | Ключ сортировки (SK) | Назначение |
|---|---|---|
SOLVE#{captcha_id} |
META |
Запись о решении |
SITE#{sitekey} |
SOLVE#{timestamp} |
История решений по конкретному sitekey |
STATS#{date} |
TYPE#{captcha_type} |
Сводная статистика за день |
ACTIVE#{captcha_id} |
TASK |
Задачи в процессе выполнения |
Такое разбиение ключей закрывает четыре сценария одним и тем же индексом:
- По ID решения — быстрый lookup статуса конкретной задачи (
SOLVE#{captcha_id}). - По sitekey — история решений для конкретного сайта, отсортированная по времени.
- По дате — дневная статистика без Scan по всей таблице.
- По активной задаче — TTL-запись, которая сама исчезает через 10 минут, если решение зависло.
Схема таблицы
{
"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 (on-demand) не требует заранее угадывать нагрузку — удобно при нерегулярных триггерах Lambda.
Реализация на Python
Настройка окружения
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"]
Функция solve_and_track: отправка, опрос и запись результата
Отправляет задачу в in.php, опрашивает res.php каждые 5 секунд (до 60 попыток — укладывается в лимит выполнения Lambda) и на каждом исходе пишет отдельную запись: активную задачу, итог по sitekey и обновление дневной статистики. Поле GSI1PK дублирует статус решения, чтобы get_active_tasks мог выбрать задачи в процессе без Scan по всей таблице:
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
)
Готовые запросы
История по sitekey, статистика за день и активные задачи — все три запроса опираются на один и тот же GSI1, поэтому выборка по статусу не требует Scan и дополнительного индекса:
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"]
Оптимизация расходов
| Стратегия | Эффект |
|---|---|
| On-demand биллинг для нестабильной нагрузки | Не резервировать пропускную способность заранее |
| TTL для автоочистки старых записей | Снижает расходы на хранение |
| Проекция только нужных атрибутов | Меньше единиц чтения |
Пакетная запись через BatchWriteItem |
Меньше вызовов API |
| DynamoDB Streams для аналитики | Агрегация уходит в отдельную Lambda |
При тарификации on-demand DynamoDB стоит ориентировочно $1,25 за миллион операций записи и $0,25 за миллион операций чтения. При 10 000 решённых CAPTCHA в день на каждое решение приходится по 3–5 операций записи (активная задача, запись решения, история по sitekey, обновление статистики) — это заметно дешевле $1 в месяц на хранение и доступ. TTL и точечные GSI-запросы держат объём таблицы предсказуемым, поэтому пересчитывать расходы вручную не нужно.
Реализация на Node.js
Та же логика на @aws-sdk/lib-dynamodb (SDK v3):
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;
}
Типичные проблемы и их решение
Ниже — ошибки, с которыми чаще всего сталкиваются при переходе с provisioned-режима или при первом всплеске нагрузки:
| Проблема | Причина | Решение |
|---|---|---|
ProvisionedThroughputExceededException |
Много записей в секунду при provisioned-режиме | Перейти на on-demand или увеличить WCU |
| TTL не удаляет записи мгновенно | Удаление по TTL идёт с задержкой (до ~48 часов) | Не полагаться на TTL для мгновенной очистки — фильтровать просроченные элементы в запросах |
Горячий раздел на STATS#{date} |
Все воркеры пишут в один partition key | Суффикс-шард: STATS#{date}#shard{0-9} |
| Запрос возвращает слишком много элементов | Слишком широкий partition key | Добавить условия по SK, чтобы сузить выборку |
Частые вопросы
Вопросы, которые обычно возникают уже после первого разворачивания такой схемы в проде:
Можно ли использовать одну таблицу для нескольких проектов?
Да — в схеме уже есть атрибут project (см. project or "default" в коде). Добавьте его в PK, например PROJECT#{project}#SITE#{sitekey}, и одна таблица обслужит нескольких клиентов без лишних расходов.
Как получить статистику по всем типам CAPTCHA и ловить падение успешности?
GSI1 даёт выборку по статусу вне зависимости от типа. DynamoDB Streams + Lambda-агрегатор в STATS#{date} — сводка по reCAPTCHA/Turnstile/GeeTest v3 и алерт при просадке successful/total_solves без сканирования таблицы.
Что учитывать по 152-ФЗ, если в таблице хранятся pageurl и sitekey?
sitekey и pageurl сами по себе не персональные данные, но если в запись попадает логин или email из формы, для аудитории в РФ это уже 152-ФЗ, а для трансграничных команд — due diligence в духе GDPR. Храните только то, что нужно для отладки.
В каком регионе AWS размещать таблицу, если команда работает из СНГ?
DynamoDB — региональный сервис: держите таблицу в том же регионе, что и Lambda, иначе к времени решения CAPTCHA добавится межрегиональная сетевая задержка. Команды из России, Казахстана и Центральной Азии чаще всего берут eu-central-1 (Франкфурт) как ближайший регион AWS с полным набором сервисов. Global Tables для этой нагрузки избыточны — записи короткоживущие, и TTL удаляет их сам.