인셔셔RSS 관심 있는 블로그, 뉴스, 기술 정보를 효율적으로 추적하고 읽으세요
원문 읽기 InertiaRSS에서 열기

추천 피드

博客园 - 司徒正美
V
V2EX
T
Tailwind CSS Blog
有赞技术团队
有赞技术团队
aimingoo的专栏
aimingoo的专栏
Apple Machine Learning Research
Apple Machine Learning Research
IT之家
IT之家
Blog — PlanetScale
Blog — PlanetScale
A
About on SuperTechFans
月光博客
月光博客
T
The Blog of Author Tim Ferriss
宝玉的分享
宝玉的分享
Martin Fowler
Martin Fowler
博客园 - 聂微东
The GitHub Blog
The GitHub Blog
V
Visual Studio Blog
WordPress大学
WordPress大学
酷 壳 – CoolShell
酷 壳 – CoolShell
Engineering at Meta
Engineering at Meta
GbyAI
GbyAI

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant Common SOC 2 Failures (Real World) Stop Vibe-Checking Your AI App: A Practical Guide to Evals How to Use SonarQube and SonarScanner Locally to Level Up Your Code Quality Your Next To-Do App Is Dead — I Replaced Mine with an OpenClaw AI Sign a Nostr event in 60 lines of Python using coincurve — no nostr-sdk, no nbxplorer, no rust toolchain ITGC Audit Explained Like You’re in Big 4 Patch Tuesday abril 2026: Microsoft parcha 163 vulnerabilidades y un zero-day en SharePoint Stop scraping everything: a better way to track competitor price changes Listing on MCPize + the Official MCP Registry while routing payments OUTSIDE the marketplace — how I kept 100% of my x402 revenue Building an AI-Powered Risk Intelligence System Using Serverless Architecture Why We Ripped Function Overloading Out of Our AI Toolchain Testing AI-Generated Code: How to Actually Know If It Works SaaS Churn Is Killing Your Business. Here Is What to Do About It (Without a Support Team) The Speed of AI Is No Longer Linear - And Self-Improving Models Are Why How to Implement RBAC for MCP Tools: A Practical Guide for Engineering Teams From Standard Quote to Persuasive Proposal: AI Automation for Arborists I built a CLI that scaffolds complete multi-tenant SaaS apps Axios CVE-2025–62718: The Silent SSRF Bug That Could Be Hiding in Your Node.js App Right Now The dashboard that ended our friendship Data Pipelines Explained Simply (and How to Build Them with Python)
제가 두둑한 장부에서 스트레스 테스트로 발견한 경쟁 상태 조건과 그것을 어떻게 수정했는지
Do Pham Dinh · 2026-05-24 · via DEV Community

저는 ledger-service를 구축하고 있어요, Java 21 / Spring Boot 3.5 / PostgreSQL을 사용한 이중 기록형 전자 지갑 레지스트리예요. 이것은 Render에서 동일한 계정에 50개의 이체를 발사하는 스트레스 테스트를 처음에 작성했어요.를 즉시 확인하고 책이 결코 손상되지 않는다고 주장합니다. 빨갛게 변했고 — 그 빨갛게 변하는 방식이 이 프로젝트를 만들면서 배운 가장 유용한 것이었습니다.

이 포스트는 전체 체인을 다룹니다: 금전 일지가 균형을 유지하는 이유 캐시는 전혀, 캐시가 초대하는 읽기-수정-쓰기 경쟁, 어떻게 감지했는지, 수정(긍정적 잠금 + 제한된 재시도), 긍정적 잠금을 부정적 잠금보다 선택하도록 근거하는 벤치마크, 그리고 idempotency가 재시도와 어떻게 조합되어야 하는지에 대한 설명은 네트워크 장애가 중복 지출하지 않도록 항상 구성되어야 한다는 점.

설정: 정산부와 그것이 캐시를 가지고 있는 이유

진실의 출처는 이중 기록, 추가 전용 테이블 (ADR-0005). 모든 금전적 작업은 최소한 하나의 균형 잡힌 DEBIT/CREDIT 쌍을 기록합니다. 여기서 Σ DEBIT == Σ CREDIT이며, ledger_entries은 삽입 전용입니다 — UPDATE은 없으며, DELETE도 없습니다. 오류는 수정을 게시하여 수정됩니다. 항목은 역사를 편집하지 않고 생성됩니다. 이것은 Stripe, Modern Treasury, 그리고 Formance가 모두 사용하는 모델이며, 신뢰할 수 있는 감사 추적을 제공하는 것이죠.

