Ikki marta yechilgan to'lov
Qayta urinish takroriy so'rov emas — to'lov ikki marta o'tib ketguncha. Idempotentlik kalitlari aslida qanday ishlaydi, sodda variant nega baribir ikki marta yechadi va qaysi Postgres cheklovi kafolatni haqiqiy qiladi.

Foydalanuvchi "To'lash" tugmasini bosdi. Spinner aylanyapti. Telefon bilan server orasidagi qayerdadir aloqa uzildi. Ilova so'rovni qayta yubordi. Kartadan ikki marta yechildi.
Bu hikoyada birorta ham xato yo'q. Har bir komponent aynan loyihalanganidek ishladi. Telefon qayta urindi, chunki javob kelmagan so'rovni qayta yuborish — to'g'ri xatti-harakat. Server ikkala so'rovni ham qayta ishladi, chunki uning nazarida bu ikki xil odam to'layotgan edi. To'lov provayderi ikki marta yechdi, chunki siz undan ikki marta so'radingiz.
Idempotentlik aslida nima uchun kerakligini shu nosozlik o'rgatadi.
So'zning ma'nosi ko'pchilik o'ylagandan tor
Amal idempotent deyiladi, agar uni ikki marta bajarish bir marta bajarish bilan bir xil natija bersa. DELETE /users/42 tabiatan idempotent — foydalanuvchi ikkala holda ham o'chgan. POST /payments esa emas: har chaqiruv yangi narsa yaratadi.
Jamoalar shu yerda adashadi: HTTP metod semantikasi ishni o'zi bajaradi deb o'ylashadi. Bajarmaydi. PUTning "spetsifikatsiya bo'yicha idempotent" bo'lishi — mijoz nimani taxmin qilishi mumkinligi haqidagi gap, sizning handleringiz beradigan kafolat emas. Agar PUT audit jadvaliga qator qo'shsa, hisoblagichni oshirsa yoki webhook yuborsa, RFC nima desa ham u idempotent emas. Bu xususiyat metod nomida emas, sizning kodingizda yashaydi.
Shuning uchun asl savol hech qachon "bu endpoint idempotentmi?" emas. Asl savol: bu amalning o'ziga xosligi nimada va men uni allaqachon bajarganimni qayerda saqlayman?
Sodda variant va uning qulashi
Deyarli hamma birinchi bo'lib mana shunday yozadi. Mijoz noyob kalit yuboradi, server esa uni ko'rgan-ko'rmaganini tekshiradi.
async function createPayment(key: string, input: PaymentInput) {
const existing = await db.payment.findUnique({ where: { idempotencyKey: key } });
if (existing) return existing; // bajarilgan — o'shani qaytar
const charge = await provider.charge(input); // provayderni chaqiramiz
return db.payment.create({
data: { idempotencyKey: key, chargeId: charge.id, amount: input.amount },
});
}Endi shu kodni ikkita so'rov bir vaqtda kelayotganini tasavvur qilib qayta o'qing — qayta urinishlar to'lqini aynan shuni hosil qiladi.
A so'rovi tekshiradi: hech narsa yo'q. B so'rovi tekshiradi: hech narsa yo'q — A hali qatorini yozib ulgurmagan. Ikkalasi ham davom etadi. Ikkalasi ham provider.charge chaqiradi. Ikki marta yechildi. findUnique sizga o'qish berdi, o'qish esa o'tmish haqida gapiradi, boshqa ulanish hozir nima qilayotgani haqida emas.
Bu — tekshiruv vaqti bilan foydalanish vaqti orasidagi poyga (TOCTOU), va idempotentlikni noto'g'ri amalga oshirishning eng keng tarqalgan usuli. Oyna kichkina — ko'pincha bir necha millisekund — aynan shuning uchun u testlardan o'tib ketadi va production'da, qayta urinish original so'rovdan 40 ms keyin kelganda ko'rinadi.
Qarorni ma'lumotlar bazasi chiqarsin
Yechim — so'rashni bas qilib, egallashni boshlash. Bu tizimda parallel ulanishlar bo'ylab atomar qaror chiqara oladigan yagona ishtirokchi — unique cheklov.
CREATE TABLE payment_attempts (
idempotency_key TEXT PRIMARY KEY,
request_hash TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'in_progress',
response_body JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);Endi handler qiladigan birinchi ish — insert. Insert'da kim yutsa, amal o'shaniki; qolganlari ta'rifi bo'yicha takror.
async function createPayment(key: string, input: PaymentInput) {
const hash = sha256(JSON.stringify(input));
// Kalitni egallaymiz. Parallel chaqiruvlardan faqat bittasi yutadi.
const claimed = await db.$queryRaw`
INSERT INTO payment_attempts (idempotency_key, request_hash)
VALUES (${key}, ${hash})
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING idempotency_key
`;
if (claimed.length === 0) {
return handleReplay(key, hash); // egasi boshqa so'rov
}
const charge = await provider.charge(input, { idempotencyKey: key });
await db.$executeRaw`
UPDATE payment_attempts
SET status = 'succeeded', response_body = ${JSON.stringify(charge)}::jsonb
WHERE idempotency_key = ${key}
`;
return charge;
}Poyga yo'qoldi, va u strukturaviy sabab bilan yo'qoldi: qarorni bitta tranzaksiya ichidagi yagona yozuvchi indeks chiqaradi, hali ham to'g'ri bo'lishiga umid qilingan qiymatni o'qiydigan dastur mantig'i emas.
Uchta unutiladigan holat
Insert'da yutish yoki yutqazish — oson qismi. Haqiqiy to'g'rilik takroriy so'rovda nima qilishingizda.
Bir xil kalit, boshqa tana. Mijoz idempotentlik kalitini butunlay boshqa so'rov uchun qayta ishlatadi — odatda uning qayta urinish mantig'idagi xato, ba'zan hujum. Agar keshdagi javobni ko'r-ko'rona qaytarsangiz, foydalanuvchi qilmoqchi bo'lgan to'lovni jimgina yutib yuborgan bo'lasiz. request_hash aynan shuning uchun kerak:
async function handleReplay(key: string, hash: string) {
const row = await db.paymentAttempt.findUnique({ where: { idempotencyKey: key } });
if (row.requestHash !== hash) {
throw new ConflictError('Kalit boshqa payload bilan qayta ishlatilgan');
}
if (row.status === 'in_progress') {
throw new ConflictError('Birinchi so\'rov hali jarayonda — birozdan keyin urining');
}
return row.responseBody;
}Bu yerda 409 qaytarish ortiqcha qattiqqo'llik emas. Bu — yagona halol javob: siz hech qachon qayta ishlamagan so'rov uchun keshlangan natija bera olmaysiz.
Hali jarayondagi takror. Qayta urinish original so'rov provayder bilan gaplashib turgan paytda keladi. Qaytaradigan natija hali yo'q. Retry-After bilan 409 qaytarish to'g'ri; birinchi so'rov tugaguncha ulanishni ushlab turish esa — aynan qayta urinishlarni keltirib chiqargan hodisa paytida ulanishlar pulini tugatishning yaxshi usuli.
Egallash bilan yakunlash orasidagi qulash. Jarayoningiz provider.charge muvaffaqiyatli o'tgandan keyin, lekin UPDATEdan oldin o'ldi. Qator endi abadiy in_progress bo'lib qoldi, va aslida o'tib ketgan to'lov uchun har bir keyingi urinish 409 oladi. Kichik xatoni haqiqiy pul biriktirilgan support tiketiga aylantiradigan holat shu.
Yechim — so'rov yo'lidagi ayyorroq kod emas, alohida solishtirish (reconciliation) vazifasi:
// Bir necha daqiqada bir ishlaydi. Eskirgan da'volar taxmin qilinmaydi — so'raladi.
const stale = await db.paymentAttempt.findMany({
where: { status: 'in_progress', createdAt: { lt: minutesAgo(5) } },
});
for (const attempt of stale) {
const remote = await provider.lookupByIdempotencyKey(attempt.idempotencyKey);
await db.paymentAttempt.update({
where: { idempotencyKey: attempt.idempotencyKey },
data: remote
? { status: 'succeeded', responseBody: remote }
: { status: 'failed' },
});
}Buni ishlatadigan narsaga e'tibor bering: haqiqat manbai — provayder, va siz undan aynan o'sha kalit bilan so'radingiz. Sizning idempotentlik kalitingiz o'sha kalitning pul harakatlantira oladigan har bir quyi tizimga yetib borishi darajasidagina qimmatga ega.
Kalit qayerdan kelishi kerak
Uni mijoz yaratadi — foydalanuvchining bitta niyati uchun bir marta, har HTTP urinishi uchun emas. Checkout ekrani ochilganda yaratilgan UUID — to'g'ri. Qayta urinish o'ramining ichida yaratilgan UUID esa befoyda, chunki har urinish yangi kalit oladi va siz yana ikki marta yechishga qaytasiz.
Tajribamda butun bu dizayndagi eng ko'p buziladigan qoida — aynan shu bitta jumla. Server tarafidagi mexanizm odatda joyida bo'ladi. Kalit noto'g'ri joyda yaratiladi, va shu sababli qolgan hammasi bezakka aylanadi.
Server tomonda yaratilgan kalitlar ham noto'g'ri shakl, garchi ba'zan jozibali ko'rinsa ham. Agar kalitni (user_id, summa, daqiqa)dan hosil qilsangiz, siz "foydalanuvchi bir daqiqada bir xil narsani ikki marta sotib ololmaydi" degan qoida qurgan bo'lasiz — bu esa infratuzilma tafsiloti niqobi ostida kiritilgan mahsulot qarori. Ba'zan aynan shu kerak. Odatda esa olti oydan keyin kimnidir hayron qoldiradi.
Nimani va qancha saqlash kerak
Urinish qatorlarini mijoz yuborishi mumkin bo'lgan har qanday qayta urinishdan uzoqroq, lekin undan ortiq emas darajada saqlang. Yigirma to'rt soat real qayta urinish xatti-harakatlarining deyarli hammasini qoplaydi, jumladan ilovani fon rejimiga o'tkazib, keyin qaytgan foydalanuvchini ham. Ba'zi provayderlar 24 soat, ba'zilari uzunroq muddat ishlatadi. Bitta raqamni tanlang, uni API hujjatiga yozing va rejaga muvofiq o'chiring — cheksiz o'sadigan kalitlar jadvali o'zi alohida operatsion muammoga aylanadi.
DELETE FROM payment_attempts WHERE created_at < now() - INTERVAL '24 hours';Javob tanasini saqlash ko'ringanidan muhimroq. Takroriy so'rov aynan o'sha natijani qaytarishi kerak — o'sha charge ID, o'sha vaqt belgilari — yangidan hisoblangan ekvivalentni emas. Mijozlar bularni solishtiradi. Agar ikkinchi chaqiruv boshqa created_at qaytarsa, kimningdir solishtirish jarayoni ertami-kechmi buni belgilab qo'yadi.
Bu post siz yuboradigan so'rov haqida. Uning oynadagi aksi — provayder sizga yuboradigan callback va uni dedupe qilish uchun nega unique indeks emas, state machine kerakligi — bu yerda: Idempotentlik — header emas, shartnoma
Umumiy shakl
Idempotentlik kalitlari — umumiyroq yondashuvning xususiy holati: takroriy ishning oldini ololmasangiz, uni aniqlanadigan va arzon qiling. Xuddi shu shakl xabar iste'molchilarida (message ID bo'yicha dedupe), webhook qabul qiluvchilarida (event ID bo'yicha dedupe) va vazifalar navbatlarida (bir vazifa ikki marta navbatga tushmasligi uchun unique job key) uchraydi.
Ularning barchasida intizom bir xil. Amalni qayta urinishlardan omon qoladigan barqaror identifikator bilan nomlang. Uni atomar egallang. Natijani yozib qo'ying. Takror nimani anglatishini aniq hal qiling.
Shu to'rttasini to'g'ri qilsangiz, takroriy yetkazib berish xatolar toifasi bo'lishdan to'xtaydi. U kuniga bir necha marta sodir bo'ladigan va hech kim sezmaydigan narsaga aylanadi — ishonchlilik ichkaridan qaraganda aynan shunday ko'rinadi.


