Хранение финансовых данных в PostgreSQL: 100K транзакций в сутки

Задача клиента: платёжный процессинг без потери копеек

Финтех-стартап «ПлатёжКа» обратился к инженерам itfresh.ru на этапе перепроектирования базы данных. Система обрабатывала 100 000 платёжных транзакций в сутки — переводы между кошельками, пополнения, выводы, конвертации валют. Общий дневной оборот превышал 50 миллионов рублей.

При аудите существующей системы мы обнаружили три критические проблемы:

  • Потеря точности. Суммы хранились в типе DOUBLE PRECISION. При конвертации 1000.05 рублей в доллары и обратно получалось 1000.0499999999998. За месяц расхождения накапливались до десятков тысяч рублей.
  • Отсутствие атомарности. Перевод между кошельками выполнялся двумя отдельными UPDATE-запросами. При сбое между ними деньги исчезали с одного счёта, не появляясь на другом.
  • Нет аудита. Невозможно было восстановить историю изменения баланса — только текущее состояние. При спорных ситуациях с клиентами приходилось разбирать логи приложения.

Нашей задачей было перепроектировать схему базы данных так, чтобы исключить потерю точности, гарантировать целостность транзакций и обеспечить полный аудит каждой операции.

NUMERIC vs FLOAT vs INTEGER: выбор типа данных для денег

Выбор типа данных для хранения денежных сумм — фундаментальное решение, от которого зависит корректность всех расчётов. Рассмотрим три подхода.

FLOAT / DOUBLE PRECISION — категорически нет.

Числа с плавающей запятой используют двоичное представление по стандарту IEEE 754. Десятичная дробь 0.1 не имеет точного двоичного представления — в памяти хранится 0.1000000000000000055511151231257827021181583404541015625. Это не баг, а особенность формата.

-- Демонстрация проблемы FLOAT
SELECT 0.1::DOUBLE PRECISION + 0.2::DOUBLE PRECISION;
-- Результат: 0.30000000000000004

SELECT (0.1::DOUBLE PRECISION + 0.2::DOUBLE PRECISION) = 0.3::DOUBLE PRECISION;
-- Результат: false

-- Накопление ошибки при суммировании
SELECT SUM(amount) FROM generate_series(1, 1000000)
  CROSS JOIN LATERAL (SELECT 0.01::DOUBLE PRECISION AS amount) t;
-- Ожидаем: 10000.00
-- Получаем: 9999.999999999831

INTEGER (хранение в копейках) — надёжно, но с оговорками.

Идея: хранить 10.95 рублей как 1095 копеек. Целочисленная арифметика точна. Этот подход используют Stripe, MasterCard, Adyen.

-- Сумма в копейках
CREATE TABLE payments_int (
    id BIGSERIAL PRIMARY KEY,
    amount_cents BIGINT NOT NULL CHECK (amount_cents >= 0),
    currency CHAR(3) NOT NULL
);

-- Отображение: 1095 → 10.95
SELECT amount_cents / 100.0 AS amount_display
FROM payments_int;

Проблемы возникают при работе с валютами, имеющими разное количество знаков: японская иена — 0, иорданский динар — 3, криптовалюты — до 18. Нужна таблица «множителей» для каждой валюты.

NUMERIC / DECIMAL — оптимальный баланс.

PostgreSQL тип NUMERIC хранит числа в десятичном формате с произвольной точностью. Арифметика точна, поддерживается любое количество знаков после запятой.

-- Точное хранение и арифметика
SELECT 0.1::NUMERIC + 0.2::NUMERIC = 0.3::NUMERIC;
-- Результат: true

-- Рекомендуемое определение для финансовых столбцов
-- NUMERIC(19, 4) — до 15 целых + 4 дробных знака
-- Достаточно для сумм до 999 триллионов с точностью до 0.01 копейки
CREATE TABLE payments (
    id BIGSERIAL PRIMARY KEY,
    amount NUMERIC(19, 4) NOT NULL,
    currency CHAR(3) NOT NULL DEFAULT 'RUB'
);

Мы выбрали NUMERIC(19, 4) для «ПлатёжКа» — четыре знака после запятой обеспечивают запас для промежуточных вычислений (курсы валют, процентные ставки), а финальный результат округляется до двух знаков при отображении.

Схема двойной записи (double-entry bookkeeping)

В бухгалтерском учёте каждая операция записывается дважды: дебет одного счёта и кредит другого. Сумма всех дебетов всегда равна сумме всех кредитов. Это фундаментальный принцип, изобретённый в XV веке и до сих пор являющийся основой финансовых систем.

Мы применили этот принцип в схеме базы данных «ПлатёжКа»:

-- Счета (кошельки, системные счета, счета комиссий)
CREATE TABLE accounts (
    id BIGSERIAL PRIMARY KEY,
    owner_id BIGINT NOT NULL,
    account_type VARCHAR(20) NOT NULL
        CHECK (account_type IN ('user_wallet', 'system', 'fee', 'escrow')),
    currency CHAR(3) NOT NULL DEFAULT 'RUB',
    balance NUMERIC(19, 4) NOT NULL DEFAULT 0
        CHECK (balance >= 0 OR account_type IN ('system', 'fee')),
    created_at TIMESTAMPTZ DEFAULT now(),
    version BIGINT NOT NULL DEFAULT 0  -- для оптимистичной блокировки
);

-- Журнал проводок (immutable — только INSERT)
CREATE TABLE journal_entries (
    id BIGSERIAL PRIMARY KEY,
    transaction_id UUID NOT NULL,  -- группировка проводок одной операции
    account_id BIGINT NOT NULL REFERENCES accounts(id),
    entry_type VARCHAR(5) NOT NULL CHECK (entry_type IN ('debit', 'credit')),
    amount NUMERIC(19, 4) NOT NULL CHECK (amount > 0),
    running_balance NUMERIC(19, 4) NOT NULL,  -- баланс после операции
    description TEXT,
    created_at TIMESTAMPTZ DEFAULT now()
);

CREATE INDEX idx_journal_transaction ON journal_entries(transaction_id);
CREATE INDEX idx_journal_account ON journal_entries(account_id, created_at);

-- Метаданные операций
CREATE TABLE transactions (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    type VARCHAR(30) NOT NULL,
    status VARCHAR(15) NOT NULL DEFAULT 'pending'
        CHECK (status IN ('pending', 'completed', 'failed', 'reversed')),
    idempotency_key VARCHAR(64) UNIQUE,  -- для идемпотентности
    metadata JSONB,
    created_at TIMESTAMPTZ DEFAULT now(),
    completed_at TIMESTAMPTZ
);

Ключевой инвариант: баланс в таблице accounts всегда должен совпадать с суммой проводок в journal_entries. Для проверки мы запускаем ежечасный reconciliation-скрипт:

-- Проверка консистентности: находим расхождения
SELECT a.id, a.balance AS stored_balance,
       COALESCE(SUM(CASE WHEN j.entry_type = 'debit' THEN -j.amount
                         ELSE j.amount END), 0) AS calculated_balance
FROM accounts a
LEFT JOIN journal_entries j ON j.account_id = a.id
GROUP BY a.id, a.balance
HAVING a.balance != COALESCE(SUM(CASE WHEN j.entry_type = 'debit'
                                      THEN -j.amount ELSE j.amount END), 0);

ACID-гарантии и уровни изоляции транзакций

Перевод денег между счетами должен быть атомарным: либо обе проводки (дебет и кредит) выполняются, либо ни одна. PostgreSQL обеспечивает это через транзакции с правильно подобранным уровнем изоляции.

-- Функция перевода между счетами
CREATE OR REPLACE FUNCTION transfer_funds(
    p_from_account BIGINT,
    p_to_account BIGINT,
    p_amount NUMERIC(19, 4),
    p_idempotency_key VARCHAR(64),
    p_description TEXT DEFAULT NULL
) RETURNS UUID AS $$
DECLARE
    v_tx_id UUID;
    v_from_balance NUMERIC(19, 4);
    v_to_balance NUMERIC(19, 4);
BEGIN
    -- Проверяем идемпотентность: если операция уже выполнена, возвращаем её ID
    SELECT id INTO v_tx_id FROM transactions
    WHERE idempotency_key = p_idempotency_key AND status = 'completed';
    IF FOUND THEN
        RETURN v_tx_id;
    END IF;

    -- Создаём транзакцию
    INSERT INTO transactions (type, idempotency_key, metadata)
    VALUES ('transfer', p_idempotency_key,
            jsonb_build_object('from', p_from_account,
                               'to', p_to_account,
                               'amount', p_amount))
    RETURNING id INTO v_tx_id;

    -- Блокируем счета В ОПРЕДЕЛЁННОМ ПОРЯДКЕ (по id)
    -- чтобы избежать deadlock
    IF p_from_account < p_to_account THEN
        SELECT balance INTO v_from_balance
        FROM accounts WHERE id = p_from_account FOR UPDATE;
        SELECT balance INTO v_to_balance
        FROM accounts WHERE id = p_to_account FOR UPDATE;
    ELSE
        SELECT balance INTO v_to_balance
        FROM accounts WHERE id = p_to_account FOR UPDATE;
        SELECT balance INTO v_from_balance
        FROM accounts WHERE id = p_from_account FOR UPDATE;
    END IF;

    -- Проверяем достаточность средств
    IF v_from_balance < p_amount THEN
        UPDATE transactions SET status = 'failed' WHERE id = v_tx_id;
        RAISE EXCEPTION 'Insufficient funds: balance=%, required=%',
            v_from_balance, p_amount;
    END IF;

    -- Списание
    UPDATE accounts SET balance = balance - p_amount,
                        version = version + 1
    WHERE id = p_from_account;

    INSERT INTO journal_entries
        (transaction_id, account_id, entry_type, amount, running_balance, description)
    VALUES (v_tx_id, p_from_account, 'debit', p_amount,
            v_from_balance - p_amount, p_description);

    -- Зачисление
    UPDATE accounts SET balance = balance + p_amount,
                        version = version + 1
    WHERE id = p_to_account;

    INSERT INTO journal_entries
        (transaction_id, account_id, entry_type, amount, running_balance, description)
    VALUES (v_tx_id, p_to_account, 'credit', p_amount,
            v_to_balance + p_amount, p_description);

    -- Завершаем транзакцию
    UPDATE transactions SET status = 'completed', completed_at = now()
    WHERE id = v_tx_id;

    RETURN v_tx_id;