하지만 "계정 X의 잔액은 어느 정도인가?"는 계정이 가지고 있던 모든 항목에 대해 SUM가 되어서는 안 됩니다. 그래서 저는 캐시를 유지합니다: accounts.balance은 그 계정의 항목의 물리화된 Σ이며, 업데이트됩니다같은 거래 내에서 엔트리 자체와(ADR-0006) 동일한 거래에서 발생합니다. 엔트리는 진실이며, 잔액은 파생된 읽기 캐시로 O(1)을 유지합니다.

그 캐시가 바로 동시성이 문제를 일으키는 곳입니다.

경쟁

두 요청이 동시에 같은 계정에서 차감을 시도합니다.

R1: read balance $500 (enough)      R2: read balance $500 (enough)
R1: commit −$300 → $200             R2: commit −$400 → −$200   ← overdraft / lost update

전체 화면 모드를 입력합니다. 전체 화면 모드를 종료합니다.

두 사람 모두 $500을 읽고, 두 사람 모두 충분하다고 결정하며, 새로운 균형에 대한 자신의 생각을 쓰돌림. 하나는 조용히 다른 것을 덮어씀: 하나는 잃어버린 업데이트, 그리고 그 아래에 있는 장부 항목과 더 이상 일치하지 않는 균형.

함정은 데이터베이스가 이를 대신해서 막아준다고 가정하는 것. 그렇지 않다. PostgreSQL의 기본 격리 수준,READ COMMITTED는 당신이 비확인 데이터를 읽지 않는 것만 보장합니다 — 동시에 같은 행을 각각 읽고 쓰는 두 트랜잭션에 대해서는 아무것도 하지 않습니다. 읽기-수정-쓰기 경쟁은 그것을 완전히 무시합니다.

감지 방법: 스트레스 테스트

이것은 버그를 드러낸 테스트입니다. 하나의 계정을 조달한 다음, 발사합니다N = 50는 동시에 그것에서 전송되고 나서 책을 확인합니다:

AtomicInteger successes = new AtomicInteger();
CountDownLatch start = new CountDownLatch(1);
ExecutorService pool = Executors.newFixedThreadPool(16);
for (int i = 0; i < N; i++) {
    pool.submit(() -> {
        start.await();                       // line them all up...
        int code = post("/transfers", from, to, AMOUNT);   // ...then fire at once
        if (code == 201) successes.incrementAndGet();
    });
}
start.countDown();
// after all complete:
assertThat(balanceCache(from)).isEqualTo(ledgerBalance(from));   // cache == Σ entries
assertThat(balanceCache(from)).isGreaterThanOrEqualTo(0);        // no overdraft
assertThat(balanceCache(to)).isEqualTo((long) ok * AMOUNT);      // exact accounting

전체 화면 모드를 입력합니다 전체 화면 모드를 종료합니다

断言은 의도적으로 시간에 의존하지 않습니다 — 그들은 모든에 적용됩니다 성공과 실패의 분리는, 캐시를 일정한 예상 횟수 대신 장부의 진실과 비교하기 때문입니다. 그것이 테스트를 안정적인 회귀 방어 장치로 만드는 것이지, 불안정한 것으로 만드는 것이지요.

실험을 통해 버그를 확인했습니다: @Version 열을 제거하면, ~85%의 캐시 업데이트가 손실되었습니다와 이 주장들이 빨갛게 변해 — 캐시된 잔액이 항목들의 합에서 멀리 벗어났다. 캐시와 진실이 불일치했는데, 금전 시스템에서는 이것이 전부다.

수정, 1부: 낙관적 락

accounts에는 이미 version BIGINT 열이 있었기 때문에, 계정은 집계/락 경계이다 (ADR-0010). JPA @Version으로 매핑하면 모든 잔액 쓰기가 비교-설정으로 변환된다:

UPDATE accounts SET balance = ?, version = version + 1
 WHERE id = ? AND version = ?

전체 화면 모드로 입력하세요 전체 화면 모드 종료

