To‘lovlar va balanslar uchun ma’lumotlar bazasini loyihalash: ledger
Chuqur4 daqInnovialabs tahririyati
Qisqacha
Har bir pul harakatini summasi nolga teng provodkalarga ega o‘zgarmas tranzaksiya sifatida yozing, summalarni tiyinda butun son ko‘rinishida saqlang, provayder callback’larini unikal kalit orqali idempotent qiling va yechib olishdan oldin hisob qatorini bloklang.
Qisqa javob
Ishonchli to‘lov ledger’i beshta qoidaga tayanadi:
- Ikki tomonlama yozuv: har bir operatsiya pulni kamida ikki hisob o‘rtasida o‘tkazadi va uning provodkalari yig‘indisi nolga teng.
- O‘zgarmaslik: yozuvlar hech qachon yangilanmaydi va o‘chirilmaydi; xatolar yangi storno tranzaksiyasi bilan tuzatiladi.
- Faqat butun sonlar: summalar eng kichik birlikda — tiyin yoki sentda —
bigint, hech qachonfloatemas. - Idempotentlik: Payme, Click yoki Uzum’dan kelgan takroriy callback ikkinchi to‘lovni yarata olmaydi.
- Bloklash: ikkita parallel xarid bitta balansni ikkalasi ham sarflay olmaydi.
Oshirib-kamaytiriladigan oddiy users.balance ustuni bu qoidalarning birortasini ham bajarmaydi: raqam qayerdan kelganini tushuntirib ham, to‘g‘riligini isbotlab ham bo‘lmaydi.
Sxema
CREATE TABLE accounts (
id bigserial PRIMARY KEY,
owner_id bigint,
kind text NOT NULL, -- user_wallet, provider_clearing, revenue
currency char(3) NOT NULL DEFAULT 'UZS',
balance bigint NOT NULL DEFAULT 0, -- kesh, tiyinda
allow_negative boolean NOT NULL DEFAULT false
);
CREATE TABLE ledger_transactions (
id bigserial PRIMARY KEY,
idempotency_key text NOT NULL UNIQUE,
kind text NOT NULL, -- topup, purchase, refund, reversal
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE ledger_entries (
id bigserial PRIMARY KEY,
transaction_id bigint NOT NULL REFERENCES ledger_transactions(id),
account_id bigint NOT NULL REFERENCES accounts(id),
amount bigint NOT NULL CHECK (amount <> 0) -- + oshiradi, - kamaytiradi
);
Bu yerda soddalashtirilgan ishora qoidasi ishlatilgan: musbat summa hisob balansini oshiradi, manfiy summa kamaytiradi. Har bir tranzaksiyada SUM(amount) = 0 — buni provodkalarni yozayotgan o‘sha baza tranzaksiyasida yoki kechiktirilgan trigger-cheklov bilan tekshiring.
Provayder orqali 50 000 so‘mlik to‘ldirish ikkita provodkaga aylanadi: provider_clearing hisobida −5 000 000 va foydalanuvchi hamyonida +5 000 000. Pul yo‘qdan paydo bo‘lmadi: kliring hisobi hisob-kitob vaqtigacha provayder sizga qancha qarzdorligini ko‘rsatadi. Shuning uchun bunday tizim hisoblarida allow_negative = true, foydalanuvchi hamyonlarida esa yo‘q.
Pul butun son sifatida
floatko‘plab o‘nli kasrlarni aniq ifodalay olmaydi va xatolar yig‘indilarda to‘planadi.numericaniq, lekin sekinroq va tiyin bo‘laklarini saqlashga undaydi.- Tiyindagi
bigintaniq, tez va oddiy. So‘mga faqat ko‘rsatishda formatlang.
Provayderlar summalarni turli birliklarda yuborishi mumkin: ba’zilari tiyinda, ba’zilari kasr qismli so‘mda. Har bir provayder hujjatlarini tekshiring va bir marta — integratsiya chegarasida konvertatsiya qiling.
O‘zgarmas yozuvlar
- Ilova roli uchun ledger jadvallarida
UPDATEvaDELETEhuquqlarini olib qo‘ying yoki ularni trigger bilan bloklang. - Qaytarish — asl tranzaksiyaga havola qiluvchi, ko‘zgu provodkalarga ega yangi tranzaksiya.
- Statuslar (kutilmoqda, o‘tkazildi, bekor qilindi) provodkalarga emas, to‘lov obyektiga tegishli. Ledger’ga faqat pul bilan haqiqatan sodir bo‘lgan narsa yoziladi.
Idempotent callback’lar
To‘lov provayderlari taymaut va tarmoq xatolarida so‘rovlarni takrorlaydi, bitta operatsiya bir necha marta kelishi mumkin. Kalitni provayder va uning tranzaksiya id’sidan tuzing:
INSERT INTO ledger_transactions (idempotency_key, kind)
VALUES ('payme:6720f1c2a9', 'topup')
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING id;
Agar hech narsa qaytmasa, operatsiya allaqachon qayta ishlangan: uni toping va provayderga birinchi martadagi aynan o‘sha javobni yuboring. Har bir provayder protokolining o‘z bosqichlari bor (tekshirish, yaratish, o‘tkazish, bekor qilish) va har bir bosqich takrorlanishga xavfsiz bardosh berishi kerak.
Ikki marta yechib olishga qarshi bloklash
BEGIN;
SELECT balance FROM accounts WHERE id = 42 FOR UPDATE;
-- agar balance < 1500000 bo‘lsa: ROLLBACK va "mablag‘ yetarli emas" javobi
INSERT INTO ledger_transactions (idempotency_key, kind)
VALUES ('order:981', 'purchase') RETURNING id; -- :tx
INSERT INTO ledger_entries (transaction_id, account_id, amount)
VALUES (:tx, 42, -1500000), (:tx, 7, 1500000);
UPDATE accounts SET balance = balance - 1500000 WHERE id = 42;
UPDATE accounts SET balance = balance + 1500000 WHERE id = 7;
COMMIT;
FOR UPDATEikkinchi parallel xaridni birinchisi tugaguncha kutishga va keyin yangi balansni ko‘rishga majbur qiladi.- Deadlock’larning oldini olish uchun bir nechta hisobni qat’iy tartibda (masalan, id bo‘yicha) bloklang.
- Daromad kabi tizim hisobi issiq qatorga aylanadi. Variantlar: uning balansini keshlamaslik va provodkalardan hisoblash yoki uni bir nechta subhisobga bo‘lish.
- Muqobil yo‘l —
SERIALIZABLEizolyatsiya darajasi va serializatsiya xatolarida qayta urinish.
Balanslar va solishtirish
Keshlangan balance — qulaylik, haqiqat manbai esa provodkalar yig‘indisi. Ularning mos kelishini muntazam tekshiring:
SELECT a.id, a.balance, COALESCE(SUM(e.amount), 0) AS from_entries
FROM accounts a LEFT JOIN ledger_entries e ON e.account_id = a.id
GROUP BY a.id HAVING a.balance <> COALESCE(SUM(e.amount), 0);
Hajm katta bo‘lsa, balanslarning davriy suratlarini saqlang va faqat yangiroq provodkalarni qo‘shing.
Solishtirish (reconciliation) ledger’ingizni provayder hisobotlari bilan taqqoslaydi: provayderning har bir tranzaksiyasiga ledger’da xuddi shu summali aynan bitta tranzaksiya mos kelishi kerak. Farqlarni “ledger’da yo‘q”, “provayderda yo‘q” va “summa mos emas” turlariga ajrating va ularni faqat yangi tuzatuvchi tranzaksiyalar bilan bartaraf eting.