Workspace IndexAlgorithms › Day 69

MVCC Internals and Snapshot Isolation's Anomaly: Write Skew TODO

Algorithms · Day 69 / 100 · E. Data & Storage Engines (Day 69-81)

Concept

MVCC never overwrites an existing row on update — it creates a new version, and each transaction sees only the versions visible under its own snapshot. That lets reads never block writes and writes never block reads; which version is visible is decided by transaction ID and visibility rules. Snapshot isolation, built on top of this, prevents dirty reads, non-repeatable reads, and lost updates, but it does not guarantee serializability. The classic anomaly is write skew: two transactions read the same set of rows and each updates a different, non-overlapping row, so each transaction individually respects the constraint, but the two together violate an invariant. The fixes are to use a serializable isolation level such as SSI, to explicitly lock the rows a decision was based on, or to physicalize the invariant as a single row or a unique constraint.

Trusting an isolation level's name and leaving application invariants to the database creates bugs that surface quietly, only once load rises enough for concurrent transactions to actually overlap.

Code & Formula

# MVCC 내부와 스냅샷 격리의 이상현상(write skew) — 두 트랜잭션이 같은 스냅샷을 읽고 서로 다른 행을 갱신해 불변식이 깨진다.
# "온콜 최소 1명" 불변식을 스냅샷 격리에서 재현한다: 각자 상대가 아직 온콜이라 보고 자신을 뺐지만 합치면 0명이 된다.

class VersionedTable:
    def __init__(self, initial):
        self.versions = {k: [(0, v)] for k, v in initial.items()}  # key -> [(txn_id, value), ...]

    def snapshot_read(self, key, as_of_txn):
        # as_of_txn 이전에 커밋된 가장 최신 버전만 보인다 (스냅샷 격리)
        visible = [v for (tid, v) in self.versions[key] if tid <= as_of_txn]
        return visible[-1] if visible else None

    def write(self, key, txn_id, value):
        self.versions[key].append((txn_id, value))

table = VersionedTable({"alice_on_call": True, "bob_on_call": True})

SNAPSHOT_TXN = 0  # 두 트랜잭션 모두 같은 시점의 스냅샷에서 시작
a_sees_bob = table.snapshot_read("bob_on_call", SNAPSHOT_TXN)     # A: bob이 아직 온콜이니 alice는 빠져도 된다
b_sees_alice = table.snapshot_read("alice_on_call", SNAPSHOT_TXN)  # B: alice가 아직 온콜이니 bob도 빠져도 된다

print("A가 본 bob 상태:", a_sees_bob, "→ alice를 오프콜로 전환")
print("B가 본 alice 상태:", b_sees_alice, "→ bob을 오프콜로 전환")

if a_sees_bob:
    table.write("alice_on_call", txn_id=1, value=False)
if b_sees_alice:
    table.write("bob_on_call", txn_id=2, value=False)

final_alice = table.snapshot_read("alice_on_call", as_of_txn=99)
final_bob = table.snapshot_read("bob_on_call", as_of_txn=99)
print(f"\n최종 상태: alice={final_alice}, bob={final_bob}")

invariant_ok = final_alice or final_bob
note = "" if invariant_ok else " ← write skew로 위반됨"
print("불변식(최소 1명 온콜) 유지 여부:", invariant_ok, note)

Exercise

In PostgreSQL, reproduce a write skew that violates a balance-sum constraint using two sessions under REPEATABLE READ, then switch to SERIALIZABLE and check whether a serialization failure now occurs.

Practical Connection

Any path that 'writes based on a value it just read' — deducting remaining order quantity, checking a collateral limit — is a textbook case of write skew in an off-chain database; on-chain, the same problem is solved by atomizing it into a single state update.

If you study this on a given day, add a note link and a ✅ to this line in the source curriculum (docs/knowledge/dev-100-curriculum.md) and this spot will lead straight to the note body. You can also write directly on this page — but regenerating overwrites it, so it's safer to keep anything you want to save as markdown under docs/algorithms/.


한국어

MVCC 내부와 스냅샷 격리의 이상현상(write skew) TODO

Algorithms · Day 69 / 100 · E. 데이터·스토리지 엔진 (Day 69–81)

개념