두 개의 동시 작성자가 모두 버전을 로드합니다7가장 먼저 저지른 사람이 설정을 변경합니다8두 번째의UPDATE ... WHERE version = 7이제 매치됩니다영 행, 그리고 Hibernate는OptimisticLockingFailureException커밋 시간에. 손실된 업데이트는 이제 불가능합니다: 조용히 덮어쓰는 대신 패자는에게 손실이 있었다고 말했다.

주요 특징: 이것은 탐지이지 차단이 아니라.. 어떤 독자도도 잠금을 기다리지 않는다. 잔액/역사 읽기가 쓰기보다 훨씬 많은 레지스터에서는 이것이 중요하다.

수정, 부분 2: 제한된 재시도

타이밍만으로는 충분하지 않다.@Version로 설정되어 있지만 재시도 없이, 스트레스 테스트는 데이터 손상을 멈추었지만 지금은 큰 조각의 전송이 실패되며 충돌이 발생한다 — 정확하지만 나쁜 경험. 그래서 패자는 재시도해야 한다.

재시도 보조 프로그램은 외부 @Transactional에 위치해 있으며, 그 위치가 전부의 포인트이다:

public <T> T execute(Supplier<T> operation) {
    for (int attempt = 1; ; attempt++) {
        try {
            return operation.get();           // a FRESH transaction each attempt
        } catch (OptimisticLockingFailureException e) {
            if (attempt >= maxAttempts) throw new ConcurrencyConflictException();
            sleep(backoffWithFullJitter(attempt));   // 25–200 ms, capped
        }
    }
}

전체 화면 모드로 진입 전체 화면 모드로 나가기

각 시도는 새로운 거래로서 현재 버전의 행을 다시 로드합니다 — 실패한 거래 내에서 재시도하는 것은 오래된 버전에 다시 실패할 뿐입니다. 기본값: 5번 시도, 지수적 백오프와 완전한 줄기 (그래서 천둥 질풍의 무리가 다른 충돌로 다시 동기화하지 않도록), 소진 시 깨끗한 409 Conflict — 결코500.

적당한 부하 하에서 이것이 증명 가능한 종료되도록 하는 작은 이유가 있습니다: k번째 커밋터는 오직 다른 이전 커밋터에게만 지배될 수 있으므로, 그것은 최대한k 시도. 4개의 동시 작성자와 5번 시도 예산으로 모든 4개가 결정론적으로 성공하며 — 불안정한 테스트 없음. 진정한 뜨거운 계정 (409)이 나타나며, 이는 숨겨진 손상보다는 정직한 백프레셔임.

낙관적 vs 비관적: 측정된 선택

가장 очевидный 대안은 비관적 락 —SELECT ... FOR UPDATE그 행을触れる前に잠그기 위해, 그래서 작성자 #2는 단순히 기다린다. 재시도 없이, 이해하기 쉽다. 그래서 왜 낙관적일까?

감정적인 이유로 이것에 대해 논쟁하고 싶지 않았기 때문에 벤치마크를 작성했습니다 (Benchmark)TransferConcurrencyBenchmark)를 실행하는동일한 전송 논리두 전략 모두에서, 50개의 동시 작성자가 실제 PostgreSQL에 대항합니다:

시나리오 긍정적 재시도 부정적FOR UPDATE
낮은 경쟁 (50개의 분리된 계정 쌍) 34 ms · 50/50 성공 · 0 재시도 낭비 31 ms · 50/50 성공
높은 경쟁 (50번의 전송 → 1 뜨거운 행) 731 ms · 50/50 ok · 185 재시도 낭비 358 ms · 50/50 ok

숫자를 읽는 것:

  • 낮은 경쟁은 일반적인 경우이며, 비긴다 (34 대 31 ms) — 하지만 낙관적이면 영(0) 재시도를 낭비하고, 중요한 것은 항상 읽기를 블락하지 않는다. 읽기 중심의 레지스터에 대한 결정적인 요인입니다.
  • 단일 뜨거운 행에서는 비관적이면 ~2배 빠릅니다(358 대 731 ms)와 아무것도 낭비하지 않는 반면, 낙관적은 충돌과 백오프에 대해 185회의 추가 시도 (≈4.7배의 작업)를 소모합니다. 하지만 비관적은 여기서 정확히 "이깁니다"직렬화 및 블로킹 읽기— 피하고 싶은 일 — 그리고 사실은을 해결하면 이 뜨거운 계정이면 그냥 큐에 넣는다.

