모든 주제와 태그

동시성·분산

23개의 글

[쿠버네티스 아래의 리눅스 5] Service의 실체는 커널 규칙: iptables DNAT와 conntrack, 그리고 조용히 끊긴 DNS

ClusterIP로 보낸 패킷이 netfilter 훅과 kube-proxy의 KUBE-SERVICES, KUBE-SVC, KUBE-SEP 체인을 지나 파드 IP로 DNAT되는 과정을 따라갑니다. conntrack이 첫 패킷의 결정을 기억하는 방식과, 낡은 UDP conntrack 항목 때문에 DNS가 계속 타임아웃 난 ThousandEyes 장애(SREcon26 Americas 발표)를 함께 정리합니다.

[DE Ch-03] 멱등성과 재시도·백오프: 몇 번 돌려도 같은 결과와 흩어지는 재시도

오케스트레이터는 실패한 태스크를 처음부터 다시 돌립니다. 그래서 태스크는 몇 번을 돌려도 같은 결과를 내야 합니다. append와 파티션 덮어쓰기를 DuckDB로 비교하고 지운 직후 죽는 경우를 트랜잭션으로 막습니다. 지터 없는 지수 백오프가 서버가 살아나는 순간 요청을 한꺼번에 몰아오는 것과 지터가 그 대가로 늦추는 것을 시뮬레이션으로 셉니다.

[DE Ch-06] 동시성 제한, 중복 실행, 사람 승인: 오케스트레이터가 막아 주는 것과 맡겨 두는 것

Airflow는 동시 실행을 스케줄러, DAG, run, 태스크, pool 다섯 층에서 제한합니다. 그런데 max_active_runs=1은 겹침을 막을 뿐 같은 일이 두 번 도는 것은 막지 않습니다. pool로 외부 API를 보호하는 효과와 이벤트 키로 run_id를 정하는 방법을 시뮬레이션으로 확인하고 오케스트레이터의 락이 왜 최종 방어선이 될 수 없는지 분산 락 이론으로 정리합니다. 사람 승인 단계는 Airflow 3.2와 3.3, Argo suspend를 비교합니다.

복제(하): 트랜잭션, 일관성, 합의

메이퇀 기술 블로그의 "Replication(下):事务,一致性与共识"를 절 순서 그대로 따라가며 트랜잭션, 내부 일관성, 합의, Kafka의 복제를 정리했습니다. 원문이 공식 문서나 논문과 다른 곳은 그 자리에서 고쳐 적었고 etcd 세 대, PostgreSQL 두 대, Kafka 한 대로 직접 실행한 읽기 지연과 끊긴 노드의 동작, 리더 교체 중의 쓰기, 조정자가 멈춘 2PC의 잠금, 열린 트랜잭션에 붙잡히는 컨슈머를 해당 절에 넣었습니다. Google, Roblox, Amazon, PingCAP, OceanBase, 텐센트, 앤트그룹, Confluent, 메이퇀이 공개한 글과 논문에서 옮긴 사례도 내부 일관성과 합의 장부터 해당 절에 붙였습니다.

멀티스레드와 동기화를 한 줄기로: 스레드, 락, 락프리, 메모리 순서, 거짓 공유

스레드가 무엇인지부터 락, 조건 변수, 락프리, 메모리 순서, 거짓 공유까지를 한 줄기로 잇고 Java로 직접 쟀습니다. 같은 64바이트 안의 두 카운터는 떨어뜨렸을 때의 7.5배가 걸렸고 일반 필드에서는 3,600만 회 중 787만 회에서 두 스레드가 둘 다 상대의 옛 값을 읽었습니다. 깊은 내용은 시리즈의 다른 글로 이었습니다.

복제(상): 흔히 쓰는 복제 모델과 분산 시스템의 어려움

메이퇀 기술 블로그의 "Replication(上):常见复制模型&分布式系统挑战"를 절 순서대로 따라가며 주종, 다중 리더, 리더 없는 복제와 부분 실패, 네트워크, 시계의 문제를 정리했습니다. 원문이 공식 문서와 다른 곳은 그 자리에서 고쳤고 PostgreSQL로 잰 동기 수준별 커밋 지연과 쓴 직후 읽기의 실패 횟수, GitHub, Meta, Cloudflare, Aurora 같은 회사 사례를 해당 절에 붙였습니다.