END;
$$ LANGUAGE plpgsql;

Уровень изоляции по умолчанию в PostgreSQL — READ COMMITTED. Для финансовых операций это приемлемо при использовании SELECT ... FOR UPDATE, который блокирует строку до завершения транзакции. Уровень SERIALIZABLE обеспечивает максимальную защиту от аномалий, но увеличивает количество retry из-за сериализационных конфликтов. В «ПлатёжКа» мы используем READ COMMITTED + явные блокировки как оптимальный компромисс.

Идемпотентные операции и защита от дублей

В распределённых системах запрос может быть выполнен повторно: сетевой таймаут, retry на стороне клиента, дублирование в очереди сообщений. Для финансовой системы повторное списание — катастрофа.

Мы реализовали идемпотентность через ключ idempotency_key — уникальный идентификатор операции, генерируемый клиентом:

-- Уникальный индекс гарантирует, что одна операция не выполнится дважды
ALTER TABLE transactions
    ADD CONSTRAINT uq_idempotency_key UNIQUE (idempotency_key);

-- В коде приложения (Python)
import uuid
import hashlib

def generate_idempotency_key(user_id: int, operation: str, params: dict) -> str:
    """Генерирует детерминированный ключ идемпотентности.
    Одинаковые параметры всегда дают одинаковый ключ."""
    raw = f"{user_id}:{operation}:{sorted(params.items())}"
    return hashlib.sha256(raw.encode()).hexdigest()[:64]

# Повторный вызов с теми же параметрами не создаст дубль
key = generate_idempotency_key(
    user_id=42,
    operation="transfer",
    params={"from": 100, "to": 200, "amount": "500.00"}
)

# Первый вызов — выполняет операцию
result1 = db.execute("SELECT transfer_funds(100, 200, 500.00, %s)", [key])

# Повторный вызов — возвращает ID существующей операции
result2 = db.execute("SELECT transfer_funds(100, 200, 500.00, %s)", [key])
# result1 == result2

Дополнительная защита — advisory locks в PostgreSQL для предотвращения конкурентной обработки одного и того же платежа:

-- В начале обработки запроса
SELECT pg_try_advisory_xact_lock(hashtext(p_idempotency_key));
-- Если вернул false — другой процесс уже обрабатывает эту операцию

Предотвращение deadlock и аудит операций

Deadlock возникает, когда две транзакции ожидают блокировок друг друга. Классический сценарий: транзакция A переводит деньги со счёта 1 на счёт 2 (блокирует 1, ждёт 2), а транзакция B переводит со счёта 2 на счёт 1 (блокирует 2, ждёт 1).

Решение — каноническое упорядочивание блокировок. В нашей функции transfer_funds мы всегда блокируем счёт с меньшим ID первым. Это гарантирует, что два процесса, работающие с одной парой счетов, блокируют их в одинаковом порядке:

-- Из функции transfer_funds (повтор для акцента)
IF p_from_account < p_to_account THEN
    PERFORM 1 FROM accounts WHERE id = p_from_account FOR UPDATE;
    PERFORM 1 FROM accounts WHERE id = p_to_account FOR UPDATE;
ELSE
    PERFORM 1 FROM accounts WHERE id = p_to_account FOR UPDATE;
    PERFORM 1 FROM accounts WHERE id = p_from_account FOR UPDATE;
END IF;

Для аудита всех изменений данных мы используем триггер, записывающий каждое изменение баланса в отдельную таблицу:

CREATE TABLE audit_log (
    id BIGSERIAL PRIMARY KEY,
    table_name TEXT NOT NULL,
    record_id BIGINT NOT NULL,
    operation VARCHAR(10) NOT NULL,  -- INSERT, UPDATE, DELETE
    old_data JSONB,
    new_data JSONB,
    changed_by TEXT DEFAULT current_user,
    changed_at TIMESTAMPTZ DEFAULT now(),
    app_user_id BIGINT,  -- из current_setting
    ip_address INET
);