그래서 판결은 낙관적 + 재시도이며, 벤치마크의 값은 "낙관적이면 빠르다"가 아니라 (경쟁 중이면 그렇지 않다) — 그것은 185 낭비된 재시도가 진정으로 뜨거운 계정(생각해보세요: 모든 충전 시 공유)의 임계값을 측정한다.SYSTEM_FUNDING 행) 실제로 상승이 필요합니다: 비동기 큐잉 또는 서브 계정 셰이딩, 전체 시스템을 비관적 락으로 전환하는 것이 아닙니다.

무효화 가능성 있는 조합

그리고 재시도가 실제로 더 악화시키는 다른 방법이 있습니다. 주의하지 않으면. 서버가 커밋한 후 연결이 끊어지는 클라이언트는 전체 HTTP 요청을 재시도할 것이며 이제 전송을 두 번 게시할 위험이 있습니다. 충돌 시 재시도와 네트워크 플립 시 재시도는 다른 문제이며, 하나를 수정하는 방법이 다른 것을 깨뜨리면 안 됩니다.

따라서 두 가지 금전적 엔드포인트는 Idempotency-Key 헤더가 필요합니다 (ADR-0012, Stripe 패턴). 이것을 동시성이 안전하게 만드는 메커니즘은주장-첫 번째:

INSERT INTO idempotency_keys (key, status) VALUES (?, 'PENDING')
ON CONFLICT (key) DO NOTHING;     -- committed immediately, before business logic

전체 화면 모드를 입력하세요 전체 화면 모드를 종료하세요

그 원자 삽입이 직렬화 지점입니다. 주장에서 이긴 사람이 작업을 실행합니다; 동시 요청이 동일한 키를 가지고 있으면 커밋된 PENDING 행을 볼 수 있고 받습니다409 (비행 중) 대신 두 번째로 실행하지 않습니다. 완료된 키는 저장된 응답을 재생합니다; 다른 본문을 사용한 키는 422 (클라이언트 계약 위반, 의도적으로 409 갈등 코드와 구별됨)을 얻습니다.

이 이전의 재시도와 깔끔하게 구성되는 이유는: 낙관적 잠금 재시도가 다음에 있습니다.키는 주장되었습니다. 모든 내부 시도는 이미 주장된 idempotency 키 아래에서 일어나므로, 그들은 클라이언트에게 완전히 가려져 있고 두 번째 게시물을 결코 생성할 수 없습니다. 충돌 재시도와 요청 idempotency 스택 대신 싸우지 말고.

다음에 손을 뻗을 것은 무엇인가요

캐시는 빠르지만 그래서일 수 있습니다드리프트(버그, 일부 실패). 그래서 예약된조정 작업은 모든 잔액을 불변 항목에서 재파생하고 일치하지 않는 경우 경고를 발생시킵니다 — 자동으로 수정하지 않으며; 운영자가 수정 항목을 게시합니다. 추가만 허용되는 장부는 진실이 항상 복원될 수 있다는 의미입니다.

그리고 185번 재시도로 드러난 뜨거운 계정 한도는 다음 실질적인 확장 문제입니다: 하나의 행이 진정으로 경쟁하고 있을 때, 답은 비동기 게시 또는 해당 계정을 분산시키는 것입니다. 재시도율은 경계를 넘었을 때를 알려주는 신호입니다.


주요 논점: 돈 시스템에서 캐시가 세계장부와 동의하지 않을 때는 중요한 실패이며, 기본값 분리 데이터베이스는 이를 생성하지 않도록 막지 못합니다. @Version 비교 및 설정은 손실된 업데이트를 불가능하게 만들고, 한계된 재시도와 요동은 일반적인 부하 하에서 이를 보이지 않게 합니다. 벤치마크는 당신이 지불하는 비용과 한계가 어디 있는지 알려주며, 단일성은 모든 레이어에서 재시도가 이중 지출로 변하지 않도록 보장합니다.

코드: github.com/xidoke/ledger-service동시성 모델 문서와 ADRs 0005, 0006, 0011, 0012는 더 깊이 파고듭니다. Live demo: ledger-service-bjzr.onrender.com (무료 인스턴스 — 첫 요청 시 냉각 시작 ~50초).