MVCC는 갱신 시 기존 행을 덮어쓰지 않고 새 버전을 만들어, 각 트랜잭션이 자신의 스냅샷에 보이는 버전만 읽게 하는 기법이다. 덕분에 읽기가 쓰기를 막지 않고 쓰기도 읽기를 막지 않으며, 어떤 버전이 보이는지는 트랜잭션 ID와 가시성 규칙으로 판정한다. 스냅샷 격리는 이 위에서 더티 리드·논리피터블 리드·로스트 업데이트를 막지만 직렬화 가능성을 보장하지는 않는다. 대표적 이상현상이 write skew로, 두 트랜잭션이 같은 집합을 읽고 각자 겹치지 않는 서로 다른 행을 갱신해, 개별적으로는 제약을 지키지만 합쳐 놓으면 불변식이 깨지는 경우이다. 해결은 SSI 같은 직렬화 가능 격리를 쓰거나, 읽은 근거를 명시적으로 잠그거나, 불변식을 단일 행·유니크 제약으로 물리화하는 것이다.

격리 수준 이름만 믿고 애플리케이션 불변식을 DB에 맡기면, 부하가 올라가 동시 실행이 겹치는 순간에만 조용히 깨지는 버그가 생긴다.

코드 · 수식

# MVCC 내부와 스냅샷 격리의 이상현상(write skew) — 두 트랜잭션이 같은 스냅샷을 읽고 서로 다른 행을 갱신해 불변식이 깨진다.
# "온콜 최소 1명" 불변식을 스냅샷 격리에서 재현한다: 각자 상대가 아직 온콜이라 보고 자신을 뺐지만 합치면 0명이 된다.

class VersionedTable:
    def __init__(self, initial):
        self.versions = {k: [(0, v)] for k, v in initial.items()}  # key -> [(txn_id, value), ...]

    def snapshot_read(self, key, as_of_txn):
        # as_of_txn 이전에 커밋된 가장 최신 버전만 보인다 (스냅샷 격리)
        visible = [v for (tid, v) in self.versions[key] if tid <= as_of_txn]
        return visible[-1] if visible else None

    def write(self, key, txn_id, value):
        self.versions[key].append((txn_id, value))

table = VersionedTable({"alice_on_call": True, "bob_on_call": True})

SNAPSHOT_TXN = 0  # 두 트랜잭션 모두 같은 시점의 스냅샷에서 시작
a_sees_bob = table.snapshot_read("bob_on_call", SNAPSHOT_TXN)     # A: bob이 아직 온콜이니 alice는 빠져도 된다
b_sees_alice = table.snapshot_read("alice_on_call", SNAPSHOT_TXN)  # B: alice가 아직 온콜이니 bob도 빠져도 된다

print("A가 본 bob 상태:", a_sees_bob, "→ alice를 오프콜로 전환")
print("B가 본 alice 상태:", b_sees_alice, "→ bob을 오프콜로 전환")

if a_sees_bob:
    table.write("alice_on_call", txn_id=1, value=False)
if b_sees_alice:
    table.write("bob_on_call", txn_id=2, value=False)

final_alice = table.snapshot_read("alice_on_call", as_of_txn=99)
final_bob = table.snapshot_read("bob_on_call", as_of_txn=99)
print(f"\n최종 상태: alice={final_alice}, bob={final_bob}")

invariant_ok = final_alice or final_bob
note = "" if invariant_ok else " ← write skew로 위반됨"
print("불변식(최소 1명 온콜) 유지 여부:", invariant_ok, note)

연습

PostgreSQL에서 두 세션으로 잔고 합계 제약을 어기는 write skew를 REPEATABLE READ에서 재현하고, SERIALIZABLE로 바꿔 직렬화 실패가 나는지 확인해 보기.

실무 · Verex 연결

주문 잔량 차감이나 담보 한도 검사처럼 "읽은 값에 근거해 쓰는" 경로는 오프체인 DB에서 write skew의 정확한 표본이고, 온체인이면 같은 문제를 단일 상태 갱신으로 원자화해 푼다.

공부한 날 원본 커리큘럼(docs/knowledge/dev-100-curriculum.md)의 이 줄에 노트 링크와 ✅ 를 붙이면, 이 자리는 노트 본문으로 바로 이어집니다. 노트 없이 이 페이지에 바로 적어도 됩니다 — 다만 다시 생성하면 덮어쓰이므로, 남길 글은 docs/algorithms/ 의 마크다운으로 쓰는 편이 안전합니다.

← 68. [복습] 장애 모델과 신뢰 가정을 먼저 쓰는 습관70. WAL·그룹 커밋·fsync 비용 →