재고 차감 락을 인메모리 DB로 고르면 틀린다: 실 MySQL에서 낙관적 락이 가장 느렸다

재고 20개에 스레드 30개가 동시에 차감하는 실험에서 세 락 전략 모두 초과판매는 0이었고 소요 시간만 갈렸다. H2 인메모리에서는 낙관적 락이 비관적 락보다 빨랐는데 실 MySQL 8.4에서는 조건부 UPDATE 40ms, 비관적 락 75ms, 낙관적 락 151ms로 순서가 뒤집혔다. 고경합 150스레드에서는 조건부 대 낙관적이 79ms 대 429ms였다.

정산을 떼어 낼 근거 하나를 기각했다: 그 실험이 대신 포인트 적립의 동시성 결함을 찾았다

"정산 배치가 결제를 느리게 한다"는 가설은 배치를 7번 연달아 돌려도 결제 p95가 변하지 않아 기각했다. 커넥션 풀 20개 중 최대 15개를 썼고 대기는 0이었다. 대신 그 실험의 1차 실행에서 결제 승인 70%가 500으로 무너졌고 원인은 포인트 적립의 낙관적 락 충돌이었다. 적립을 DB 원자 증가로 바꿔 같은 최악 조건의 승인 성공률을 39.6%에서 100%로 올렸다.

늦게 도착한 이벤트를 버리고 있었다: 순서를 사는 대신 병합을 순서에 무관하게 만들었다

같은 활동 로그로 만든 온라인 컨텍스트와 오프라인 재계산이 순서가 뒤집힌 조건에서 20명 전원 갈라졌다(일치율 0%). 병합 규칙이 늦게 온 이벤트를 버리고 있었다. 사용자별 순서 보장을 사는 대신 병합을 순서에 무관하게 바꿔 100%로 맞췄다. 목록 길이로 세던 창 집계는 60%만 맞았고 재는 도구도 같은 잘림을 물려받고 있었다.

분산 시스템, 먼저 제대로 배우고 Kafka를 만든다: MIT 6.5840

자작 스택(커널·DB·JVM)이 다 단일 노드라, 다음 축은 분산이다. 분산을 배우려면 그냥 분산 시스템 하나를 밑바닥부터 만들면 될 것 같지만 거기엔 치명적 구멍이 있다. 네트워크 분단·크래시 같은 실패를 스스로 테스트할 수가 없다. 그래서 MIT 6.5840로 fundamentals를 먼저 정면돌파하고, 그 토대 위에 Kafka를 짓기로 했다. 학습 플랜, Go를 고른 이유, 첫날 MapReduce 셋업까지.

왜 Kafka를 직접 만드나: 내 첫 분산 시스템

미니 RDBMS(db-hobby)를 만들며 남긴 WAL을 보다가 생각이 하나로 모였다. 이 로그를 주인공으로 승격시켜 여러 노드에 복제하면 그게 Kafka다. Java로 바닥부터 만드는 토이 Kafka 연재의 1편. 코드를 쓰기 전에 끝낸 설계 결정들(분산 로그가 뭔지, 왜 Java인지, 단일 브로커 먼저 왜 그런지)과, 이번엔 단위 테스트 대신 "상황 시뮬레이션"으로 검증하기로 한 이유를 정리한다.

DB 내부 ⑩: Raft, primary가 죽으면 누가 결정하는가, 합의에서 HA DB까지

복제의 최대 약점은 'primary가 죽으면?'이다. replica 승격을 사람이 하면 밤에 전화가 오고, 자동으로 하면 split-brain이 온다. 이건 결국 합의(consensus) 문제고, 답이 Raft다. 리더 선출(임기·과반·무작위 타임아웃), 로그 복제(prevLogIndex 정합 검사), 그리고 '과반에 있어도 현재 임기여야만 커밋'이라는 5.4.2절의 미묘함까지, 다섯 안전성 성질을 결정적 시뮬레이션 네트워크(분단·크래시·재정렬을 재현 가능하게 주입) 위에서 확인한다. 이어서 프로덕션의 조각들: 재시작 후 이중 투표를 막는 지속성(currentTerm/votedFor fsync, 5.1절), 무한히 크는 로그를 자르는 스냅샷(7절, 그리고 오프셋 0이 no-op이라 기존 테스트가 버그를 못 잡는 함정), 돌아가는 중에 노드를 넣고 빼는 멤버십 변경(6절, 겹치는 과반), 상태기계 복제(SMR)로 진짜 SQL 엔진을 복제해 리더가 죽어도 살아남는 HA DB, 마지막으로 파티션된 옛 리더가 낡은 값을 주지 않게 하는 선형화 읽기(ReadIndex, 8절)까지. 적대적 리뷰가 잡아낸 실제 버그들(스냅샷 미설치 발산, apply 에러 무시, read barrier의 epoch 부재)이 각 절의 정직한 경계다.

