diff --git a/docs/decisions/replication-lag-and-semisync.md b/docs/decisions/replication-lag-and-semisync.md index dcf517c9..fe3f501a 100644 --- a/docs/decisions/replication-lag-and-semisync.md +++ b/docs/decisions/replication-lag-and-semisync.md @@ -1,6 +1,6 @@ # 복제 — 「한 대가 죽어도 된다」를 지연과 대가로 바꾼다 -작성일: 2026-08-13 · 갱신: 2026-11-07 (Q4 미복귀 원인 분리 — §0-2) +작성일: 2026-08-13 · 갱신: 2026-11-07 (Q4 미복귀 원인 분리 · 리플리카 2대 — §0-2) 상태: ✅ **같은 AZ·다른 AZ 두 라운드 모두 완료.** Q1·Q2 에 값이 붙었다 — **같은 AZ(08-22)** 는 반동기의 대가 처리량 −5.9% · 커밋 p95 +13.0%, 단일 핫세션이면 −10.3% 로 거의 두 배(H3). **다른 AZ(09-02, §0-5)** 는 같은 대가가 **처리량 −17.2% · @@ -150,6 +150,11 @@ AZ 무관). Q4·Q5 는 로컬 라운드(2026-08-17)가 닫았고, **Q3 은 「 > (`plugin-load-add` + `loose-...enabled=ON`) **3/3 복귀**(버림 포함 4/4), 추가 조작 없음. > ⚠️ `SET PERSIST` 가 왜 안 되는지(수신 스레드가 붙는 시점과 설정 반영 순서)는 로그에서 읽은 > **추론**이고 계측하지 않았다. 리플리카 1대 · `docker stop/start` · 포지션 기반 조건이다. +> +> ✅ **2026-11-07 — 리플리카 2대면 «1대 죽음 = 곧바로 강등» 을 피한다**([결과](../../loadtest/results/replication-semisync-two-replicas-2026-11-07/README.md), +> 로컬 · 본판 9 + 버림 1). `wait_for_replica_count=1` 에서 1대를 죽이면 **4/4 버팀**(첫 쓰기가 +> timeout 을 안 물고 3건 모두 반동기로 셈). 같은 설정에서 2대 다 죽이면 3/3 강등(양성 대조군), `count=2` 면 1대만 +> 죽어도 3/3 강등. 🔴 부산물: 강등된 뒤에도 소스 `clients` 가 수 초간 줄지 않았다 — 알람은 `status`·`no_tx` 에 건다. ### Q5 ✅ — SBS 는 거짓말이 아니라 **침묵**한다 @@ -908,3 +913,8 @@ Q1 은 통째로 인위 지연이다. 반동기 없이 붙었고, IO 재시작만으로 돌아왔다. 그 순서 문제는 **추론**이다(계측 안 함). D 1회차는 `loose-` 없이 줘서 이미지 첫 기동(`--initialize`)이 `unknown variable` 로 거부했다 — 부산물로 남긴다. A·B·C 는 라틴 방격 · 버림 1, D 는 시작 옵션이 달라 따로 돌아 판 순서 균형이 없다. +- 2026-11-07: ✅ **리플리카 2대 실험** (로컬 · 8.0.46 · [결과](../../loadtest/results/replication-semisync-two-replicas-2026-11-07/README.md)). + Q4 에서 본 «리플리카 1대면 곧바로 조용히 강등» 을 실무식 구성(2대 + `wait_for_replica_count=1`)이 피하는지 확인. + 본판 기준 P(count=1·1대 죽임) **4/4 버팀** · Q(count=1·2대 죽임) 3/3 강등(양성 대조군) · R(count=2·1대 죽임) 3/3 강등. + 리플리카 반동기는 시작 옵션이라 10/10 자동 복귀. 🔴 강등 뒤에도 `clients` 가 바로 줄지 않아 알람 지표로 부적합(추론, + 언제 줄어드는지는 안 잼). 같은 호스트라 «AZ 별 1대씩» 의 비용은 말하지 못한다. diff --git a/docs/decisions/rpo-rto-targets.md b/docs/decisions/rpo-rto-targets.md index d71ef938..d6ac5dcb 100644 --- a/docs/decisions/rpo-rto-targets.md +++ b/docs/decisions/rpo-rto-targets.md @@ -192,7 +192,7 @@ DAU 와 비교해 판단을 닫지 않는다는 원칙과도 어긋난다([[feed |---|---|---|---| | R1 | **구성 2 의 실제 복구 시간**(새 서버 + 백업 붓기 + binlog 재생)이 Q5 에서 정할 RTO 를 넘는다 | §6-2 의 binlog 재생 시간 측정 · 정기 복구 훈련 | 비동기 리플리카(구성 3) | | R2 | **데이터가 커져** 백업 붓기 시간이 늘어난다 — 174초는 1억 행(합성) 값이고 백업 문서 H5 가 «크기에 선형인가» 를 물었다 | 복구 훈련 때 붓기 시간 추세 | 먼저 물리 백업 유지 확인(논리는 약 7배 느림), 그다음 리플리카(구성 3) | -| R3 | **몇 분치도 못 잃는 데이터가 생긴다** — 예: 결제·포인트처럼 유실이 돈이 되는 데이터 | §3 데이터 분류에 새 A 등급 이상이 들어올 때 | 반동기(구성 4) — 단 리플리카 2대 이상으로 강등을 피하는 구성을 같이 본다. 또는 그 데이터만 저장소 분리(§7 을 다시 연다) | +| R3 | **몇 분치도 못 잃는 데이터가 생긴다** — 예: 결제·포인트처럼 유실이 돈이 되는 데이터 | §3 데이터 분류에 새 A 등급 이상이 들어올 때 | 반동기(구성 4) — 단 리플리카 2대 이상 + `wait_for_replica_count=1` 로 강등을 피한다(로컬 실측: 1대가 죽어도 4/4 버팀, [결과](../../loadtest/results/replication-semisync-two-replicas-2026-11-07/README.md)). 또는 그 데이터만 저장소 분리(§7 을 다시 연다) | | R4 | **읽기 부하가 소스를 압박한다** | 소스 CPU·커넥션 풀 포화 지표 | 읽기 리플리카. 🔴 단 «운동 직후 리포트» 처럼 방금 쓴 것을 읽는 경로는 소스에서 읽는다 — 리플리카는 부하 아래 약 60초 밀렸다([복제 §0-3](./replication-lag-and-semisync.md)) | | R5 | **binlog 반출 주기를 줄여도** Q4 의 RPO 를 못 맞춘다 | 반출 주기 실측 · 업로드 지연 | 실시간 스트리밍(구성 2') | | R6 | **운영 호스트가 정해진다** | — | Q6(백업 주기·보존 기간)과 §4 의 미측정 칸을 그 호스트에서 다시 잰다 | diff --git a/loadtest/results/replication-semisync-two-replicas-2026-11-07/README.md b/loadtest/results/replication-semisync-two-replicas-2026-11-07/README.md new file mode 100644 index 00000000..6d969781 --- /dev/null +++ b/loadtest/results/replication-semisync-two-replicas-2026-11-07/README.md @@ -0,0 +1,80 @@ +# 반동기 리플리카 2대 — 한 대가 죽으면 강등되나 (2026-11-07, 로컬) + +설계 문서: [`../../../docs/decisions/replication-lag-and-semisync.md`](../../../docs/decisions/replication-lag-and-semisync.md) §0-2 Q4 · +[`../../../docs/decisions/rpo-rto-targets.md`](../../../docs/decisions/rpo-rto-targets.md) §8 R3 +앞 실험: [`../replication-semisync-recovery-2026-11-07/`](../replication-semisync-recovery-2026-11-07/README.md) (리플리카 1대 · 미복귀 원인) +무대: 로컬 Docker · `mysql:8.0` = **8.0.46** · 소스 1 + 리플리카 2 · 포지션 기반 · `wait_point=AFTER_SYNC` · +`timeout=10000ms` · 리플리카 반동기는 **시작 옵션**(앞 실험에서 재기동 뒤 자동 복귀가 확인된 방식) + +## 0. 한 줄 + +**리플리카를 2대 두고 «1대만 받으면 커밋»(`wait_for_replica_count=1`)이면, 1대가 죽어도 강등되지 않았다** +— 첫 쓰기가 timeout 을 물지 않았고 쓰기 3건이 전부 반동기로 셌다(본판 4/4). 같은 설정에서 2대가 다 죽으면 +강등됐고(3/3), «2대 다 받아야 커밋»(`count=2`)이면 1대만 죽어도 강등됐다(3/3). 08-17·11-07 의 «리플리카 1대면 +곧바로 조용히 강등» 은 리플리카 수와 `count` 의 조합으로 피할 수 있다. + +## 1. 질문 + +앞 실험들은 리플리카가 1대라 그 한 대가 죽으면 첫 쓰기가 timeout(10초)을 물고 비동기로 내려갔다. +실무는 리플리카를 여럿 두고 «그중 N대만 받으면 커밋» 으로 이걸 피한다고 알려져 있다 — 이 무대에서 그게 +실제로 그렇게 동작하는지 본다. 이진 사실이라 로컬로 충분하다. + +## 2. 팔과 판 + +| 팔 | 소스 `wait_for_replica_count` | 죽이는 것 | 기대 | +|---|---|---|---| +| **P** | 1 | 리플리카 2 | 버팀 (관심 팔) | +| **Q** | 1 | 리플리카 1·2 둘 다 | 강등 (**양성 대조군** — 계측이 강등을 잡는지) | +| **R** | 2 | 리플리카 2 | 강등 (둘 다 받아야 하니) | + +- 버림 1(P) + 본판 9, 라틴 방격(PQR·QRP·RPQ). 판마다 `count` 만 바꾸고 소스는 재기동하지 않는다 +- 판마다: 두 리플리카가 반동기로 붙은 상태(`ON`/2) 확인 → `docker stop` → 소스에 쓰기 3건(각각 지연 측정) → + 소스 `status`·`yes_tx`/`no_tx` 증가분 → `docker start` → `ON`/2 로 돌아오는지 +- 판정 «버팀» = 쓰기 뒤 `status=ON` · `yes_tx` +3 · `no_tx` +0. «강등» = `status=OFF` 또는 `no_tx` 증가 + +## 3. 결과 + +| 팔 | 본판 | 첫 쓰기 (ms) | 쓰기 뒤 status | yes_tx / no_tx 증가 | 판정 | +|---|---:|---|---|---|---| +| **P** count=1 · 1대 죽임 | 4 (+버림 1) | **941 ~ 1,482** (버림 1,956) | **ON** | **+3 / +0** | **4/4 버팀** (버림 포함 5/5) | +| Q count=1 · 2대 죽임 | 3 | **10,878 ~ 11,708** | OFF | +0 / +3 | 3/3 강등 | +| R count=2 · 1대 죽임 | 3 | **11,058 ~ 13,437** | OFF | +0 / +3 | 3/3 강등 | + +원본: `_out/trials.tsv` · `_out/D0_stage.txt` · `run.log`. 스모크(팔당 1판, 같은 패턴): `_smoke/`. + +- **Q 가 강등을 잡았다** — 같은 계측이 강등을 볼 수 있다는 뜻이라, P 의 «버팀» 은 «못 잡음» 이 아니다 +- **R 은 Q 와 같은 얼굴**이다 — `count=2` 는 리플리카 2대여도 사실상 «한 대만 죽어도 강등» 이라, 1대 구성의 위험을 + 그대로 가진다 +- 되살린 뒤 **10/10 전부 1초 안에 `ON`/2 로 복귀** — 시작 옵션 방식의 자동 복귀가 리플리카 2대에서도 재현됐다 + +### 3-1. 🔴 부산물 — `clients` 는 리플리카가 죽은 직후 바로 줄지 않았다 + +10판 모두 리플리카를 `docker stop` 한 뒤 쓰기 3건을 마친 시점(수 초 뒤)에도 소스 `Rpl_semi_sync_source_clients` +가 **2 그대로**였다 — Q·R 에서 이미 `status=OFF` 로 강등된 뒤에도. 소스가 끊긴 연결을 아직 정리하지 않은 것으로 +읽는다(⚠️ 추론, 언제 줄어드는지는 안 쟀다). + +→ 알람을 `clients` 에 걸면 **강등 직후를 놓친다.** 강등을 보려면 `status`(=OFF)와 `no_tx` 증가를 봐야 한다 — +11-07 미복귀 실험 §5 의 «알람은 `status=OFF` 와 `no_tx` 증가에» 를 뒷받침한다. + +## 4. 조건 — 인용할 때 떼면 안 되는 것 + +1. **로컬 · 같은 호스트 · 8.0.46** 의 이진 사실이다. 쓰기 지연 절대값은 `docker exec` 왕복(약 1초)이 섞인 값이라 + **10초 timeout 을 물었나 안 물었나를 가르는 데만** 쓴다. P 의 쓰기 3건 최대 2,713ms(판 2)는 평소 왕복보다 + 크지만 timeout 과는 자릿수가 다르다 — 리플리카를 죽이지 않은 기준판은 따로 안 쟀다 +2. P 에서 죽인 것은 **리플리카 2 만**이다. 리플리카 1 만 죽이는 쪽은 안 돌렸다(대칭이라 같을 것으로 보지만 미확인) +3. 죽음은 `docker stop` 이다 — 네트워크 단절·느린 리플리카(살아 있지만 응답이 늦음)는 안 봤다. 특히 + «느린 리플리카» 는 `count=1` 에서도 다른 한 대가 대신 받으니 영향이 작을 것으로 추정만 된다 +4. 리플리카 2대 모두 같은 호스트라 «다른 AZ 에 1대씩» 의 비용은 이 무대로 말할 수 없다 — 그건 EC2 일이다 + +## 5. 운영으로 옮기면 (제안 — 이 프로젝트 운영엔 리플리카가 없다) + +- 반동기를 쓴다면 **리플리카 2대 이상 + `wait_for_replica_count=1`** 이 «한 대가 죽으면 곧바로 조용히 강등» 을 피한다 +- `count` 를 리플리카 수와 같게 두면(`count=2` · 2대) 1대 구성과 같은 위험으로 돌아간다 +- 강등 알람은 `status=OFF`·`no_tx` 증가에 건다. `clients` 는 죽은 직후 바로 줄지 않았다(§3-1) + +## 6. 재현 + +```bash +bash semisync_two_replicas.sh # 10판 (버림 1 + 라틴 방격 9) +OUT="$PWD/_smoke" ORDER="P Q R" bash semisync_two_replicas.sh # 스모크 +``` diff --git a/loadtest/results/replication-semisync-two-replicas-2026-11-07/_out/D0_stage.txt b/loadtest/results/replication-semisync-two-replicas-2026-11-07/_out/D0_stage.txt new file mode 100644 index 00000000..fa8b848e --- /dev/null +++ b/loadtest/results/replication-semisync-two-replicas-2026-11-07/_out/D0_stage.txt @@ -0,0 +1,21 @@ +# D0 — 무대 +# 생성: 2026-11-07T13:02:51Z UTC +source version 8.0.46 +source server_id 1 +source gtid_mode OFF +source binlog_format ROW +source sync_binlog 1 +source innodb_flush_log_at_trx_commit 1 +source rpl_semi_sync_source_enabled 1 +source rpl_semi_sync_source_timeout 10000 +source rpl_semi_sync_source_wait_point AFTER_SYNC +source rpl_semi_sync_source_wait_for_replica_count 1 +replica1 version 8.0.46 +replica1 server_id 2 +replica1 read_only 1 +replica1 rpl_semi_sync_replica_enabled 1 +replica2 version 8.0.46 +replica2 server_id 3 +replica2 read_only 1 +replica2 rpl_semi_sync_replica_enabled 1 +source status/clients ON/2 diff --git a/loadtest/results/replication-semisync-two-replicas-2026-11-07/_out/trials.tsv b/loadtest/results/replication-semisync-two-replicas-2026-11-07/_out/trials.tsv new file mode 100644 index 00000000..16b7bd55 --- /dev/null +++ b/loadtest/results/replication-semisync-two-replicas-2026-11-07/_out/trials.tsv @@ -0,0 +1,11 @@ +trial kind arm count killed pre_status/clients w1_ms w2_ms w3_ms down_status/clients yes_tx_delta no_tx_delta verdict back_s post_status/clients +1 버림 P 1 replica2 ON/2 1956 1223 1033 ON/2 3 0 버팀 1 ON/2 +2 본판 P 1 replica2 ON/2 1482 2626 2713 ON/2 3 0 버팀 1 ON/2 +3 본판 Q 1 replica1+replica2 ON/2 11557 1365 1123 OFF/2 0 3 강등 1 ON/2 +4 본판 R 2 replica2 ON/2 13437 1121 1665 OFF/2 0 3 강등 1 ON/2 +5 본판 Q 1 replica1+replica2 ON/2 10878 1395 1138 OFF/2 0 3 강등 1 ON/2 +6 본판 R 2 replica2 ON/2 11058 916 1160 OFF/2 0 3 강등 1 ON/2 +7 본판 P 1 replica2 ON/2 941 1470 764 ON/2 3 0 버팀 1 ON/2 +8 본판 R 2 replica2 ON/2 11951 972 997 OFF/2 0 3 강등 1 ON/2 +9 본판 P 1 replica2 ON/2 1122 1661 1142 ON/2 3 0 버팀 1 ON/2 +10 본판 Q 1 replica1+replica2 ON/2 11708 1783 921 OFF/2 0 3 강등 1 ON/2 diff --git a/loadtest/results/replication-semisync-two-replicas-2026-11-07/_smoke/D0_stage.txt b/loadtest/results/replication-semisync-two-replicas-2026-11-07/_smoke/D0_stage.txt new file mode 100644 index 00000000..823d2258 --- /dev/null +++ b/loadtest/results/replication-semisync-two-replicas-2026-11-07/_smoke/D0_stage.txt @@ -0,0 +1,21 @@ +# D0 — 무대 +# 생성: 2026-11-07T12:54:22Z UTC +source version 8.0.46 +source server_id 1 +source gtid_mode OFF +source binlog_format ROW +source sync_binlog 1 +source innodb_flush_log_at_trx_commit 1 +source rpl_semi_sync_source_enabled 1 +source rpl_semi_sync_source_timeout 10000 +source rpl_semi_sync_source_wait_point AFTER_SYNC +source rpl_semi_sync_source_wait_for_replica_count 1 +replica1 version 8.0.46 +replica1 server_id 2 +replica1 read_only 1 +replica1 rpl_semi_sync_replica_enabled 1 +replica2 version 8.0.46 +replica2 server_id 3 +replica2 read_only 1 +replica2 rpl_semi_sync_replica_enabled 1 +source status/clients ON/2 diff --git a/loadtest/results/replication-semisync-two-replicas-2026-11-07/_smoke/trials.tsv b/loadtest/results/replication-semisync-two-replicas-2026-11-07/_smoke/trials.tsv new file mode 100644 index 00000000..87322960 --- /dev/null +++ b/loadtest/results/replication-semisync-two-replicas-2026-11-07/_smoke/trials.tsv @@ -0,0 +1,4 @@ +trial kind arm count killed pre_status/clients w1_ms w2_ms w3_ms down_status/clients yes_tx_delta no_tx_delta verdict back_s post_status/clients +1 버림 P 1 replica2 ON/2 1198 1301 1391 ON/2 3 0 버팀 1 ON/2 +2 본판 Q 1 replica1+replica2 ON/2 11738 1607 1397 OFF/2 0 3 강등 1 ON/2 +3 본판 R 2 replica2 ON/2 11278 1327 787 OFF/2 0 3 강등 1 ON/2 diff --git a/loadtest/results/replication-semisync-two-replicas-2026-11-07/docker-compose.yml b/loadtest/results/replication-semisync-two-replicas-2026-11-07/docker-compose.yml new file mode 100644 index 00000000..6b6f9aba --- /dev/null +++ b/loadtest/results/replication-semisync-two-replicas-2026-11-07/docker-compose.yml @@ -0,0 +1,69 @@ +# 반동기 리플리카 2대 rig — 소스 1 + 리플리카 2 (로컬 전용) +# +# 11-07 미복귀 실험(../replication-semisync-recovery-2026-11-07/)과 같은 서버 설정이고, +# 리플리카 반동기는 그 실험에서 재기동 뒤 자동 복귀가 확인된 «시작 옵션» 방식으로 켠다(D 팔). +# 그래야 이번 실험의 판정(강등되나)이 «재기동 뒤 반동기가 안 붙는» 복구 절차 문제와 섞이지 않는다. +# +# 이 무대가 답하는 것은 «리플리카 한 대가 죽으면 소스가 강등되나» 라는 이진 사실과 +# 첫 쓰기가 timeout 을 무는지다. 처리량·지연 크기는 재지 않는다(같은 호스트·RTT ≈ 0). + +services: + source: + image: mysql:8.0 + container_name: s2r_source + command: + - --server-id=1 + - --log-bin=binlog + - --binlog-format=ROW + - --gtid-mode=OFF + - --log-replica-updates=ON + - --enforce-gtid-consistency=OFF + - --innodb-flush-log-at-trx-commit=1 + - --sync-binlog=1 + environment: + MYSQL_ROOT_PASSWORD: repl + networks: [s2r] + + replica1: + image: mysql:8.0 + container_name: s2r_replica1 + command: + - --server-id=2 + - --log-bin=binlog + - --binlog-format=ROW + - --gtid-mode=OFF + - --log-replica-updates=ON + - --enforce-gtid-consistency=OFF + - --innodb-flush-log-at-trx-commit=1 + - --sync-binlog=1 + - --read-only=ON + - --plugin-load-add=semisync_replica.so + # loose- 가 없으면 이미지 첫 기동(--initialize)이 unknown variable 로 거부한다(11-07 실험 §3-2) + - --loose-rpl-semi-sync-replica-enabled=ON + environment: + MYSQL_ROOT_PASSWORD: repl + networks: [s2r] + + replica2: + image: mysql:8.0 + container_name: s2r_replica2 + command: + - --server-id=3 + - --log-bin=binlog + - --binlog-format=ROW + - --gtid-mode=OFF + - --log-replica-updates=ON + - --enforce-gtid-consistency=OFF + - --innodb-flush-log-at-trx-commit=1 + - --sync-binlog=1 + - --read-only=ON + - --plugin-load-add=semisync_replica.so + # loose- 가 없으면 이미지 첫 기동(--initialize)이 unknown variable 로 거부한다(11-07 실험 §3-2) + - --loose-rpl-semi-sync-replica-enabled=ON + environment: + MYSQL_ROOT_PASSWORD: repl + networks: [s2r] + +networks: + s2r: + name: s2r_net diff --git a/loadtest/results/replication-semisync-two-replicas-2026-11-07/run.log b/loadtest/results/replication-semisync-two-replicas-2026-11-07/run.log new file mode 100644 index 00000000..162df0eb --- /dev/null +++ b/loadtest/results/replication-semisync-two-replicas-2026-11-07/run.log @@ -0,0 +1,71 @@ +[s2r] compose up + Network s2r_net Creating + Network s2r_net Created + Container s2r_source Creating + Container s2r_replica1 Creating + Container s2r_replica2 Creating + Container s2r_replica1 Created + Container s2r_source Created + Container s2r_replica2 Created + Container s2r_replica1 Starting + Container s2r_replica2 Starting + Container s2r_source Starting + Container s2r_replica2 Started + Container s2r_replica1 Started + Container s2r_source Started +# D0 — 무대 +# 생성: 2026-11-07T13:02:51Z UTC +source version 8.0.46 +source server_id 1 +source gtid_mode OFF +source binlog_format ROW +source sync_binlog 1 +source innodb_flush_log_at_trx_commit 1 +source rpl_semi_sync_source_enabled 1 +source rpl_semi_sync_source_timeout 10000 +source rpl_semi_sync_source_wait_point AFTER_SYNC +source rpl_semi_sync_source_wait_for_replica_count 1 +replica1 version 8.0.46 +replica1 server_id 2 +replica1 read_only 1 +replica1 rpl_semi_sync_replica_enabled 1 +replica2 version 8.0.46 +replica2 server_id 3 +replica2 read_only 1 +replica2 rpl_semi_sync_replica_enabled 1 +source status/clients ON/2 +[s2r] ── 판 1 (버림) 팔 P count=1 죽일 것: s2r_replica2 +1 버림 P 1 replica2 ON/2 1956 1223 1033 ON/2 3 0 버팀 1 ON/2 +[s2r] ── 판 2 (본판) 팔 P count=1 죽일 것: s2r_replica2 +2 본판 P 1 replica2 ON/2 1482 2626 2713 ON/2 3 0 버팀 1 ON/2 +[s2r] ── 판 3 (본판) 팔 Q count=1 죽일 것: s2r_replica1 s2r_replica2 +3 본판 Q 1 replica1+replica2 ON/2 11557 1365 1123 OFF/2 0 3 강등 1 ON/2 +[s2r] ── 판 4 (본판) 팔 R count=2 죽일 것: s2r_replica2 +4 본판 R 2 replica2 ON/2 13437 1121 1665 OFF/2 0 3 강등 1 ON/2 +[s2r] ── 판 5 (본판) 팔 Q count=1 죽일 것: s2r_replica1 s2r_replica2 +5 본판 Q 1 replica1+replica2 ON/2 10878 1395 1138 OFF/2 0 3 강등 1 ON/2 +[s2r] ── 판 6 (본판) 팔 R count=2 죽일 것: s2r_replica2 +6 본판 R 2 replica2 ON/2 11058 916 1160 OFF/2 0 3 강등 1 ON/2 +[s2r] ── 판 7 (본판) 팔 P count=1 죽일 것: s2r_replica2 +7 본판 P 1 replica2 ON/2 941 1470 764 ON/2 3 0 버팀 1 ON/2 +[s2r] ── 판 8 (본판) 팔 R count=2 죽일 것: s2r_replica2 +8 본판 R 2 replica2 ON/2 11951 972 997 OFF/2 0 3 강등 1 ON/2 +[s2r] ── 판 9 (본판) 팔 P count=1 죽일 것: s2r_replica2 +9 본판 P 1 replica2 ON/2 1122 1661 1142 ON/2 3 0 버팀 1 ON/2 +[s2r] ── 판 10 (본판) 팔 Q count=1 죽일 것: s2r_replica1 s2r_replica2 +10 본판 Q 1 replica1+replica2 ON/2 11708 1783 921 OFF/2 0 3 강등 1 ON/2 +[s2r] 완료 → /e/init-wt-srec/loadtest/results/replication-semisync-two-replicas-2026-11-07/_out/trials.tsv + Container s2r_replica1 Stopping + Container s2r_replica2 Stopping + Container s2r_source Stopping + Container s2r_replica1 Stopped + Container s2r_replica1 Removing + Container s2r_source Stopped + Container s2r_source Removing + Container s2r_replica2 Stopped + Container s2r_replica2 Removing + Container s2r_replica1 Removed + Container s2r_source Removed + Container s2r_replica2 Removed + Network s2r_net Removing + Network s2r_net Removed diff --git a/loadtest/results/replication-semisync-two-replicas-2026-11-07/semisync_two_replicas.sh b/loadtest/results/replication-semisync-two-replicas-2026-11-07/semisync_two_replicas.sh new file mode 100644 index 00000000..9bf799d0 --- /dev/null +++ b/loadtest/results/replication-semisync-two-replicas-2026-11-07/semisync_two_replicas.sh @@ -0,0 +1,178 @@ +#!/usr/bin/env bash +# 반동기 리플리카 2대 — 한 대가 죽으면 소스가 강등되나 +# +# 설계 문서: docs/decisions/replication-lag-and-semisync.md §0-2 Q4 · docs/decisions/rpo-rto-targets.md §8 R3 +# 08-17·11-07 은 리플리카 1대라 그 한 대가 죽으면 곧바로 첫 쓰기가 timeout(10초)을 물고 +# 비동기로 강등됐다. 실무는 리플리카를 2대 이상 두고 «그중 N대만 받으면 커밋» +# (rpl_semi_sync_source_wait_for_replica_count)으로 이걸 피한다고 알려져 있다 — 그걸 확인한다. +# +# 팔 (판마다 리플리카를 죽였다 살린다 · 소스 wait_for_replica_count 만 바꾼다) +# P count=1 · 리플리카 2 만 죽인다 — 강등 없이 버티나 (관심 팔) +# Q count=1 · 리플리카 1·2 둘 다 죽인다 — 강등돼야 한다 (양성 대조군: 계측이 강등을 잡는지) +# R count=2 · 리플리카 2 만 죽인다 — «둘 다 받아야» 면 한 대만 죽어도 강등되나 +# +# 판 설계 — 버림 1(P) + 본판 9, 라틴 방격(PQR·QRP·RPQ). +# 판정 — 죽인 뒤 쓰기 3건: 첫 쓰기 지연(ms) · 소스 status · yes_tx/no_tx 증가분. +# «버팀» = status 가 ON 으로 남고 3건 모두 yes_tx 로 센다. «강등» = status OFF 또는 no_tx 증가. +# 되살린 뒤 소스 clients=2 · status=ON 으로 돌아오는지도 본다(리플리카는 시작 옵션이라 자동 복귀가 기대값). + +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +OUT="${OUT:-$HERE/_out}" +mkdir -p "$OUT" + +SRC=s2r_source +R1=s2r_replica1 +R2=s2r_replica2 +MP='-uroot -prepl' +SP=rpl_semi_sync_source +RP=rpl_semi_sync_replica + +ORDER="${ORDER:-P P Q R Q R P R P Q}" # 첫 판은 버림판 + +log() { printf '[s2r] %s\n' "$*" >&2; } +sql() { docker exec -i "$1" mysql $MP -N -s -e "$2" 2>/dev/null; } +# 상태 조회 — 실패하면 빈 값 대신 NA 를 낸다(11-07 rig 에 CodeRabbit 이 짚은 빈 값 산술을 피한다) +gstat() { local v; v=$(sql "$SRC" "SELECT VARIABLE_VALUE FROM performance_schema.global_status WHERE VARIABLE_NAME='${SP}_$1';") || true; echo "${v:-NA}"; } +num() { case "$1" in ''|*[!0-9]*) echo NA;; *) echo "$1";; esac; } +delta() { [ "$1" = NA ] || [ "$2" = NA ] && { echo NA; return; }; echo $(( $2 - $1 )); } +rstat() { docker exec -i "$1" mysql $MP -e "SHOW REPLICA STATUS\G" 2>/dev/null | awk -F': *' -v k="$2" '$0 ~ "^[ ]*"k":" {print $2; exit}'; } +now_ms() { date +%s%3N; } + +wait_up() { + local c=$1 i + for i in $(seq 1 90); do + docker exec "$c" mysqladmin ping -h 127.0.0.1 $MP >/dev/null 2>&1 && return 0 + sleep 1 + done + log "!! $c 가 안 뜬다"; return 1 +} + +wait_repl() { + local c=$1 i + for i in $(seq 1 30); do + [ "$(rstat "$c" Replica_IO_Running)" = Yes ] && [ "$(rstat "$c" Replica_SQL_Running)" = Yes ] && return 0 + sleep 1 + done + return 1 +} + +# 소스가 status=ON · clients=N 이 될 때까지 최대 n 초, 1초마다 한 행씩 쓰면서 +wait_on() { + local want=$1 n=$2 i + for i in $(seq 1 "$n"); do + sql "$SRC" "INSERT INTO s2rdb.t (note) VALUES ('poll');" >/dev/null || true + [ "$(gstat status)" = ON ] && [ "$(gstat clients)" = "$want" ] && { echo "$i"; return 0; } + sleep 1 + done + echo "-"; return 1 +} + +attach() { + local rep=$1 file=$2 pos=$3 + sql "$rep" "STOP REPLICA; RESET REPLICA ALL; + CHANGE REPLICATION SOURCE TO SOURCE_HOST='$SRC', SOURCE_PORT=3306, + SOURCE_USER='repl', SOURCE_PASSWORD='replpw', + SOURCE_LOG_FILE='$file', SOURCE_LOG_POS=$pos, GET_SOURCE_PUBLIC_KEY=1; + START REPLICA;" +} + +setup() { + log "compose up" + docker compose -f "$HERE/docker-compose.yml" -p s2r up -d >/dev/null + wait_up "$SRC"; wait_up "$R1"; wait_up "$R2" + + sql "$SRC" "CREATE USER IF NOT EXISTS 'repl'@'%' IDENTIFIED WITH mysql_native_password BY 'replpw'; + GRANT REPLICATION SLAVE ON *.* TO 'repl'@'%';" + local coord file pos + coord=$(sql "$SRC" "SHOW MASTER STATUS;") + file=$(printf '%s' "$coord" | awk '{print $1}'); pos=$(printf '%s' "$coord" | awk '{print $2}') + [ -n "$file" ] && [ -n "$pos" ] || { log "!! 소스 좌표 못 읽음"; return 1; } + attach "$R1" "$file" "$pos"; attach "$R2" "$file" "$pos" + # 스키마는 좌표 뒤에 — 복제를 타고 두 리플리카로 넘어가야 한다 + sql "$SRC" "CREATE DATABASE IF NOT EXISTS s2rdb;" + sql "$SRC" "CREATE TABLE IF NOT EXISTS s2rdb.t (id BIGINT AUTO_INCREMENT PRIMARY KEY, note VARCHAR(32), at TIMESTAMP(6) DEFAULT CURRENT_TIMESTAMP(6));" + wait_repl "$R1" && wait_repl "$R2" || { log "!! 복제가 안 붙었다"; return 1; } + + sql "$SRC" "INSTALL PLUGIN $SP SONAME 'semisync_source.so';" || true + sql "$SRC" "SET GLOBAL ${SP}_enabled = 1;" + # 리플리카는 시작 옵션으로 이미 켜져 있지만, 소스 플러그인이 방금 올라왔으니 반동기로 다시 붙인다 + sql "$R1" "STOP REPLICA IO_THREAD; START REPLICA IO_THREAD;" + sql "$R2" "STOP REPLICA IO_THREAD; START REPLICA IO_THREAD;" + wait_on 2 20 >/dev/null || { log "!! 시작 상태에서 clients=2 가 안 된다"; return 1; } + + { + echo "# D0 — 무대" + echo "# 생성: $(date -u '+%Y-%m-%dT%H:%M:%SZ') UTC" + for v in version server_id gtid_mode binlog_format sync_binlog innodb_flush_log_at_trx_commit \ + ${SP}_enabled ${SP}_timeout ${SP}_wait_point ${SP}_wait_for_replica_count; do + printf 'source %-44s %s\n' "$v" "$(sql "$SRC" "SELECT @@$v;")" + done + for r in "$R1" "$R2"; do + for v in version server_id read_only ${RP}_enabled; do + printf '%-9s %-44s %s\n' "${r#s2r_}" "$v" "$(sql "$r" "SELECT @@$v;")" + done + done + printf 'source status/clients %s/%s\n' "$(gstat status)" "$(gstat clients)" + } > "$OUT/D0_stage.txt" + cat "$OUT/D0_stage.txt" >&2 +} + +trial() { + local idx=$1 arm=$2 kind=$3 count victims + case "$arm" in + P) count=1; victims="$R2" ;; + Q) count=1; victims="$R1 $R2" ;; + R) count=2; victims="$R2" ;; + esac + log "── 판 $idx ($kind) 팔 $arm count=$count 죽일 것: $victims" + + # 0. 시작 상태 — count 설정 → 두 리플리카가 반동기로 붙어 status=ON · clients=2 + sql "$SRC" "SET GLOBAL ${SP}_wait_for_replica_count = $count;" + local pre_on pre + pre_on=$(wait_on 2 20) || true + pre="$(gstat status)/$(gstat clients)" + + # 1. 죽이고 쓴다 — 쓰기 3건, 각각 지연을 잰다 + local v; for v in $victims; do docker stop "$v" >/dev/null; done + local yes0 no0 w0 w1 ms1 ms2 ms3 + yes0=$(num "$(gstat yes_tx)"); no0=$(num "$(gstat no_tx)") + w0=$(now_ms); sql "$SRC" "INSERT INTO s2rdb.t (note) VALUES ('down1');"; w1=$(now_ms); ms1=$((w1 - w0)) + w0=$(now_ms); sql "$SRC" "INSERT INTO s2rdb.t (note) VALUES ('down2');"; w1=$(now_ms); ms2=$((w1 - w0)) + w0=$(now_ms); sql "$SRC" "INSERT INTO s2rdb.t (note) VALUES ('down3');"; w1=$(now_ms); ms3=$((w1 - w0)) + local down_status down_clients dyes dno verdict + down_status=$(gstat status); down_clients=$(gstat clients) + dyes=$(delta "$yes0" "$(num "$(gstat yes_tx)")"); dno=$(delta "$no0" "$(num "$(gstat no_tx)")") + if [ "$down_status" = ON ] && [ "$dyes" = 3 ] && [ "$dno" = 0 ]; then verdict=버팀 + elif [ "$down_status" = OFF ] || { [ "$dno" != NA ] && [ "$dno" -gt 0 ]; }; then verdict=강등 + else verdict=판정불가; fi + + # 2. 되살린다 — 시작 옵션이라 추가 조작 없이 clients=2 로 돌아오는지 + for v in $victims; do docker start "$v" >/dev/null; done + for v in $victims; do wait_up "$v"; done + sql "$R1" "START REPLICA;" || true; sql "$R2" "START REPLICA;" || true + local back_s post + back_s=$(wait_on 2 30) || true + post="$(gstat status)/$(gstat clients)" + + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s/%s\t%s\t%s\t%s\t%s\t%s\n' \ + "$idx" "$kind" "$arm" "$count" "$(echo $victims | sed 's/s2r_//g; s/ /+/g')" "$pre" \ + "$ms1" "$ms2" "$ms3" "$down_status" "$down_clients" "$dyes" "$dno" "$verdict" "$back_s" "$post" +} + +main() { + setup + local tsv="$OUT/trials.tsv" + printf 'trial\tkind\tarm\tcount\tkilled\tpre_status/clients\tw1_ms\tw2_ms\tw3_ms\tdown_status/clients\tyes_tx_delta\tno_tx_delta\tverdict\tback_s\tpost_status/clients\n' > "$tsv" + local i=0 arm kind + for arm in $ORDER; do + i=$((i + 1)) + kind=$([ "$i" = 1 ] && echo 버림 || echo 본판) + trial "$i" "$arm" "$kind" | tee -a "$tsv" + done + log "완료 → $tsv" + [ "${KEEP:-0}" = 1 ] || docker compose -f "$HERE/docker-compose.yml" -p s2r down -v >/dev/null +} + +main "$@"