FlashDrop
Ortiqcha berilmaydigan navbat kuponlari: 4 concurrency usuli yozilib, k6 bilan o'lchab solishtirildi
Bu loyiha nimani isbotlaydi
- Muammoni isbotlash: lock'siz usul 50 o'rniga 300 ta kupon berdi, remaining_quantity esa 0 emas, 46 da qoldi
- To'g'ri yechimlar: qolgan uch strategiya 300 so'rovdan aniq 50 tasini o'tkazdi, DB'da ham aynan 50 ta yozuv
- Natijani tushuntirish: bu yukda optimistic lock FOR UPDATE'dan nega tez chiqqani topildi — FOR UPDATE rad etiladigan 250 so'rovni ham navbatda kutdiradi
- O'lchov intizomi: warm-up, 3 run mediana, TCP backlog'dan qisman rad etilgan runlar avtomatik yaroqsiz; qayta o'lchov xulosani o'zgartirganda README tuzatildi
- Operatsiya: SHA-tagli deploy va rollback, har kungi zaxira va tiklash testi, conflict belgisi deploy bo'lgan holatdan keyin qo'yilgan himoya
- Rol
- Yakka: dizayn, backend, testlar, yuk testi, frontend, deploy va ekspluatatsiya (21 commit)
- Muddat
- 2026.09 – 2026.10
- Texnologiyalar
- Java
- Spring Boot
- Spring Data JPA
- PostgreSQL
- Redis
- Lua
- Flyway
- Bucket4j
- JUnit 5
- Testcontainers
- k6
- Docker
- Caddy
- Cloudflare
Asosiy raqamlar
| Ko'rsatkich | Qiymat | Turi | Dalil |
|---|---|---|---|
| Redis Lua throughput | 873 req/s | O'lchangan | Dalilni ko'rish |
| Redis Lua p95 | 178 ms | O'lchangan | Dalilni ko'rish |
| SELECT FOR UPDATE throughput | 188 req/s | O'lchangan | Dalilni ko'rish |
| SELECT FOR UPDATE p95 | 1 465 ms | O'lchangan | Dalilni ko'rish |
| Optimistic lock throughput | 331 req/s | O'lchangan | Dalilni ko'rish |
| Optimistic lock p95 | 831 ms | O'lchangan | Dalilni ko'rish |
| Lock'siz: 50 kupondan haqiqatda berilgani | 300 | O'lchangan | Dalilni ko'rish |
| Rollback buyrug'i vaqti | 1,2 s | O'lchangan | Dalilni ko'rish |
| Backup'dan DB tiklash (11/11 jadval) | 0,22 s | O'lchangan | Dalilni ko'rish |
Hal qilinadigan muammo
50 dona kuponga bir vaqtda 300 ta so'rov keladi va bitta ham ortiqcha chiqmasligi kerak. Oddiy "o'qi → tekshir → yoz" mantig'i lost update tufayli 50 o'rniga 300 ta kupon chiqaradi. Bir xil biznes qoidasini 4 usulda yozib, to'g'rilik, throughput va latency o'rtasidagi muvozanatni real yuklama ostida o'lchash kerak edi.
Arxitektura
Caddy statik frontend'ni beradi va /api/* ni Spring Boot'ga uzatadi. So'rov avval Bucket4j rate limiter'dan o'tadi (holati Redis'da). Har strategiyaning o'z endpoint'i, entity'si va jadvali bor. Redis usulida qoldiq va "olganlar" to'plami bitta Lua skriptda atomik tekshiriladi, PostgreSQL'ga yozish @Async bilan fonda.
Ma'lumotlar modeli
Asosiy cheklovlar va indekslar
- campaign_*.code VARCHAR(50) NOT NULL UNIQUE
- claim_*: UNIQUE (campaign_id, user_id): 중복 발급을 DB에서 차단하고 existsByCampaignIdAndUserId 조회를 빠르게 함
- campaign_optimistic.version BIGINT NOT NULL DEFAULT 0: @Version 낙관적 락
- V9 CHECK (remaining_quantity >= 0): for_update, optimistic, redis에만 적용 (no_lock은 oversell이 보이도록 일부러 제외)
- claim_redis.id UUID: 앱에서 생성해 DB 기록을 기다리지 않고 응답
- Redis 키 (claim.lua): stock(DECR), claimed set(SADD). 결과 1 발급, 0 품절, -1 캠페인 없음, -2 이미 받음
Asosiy loyihaviy qarorlar
Redis: Lua'da DECR + SADD, DB'ga @Async
- Vaziyat
- FOR UPDATE barcha so'rovlarni bitta hot row'da navbatga qo'ydi (p95 1465 ms).
- Qaror
- SISMEMBER → GET → DECR + SADD bitta claim.lua ichida: hot path'da Postgres ham, check-then-act oralig'i ham yo'q.
- Evaziga
- Redis va DB orasida ilova qulasa, kupon DB'da yo'qolishi mumkin.
Optimistic retry ikki bean'ga bo'lingan
- Vaziyat
- @Transactional proxy self-invocation'ni ushlamaydi, bitta klassda bo'lsa tranzaksiya amalda qo'llanmaydi.
- Qaror
- OptimisticClaimService (retry, maks. 20) → OptimisticClaimAttempt (@Transactional, @Version)
- Evaziga
- Bitta mantiq ikki klassga bo'linadi.
Har strategiyaga alohida jadval va endpoint
- Vaziyat
- k6 natijalari bir-biriga aralashmasin.
- Qaror
- To'rt strategiyaning har biri o'z campaign / claim jadvallari, entity va endpoint'iga ega.
- Evaziga
- Kod va migratsiyalar takrorlanadi (8 jadval).
Lock'siz usul ataylab qoldirilgan
- Vaziyat
- Muammoni raqam bilan ko'rsatish uchun.
- Qaror
- Lost update aynan sodir bo'ladigan endpoint saqlandi, uning jadvaliga CHECK ham qo'yilmadi.
- Evaziga
- Production'da bunday endpoint bo'lmaydi.
Idempotency va qoldiq chegarasi bazada: UNIQUE(campaign_id, user_id), CHECK (remaining_quantity >= 0)
- Vaziyat
- Ilova xatosi ham ikki marta berolmasin va qoldiq manfiy bo'lmasin.
- Qaror
- Har bir claim jadvaliga UNIQUE, uchta to'g'ri strategiyaga V9 CHECK; production'da remaining_quantity = -1 yozish rad etildi.
- Evaziga
- Redis strategiyasida ikki joyda tekshiriladi.
Rate limit kaliti — userId
- Vaziyat
- Loyihadagi yagona identifikator.
- Qaror
- Bucket4j + Redis (Lettuce) token bucket userId bo'yicha, bir nechta nusxada ham limit umumiy.
- Evaziga
- Mijoz uni o'zgartira oladi; production'da IP bilan birga kerak.
Oldin va keyin
- Nima o'zgartirildi
- Birinchi jadval JIT va connection pool hali tayyor bo'lmagan bitta sovuq rundan olingan edi. scripts/bench.ps1'ga warm-up, 3 run, mediana va yaroqlilik qoidasi (muvaffaqiyatli + sold out < 300 bo'lsa yaroqsiz) qo'shilib, qayta o'lchanganda ikki xulosa teskari bo'ldi: lock'siz usul "eng sekin" emas, "tez, lekin noto'g'ri" (80.4 → 307 rps), optimistic lock esa "eng sekin" emas, FOR UPDATE'dan 1.76 marta tez (114.1 → 331 rps). Sababi: findByCodeForUpdate qoldiqni tekshirishdan oldin qulf oladi. Yaxshilash (qulfdan oldin oddiy SELECT bilan sotuv tugaganini tekshirish) hali qilinmagan.
Nosozlik tarixi
- Belgi
- 2026-10-09 deploy'dan keyin flashdrop.javohir.dev'da UI buzildi, sahifada xom <<<<<<< HEAD matni chiqdi.
- Sabab
- Lokal repo GitHub'dan orqada qolgan edi. Push rad etilgach git pull merge'ida 4 ta faylda conflict chiqdi va belgilar tozalanmasdan commit qilindi. Frontend Caddy orqali git ishchi papkasidan berilgani uchun git pull bilan darhol production'ga chiqdi; backend rollback'i esa frontend'ni qamramadi.
- Yechim
- frontend/index.html oxirgi ishlagan commit'dan tiklandi, README'lar qayta yozildi, tasodifan commit bo'lgan IDE build natijasi (bin/) olib tashlanib .gitignore'ga qo'shildi va qayta deploy qilindi (7ac0754).
- Takrorlanmasligi uchun
- deploy.sh endi git grep bilan upstream'da conflict belgisi bormi tekshiradi va topsa deploy'ni to'xtatadi, merge faqat --ff-only bilan (a2dd204). Yashirin oqibat ham topildi: conflict tuzatilganda README'lar (ko/en/uz) eski sovuq run jadvaliga qaytib qolgan edi; portfolio matnini repo bilan solishtirganda topilib, qayta o'lchangan natijalar tiklandi (a29c567).
Test va sifat
- Testlar soni
- 4
- Qamrov
- —
- CI
- —
- Test vositalari
- JUnit 5, Testcontainers (PostgreSQL 17, Redis 7), virtual threads, k6, bench.ps1
Deploy va ekspluatatsiya
- Deploy muhiti
- iwinv VPS, Docker Compose: app (Spring Boot, root bo'lmagan, 1 GB xotira cheklovi), web (Caddy: statik konsol + /api/* proxy), PostgreSQL 17, Redis 7, seed (demo kampaniyalar). Postgres va Redis portlari ochilmagan. deploy.sh: conflict belgisi tekshiruvi → git merge --ff-only → image SHA tegi bilan ham build → app va web yangilanadi, SHA .deploy-history'ga yoziladi. HikariCP 50, Tomcat accept-count 500.
- Backup
- Kunlik pg_dump (03:00 KST, cron), 7 kun saqlanadi, serverdagi 9 ta Postgres bazasini qamraydi. 2026-10-09 03:00 da avtomatik bajarilgani tasdiqlangan. Tiklash sinalgan: FlashDrop 0.22 s (11/11 jadval), LedgerCore 0.66 s (14/14)
- Rollback
- rollback.sh oldingi SHA image'ini latest qilib, build'siz app'ni qayta yaratadi. Production'da buyruq real 0m1.201s, tashqaridan kuzatilganda 03:32:00–03:32:07 oralig'ida 502, keyin yana 200.
- Monitoring
- pg_stat_statements yoqilgan, DB PG Insight'ga monitoring tarmog'i orqali ulangan
Skrinshotlar
Cheklovlar va xulosalar
Hozirgi cheklovlar
- Redis va Postgres orasidagi bo'shliq: Redis'da kupon berilgan, lekin @Async yozuvdan oldin ilova qulasa Postgres'da yo'q. @Async xatolari yo'qoladi (handler yo'q), executor navbati (500) to'lsa DECR'dan keyin TaskRejectedException chiqadi; campaign_redis.remaining_quantity init'dan keyin sinxronlanmaydi, reconciliation job yo'q
- FOR UPDATE yo'li optimal emas: qulf qoldiqni tekshirishdan oldin olinadi, rad etiladigan so'rovlar ham navbat kutadi; qulfdan oldin oddiy SELECT hali qo'shilmagan
- Rate limit kaliti zaif: userId mijozdan keladi va o'zgartirilishi mumkin; production'da IP bilan birga kerak
- Testlar kam: 4 ta parallellik testi bor, unit va HTTP darajasidagi testlar yo'q. NoLockConcurrencyTest ataylab qizil, shuning uchun gradlew test hech qachon to'liq yashil bo'lmaydi
- Bitta instansiya: rollback buyrug'i 1.2 s, lekin JVM ishga tushguncha ~8 s 502 qaytadi; health check'li almashtirish yo'q, deploy'da ham health kutilmaydi, backup'lar baza bilan bir diskda
- O'lchov muhiti: raqamlar bitta noutbukdan (Windows + Docker Desktop, i7-8565U); OS listen backlog'i ba'zi runlarda so'rovlarni rad etgan
- Konsol hisoblagichlari brauzer sessiyasiga xos, sahifa yangilanganda nolga tushadi; umumiy metrika endpoint'i yo'q
Qayta qursam
- Fonda yozish uchun boshidan Redis Stream yoki Outbox: ACK faqat DB commit'dan keyin, yo'qolgan xabar qayta o'qiladi, qo'shimcha reconciliation job
- Birinchi kundan CI (testlar, conflict tekshiruvi, kichik k6 smoke), frontend'ni image ichiga olish va health check bilan blue-green deploy
Asosiy kod
src/main/java/com/flashdrop/strategy/nolock/NoLockClaimService.javaAtaylab qilingan lost update: qulfsiz o'qish → tekshirish → yozish
src/main/java/com/flashdrop/strategy/forupdate/ForUpdateCampaignRepository.java@Lock(PESSIMISTIC_WRITE) bilan findByCodeForUpdate
src/main/java/com/flashdrop/strategy/optimistic/OptimisticClaimAttempt.javaSelf-invocation'ni chetlab o'tish uchun alohida bean'ga chiqarilgan tranzaksiya
src/main/resources/scripts/claim.luaDublikat, qoldiq va kamaytirish bitta atomik amalda
src/main/java/com/flashdrop/strategy/redis/RedisClaimService.javaLua natija kodlarini biznes statuslarga aylantiradi va DB yozuvini fonga uzatadi
src/main/java/com/flashdrop/strategy/redis/RedisClaimWriteBackService.javaPostgres'ga @Async bilan keyin yozish
src/main/java/com/flashdrop/ratelimit/RateLimitInterceptor.javaBucket4j + Redis token bucket, 429 va Retry-After
scripts/bench.ps1Warm-up, 3 run, mediana, yaroqsiz runni qayta yugurish
scripts/deploy.sh, scripts/rollback.shConflict guard, SHA-tagli image, rollback
Hozirgi holat va keyingi qadamlar
Lock'siz usul 50 o'rniga 300 ta kupon berdi (remaining_quantity 0 emas, 46 da qoldi), qolgan uch strategiya 300 so'rovdan aniq 50 tasini o'tkazdi va DB'da ham aynan 50 ta yozuv qoldi. Redis FOR UPDATE'ga nisbatan throughput bo'yicha 4.6, p95 bo'yicha 8.2 marta yaxshi chiqdi. Raqamlar 1 warm-up'dan keyingi 3 ta yaroqli run medianasi; muvaffaqiyatli + sold out yig'indisi 300 dan kam bo'lgan runlar (TCP backlog tufayli qisman rad etilgan) avtomatik yaroqsiz deb belgilanib, qayta yugurildi. Rate limit: bitta userId bilan 12 so'rovdan 6 tasi o'tdi, 6 tasi 429 (5 token + greedy refill).