DB 내부 ⑨: 복제, 복구의 redo를 스트림으로, base backup + WAL 스트리밍까지

복제의 첫 통찰은 '복제가 거의 공짜'라는 것이다. no-force WAL이 이미 커밋의 순차 스트림이라, replica가 할 일은 크래시 복구의 redo를 '파괴적 일회성'이 아니라 '증분·연속'으로 돌리는 것뿐이다. PostgreSQL에서 walreceiver가 WAL을 받아 쓰고 startup process가 replay하는 그 구조. 이를 소켓에 올리면 walsender/walreceiver가 되는데, 스트림엔 경계가 없어 길이 프레이밍이 필요하고, 수신자는 읽기 전용 fd에 쓸 수 없어 실제로 교착을 밟았다. 마지막은 캡스톤, 곧 진짜 엔진의 커밋이 복제본에서 SELECT되기까지다. 코드보다 어려웠던 건 조용히 깨지는 landmine 셋: 재오픈이 WAL을 truncate하고 LSN을 리셋해 replica가 신규 커밋을 조용히 스킵하는 것, base 스냅샷에 steal된 미커밋 페이지가 딸려오는 것, 카탈로그의 낡은 next_txn이 복제된 행을 미커밋으로 보이게 하는 것. 셋을 정리하면 필연적으로 실제 시스템과 같은 모델, 곧 pg_basebackup + streaming replication에 도달한다. 구조가 그렇게 강제한다.

DB 내부 ⑧: 병렬 실행: latch를 계층별로 걷어내고, 자로 재고, 병목을 고치기

커넥션마다 스레드를 줘도 실행이 굵은 latch 하나로 직렬화돼 있으면 멀티코어는 한 코어처럼 돈다. 그 latch를 계층별로 걷어내는 여정: B+Tree latch crabbing(노드별 rwlock, 자식이 안전하면 조상 해제), 병렬 풀 스캔(워커가 disjoint 페이지 범위를 스레드 안전 버퍼 풀 위에서 동시에, 락 없는 워커별 지역 결과), 실제 SELECT 배선(워커는 가시성+WHERE 판정, 출력은 leader, 직렬과 바이트 동일), 병렬 집계(하다가 조용히 틀리던 materialize cap 절단 버그를 발견해 고침), 그리고 진짜 부분 집계(행을 안 모으고 누적만, PostgreSQL의 Partial→Finalize Aggregate, 메모리 O(1)). 그다음 자로 쟀다. 워밍 최고 2.16배(4워커, 이상 4배 대비), 2~4워커 정점 후 하락. 콜드의 천장은 CPU가 아니라 공유 latch였다: 콜드 캐시에서 풀 latch가 디스크 I/O를 직렬화해 8워커가 0.62배(직렬보다 느림). read-in-progress로 I/O를 latch 밖으로 빼자 같은 조건이 2.39배로 올랐다. 측정→수정→재측정의 A/B로 인과를 확증했다. 모든 단계는 ThreadSanitizer로 data race 0을 계측하며.

Redis 샤딩: Consistent Hashing으로 워크로드 격리

단일 Redis 인스턴스에서 KEYS 블로킹(34.6ms), 배치↔실시간 워크로드 간섭(GET 최악 15.5ms), volatile-lru 보안 위험을 실측하고, KEYS→SCAN 전환 + 3노드 Consistent Hashing + 블랙리스트 전용 인스턴스 격리로 해결한다. 가상 노드 150개 ConcurrentSkipListMap 라우팅, 노드 장애 시 Lucene fallback, 100 VU 부하 테스트로 검증한 과정을 정리한다.