CREATE OR REPLACE FUNCTION audit_trigger_func()
RETURNS TRIGGER AS $$
BEGIN
    INSERT INTO audit_log (table_name, record_id, operation, old_data, new_data,
                           app_user_id)
    VALUES (
        TG_TABLE_NAME,
        COALESCE(NEW.id, OLD.id),
        TG_OP,
        CASE WHEN TG_OP != 'INSERT' THEN to_jsonb(OLD) END,
        CASE WHEN TG_OP != 'DELETE' THEN to_jsonb(NEW) END,
        current_setting('app.current_user_id', true)::BIGINT
    );
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER audit_accounts
    AFTER INSERT OR UPDATE OR DELETE ON accounts
    FOR EACH ROW EXECUTE FUNCTION audit_trigger_func();

-- Из приложения перед каждой операцией
-- устанавливаем контекст пользователя
SET LOCAL app.current_user_id = '42';

Таблица audit_log является append-only: никакие DELETE или UPDATE не допускаются. Для этого используется Row Level Security:

ALTER TABLE audit_log ENABLE ROW LEVEL SECURITY;

-- Политика запрещает удаление и обновление для всех ролей
CREATE POLICY audit_immutable ON audit_log
    FOR ALL
    USING (true)
    WITH CHECK (false);  -- Блокирует UPDATE

-- Разрешаем только INSERT
CREATE POLICY audit_insert ON audit_log
    FOR INSERT
    WITH CHECK (true);

Результаты и рекомендации

После перепроектирования базы данных «ПлатёжКа» получила систему, прошедшую аудит по требованиям ЦБ РФ:

МетрикаДоПосле
Расхождение балансов за месяц~37 000 ₽0 ₽
Потерянные транзакции при сбоях2-5 в неделю0
Время разбора спорных операций2-4 часа (логи)5 минут (audit_log)
Дублированные платежи0.1% от объёма0%
Средний отклик API перевода45 мс18 мс (advisory locks быстрее row locks)

Ключевые принципы хранения финансовых данных:

  • Никогда не используйте FLOAT/DOUBLE для денег — только NUMERIC или INTEGER (в минорных единицах).
  • Применяйте двойную запись: каждая операция = дебет + кредит, сумма всегда сходится.
  • Реализуйте идемпотентность через уникальные ключи операций.
  • Предотвращайте deadlock упорядочиванием блокировок по ID ресурса.
  • Ведите неизменяемый аудит каждого изменения.

Если вы разрабатываете финтех-продукт и сталкиваетесь с проблемами точности расчётов или целостности данных — обращайтесь к специалистам itfresh.ru. Мы поможем спроектировать надёжную финансовую базу данных, устойчивую к сбоям и соответствующую требованиям регуляторов.

Часто задаваемые вопросы

Тип MONEY в PostgreSQL зависит от локали сервера, имеет фиксированную точность (2 знака после запятой) и плохо работает с арифметическими операциями между разными валютами. При смене локали значения могут интерпретироваться некорректно. Используйте NUMERIC(19, 4) — это стандартный и предсказуемый подход.
READ COMMITTED (по умолчанию в PostgreSQL) в сочетании с SELECT ... FOR UPDATE достаточен для большинства финансовых систем. Уровень SERIALIZABLE обеспечивает максимальную защиту от аномалий, но увеличивает количество retry на 5-15% из-за сериализационных конфликтов. Мы рекомендуем READ COMMITTED + явные блокировки.
Храните курс как NUMERIC(19, 8) — восемь знаков после запятой. Выполняйте вычисления с полной точностью, а округление до валютной точности делайте только на финальном шаге: ROUND(amount * rate, 2). Разницу от округления записывайте на специальный системный счёт «курсовые разницы».
Идемпотентный ключ — уникальный идентификатор операции, генерируемый клиентом. При повторном запросе с тем же ключом система возвращает результат первой операции вместо повторного выполнения. Это защищает от дублирования платежей при сетевых ошибках, retry и повторных нажатиях кнопки оплаты.
Первый шаг — read-реплики для аналитических запросов и отчётов. Второй — партиционирование journal_entries по месяцам (PARTITION BY RANGE). Третий — шардирование по account_id с помощью Citus или ручного шардирования. До 1 миллиона транзакций в день PostgreSQL справляется на одном сервере с SSD.

Нужна помощь с проектом?

Специалисты АйТи Фреш помогут с архитектурой, DevOps, безопасностью и разработкой — 15+ лет опыта

📞 Связаться с нами
#postgresql финансы#хранение денег в базе данных#numeric vs float#двойная запись#acid транзакции#isolation levels#идемпотентные операции#audit trail
Комментарии 0

Оставить комментарий

загрузка...