From c1f5f06fd62bc78e7dfcfac763c9a44d89f3576a Mon Sep 17 00:00:00 2001 From: Khyojae Date: Thu, 24 Sep 2026 10:48:37 +0900 Subject: [PATCH 1/2] =?UTF-8?q?measure(r276):=20=EA=B2=B0=EC=A0=95?= =?UTF-8?q?=EC=A0=81=20=EC=9E=AC=ED=98=84=20=E2=80=94=20=EC=A4=91=EB=B3=B5?= =?UTF-8?q?=20=ED=95=9C=20=EA=B1=B4=EC=9D=B4=20=ED=8C=8C=ED=8B=B0=EC=85=98?= =?UTF-8?q?=20=EB=81=9D(supremum)=EC=9D=84=20=EC=9E=A0=EA=B7=BC=EB=8B=A4,?= =?UTF-8?q?=20RC=C2=B7=EC=9E=90=EC=97=B0=ED=82=A4=20PK=20=EB=A1=9C=200/960?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 지금까지의 #276 판은 전부 확률(워커 N 개의 데드락 비율)이었고 처방도 재시도 상한이었다. 두 세션을 한 문장씩 진행시키며 단계마다 performance_schema.data_locks 를 찍었다. - RR 에서 중복 키 한 건이 PRIMARY supremum 에 X 를 잡는다 — ODKU·IGNORE·평범한 INSERT 모두 - 그 X 는 커밋까지 무관한 세션의 신규 삽입까지 세운다(파티션 끝 직렬화) - RC: supremum 사라짐, uk next-key 로 한 방향 대기만 남음 - 멱등 키 = PK(id 는 보조 인덱스로 유지 가능): 원본 레코드 REC_NOT_GAP 하나뿐 - 동시 부하(워커 8, 라틴 방격 3블록): base 45.9%, RC·자연키 PK 두 변형 0/960, 멱등 유지 trace 중 세운 «클러스터 삽입 후 되감기 상속» 가설은 단일 세션 대조로 철회했다. 분기점 문서 r276-lock-root-cause-fix.md — 추천은 RC 먼저·자연키 PK 는 비용 측정 뒤, 결정은 미정. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/decisions/r276-lock-root-cause-fix.md | 80 +++ loadtest/measure_r276_lock_trace.sh | 190 ++++++ .../measure_r276_lock_trace_concurrent.sh | 64 ++ .../r276-lock-trace-2026-09-24/README.md | 83 +++ .../r276-lock-trace-2026-09-24/concurrent.txt | 18 + .../r276-lock-trace-2026-09-24/trace.txt | 621 ++++++++++++++++++ 6 files changed, 1056 insertions(+) create mode 100644 docs/decisions/r276-lock-root-cause-fix.md create mode 100644 loadtest/measure_r276_lock_trace.sh create mode 100644 loadtest/measure_r276_lock_trace_concurrent.sh create mode 100644 loadtest/results/r276-lock-trace-2026-09-24/README.md create mode 100644 loadtest/results/r276-lock-trace-2026-09-24/concurrent.txt create mode 100644 loadtest/results/r276-lock-trace-2026-09-24/trace.txt diff --git a/docs/decisions/r276-lock-root-cause-fix.md b/docs/decisions/r276-lock-root-cause-fix.md new file mode 100644 index 00000000..44f74d80 --- /dev/null +++ b/docs/decisions/r276-lock-root-cause-fix.md @@ -0,0 +1,80 @@ +# #276 근본 처방 — 재시도가 아니라 «중복 하나가 파티션 끝을 잠그는» 자리를 없앤다 + +작성: 2026-09-24 +상태: **분기점 — 사용자 결정 대기** (후보와 트레이드오프까지만. 채택은 confirm 뒤 별도로 박제) +근거: [`loadtest/results/r276-lock-trace-2026-09-24/`](../../loadtest/results/r276-lock-trace-2026-09-24/README.md) +관련: [#276](https://github.com/Shadowfit/init/issues/276) · [`r276-retry-followup.md`](./r276-retry-followup.md) · [`pose-batch-idempotency-vs-partition.md`](./pose-batch-idempotency-vs-partition.md) · [`online-ddl-vs-blocking-alter.md`](./online-ddl-vs-blocking-alter.md) + +--- + +## 0. 한 줄 + +지금 처방(데드락 재시도 상한 5)은 **그물**이다. 09-24 결정적 재현이 **그물 아래의 자리**를 찾았다 — +RR 에서 **중복 키 한 건**이 `PRIMARY` 의 파티션 끝(supremum)에 `X` 를 잡고, 커밋까지 **그 파티션의 모든 신규 삽입을 세운다.** +데드락은 그 `X` 를 두 트랜잭션이 동시에 쥘 때 닫히는 결과일 뿐이다. 그 `X` 를 안 생기게 하는 후보가 둘 있고, 둘 다 동시 부하에서 0/960 이었다. + +## 1. 확인된 것 (측정) + +| 사실 | 근거 | +|---|---| +| supremum `X` 는 중복 **한 건**에서 생긴다(엇갈림 불필요) | trace base 단계 1 | +| ODKU 고유가 아니다 — `INSERT IGNORE`·평범한 `INSERT`(1062) 도 같다 | trace insert_ignore · plain_insert | +| 그 X 는 **무관한 세션의 신규 키**도 커밋까지 세운다 | trace dup_blocks_new | +| RC 에서는 supremum 이 없다. 단 uk 원본 레코드의 next-key `X` 가 남아 **한 방향 대기**가 생긴다 | trace read_committed (3.26초 대기, 데드락 0) | +| 멱등 키를 PK 로 하면 원본 레코드 `REC_NOT_GAP` 하나만 잡는다. id 를 보조 인덱스로 남겨도 같다 | trace natural_pk · natural_pk_keep_id | +| 동시 부하(워커 8·중복): base **45.9%**, RC·자연키 PK 두 변형 **0/960**, 네 팔 모두 멱등 유지(200행) | concurrent.txt, 라틴 방격 3블록 | + +**미검증**: supremum `X` 가 «왜» 생기는지의 소스 수준 설명(가설 하나를 세웠다가 철회했다 — 결과 README §4). + +## 2. 후보 + +### ㄱ. 현상 유지 — 재시도 상한 5 + +- 비용 0. 08-26 에 확정된 값 +- 남는 것: 데드락은 계속 나고(앱 경로 상한 5 에서도 잔여 4.2%, 08-23), 재시도 루프 p99 약 4초. + 그리고 이번에 보인 **«중복 하나가 파티션 끝을 세운다»** 는 재시도와 무관하게 그대로다 + +### ㄴ. 이 쓰기 경로만 READ COMMITTED + +`PoseDataService.savePoseDataBatch` 의 `@Transactional` 에 `isolation = READ_COMMITTED` 한 줄. + +| 얻는 것 | 치르는 것 / 모르는 것 | +|---|---| +| supremum `X` 가 사라진다 · 동시 부하 0/960 | **한 방향 대기는 남는다** — 재전송 트랜잭션이 커밋할 때까지 «다음 세션 원본 앞 gap» 에 들어갈 신규 삽입이 기다린다 | +| 코드 한 줄 · 되돌리기 쉽다 · 스키마 불변 | 「순환이 안 생긴다」는 **세션 키가 `session_id` 로 묶여 있다는 논증**이다. 한 트랜잭션이 여러 세션 키를 섞는 경로가 생기면 안 선다 | +| | 같은 트랜잭션 안의 세션 조회(`findById`)도 RC 가 된다 — 이 경로에선 무해해 보이나 **확인 안 함** | +| | binlog 는 8.0 기본값 ROW 에 기대고 있다(설정 파일에 명시 없음). STATEMENT 로 바뀌면 RC 와 못 산다 | + +### ㄷ. 멱등 키를 PK 로 — `(session_id, rep_number, timestamp_sec, created_at)`, id 는 AUTO_INCREMENT 보조 인덱스로 유지 + +| 얻는 것 | 치르는 것 / 모르는 것 | +|---|---| +| supremum 도, 한 방향 대기도 없다 — 원본 레코드 하나만 잠근다 · 동시 부하 0/960 | **1억 행급 파티션 표의 PK 재구성** — 테이블 재작성이다. 온라인으로 가능한지·걸리는 시간은 **안 쟀다**([`online-ddl-vs-blocking-alter.md`](./online-ddl-vs-blocking-alter.md) 의 축) | +| `uk_pose_event` 가 PK 로 흡수돼 **인덱스 하나가 줄어든다** | PK 폭 12B → 21B — 보조 인덱스(`idx_session_timestamp`, `idx_pose_id`)가 그만큼 커진다. `idx_session_timestamp` 는 PK 앞부분과 겹쳐 **중복 인덱스가 될 수 있다**(검토 안 함) | +| 행이 **세션 순으로 물리 정렬**된다 — 세션 단위 읽기(`findFramesBySessionId`)가 PK 범위 스캔이 될 수 있다 | 그 이득도 **안 쟀다.** 삽입이 파티션 끝 한 점이 아니라 **동시 세션 수만큼의 지점**으로 흩어진다 — 페이지 분할·버퍼풀 영향 미측정(uk-bufferpool 08-23 판이 «흩어진 삽입 −46%» 를 본 적 있다) | +| id 를 남기므로 `reports.detailed_analysis` 의 `poseDataId` 참조가 산다 | id 로 찾는 조회(`PoseDataRepository.findJointCoordinatesById`)가 PK 다이브에서 **보조 인덱스 + PK 룩업**으로 바뀐다(행 1개라 작을 것 — 미측정). JPA 엔티티 `@Id` 변경 필요 | + +(id 를 아예 지우는 변형도 잠금은 같았지만, 저장된 `poseDataId` 가 깨지므로 후보에서 뺀다.) + +### ㄹ. 기존 키를 먼저 조회해 신규 행만 INSERT + +- 조회와 삽입 사이에 재전송이 끼면 같은 경로로 떨어진다 — 창을 좁힐 뿐 자리를 없애지 않는다. **측정 안 함.** 비교용으로만 적는다 + +## 3. 추천 (결정 아님) + +**ㄴ 을 먼저, ㄷ 은 비용을 잰 뒤에.** + +- ㄴ 은 한 줄이고 되돌리기 쉽다. 측정으로 데드락 0/960 이 나왔고, 남는 한 방향 대기는 재전송 트랜잭션 길이(배치 하나, ms 단위)만큼이다 +- ㄷ 은 자리를 완전히 없애는 **구조적 답**이고 DB 이야기로는 가장 굵다(인덱스 설계·클러스터링·온라인 DDL). 하지만 치르는 것 네 줄이 전부 미측정이라, + 지금 채택하면 «근거 없는 결정» 이 된다. 착수한다면 순서는 ① 삽입 처리량(append vs 세션별 지점) ② PK 재구성 시간·온라인 여부 ③ 세션 단위 읽기 이득 +- 재시도(ㄱ)는 어느 쪽을 골라도 **남겨 둔다** — 다른 원인의 데드락에 대한 그물로서. 상한을 줄일지는 ㄴ/ㄷ 적용 뒤 `pose_batch_deadlock_retries` 로 본다 + +## 4. 미결정 (사용자 confirm 필요) + +1. ㄴ 을 적용할지 +2. ㄷ 의 비용 측정(§3 순서)을 착수할지 +3. #276 에 이 판 결과를 코멘트로 남기고, 이슈 상태 블록을 갱신할지 + +## 결정 로그 + +- 2026-09-24 — 문서 작성. 결정 없음 diff --git a/loadtest/measure_r276_lock_trace.sh b/loadtest/measure_r276_lock_trace.sh new file mode 100644 index 00000000..db061f2c --- /dev/null +++ b/loadtest/measure_r276_lock_trace.sh @@ -0,0 +1,190 @@ +#!/usr/bin/env bash +# #276 결정적 재현 — 두 세션을 한 문장씩 진행시키며 단계마다 performance_schema.data_locks 를 찍는다 +# +# 이슈: https://github.com/Shadowfit/init/issues/276 +# 지금까지의 판(r276-*)은 전부 «확률» 이었다 — 워커 N 개를 풀어놓고 데드락 비율을 셌다. +# 잠금 자리(PRIMARY supremum, 파티션 p2026_05)는 데드락 덤프 한 쌍으로만 봤고, +# **어느 문장·어느 시점에 그 X 가 생기는지**는 미검증으로 남아 있었다(08-20 코멘트). +# 이 rig 은 부하를 걸지 않는다. 세션 둘(t1·t2)에 문장을 하나씩 넣고, 넣을 때마다 제3의 +# 세션(root)이 잠금 표를 읽는다. 같은 입력이면 같은 출력이 나오므로 판 수가 필요 없다. +# +# 무대: 격리 컨테이너(mysql:8.0) 에 Flyway V1~V26 을 그대로 적용한 스키마. +# 대상 표는 매 시나리오마다 `pose_lab` 을 `CREATE TABLE ... LIKE pose_data` 로 새로 만든다 +# (파티션·PK·uk_pose_event 전부 복제). 팔에 따라 키·격리수준·INSERT 형태만 바꾼다. +# +# 쓰는 법: +# docker run -d --name r276-locktrace -e MYSQL_ROOT_PASSWORD=root -e MYSQL_DATABASE=shadowfit mysql:8.0 +# (V*.sql 을 버전 순으로 적용 — README 참고) +# CONTAINER=r276-locktrace ./loadtest/measure_r276_lock_trace.sh > out.txt +# +# ⚠️ 단계 사이 간격은 SLEEP(기본 1초)이다. 막힌 문장은 innodb_lock_wait_timeout(기본 50초) +# 안에 다음 단계가 풀어주므로 간격이 결과를 바꾸지 않는다 — 단 박스가 극단적으로 느리면 +# «막혔다» 와 «아직 안 끝났다» 를 구분 못 한다. 각 dump 의 LOCK_STATUS=WAITING 으로 판정한다. +set -uo pipefail +export MSYS_NO_PATHCONV=1 # Git Bash 가 docker exec 인자의 /tmp 를 Windows 경로로 바꾸지 않게 + +CONTAINER=${CONTAINER:-r276-locktrace} +SLEEP=${SLEEP:-1} +ARMS=${ARMS:-"base dup_blocks_new plain_insert dup_only new_only no_uk insert_ignore read_committed natural_pk natural_pk_keep_id"} + +# 컨테이너 안에서 도는 드라이버. 인자: 시나리오 파일 경로(컨테이너 안). +# 시나리오 줄 형식: T1|sql · T2|sql · ROOT|sql · DUMP|라벨 +read -r -d '' DRIVER <<'EOS' +set -u +SCEN=$1; SLEEP=$2 +cd /tmp && rm -f p1 p2 t1.log t2.log && mkfifo p1 p2 +# -n: 버퍼링 끔 · --force: 데드락 뒤에도 세션을 살려 둔다 · -vvv: 문장과 결과를 같이 남긴다 +mysql -ut1 -pp1 -n --force -vvv shadowfit t1.log 2>&1 & +mysql -ut2 -pp2 -n --force -vvv shadowfit t2.log 2>&1 & +exec 3>p1 4>p2 +Q="SELECT t.PROCESSLIST_USER AS who, l.INDEX_NAME AS idx, l.PARTITION_NAME AS part, + l.LOCK_TYPE AS type, l.LOCK_MODE AS mode, l.LOCK_STATUS AS status, l.LOCK_DATA AS data + FROM performance_schema.data_locks l + JOIN performance_schema.threads t ON t.THREAD_ID = l.THREAD_ID + WHERE l.OBJECT_NAME = 'pose_lab' + ORDER BY who, l.LOCK_TYPE, idx, l.ENGINE_LOCK_ID;" +while IFS='|' read -r who sql; do + [ -z "$who" ] && continue + case "$who" in + \#*) ;; + T1) echo "$sql" >&3; echo ">>> T1: $sql"; sleep "$SLEEP" ;; + T2) echo "$sql" >&4; echo ">>> T2: $sql"; sleep "$SLEEP" ;; + ROOT) mysql -uroot -proot shadowfit -e "$sql" 2>&1 | grep -v 'Using a password' ;; + DUMP) echo "=== [$sql]"; mysql -uroot -proot -t -e "$Q" 2>&1 | grep -v 'Using a password' || true + echo " (잠금 없음이면 표가 비어 있다)" ;; + esac +done < "$SCEN" +exec 3>&- 4>&- +wait +echo "--- t1 세션 로그 (오류만)"; grep -E 'ERROR|Query OK|rows affected' t1.log | sed 's/^/ /' +echo "--- t2 세션 로그 (오류만)"; grep -E 'ERROR|Query OK|rows affected' t2.log | sed 's/^/ /' +EOS + +v() { echo "($1, $2, $3.000, JSON_OBJECT('k',1), 50.00, 0.00, NULL, '2026-05-15 10:00:00')"; } +COLS="(session_id, rep_number, timestamp_sec, joint_coordinates, sync_rate, smoothed_knee_angle, feedback_message, created_at)" +ODKU="ON DUPLICATE KEY UPDATE session_id = session_id" # PoseDataService.INSERT_POSE_SQL 과 같은 꼴 +ins() { echo "INSERT INTO pose_lab $COLS VALUES $(v "$1" "$2" "$3") $ODKU;"; } +insi() { echo "INSERT IGNORE INTO pose_lab $COLS VALUES $(v "$1" "$2" "$3");"; } + +# 공통 준비: 원본(재전송이 겹칠 대상) 두 행을 커밋해 둔다 — 세션 901·902 의 rep1. +setup() { # $1 = 표 변형 DDL(없으면 빈 문자열) + cat </dev/null +echo "# MySQL $(docker exec "$CONTAINER" mysql -uroot -proot -N -e 'select version()' 2>/dev/null) · 기본 격리 $(docker exec "$CONTAINER" mysql -uroot -proot -N -e 'select @@transaction_isolation' 2>/dev/null) · SLEEP=$SLEEP" +docker exec -i "$CONTAINER" bash -c 'cat > /tmp/driver.sh' <<<"$DRIVER" +dl() { docker exec "$CONTAINER" mysql -uroot -proot -N -e "SELECT COUNT FROM information_schema.INNODB_METRICS WHERE NAME='lock_deadlocks'" 2>/dev/null; } +for arm in $ARMS; do + echo; echo "################ 팔: $arm ################" + scenario "$arm" | docker exec -i "$CONTAINER" bash -c 'cat > /tmp/scen.txt' + before=$(dl) + docker exec "$CONTAINER" bash /tmp/driver.sh /tmp/scen.txt "$SLEEP" + after=$(dl) + echo "--- 이 팔의 데드락 수: $((after - before))" + # LATEST DETECTED DEADLOCK 은 서버 수명 동안 남으므로, 이 팔에서 새로 났을 때만 찍는다 + [ "$after" -gt "$before" ] && docker exec "$CONTAINER" mysql -uroot -proot -e "SHOW ENGINE INNODB STATUS\G" 2>/dev/null \ + | sed -n '/LATEST DETECTED DEADLOCK/,/^TRANSACTIONS$/p' | grep -E 'TRANSACTION|index|lock_mode|lock mode|WE ROLL BACK|supremum|INSERT' | head -30 \ + | sed 's/^/ [deadlock] /' +done diff --git a/loadtest/measure_r276_lock_trace_concurrent.sh b/loadtest/measure_r276_lock_trace_concurrent.sh new file mode 100644 index 00000000..de4b2376 --- /dev/null +++ b/loadtest/measure_r276_lock_trace_concurrent.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# #276 — 결정적 trace(measure_r276_lock_trace.sh)의 판정을 동시 부하에서 확인한다 +# +# trace 는 «두 세션 · 한 순서» 라 「데드락 0」을 일반화할 수 없다. 여기서는 기존 확률 rig +# (measure_r276_deadlock.sh 의 same_partition 팔)과 같은 모양 — 워커 W 개가 각자 다른 세션의 +# 같은 R 행을 I 번 반복(첫 문장만 신규, 나머지 전부 중복) — 을 팔 넷에 건다: +# base 운영 그대로 (RR · PK=(id, created_at) · uk_pose_event) +# read_committed 격리수준만 RC +# natural_pk auto-increment id 를 없애고 멱등 키를 PK 로 +# natural_pk_keep_id 멱등 키를 PK 로 + id 는 AUTO_INCREMENT 보조 인덱스로 남김(리포트의 poseDataId 참조 보존) +# 각 문장은 autocommit(= 문장 하나가 트랜잭션 하나). 판 순서는 블록마다 회전(라틴 방격), 첫 블록 버림. +# +# 무대는 trace 와 같은 격리 컨테이너. 운영 compose DB 는 안 건드린다. +set -uo pipefail +export MSYS_NO_PATHCONV=1 + +CONTAINER=${CONTAINER:-r276-locktrace} +WORKERS=${WORKERS:-8} +ITER=${ITER:-40} +ROWS=${ROWS:-25} +BLOCKS=${BLOCKS:-4} # 첫 블록은 버림 +ARMS=(base read_committed natural_pk natural_pk_keep_id) + +read -r -d '' RUN <<'EOS' +set -u +ARM=$1 W=$2 I=$3 R=$4 +M="mysql -uroot -proot shadowfit" +$M -e "DROP TABLE IF EXISTS pose_lab; CREATE TABLE pose_lab LIKE pose_data;" 2>/dev/null +if [ "$ARM" = natural_pk ]; then + $M -e "ALTER TABLE pose_lab MODIFY id BIGINT NOT NULL; ALTER TABLE pose_lab DROP INDEX uk_pose_event, DROP PRIMARY KEY, DROP COLUMN id, ADD PRIMARY KEY (session_id, rep_number, timestamp_sec, created_at);" 2>/dev/null +fi +if [ "$ARM" = natural_pk_keep_id ]; then + $M -e "ALTER TABLE pose_lab DROP INDEX uk_pose_event, DROP PRIMARY KEY, ADD PRIMARY KEY (session_id, rep_number, timestamp_sec, created_at), ADD KEY idx_pose_id (id);" 2>/dev/null +fi +PRE=""; [ "$ARM" = read_committed ] && PRE="SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;" +cd /tmp && rm -f w*.sql w*.err +for w in $(seq 1 "$W"); do + sid=$((900 + w)); vals="" + for r in $(seq 1 "$R"); do + vals="$vals${vals:+,}($sid, $r, $r.000, JSON_OBJECT('k',1), 50.00, 0.00, NULL, '2026-05-15 10:00:00')" + done + { echo "$PRE"; for i in $(seq 1 "$I"); do + echo "INSERT INTO pose_lab (session_id, rep_number, timestamp_sec, joint_coordinates, sync_rate, smoothed_knee_angle, feedback_message, created_at) VALUES $vals ON DUPLICATE KEY UPDATE session_id = session_id;" + done; } > "w$w.sql" +done +for w in $(seq 1 "$W"); do mysql -ut1 -pp1 --force shadowfit < "w$w.sql" 2> "w$w.err" & done +wait +dead=$(cat w*.err | grep -c 'ERROR 1213' || true) +other=$(cat w*.err | grep 'ERROR' | grep -vc 'ERROR 1213' || true) +rows=$($M -N -e "SELECT COUNT(*) FROM pose_lab" 2>/dev/null) +echo "$ARM $dead $((W * I)) $other $rows" +EOS +docker exec -i "$CONTAINER" bash -c 'cat > /tmp/run.sh' <<<"$RUN" + +echo "# MySQL $(docker exec "$CONTAINER" mysql -uroot -proot -N -e 'select version()' 2>/dev/null) · W=$WORKERS I=$ITER R=$ROWS · 블록 $BLOCKS(첫 블록 버림)" +echo "# block order arm deadlocks attempts other_errors final_rows" +n=${#ARMS[@]} +for b in $(seq 0 $((BLOCKS - 1))); do + for k in $(seq 0 $((n - 1))); do + arm=${ARMS[$(( (k + b) % n ))]} + printf '%s %s ' "$b" "$k" + docker exec "$CONTAINER" bash /tmp/run.sh "$arm" "$WORKERS" "$ITER" "$ROWS" + done +done diff --git a/loadtest/results/r276-lock-trace-2026-09-24/README.md b/loadtest/results/r276-lock-trace-2026-09-24/README.md new file mode 100644 index 00000000..ac2a2216 --- /dev/null +++ b/loadtest/results/r276-lock-trace-2026-09-24/README.md @@ -0,0 +1,83 @@ +# #276 결정적 재현 — 중복 하나가 파티션 끝을 잠근다 (2026-09-24) + +이슈: [#276](https://github.com/Shadowfit/init/issues/276) · rig: [`measure_r276_lock_trace.sh`](../../measure_r276_lock_trace.sh)(단계별 잠금 표) · [`measure_r276_lock_trace_concurrent.sh`](../../measure_r276_lock_trace_concurrent.sh)(동시 부하 확인) +원문: [`trace.txt`](./trace.txt) · [`concurrent.txt`](./concurrent.txt) + +## 0. 왜 이 판인가 + +08-17~08-30 의 판은 전부 **확률**(워커 N 개를 풀어 데드락 비율을 셈)이었고, 처방도 **재시도 상한**(현재 5)이었다. +잠금 자리(`PRIMARY` supremum)는 데드락 덤프 한 쌍으로만 봤고 **어느 문장이 그 X 를 만드는지**는 미검증이었다. +이 판은 부하 없이 두 세션을 한 문장씩 진행시키며 단계마다 `performance_schema.data_locks` 를 찍는다 — +같은 입력이면 같은 출력이라 판 수가 필요 없다. 그리고 **재시도가 아니라 그 X 를 안 생기게 하는 구조**를 팔로 넣었다. + +## 1. 무대 + +- 격리 컨테이너 `mysql:8.0` → **8.0.46**, 기본 격리 REPEATABLE-READ, `innodb_autoinc_lock_mode=2` +- 스키마: Flyway `V1`~`V26` 을 버전 순으로 그대로 적용. 대상 표 `pose_lab` = `CREATE TABLE ... LIKE pose_data` + (PK `(id, created_at)` · `uk_pose_event` · 월 파티션 전부 복제). 운영 compose DB 는 안 건드렸다 +- 삽입문은 `PoseDataService.INSERT_POSE_SQL` 과 같은 꼴(`ON DUPLICATE KEY UPDATE session_id = session_id`) +- 원본 행: 세션 901·902 의 `(rep 1, ts 1.000)` 을 먼저 커밋 — 재전송이 겹칠 대상 +- 로컬 박스(i3-6100, 2물리코어). **비율의 절대값은 이 박스의 것**이다 + +## 2. 결정적 trace — 판정 + +| 팔 | 중복 1건 직후 T1 이 쥐는 것 | 남(T2)의 삽입 | 데드락 | +|---|---|---|---| +| **base** (운영 그대로) | `PRIMARY` **supremum `X`** + 원본 PK 행 `X,REC_NOT_GAP` + uk 원본 `X` | **막힘** — `X,INSERT_INTENTION WAITING` on supremum, T1 COMMIT 까지(3.09초) | 0 (T2 가 먼저 막혀 순환이 안 닫힘) | +| **dup_blocks_new** | 같음 | **무관한 세션(903)의 신규 키도 막힘** | 0 | +| plain_insert (ODKU 아님, 1062 실패) | **supremum `X` 가 똑같이 남는다** | — | — | +| insert_ignore | 같음 (supremum `X`) | 막힘 | 0 | +| dup_only / new_only | dup_only 는 base 와 같음 · new_only 는 gap 락 없음 | new_only 는 안 막힘 | 0 | +| no_uk | 잠금 없음 | 안 막힘 | 0 — **대신 중복이 그대로 들어간다(6행)** | +| **read_committed** | supremum 없음. 원본 PK 행 `X,REC_NOT_GAP` + uk 원본 **`X`(next-key)** | **한 방향 대기** — T1 의 신규(901,2,2)가 T2 가 잡은 uk (902,1,1) 앞 gap 에서 WAITING(3.26초) | 0 | +| **natural_pk** (id 제거, 멱등 키 = PK) | **원본 PK 행 `X,REC_NOT_GAP` 하나뿐** — gap·supremum 없음 | 안 막힘 | 0 | +| **natural_pk_keep_id** (멱등 키 = PK, id 는 AUTO_INCREMENT 보조 인덱스 `KEY(id)`) | natural_pk 와 같음 — `idx_pose_id` 는 잠금 목록에 안 나온다 | 안 막힘 | 0 — 기존 id(1·2) 그대로 | + +### 읽으면 + +1. **supremum `X` 의 출처는 «중복 한 건» 이다.** 두 세션이 엇갈릴 필요도 없다 — T1 혼자 중복 하나를 넣은 직후에 이미 있다. + ODKU 고유도 아니다: `INSERT IGNORE` 와 평범한 `INSERT`(1062 실패)도 같은 락을 남긴다. 즉 **RR 에서의 중복 키 처리 경로**다. +2. **그 X 는 데드락 이전에 «파티션 끝 직렬화» 다.** 중복을 넣은 트랜잭션이 커밋할 때까지, 같은 파티션(= 같은 달)에 + **새 행을 넣으려는 모든 트랜잭션**이 supremum 의 insert intention 에서 기다린다 — 원본과 무관한 세션도(dup_blocks_new). + auto-increment PK 라 모든 신규 삽입이 파티션 끝으로 가기 때문이다. +3. **데드락은 그 X 를 두 트랜잭션이 «동시에» 쥘 때 닫힌다.** 이 trace 는 문장 단위로 진행하므로 T2 가 X 를 얻기 전에 막혀 + 순환이 안 생긴다 — 둘이 같은 순간에 중복을 처리하는 창은 SQL 단계로는 못 만든다. 그래서 동시 부하 판(§3)으로 확인했다. + 동시성이 오를수록 그 창이 겹칠 확률이 오른다 — 08-20 워커 스윕(w=2 1.2% → w=16 59.5%)과 방향이 맞는다. +4. **RC 는 supremum 을 없애지만 대기를 다 없애지는 않는다.** 중복 검사는 RC 에서도 uk 원본 레코드에 next-key `X` 를 잡는다. + 관측된 대기는 **한 방향**(낮은 세션의 신규 → 다음 세션이 잡은 레코드 앞 gap)이라 이 모양에서는 순환이 안 생겼다. +5. **자연키 PK 는 둘 다 없앤다.** 중복 검사가 곧 PK 조회라 원본 레코드 하나만 `REC_NOT_GAP` 으로 잠근다. + id 를 AUTO_INCREMENT 보조 인덱스로 남겨도 같다 — 리포트가 영구 저장한 `poseDataId`(`WorstSectionDto`)를 살릴 수 있다. + +## 3. 동시 부하 확인 — trace 의 «0» 이 부하에서도 0 인가 + +기존 확률 rig 의 same_partition 팔과 같은 모양: 워커 8 · 워커마다 다른 세션의 같은 25행을 40문(첫 문장만 신규) · 문장 = autocommit. +팔 4 × 4블록, **블록마다 순서 회전(라틴 방격), 블록 0 버림.** + +| 팔 | 블록 1 | 블록 2 | 블록 3 | 중앙값 | 최종 행 | +|---|---:|---:|---:|---:|---:| +| base | 155/320 | 147/320 | 129/320 | **45.9%** | 200 | +| read_committed | 0/320 | 0/320 | 0/320 | **0 / 960** | 200 | +| natural_pk | 0/320 | 0/320 | 0/320 | **0 / 960** | 200 | +| natural_pk_keep_id | 0/320 | 0/320 | 0/320 | **0 / 960** | 200 | + +- 같은 날 팔 3 개로 먼저 돌린 판(이 파일에 없음)은 base 141·103·148(중앙값 44.1%), 나머지 둘 0/960 이었다 — 판정이 같다 +- base 의 45.9% 는 08-20 로컬(37.5%·42.2%)·08-23 AWS(42.8%)와 같은 자리다 — 무대가 기존 판을 재현한다 +- 두 후보 모두 **멱등은 유지**(최종 200행 = 8세션 × 25행) +- 그 외 에러 0 + +## 4. 정정·철회 + +- 🔴 **trace 도중 세운 가설 하나를 철회한다** — base 팔 최종 id 가 1·2·4·5 라 «T1 의 중복이 클러스터에 먼저 들어갔다 되감기며 + 락이 supremum 으로 상속됐다» 고 봤는데, **단일 세션에서 중복 하나를 넣으면 RR·RC 모두 id 가 안 빈다**(1·2 연속). + 빈 id 3 은 T1 이 아니라 **대기하던 T2** 쪽에서 났다. supremum X 가 «왜» 생기는지의 소스 수준 설명은 **미검증**이다 +- 08-17 코멘트의 «PK 가 AUTO_INCREMENT 라 삽입이 끝에 몰린다» 는 **자리의 설명으로는 맞다**(natural_pk 에서 사라지므로). + 08-20 코멘트가 no_uk 대조군을 근거로 이 서술을 «반증됐다» 고 적었고, 같은 날 덤프 코멘트는 **자리**(PRIMARY supremum)만 + 되돌렸다 — auto-increment 쪽 서술은 반증 표시가 붙은 채 남아 있었다. 이 판이 그것을 되살린다 + +## 5. 말하면 안 되는 것 + +- **RC 가 «모든 패턴에서» 데드락 0 이라는 것.** 관측한 대기가 한 방향이었던 것은 세션의 키가 `session_id` 로 묶여 있어서다. + 한 트랜잭션이 여러 세션의 키를 섞어 넣는 경로가 생기면 이 논증은 안 선다. 부하 확인도 이 모양 하나다 +- **자연키 PK 의 비용.** 이 판은 잠금만 봤다 — 쓰기 처리량·페이지 분할·보조 인덱스 크기·1억 행 PK 재구성 시간은 **안 쟀다** +- **supremum 직렬화의 운영 영향 크기.** 재전송이 드문 동안은 트랜잭션이 짧아 체감이 작을 것이다 — 이것도 **안 쟀다** +- 45.9% 등 절대 비율 — 박스·동시성·페이로드(작은 JSON) 조건의 값이다 diff --git a/loadtest/results/r276-lock-trace-2026-09-24/concurrent.txt b/loadtest/results/r276-lock-trace-2026-09-24/concurrent.txt new file mode 100644 index 00000000..e0886c96 --- /dev/null +++ b/loadtest/results/r276-lock-trace-2026-09-24/concurrent.txt @@ -0,0 +1,18 @@ +# MySQL 8.0.46 · W=8 I=40 R=25 · 블록 4(첫 블록 버림) +# block order arm deadlocks attempts other_errors final_rows +0 0 base 165 320 0 200 +0 1 read_committed 0 320 0 200 +0 2 natural_pk 0 320 0 200 +0 3 natural_pk_keep_id 0 320 0 200 +1 0 read_committed 0 320 0 200 +1 1 natural_pk 0 320 0 200 +1 2 natural_pk_keep_id 0 320 0 200 +1 3 base 155 320 0 200 +2 0 natural_pk 0 320 0 200 +2 1 natural_pk_keep_id 0 320 0 200 +2 2 base 147 320 0 200 +2 3 read_committed 0 320 0 200 +3 0 natural_pk_keep_id 0 320 0 200 +3 1 base 129 320 0 200 +3 2 read_committed 0 320 0 200 +3 3 natural_pk 0 320 0 200 diff --git a/loadtest/results/r276-lock-trace-2026-09-24/trace.txt b/loadtest/results/r276-lock-trace-2026-09-24/trace.txt new file mode 100644 index 00000000..a2703a6e --- /dev/null +++ b/loadtest/results/r276-lock-trace-2026-09-24/trace.txt @@ -0,0 +1,621 @@ +# MySQL 8.0.46 · 기본 격리 REPEATABLE-READ · SLEEP=1 + +################ 팔: base ################ +>>> T1: BEGIN; +>>> T2: BEGIN; +=== [0 시작 — 둘 다 BEGIN] + (잠금 없음이면 표가 비어 있다) +>>> T1: INSERT INTO pose_lab (session_id, rep_number, timestamp_sec, joint_coordinates, sync_rate, smoothed_knee_angle, feedback_message, created_at) VALUES (901, 1, 1.000, JSON_OBJECT('k',1), 50.00, 0.00, NULL, '2026-05-15 10:00:00') ON DUPLICATE KEY UPDATE session_id = session_id; +=== [1 T1 이 중복(901,1,1) 을 넣은 직후] ++------+---------------+----------+--------+---------------+---------+---------------------------------------+ +| who | idx | part | type | mode | status | data | ++------+---------------+----------+--------+---------------+---------+---------------------------------------+ +| t1 | PRIMARY | p2026_05 | RECORD | X | GRANTED | supremum pseudo-record | +| t1 | PRIMARY | p2026_05 | RECORD | X,REC_NOT_GAP | GRANTED | 1, 0x6A067010 | +| t1 | uk_pose_event | p2026_05 | RECORD | X | GRANTED | 901, 1, 0x800000010000, 0x6A067010, 1 | +| t1 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | ++------+---------------+----------+--------+---------------+---------+---------------------------------------+ + (잠금 없음이면 표가 비어 있다) +>>> T2: INSERT INTO pose_lab (session_id, rep_number, timestamp_sec, joint_coordinates, sync_rate, smoothed_knee_angle, feedback_message, created_at) VALUES (902, 1, 1.000, JSON_OBJECT('k',1), 50.00, 0.00, NULL, '2026-05-15 10:00:00') ON DUPLICATE KEY UPDATE session_id = session_id; +=== [2 T2 가 중복(902,1,1) 을 넣은 직후] ++------+---------------+----------+--------+--------------------+---------+---------------------------------------+ +| who | idx | part | type | mode | status | data | ++------+---------------+----------+--------+--------------------+---------+---------------------------------------+ +| t1 | PRIMARY | p2026_05 | RECORD | X | GRANTED | supremum pseudo-record | +| t1 | PRIMARY | p2026_05 | RECORD | X,REC_NOT_GAP | GRANTED | 1, 0x6A067010 | +| t1 | uk_pose_event | p2026_05 | RECORD | X | GRANTED | 901, 1, 0x800000010000, 0x6A067010, 1 | +| t1 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | +| t2 | PRIMARY | p2026_05 | RECORD | X,INSERT_INTENTION | WAITING | supremum pseudo-record | +| t2 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | ++------+---------------+----------+--------+--------------------+---------+---------------------------------------+ + (잠금 없음이면 표가 비어 있다) +>>> T1: INSERT INTO pose_lab (session_id, rep_number, timestamp_sec, joint_coordinates, sync_rate, smoothed_knee_angle, feedback_message, created_at) VALUES (901, 2, 2.000, JSON_OBJECT('k',1), 50.00, 0.00, NULL, '2026-05-15 10:00:00') ON DUPLICATE KEY UPDATE session_id = session_id; +=== [3 T1 이 신규(901,2,2) 를 넣은 직후] ++------+---------------+----------+--------+--------------------+---------+---------------------------------------+ +| who | idx | part | type | mode | status | data | ++------+---------------+----------+--------+--------------------+---------+---------------------------------------+ +| t1 | PRIMARY | p2026_05 | RECORD | X | GRANTED | supremum pseudo-record | +| t1 | PRIMARY | p2026_05 | RECORD | X,REC_NOT_GAP | GRANTED | 1, 0x6A067010 | +| t1 | PRIMARY | p2026_05 | RECORD | X,GAP | GRANTED | 4, 0x6A067010 | +| t1 | uk_pose_event | p2026_05 | RECORD | X | GRANTED | 901, 1, 0x800000010000, 0x6A067010, 1 | +| t1 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | +| t2 | PRIMARY | p2026_05 | RECORD | X,INSERT_INTENTION | WAITING | supremum pseudo-record | +| t2 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | ++------+---------------+----------+--------+--------------------+---------+---------------------------------------+ + (잠금 없음이면 표가 비어 있다) +>>> T2: INSERT INTO pose_lab (session_id, rep_number, timestamp_sec, joint_coordinates, sync_rate, smoothed_knee_angle, feedback_message, created_at) VALUES (902, 2, 2.000, JSON_OBJECT('k',1), 50.00, 0.00, NULL, '2026-05-15 10:00:00') ON DUPLICATE KEY UPDATE session_id = session_id; +=== [4 T2 가 신규(902,2,2) 를 넣은 직후] ++------+---------------+----------+--------+--------------------+---------+---------------------------------------+ +| who | idx | part | type | mode | status | data | ++------+---------------+----------+--------+--------------------+---------+---------------------------------------+ +| t1 | PRIMARY | p2026_05 | RECORD | X | GRANTED | supremum pseudo-record | +| t1 | PRIMARY | p2026_05 | RECORD | X,REC_NOT_GAP | GRANTED | 1, 0x6A067010 | +| t1 | PRIMARY | p2026_05 | RECORD | X,GAP | GRANTED | 4, 0x6A067010 | +| t1 | uk_pose_event | p2026_05 | RECORD | X | GRANTED | 901, 1, 0x800000010000, 0x6A067010, 1 | +| t1 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | +| t2 | PRIMARY | p2026_05 | RECORD | X,INSERT_INTENTION | WAITING | supremum pseudo-record | +| t2 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | ++------+---------------+----------+--------+--------------------+---------+---------------------------------------+ + (잠금 없음이면 표가 비어 있다) +>>> T1: COMMIT; +>>> T2: COMMIT; +=== [5 둘 다 COMMIT 뒤] + (잠금 없음이면 표가 비어 있다) +session_id rep_number timestamp_sec id +901 1 1.000 1 +901 2 2.000 4 +902 1 1.000 2 +902 2 2.000 5 +--- t1 세션 로그 (오류만) + Query OK, 0 rows affected (0.00 sec) + Query OK, 0 rows affected (0.00 sec) + Query OK, 1 row affected (0.00 sec) + Query OK, 0 rows affected (0.14 sec) +--- t2 세션 로그 (오류만) + Query OK, 0 rows affected (0.00 sec) + Query OK, 0 rows affected (3.24 sec) + Query OK, 1 row affected (0.00 sec) + Query OK, 0 rows affected (0.08 sec) +--- 이 팔의 데드락 수: 0 + +################ 팔: dup_blocks_new ################ +>>> T1: BEGIN; +>>> T2: BEGIN; +>>> T1: INSERT INTO pose_lab (session_id, rep_number, timestamp_sec, joint_coordinates, sync_rate, smoothed_knee_angle, feedback_message, created_at) VALUES (901, 1, 1.000, JSON_OBJECT('k',1), 50.00, 0.00, NULL, '2026-05-15 10:00:00') ON DUPLICATE KEY UPDATE session_id = session_id; +=== [1 T1 중복] ++------+---------------+----------+--------+---------------+---------+---------------------------------------+ +| who | idx | part | type | mode | status | data | ++------+---------------+----------+--------+---------------+---------+---------------------------------------+ +| t1 | PRIMARY | p2026_05 | RECORD | X | GRANTED | supremum pseudo-record | +| t1 | PRIMARY | p2026_05 | RECORD | X,REC_NOT_GAP | GRANTED | 1, 0x6A067010 | +| t1 | uk_pose_event | p2026_05 | RECORD | X | GRANTED | 901, 1, 0x800000010000, 0x6A067010, 1 | +| t1 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | ++------+---------------+----------+--------+---------------+---------+---------------------------------------+ + (잠금 없음이면 표가 비어 있다) +>>> T2: INSERT INTO pose_lab (session_id, rep_number, timestamp_sec, joint_coordinates, sync_rate, smoothed_knee_angle, feedback_message, created_at) VALUES (903, 1, 1.000, JSON_OBJECT('k',1), 50.00, 0.00, NULL, '2026-05-15 10:00:00') ON DUPLICATE KEY UPDATE session_id = session_id; +=== [2 T2 가 무관한 신규(903,1,1) 를 넣은 직후] ++------+---------------+----------+--------+--------------------+---------+---------------------------------------+ +| who | idx | part | type | mode | status | data | ++------+---------------+----------+--------+--------------------+---------+---------------------------------------+ +| t1 | PRIMARY | p2026_05 | RECORD | X | GRANTED | supremum pseudo-record | +| t1 | PRIMARY | p2026_05 | RECORD | X,REC_NOT_GAP | GRANTED | 1, 0x6A067010 | +| t1 | uk_pose_event | p2026_05 | RECORD | X | GRANTED | 901, 1, 0x800000010000, 0x6A067010, 1 | +| t1 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | +| t2 | PRIMARY | p2026_05 | RECORD | X,INSERT_INTENTION | WAITING | supremum pseudo-record | +| t2 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | ++------+---------------+----------+--------+--------------------+---------+---------------------------------------+ + (잠금 없음이면 표가 비어 있다) +>>> T1: COMMIT; +=== [3 T1 COMMIT 뒤] ++------+---------+----------+--------+--------------------+---------+------------------------+ +| who | idx | part | type | mode | status | data | ++------+---------+----------+--------+--------------------+---------+------------------------+ +| t2 | PRIMARY | p2026_05 | RECORD | X,INSERT_INTENTION | GRANTED | supremum pseudo-record | +| t2 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | ++------+---------+----------+--------+--------------------+---------+------------------------+ + (잠금 없음이면 표가 비어 있다) +>>> T2: COMMIT; +--- t1 세션 로그 (오류만) + Query OK, 0 rows affected (0.00 sec) + Query OK, 0 rows affected (0.01 sec) + Query OK, 0 rows affected (0.04 sec) +--- t2 세션 로그 (오류만) + Query OK, 0 rows affected (0.00 sec) + Query OK, 1 row affected (1.02 sec) + Query OK, 0 rows affected (0.03 sec) +--- 이 팔의 데드락 수: 0 + +################ 팔: plain_insert ################ +>>> T1: BEGIN; +>>> T1: INSERT INTO pose_lab (session_id, rep_number, timestamp_sec, joint_coordinates, sync_rate, smoothed_knee_angle, feedback_message, created_at) VALUES (901, 1, 1.000, JSON_OBJECT('k',1), 50.00, 0.00, NULL, '2026-05-15 10:00:00'); +=== [1 T1 의 평범한 INSERT 가 1062 로 실패한 직후] ++------+---------------+----------+--------+------+---------+---------------------------------------+ +| who | idx | part | type | mode | status | data | ++------+---------------+----------+--------+------+---------+---------------------------------------+ +| t1 | PRIMARY | p2026_05 | RECORD | X | GRANTED | supremum pseudo-record | +| t1 | uk_pose_event | p2026_05 | RECORD | S | GRANTED | 901, 1, 0x800000010000, 0x6A067010, 1 | +| t1 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | ++------+---------------+----------+--------+------+---------+---------------------------------------+ + (잠금 없음이면 표가 비어 있다) +>>> T1: ROLLBACK; +--- t1 세션 로그 (오류만) + Query OK, 0 rows affected (0.00 sec) + ERROR 1062 (23000) at line 2: Duplicate entry '901-1-1.000-2026-05-15 10:00:00' for key 'pose_lab.uk_pose_event' + Query OK, 0 rows affected (0.01 sec) +--- t2 세션 로그 (오류만) +--- 이 팔의 데드락 수: 0 + +################ 팔: dup_only ################ +>>> T1: BEGIN; +>>> T2: BEGIN; +>>> T1: INSERT INTO pose_lab (session_id, rep_number, timestamp_sec, joint_coordinates, sync_rate, smoothed_knee_angle, feedback_message, created_at) VALUES (901, 1, 1.000, JSON_OBJECT('k',1), 50.00, 0.00, NULL, '2026-05-15 10:00:00') ON DUPLICATE KEY UPDATE session_id = session_id; +=== [1 T1 중복] ++------+---------------+----------+--------+---------------+---------+---------------------------------------+ +| who | idx | part | type | mode | status | data | ++------+---------------+----------+--------+---------------+---------+---------------------------------------+ +| t1 | PRIMARY | p2026_05 | RECORD | X | GRANTED | supremum pseudo-record | +| t1 | PRIMARY | p2026_05 | RECORD | X,REC_NOT_GAP | GRANTED | 1, 0x6A067010 | +| t1 | uk_pose_event | p2026_05 | RECORD | X | GRANTED | 901, 1, 0x800000010000, 0x6A067010, 1 | +| t1 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | ++------+---------------+----------+--------+---------------+---------+---------------------------------------+ + (잠금 없음이면 표가 비어 있다) +>>> T2: INSERT INTO pose_lab (session_id, rep_number, timestamp_sec, joint_coordinates, sync_rate, smoothed_knee_angle, feedback_message, created_at) VALUES (902, 1, 1.000, JSON_OBJECT('k',1), 50.00, 0.00, NULL, '2026-05-15 10:00:00') ON DUPLICATE KEY UPDATE session_id = session_id; +=== [2 T2 중복] ++------+---------------+----------+--------+--------------------+---------+---------------------------------------+ +| who | idx | part | type | mode | status | data | ++------+---------------+----------+--------+--------------------+---------+---------------------------------------+ +| t1 | PRIMARY | p2026_05 | RECORD | X | GRANTED | supremum pseudo-record | +| t1 | PRIMARY | p2026_05 | RECORD | X,REC_NOT_GAP | GRANTED | 1, 0x6A067010 | +| t1 | uk_pose_event | p2026_05 | RECORD | X | GRANTED | 901, 1, 0x800000010000, 0x6A067010, 1 | +| t1 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | +| t2 | PRIMARY | p2026_05 | RECORD | X,INSERT_INTENTION | WAITING | supremum pseudo-record | +| t2 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | ++------+---------------+----------+--------+--------------------+---------+---------------------------------------+ + (잠금 없음이면 표가 비어 있다) +>>> T1: INSERT INTO pose_lab (session_id, rep_number, timestamp_sec, joint_coordinates, sync_rate, smoothed_knee_angle, feedback_message, created_at) VALUES (901, 2, 2.000, JSON_OBJECT('k',1), 50.00, 0.00, NULL, '2026-05-15 10:00:00') ON DUPLICATE KEY UPDATE session_id = session_id; +=== [3 T1 중복(두 번째)] ++------+---------------+----------+--------+--------------------+---------+---------------------------------------+ +| who | idx | part | type | mode | status | data | ++------+---------------+----------+--------+--------------------+---------+---------------------------------------+ +| t1 | PRIMARY | p2026_05 | RECORD | X | GRANTED | supremum pseudo-record | +| t1 | PRIMARY | p2026_05 | RECORD | X,REC_NOT_GAP | GRANTED | 1, 0x6A067010 | +| t1 | PRIMARY | p2026_05 | RECORD | X,REC_NOT_GAP | GRANTED | 3, 0x6A067010 | +| t1 | uk_pose_event | p2026_05 | RECORD | X | GRANTED | 901, 1, 0x800000010000, 0x6A067010, 1 | +| t1 | uk_pose_event | p2026_05 | RECORD | X | GRANTED | 901, 2, 0x800000020000, 0x6A067010, 3 | +| t1 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | +| t2 | PRIMARY | p2026_05 | RECORD | X,INSERT_INTENTION | WAITING | supremum pseudo-record | +| t2 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | ++------+---------------+----------+--------+--------------------+---------+---------------------------------------+ + (잠금 없음이면 표가 비어 있다) +>>> T2: INSERT INTO pose_lab (session_id, rep_number, timestamp_sec, joint_coordinates, sync_rate, smoothed_knee_angle, feedback_message, created_at) VALUES (902, 2, 2.000, JSON_OBJECT('k',1), 50.00, 0.00, NULL, '2026-05-15 10:00:00') ON DUPLICATE KEY UPDATE session_id = session_id; +=== [4 T2 중복(두 번째)] ++------+---------------+----------+--------+--------------------+---------+---------------------------------------+ +| who | idx | part | type | mode | status | data | ++------+---------------+----------+--------+--------------------+---------+---------------------------------------+ +| t1 | PRIMARY | p2026_05 | RECORD | X | GRANTED | supremum pseudo-record | +| t1 | PRIMARY | p2026_05 | RECORD | X,REC_NOT_GAP | GRANTED | 1, 0x6A067010 | +| t1 | PRIMARY | p2026_05 | RECORD | X,REC_NOT_GAP | GRANTED | 3, 0x6A067010 | +| t1 | uk_pose_event | p2026_05 | RECORD | X | GRANTED | 901, 1, 0x800000010000, 0x6A067010, 1 | +| t1 | uk_pose_event | p2026_05 | RECORD | X | GRANTED | 901, 2, 0x800000020000, 0x6A067010, 3 | +| t1 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | +| t2 | PRIMARY | p2026_05 | RECORD | X,INSERT_INTENTION | WAITING | supremum pseudo-record | +| t2 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | ++------+---------------+----------+--------+--------------------+---------+---------------------------------------+ + (잠금 없음이면 표가 비어 있다) +>>> T1: COMMIT; +>>> T2: COMMIT; +--- t1 세션 로그 (오류만) + Query OK, 0 rows affected (0.01 sec) + Query OK, 0 rows affected (0.00 sec) + Query OK, 0 rows affected (0.00 sec) + Query OK, 0 rows affected (0.03 sec) +--- t2 세션 로그 (오류만) + Query OK, 0 rows affected (0.01 sec) + Query OK, 0 rows affected (3.09 sec) + Query OK, 0 rows affected (0.01 sec) + Query OK, 0 rows affected (0.04 sec) +--- 이 팔의 데드락 수: 0 + +################ 팔: new_only ################ +>>> T1: BEGIN; +>>> T2: BEGIN; +>>> T1: INSERT INTO pose_lab (session_id, rep_number, timestamp_sec, joint_coordinates, sync_rate, smoothed_knee_angle, feedback_message, created_at) VALUES (901, 3, 3.000, JSON_OBJECT('k',1), 50.00, 0.00, NULL, '2026-05-15 10:00:00') ON DUPLICATE KEY UPDATE session_id = session_id; +=== [1 T1 신규] ++------+------+----------+-------+------+---------+------+ +| who | idx | part | type | mode | status | data | ++------+------+----------+-------+------+---------+------+ +| t1 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | ++------+------+----------+-------+------+---------+------+ + (잠금 없음이면 표가 비어 있다) +>>> T2: INSERT INTO pose_lab (session_id, rep_number, timestamp_sec, joint_coordinates, sync_rate, smoothed_knee_angle, feedback_message, created_at) VALUES (902, 3, 3.000, JSON_OBJECT('k',1), 50.00, 0.00, NULL, '2026-05-15 10:00:00') ON DUPLICATE KEY UPDATE session_id = session_id; +=== [2 T2 신규] ++------+------+----------+-------+------+---------+------+ +| who | idx | part | type | mode | status | data | ++------+------+----------+-------+------+---------+------+ +| t1 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | +| t2 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | ++------+------+----------+-------+------+---------+------+ + (잠금 없음이면 표가 비어 있다) +>>> T1: INSERT INTO pose_lab (session_id, rep_number, timestamp_sec, joint_coordinates, sync_rate, smoothed_knee_angle, feedback_message, created_at) VALUES (901, 4, 4.000, JSON_OBJECT('k',1), 50.00, 0.00, NULL, '2026-05-15 10:00:00') ON DUPLICATE KEY UPDATE session_id = session_id; +=== [3 T1 신규(두 번째)] ++------+------+----------+-------+------+---------+------+ +| who | idx | part | type | mode | status | data | ++------+------+----------+-------+------+---------+------+ +| t1 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | +| t2 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | ++------+------+----------+-------+------+---------+------+ + (잠금 없음이면 표가 비어 있다) +>>> T2: INSERT INTO pose_lab (session_id, rep_number, timestamp_sec, joint_coordinates, sync_rate, smoothed_knee_angle, feedback_message, created_at) VALUES (902, 4, 4.000, JSON_OBJECT('k',1), 50.00, 0.00, NULL, '2026-05-15 10:00:00') ON DUPLICATE KEY UPDATE session_id = session_id; +=== [4 T2 신규(두 번째)] ++------+------+----------+-------+------+---------+------+ +| who | idx | part | type | mode | status | data | ++------+------+----------+-------+------+---------+------+ +| t1 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | +| t2 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | ++------+------+----------+-------+------+---------+------+ + (잠금 없음이면 표가 비어 있다) +>>> T1: COMMIT; +>>> T2: COMMIT; +--- t1 세션 로그 (오류만) + Query OK, 0 rows affected (0.00 sec) + Query OK, 1 row affected (0.00 sec) + Query OK, 1 row affected (0.00 sec) + Query OK, 0 rows affected (0.09 sec) +--- t2 세션 로그 (오류만) + Query OK, 0 rows affected (0.00 sec) + Query OK, 1 row affected (0.01 sec) + Query OK, 1 row affected (0.00 sec) + Query OK, 0 rows affected (0.07 sec) +--- 이 팔의 데드락 수: 0 + +################ 팔: no_uk ################ +>>> T1: BEGIN; +>>> T2: BEGIN; +=== [0 시작 — 둘 다 BEGIN] + (잠금 없음이면 표가 비어 있다) +>>> T1: INSERT INTO pose_lab (session_id, rep_number, timestamp_sec, joint_coordinates, sync_rate, smoothed_knee_angle, feedback_message, created_at) VALUES (901, 1, 1.000, JSON_OBJECT('k',1), 50.00, 0.00, NULL, '2026-05-15 10:00:00') ON DUPLICATE KEY UPDATE session_id = session_id; +=== [1 T1 이 중복(901,1,1) 을 넣은 직후] ++------+------+----------+-------+------+---------+------+ +| who | idx | part | type | mode | status | data | ++------+------+----------+-------+------+---------+------+ +| t1 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | ++------+------+----------+-------+------+---------+------+ + (잠금 없음이면 표가 비어 있다) +>>> T2: INSERT INTO pose_lab (session_id, rep_number, timestamp_sec, joint_coordinates, sync_rate, smoothed_knee_angle, feedback_message, created_at) VALUES (902, 1, 1.000, JSON_OBJECT('k',1), 50.00, 0.00, NULL, '2026-05-15 10:00:00') ON DUPLICATE KEY UPDATE session_id = session_id; +=== [2 T2 가 중복(902,1,1) 을 넣은 직후] ++------+------+----------+-------+------+---------+------+ +| who | idx | part | type | mode | status | data | ++------+------+----------+-------+------+---------+------+ +| t1 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | +| t2 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | ++------+------+----------+-------+------+---------+------+ + (잠금 없음이면 표가 비어 있다) +>>> T1: INSERT INTO pose_lab (session_id, rep_number, timestamp_sec, joint_coordinates, sync_rate, smoothed_knee_angle, feedback_message, created_at) VALUES (901, 2, 2.000, JSON_OBJECT('k',1), 50.00, 0.00, NULL, '2026-05-15 10:00:00') ON DUPLICATE KEY UPDATE session_id = session_id; +=== [3 T1 이 신규(901,2,2) 를 넣은 직후] ++------+------+----------+-------+------+---------+------+ +| who | idx | part | type | mode | status | data | ++------+------+----------+-------+------+---------+------+ +| t1 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | +| t2 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | ++------+------+----------+-------+------+---------+------+ + (잠금 없음이면 표가 비어 있다) +>>> T2: INSERT INTO pose_lab (session_id, rep_number, timestamp_sec, joint_coordinates, sync_rate, smoothed_knee_angle, feedback_message, created_at) VALUES (902, 2, 2.000, JSON_OBJECT('k',1), 50.00, 0.00, NULL, '2026-05-15 10:00:00') ON DUPLICATE KEY UPDATE session_id = session_id; +=== [4 T2 가 신규(902,2,2) 를 넣은 직후] ++------+------+----------+-------+------+---------+------+ +| who | idx | part | type | mode | status | data | ++------+------+----------+-------+------+---------+------+ +| t1 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | +| t2 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | ++------+------+----------+-------+------+---------+------+ + (잠금 없음이면 표가 비어 있다) +>>> T1: COMMIT; +>>> T2: COMMIT; +=== [5 둘 다 COMMIT 뒤] + (잠금 없음이면 표가 비어 있다) +session_id rep_number timestamp_sec id +901 1 1.000 1 +901 1 1.000 3 +901 2 2.000 5 +902 1 1.000 2 +902 1 1.000 4 +902 2 2.000 6 +--- t1 세션 로그 (오류만) + Query OK, 0 rows affected (0.00 sec) + Query OK, 1 row affected (0.00 sec) + Query OK, 1 row affected (0.00 sec) + Query OK, 0 rows affected (0.07 sec) +--- t2 세션 로그 (오류만) + Query OK, 0 rows affected (0.00 sec) + Query OK, 1 row affected (0.01 sec) + Query OK, 1 row affected (0.00 sec) + Query OK, 0 rows affected (0.02 sec) +--- 이 팔의 데드락 수: 0 + +################ 팔: insert_ignore ################ +>>> T1: BEGIN; +>>> T2: BEGIN; +=== [0 시작 — 둘 다 BEGIN] + (잠금 없음이면 표가 비어 있다) +>>> T1: INSERT IGNORE INTO pose_lab (session_id, rep_number, timestamp_sec, joint_coordinates, sync_rate, smoothed_knee_angle, feedback_message, created_at) VALUES (901, 1, 1.000, JSON_OBJECT('k',1), 50.00, 0.00, NULL, '2026-05-15 10:00:00'); +=== [1 T1 이 중복(901,1,1) 을 넣은 직후] ++------+---------------+----------+--------+------+---------+---------------------------------------+ +| who | idx | part | type | mode | status | data | ++------+---------------+----------+--------+------+---------+---------------------------------------+ +| t1 | PRIMARY | p2026_05 | RECORD | X | GRANTED | supremum pseudo-record | +| t1 | uk_pose_event | p2026_05 | RECORD | S | GRANTED | 901, 1, 0x800000010000, 0x6A067010, 1 | +| t1 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | ++------+---------------+----------+--------+------+---------+---------------------------------------+ + (잠금 없음이면 표가 비어 있다) +>>> T2: INSERT IGNORE INTO pose_lab (session_id, rep_number, timestamp_sec, joint_coordinates, sync_rate, smoothed_knee_angle, feedback_message, created_at) VALUES (902, 1, 1.000, JSON_OBJECT('k',1), 50.00, 0.00, NULL, '2026-05-15 10:00:00'); +=== [2 T2 가 중복(902,1,1) 을 넣은 직후] ++------+---------------+----------+--------+--------------------+---------+---------------------------------------+ +| who | idx | part | type | mode | status | data | ++------+---------------+----------+--------+--------------------+---------+---------------------------------------+ +| t1 | PRIMARY | p2026_05 | RECORD | X | GRANTED | supremum pseudo-record | +| t1 | uk_pose_event | p2026_05 | RECORD | S | GRANTED | 901, 1, 0x800000010000, 0x6A067010, 1 | +| t1 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | +| t2 | PRIMARY | p2026_05 | RECORD | X,INSERT_INTENTION | WAITING | supremum pseudo-record | +| t2 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | ++------+---------------+----------+--------+--------------------+---------+---------------------------------------+ + (잠금 없음이면 표가 비어 있다) +>>> T1: INSERT IGNORE INTO pose_lab (session_id, rep_number, timestamp_sec, joint_coordinates, sync_rate, smoothed_knee_angle, feedback_message, created_at) VALUES (901, 2, 2.000, JSON_OBJECT('k',1), 50.00, 0.00, NULL, '2026-05-15 10:00:00'); +=== [3 T1 이 신규(901,2,2) 를 넣은 직후] ++------+---------------+----------+--------+--------------------+---------+---------------------------------------+ +| who | idx | part | type | mode | status | data | ++------+---------------+----------+--------+--------------------+---------+---------------------------------------+ +| t1 | PRIMARY | p2026_05 | RECORD | X | GRANTED | supremum pseudo-record | +| t1 | PRIMARY | p2026_05 | RECORD | X,GAP | GRANTED | 4, 0x6A067010 | +| t1 | uk_pose_event | p2026_05 | RECORD | S | GRANTED | 901, 1, 0x800000010000, 0x6A067010, 1 | +| t1 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | +| t2 | PRIMARY | p2026_05 | RECORD | X,INSERT_INTENTION | WAITING | supremum pseudo-record | +| t2 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | ++------+---------------+----------+--------+--------------------+---------+---------------------------------------+ + (잠금 없음이면 표가 비어 있다) +>>> T2: INSERT IGNORE INTO pose_lab (session_id, rep_number, timestamp_sec, joint_coordinates, sync_rate, smoothed_knee_angle, feedback_message, created_at) VALUES (902, 2, 2.000, JSON_OBJECT('k',1), 50.00, 0.00, NULL, '2026-05-15 10:00:00'); +=== [4 T2 가 신규(902,2,2) 를 넣은 직후] ++------+---------------+----------+--------+--------------------+---------+---------------------------------------+ +| who | idx | part | type | mode | status | data | ++------+---------------+----------+--------+--------------------+---------+---------------------------------------+ +| t1 | PRIMARY | p2026_05 | RECORD | X | GRANTED | supremum pseudo-record | +| t1 | PRIMARY | p2026_05 | RECORD | X,GAP | GRANTED | 4, 0x6A067010 | +| t1 | uk_pose_event | p2026_05 | RECORD | S | GRANTED | 901, 1, 0x800000010000, 0x6A067010, 1 | +| t1 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | +| t2 | PRIMARY | p2026_05 | RECORD | X,INSERT_INTENTION | WAITING | supremum pseudo-record | +| t2 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | ++------+---------------+----------+--------+--------------------+---------+---------------------------------------+ + (잠금 없음이면 표가 비어 있다) +>>> T1: COMMIT; +>>> T2: COMMIT; +=== [5 둘 다 COMMIT 뒤] + (잠금 없음이면 표가 비어 있다) +session_id rep_number timestamp_sec id +901 1 1.000 1 +901 2 2.000 4 +902 1 1.000 2 +902 2 2.000 5 +--- t1 세션 로그 (오류만) + Query OK, 0 rows affected (0.00 sec) + Query OK, 0 rows affected, 1 warning (0.00 sec) + Query OK, 1 row affected (0.00 sec) + Query OK, 0 rows affected (0.09 sec) +--- t2 세션 로그 (오류만) + Query OK, 0 rows affected (0.00 sec) + Query OK, 0 rows affected, 1 warning (3.15 sec) + Query OK, 1 row affected (0.01 sec) + Query OK, 0 rows affected (0.05 sec) +--- 이 팔의 데드락 수: 0 + +################ 팔: read_committed ################ +>>> T1: SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; BEGIN; +>>> T2: SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; BEGIN; +=== [0 시작 — 둘 다 BEGIN] + (잠금 없음이면 표가 비어 있다) +>>> T1: INSERT INTO pose_lab (session_id, rep_number, timestamp_sec, joint_coordinates, sync_rate, smoothed_knee_angle, feedback_message, created_at) VALUES (901, 1, 1.000, JSON_OBJECT('k',1), 50.00, 0.00, NULL, '2026-05-15 10:00:00') ON DUPLICATE KEY UPDATE session_id = session_id; +=== [1 T1 이 중복(901,1,1) 을 넣은 직후] ++------+---------------+----------+--------+---------------+---------+---------------------------------------+ +| who | idx | part | type | mode | status | data | ++------+---------------+----------+--------+---------------+---------+---------------------------------------+ +| t1 | PRIMARY | p2026_05 | RECORD | X,REC_NOT_GAP | GRANTED | 1, 0x6A067010 | +| t1 | uk_pose_event | p2026_05 | RECORD | X | GRANTED | 901, 1, 0x800000010000, 0x6A067010, 1 | +| t1 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | ++------+---------------+----------+--------+---------------+---------+---------------------------------------+ + (잠금 없음이면 표가 비어 있다) +>>> T2: INSERT INTO pose_lab (session_id, rep_number, timestamp_sec, joint_coordinates, sync_rate, smoothed_knee_angle, feedback_message, created_at) VALUES (902, 1, 1.000, JSON_OBJECT('k',1), 50.00, 0.00, NULL, '2026-05-15 10:00:00') ON DUPLICATE KEY UPDATE session_id = session_id; +=== [2 T2 가 중복(902,1,1) 을 넣은 직후] ++------+---------------+----------+--------+---------------+---------+---------------------------------------+ +| who | idx | part | type | mode | status | data | ++------+---------------+----------+--------+---------------+---------+---------------------------------------+ +| t1 | PRIMARY | p2026_05 | RECORD | X,REC_NOT_GAP | GRANTED | 1, 0x6A067010 | +| t1 | uk_pose_event | p2026_05 | RECORD | X | GRANTED | 901, 1, 0x800000010000, 0x6A067010, 1 | +| t1 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | +| t2 | PRIMARY | p2026_05 | RECORD | X,REC_NOT_GAP | GRANTED | 2, 0x6A067010 | +| t2 | uk_pose_event | p2026_05 | RECORD | X | GRANTED | 902, 1, 0x800000010000, 0x6A067010, 2 | +| t2 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | ++------+---------------+----------+--------+---------------+---------+---------------------------------------+ + (잠금 없음이면 표가 비어 있다) +>>> T1: INSERT INTO pose_lab (session_id, rep_number, timestamp_sec, joint_coordinates, sync_rate, smoothed_knee_angle, feedback_message, created_at) VALUES (901, 2, 2.000, JSON_OBJECT('k',1), 50.00, 0.00, NULL, '2026-05-15 10:00:00') ON DUPLICATE KEY UPDATE session_id = session_id; +=== [3 T1 이 신규(901,2,2) 를 넣은 직후] ++------+---------------+----------+--------+------------------------+---------+---------------------------------------+ +| who | idx | part | type | mode | status | data | ++------+---------------+----------+--------+------------------------+---------+---------------------------------------+ +| t1 | PRIMARY | p2026_05 | RECORD | X,REC_NOT_GAP | GRANTED | 1, 0x6A067010 | +| t1 | uk_pose_event | p2026_05 | RECORD | X | GRANTED | 901, 1, 0x800000010000, 0x6A067010, 1 | +| t1 | uk_pose_event | p2026_05 | RECORD | X,GAP,INSERT_INTENTION | WAITING | 902, 1, 0x800000010000, 0x6A067010, 2 | +| t1 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | +| t2 | PRIMARY | p2026_05 | RECORD | X,REC_NOT_GAP | GRANTED | 2, 0x6A067010 | +| t2 | uk_pose_event | p2026_05 | RECORD | X | GRANTED | 902, 1, 0x800000010000, 0x6A067010, 2 | +| t2 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | ++------+---------------+----------+--------+------------------------+---------+---------------------------------------+ + (잠금 없음이면 표가 비어 있다) +>>> T2: INSERT INTO pose_lab (session_id, rep_number, timestamp_sec, joint_coordinates, sync_rate, smoothed_knee_angle, feedback_message, created_at) VALUES (902, 2, 2.000, JSON_OBJECT('k',1), 50.00, 0.00, NULL, '2026-05-15 10:00:00') ON DUPLICATE KEY UPDATE session_id = session_id; +=== [4 T2 가 신규(902,2,2) 를 넣은 직후] ++------+---------------+----------+--------+------------------------+---------+---------------------------------------+ +| who | idx | part | type | mode | status | data | ++------+---------------+----------+--------+------------------------+---------+---------------------------------------+ +| t1 | PRIMARY | p2026_05 | RECORD | X,REC_NOT_GAP | GRANTED | 1, 0x6A067010 | +| t1 | uk_pose_event | p2026_05 | RECORD | X | GRANTED | 901, 1, 0x800000010000, 0x6A067010, 1 | +| t1 | uk_pose_event | p2026_05 | RECORD | X,GAP,INSERT_INTENTION | WAITING | 902, 1, 0x800000010000, 0x6A067010, 2 | +| t1 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | +| t2 | PRIMARY | p2026_05 | RECORD | X,REC_NOT_GAP | GRANTED | 2, 0x6A067010 | +| t2 | uk_pose_event | p2026_05 | RECORD | X | GRANTED | 902, 1, 0x800000010000, 0x6A067010, 2 | +| t2 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | ++------+---------------+----------+--------+------------------------+---------+---------------------------------------+ + (잠금 없음이면 표가 비어 있다) +>>> T1: COMMIT; +>>> T2: COMMIT; +=== [5 둘 다 COMMIT 뒤] + (잠금 없음이면 표가 비어 있다) +session_id rep_number timestamp_sec id +901 1 1.000 1 +901 2 2.000 3 +902 1 1.000 2 +902 2 2.000 4 +--- t1 세션 로그 (오류만) + Query OK, 0 rows affected (0.00 sec) + Query OK, 0 rows affected (0.00 sec) + Query OK, 0 rows affected (0.00 sec) + Query OK, 1 row affected (3.04 sec) + Query OK, 0 rows affected (0.05 sec) +--- t2 세션 로그 (오류만) + Query OK, 0 rows affected (0.00 sec) + Query OK, 0 rows affected (0.00 sec) + Query OK, 0 rows affected (0.00 sec) + Query OK, 1 row affected (0.00 sec) + Query OK, 0 rows affected (0.03 sec) +--- 이 팔의 데드락 수: 0 + +################ 팔: natural_pk ################ +>>> T1: BEGIN; +>>> T2: BEGIN; +=== [0 시작 — 둘 다 BEGIN] + (잠금 없음이면 표가 비어 있다) +>>> T1: INSERT INTO pose_lab (session_id, rep_number, timestamp_sec, joint_coordinates, sync_rate, smoothed_knee_angle, feedback_message, created_at) VALUES (901, 1, 1.000, JSON_OBJECT('k',1), 50.00, 0.00, NULL, '2026-05-15 10:00:00') ON DUPLICATE KEY UPDATE session_id = session_id; +=== [1 T1 이 중복(901,1,1) 을 넣은 직후] ++------+---------+----------+--------+---------------+---------+------------------------------------+ +| who | idx | part | type | mode | status | data | ++------+---------+----------+--------+---------------+---------+------------------------------------+ +| t1 | PRIMARY | p2026_05 | RECORD | X,REC_NOT_GAP | GRANTED | 901, 1, 0x800000010000, 0x6A067010 | +| t1 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | ++------+---------+----------+--------+---------------+---------+------------------------------------+ + (잠금 없음이면 표가 비어 있다) +>>> T2: INSERT INTO pose_lab (session_id, rep_number, timestamp_sec, joint_coordinates, sync_rate, smoothed_knee_angle, feedback_message, created_at) VALUES (902, 1, 1.000, JSON_OBJECT('k',1), 50.00, 0.00, NULL, '2026-05-15 10:00:00') ON DUPLICATE KEY UPDATE session_id = session_id; +=== [2 T2 가 중복(902,1,1) 을 넣은 직후] ++------+---------+----------+--------+---------------+---------+------------------------------------+ +| who | idx | part | type | mode | status | data | ++------+---------+----------+--------+---------------+---------+------------------------------------+ +| t1 | PRIMARY | p2026_05 | RECORD | X,REC_NOT_GAP | GRANTED | 901, 1, 0x800000010000, 0x6A067010 | +| t1 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | +| t2 | PRIMARY | p2026_05 | RECORD | X,REC_NOT_GAP | GRANTED | 902, 1, 0x800000010000, 0x6A067010 | +| t2 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | ++------+---------+----------+--------+---------------+---------+------------------------------------+ + (잠금 없음이면 표가 비어 있다) +>>> T1: INSERT INTO pose_lab (session_id, rep_number, timestamp_sec, joint_coordinates, sync_rate, smoothed_knee_angle, feedback_message, created_at) VALUES (901, 2, 2.000, JSON_OBJECT('k',1), 50.00, 0.00, NULL, '2026-05-15 10:00:00') ON DUPLICATE KEY UPDATE session_id = session_id; +=== [3 T1 이 신규(901,2,2) 를 넣은 직후] ++------+---------+----------+--------+---------------+---------+------------------------------------+ +| who | idx | part | type | mode | status | data | ++------+---------+----------+--------+---------------+---------+------------------------------------+ +| t1 | PRIMARY | p2026_05 | RECORD | X,REC_NOT_GAP | GRANTED | 901, 1, 0x800000010000, 0x6A067010 | +| t1 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | +| t2 | PRIMARY | p2026_05 | RECORD | X,REC_NOT_GAP | GRANTED | 902, 1, 0x800000010000, 0x6A067010 | +| t2 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | ++------+---------+----------+--------+---------------+---------+------------------------------------+ + (잠금 없음이면 표가 비어 있다) +>>> T2: INSERT INTO pose_lab (session_id, rep_number, timestamp_sec, joint_coordinates, sync_rate, smoothed_knee_angle, feedback_message, created_at) VALUES (902, 2, 2.000, JSON_OBJECT('k',1), 50.00, 0.00, NULL, '2026-05-15 10:00:00') ON DUPLICATE KEY UPDATE session_id = session_id; +=== [4 T2 가 신규(902,2,2) 를 넣은 직후] ++------+---------+----------+--------+---------------+---------+------------------------------------+ +| who | idx | part | type | mode | status | data | ++------+---------+----------+--------+---------------+---------+------------------------------------+ +| t1 | PRIMARY | p2026_05 | RECORD | X,REC_NOT_GAP | GRANTED | 901, 1, 0x800000010000, 0x6A067010 | +| t1 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | +| t2 | PRIMARY | p2026_05 | RECORD | X,REC_NOT_GAP | GRANTED | 902, 1, 0x800000010000, 0x6A067010 | +| t2 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | ++------+---------+----------+--------+---------------+---------+------------------------------------+ + (잠금 없음이면 표가 비어 있다) +>>> T1: COMMIT; +>>> T2: COMMIT; +=== [5 둘 다 COMMIT 뒤] + (잠금 없음이면 표가 비어 있다) +session_id rep_number timestamp_sec +901 1 1.000 +901 2 2.000 +902 1 1.000 +902 2 2.000 +--- t1 세션 로그 (오류만) + Query OK, 0 rows affected (0.00 sec) + Query OK, 0 rows affected (0.01 sec) + Query OK, 1 row affected (0.00 sec) + Query OK, 0 rows affected (0.06 sec) +--- t2 세션 로그 (오류만) + Query OK, 0 rows affected (0.00 sec) + Query OK, 0 rows affected (0.00 sec) + Query OK, 1 row affected (0.00 sec) + Query OK, 0 rows affected (0.07 sec) +--- 이 팔의 데드락 수: 0 + +################ 팔: natural_pk_keep_id ################ +>>> T1: BEGIN; +>>> T2: BEGIN; +=== [0 시작 — 둘 다 BEGIN] + (잠금 없음이면 표가 비어 있다) +>>> T1: INSERT INTO pose_lab (session_id, rep_number, timestamp_sec, joint_coordinates, sync_rate, smoothed_knee_angle, feedback_message, created_at) VALUES (901, 1, 1.000, JSON_OBJECT('k',1), 50.00, 0.00, NULL, '2026-05-15 10:00:00') ON DUPLICATE KEY UPDATE session_id = session_id; +=== [1 T1 이 중복(901,1,1) 을 넣은 직후] ++------+---------+----------+--------+---------------+---------+------------------------------------+ +| who | idx | part | type | mode | status | data | ++------+---------+----------+--------+---------------+---------+------------------------------------+ +| t1 | PRIMARY | p2026_05 | RECORD | X,REC_NOT_GAP | GRANTED | 901, 1, 0x800000010000, 0x6A067010 | +| t1 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | ++------+---------+----------+--------+---------------+---------+------------------------------------+ + (잠금 없음이면 표가 비어 있다) +>>> T2: INSERT INTO pose_lab (session_id, rep_number, timestamp_sec, joint_coordinates, sync_rate, smoothed_knee_angle, feedback_message, created_at) VALUES (902, 1, 1.000, JSON_OBJECT('k',1), 50.00, 0.00, NULL, '2026-05-15 10:00:00') ON DUPLICATE KEY UPDATE session_id = session_id; +=== [2 T2 가 중복(902,1,1) 을 넣은 직후] ++------+---------+----------+--------+---------------+---------+------------------------------------+ +| who | idx | part | type | mode | status | data | ++------+---------+----------+--------+---------------+---------+------------------------------------+ +| t1 | PRIMARY | p2026_05 | RECORD | X,REC_NOT_GAP | GRANTED | 901, 1, 0x800000010000, 0x6A067010 | +| t1 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | +| t2 | PRIMARY | p2026_05 | RECORD | X,REC_NOT_GAP | GRANTED | 902, 1, 0x800000010000, 0x6A067010 | +| t2 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | ++------+---------+----------+--------+---------------+---------+------------------------------------+ + (잠금 없음이면 표가 비어 있다) +>>> T1: INSERT INTO pose_lab (session_id, rep_number, timestamp_sec, joint_coordinates, sync_rate, smoothed_knee_angle, feedback_message, created_at) VALUES (901, 2, 2.000, JSON_OBJECT('k',1), 50.00, 0.00, NULL, '2026-05-15 10:00:00') ON DUPLICATE KEY UPDATE session_id = session_id; +=== [3 T1 이 신규(901,2,2) 를 넣은 직후] ++------+---------+----------+--------+---------------+---------+------------------------------------+ +| who | idx | part | type | mode | status | data | ++------+---------+----------+--------+---------------+---------+------------------------------------+ +| t1 | PRIMARY | p2026_05 | RECORD | X,REC_NOT_GAP | GRANTED | 901, 1, 0x800000010000, 0x6A067010 | +| t1 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | +| t2 | PRIMARY | p2026_05 | RECORD | X,REC_NOT_GAP | GRANTED | 902, 1, 0x800000010000, 0x6A067010 | +| t2 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | ++------+---------+----------+--------+---------------+---------+------------------------------------+ + (잠금 없음이면 표가 비어 있다) +>>> T2: INSERT INTO pose_lab (session_id, rep_number, timestamp_sec, joint_coordinates, sync_rate, smoothed_knee_angle, feedback_message, created_at) VALUES (902, 2, 2.000, JSON_OBJECT('k',1), 50.00, 0.00, NULL, '2026-05-15 10:00:00') ON DUPLICATE KEY UPDATE session_id = session_id; +=== [4 T2 가 신규(902,2,2) 를 넣은 직후] ++------+---------+----------+--------+---------------+---------+------------------------------------+ +| who | idx | part | type | mode | status | data | ++------+---------+----------+--------+---------------+---------+------------------------------------+ +| t1 | PRIMARY | p2026_05 | RECORD | X,REC_NOT_GAP | GRANTED | 901, 1, 0x800000010000, 0x6A067010 | +| t1 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | +| t2 | PRIMARY | p2026_05 | RECORD | X,REC_NOT_GAP | GRANTED | 902, 1, 0x800000010000, 0x6A067010 | +| t2 | NULL | p2026_05 | TABLE | IX | GRANTED | NULL | ++------+---------+----------+--------+---------------+---------+------------------------------------+ + (잠금 없음이면 표가 비어 있다) +>>> T1: COMMIT; +>>> T2: COMMIT; +=== [5 둘 다 COMMIT 뒤] + (잠금 없음이면 표가 비어 있다) +session_id rep_number timestamp_sec id +901 1 1.000 1 +901 2 2.000 3 +902 1 1.000 2 +902 2 2.000 4 +--- t1 세션 로그 (오류만) + Query OK, 0 rows affected (0.00 sec) + Query OK, 0 rows affected (0.00 sec) + Query OK, 1 row affected (0.00 sec) + Query OK, 0 rows affected (0.02 sec) +--- t2 세션 로그 (오류만) + Query OK, 0 rows affected (0.00 sec) + Query OK, 0 rows affected (0.00 sec) + Query OK, 1 row affected (0.00 sec) + Query OK, 0 rows affected (0.07 sec) +--- 이 팔의 데드락 수: 0 From 4d4fad271529385b2d65917e7220e78cfafa31a2 Mon Sep 17 00:00:00 2001 From: Khyojae Date: Thu, 24 Sep 2026 11:12:08 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(pose):=20savePoseDataBatch=20=EB=A5=BC?= =?UTF-8?q?=20READ=20COMMITTED=20=EB=A1=9C=20=E2=80=94=20=EC=9E=AC?= =?UTF-8?q?=EC=A0=84=EC=86=A1=20=EC=A4=91=EB=B3=B5=EC=9D=B4=20=ED=8C=8C?= =?UTF-8?q?=ED=8B=B0=EC=85=98=20=EB=81=9D=EC=9D=84=20=EC=9E=A0=EA=B0=80=20?= =?UTF-8?q?=EB=8D=B0=EB=93=9C=EB=9D=BD=20=EB=82=98=EB=8D=98=20=EC=9E=90?= =?UTF-8?q?=EB=A6=AC=EB=A5=BC=20=EC=97=86=EC=95=A4=EB=8B=A4=20(#276)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RR 에서는 중복 키 한 건이 pose_data 의 PRIMARY supremum 에 X 락을 잡아, 커밋까지 같은 파티션의 모든 신규 삽입을 세우고 서로 다른 세션의 재전송이 겹치면 데드락이 됐다(동시 부하 45.9%). RC 에서는 그 락이 안 생긴다(0/960). 데드락 재시도(상한 5)는 다른 원인에 대한 그물로 둔다. 회귀 가드 PoseDataResendDeadlockRaceTest(race 프로파일, 세션 8 × 재전송 20): RR 로 되돌리면 데드락 63/160 으로 실패, RC 에서 0 으로 통과 — 둘 다 확인. 전체 테스트 953개 통과. 분기점 문서 결정 로그 · 테스트 가이드 §2.4 · 결합면 changelog 갱신. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../service/exercise/PoseDataService.java | 14 +- .../PoseDataResendDeadlockRaceTest.java | 166 ++++++++++++++++++ docs/18-testing-guide.md | 3 +- docs/architecture/ai-backend-changelog.md | 6 + docs/decisions/r276-lock-root-cause-fix.md | 7 +- 5 files changed, 192 insertions(+), 4 deletions(-) create mode 100644 backend/src/test/java/com/shadowfit/service/exercise/PoseDataResendDeadlockRaceTest.java diff --git a/backend/src/main/java/com/shadowfit/service/exercise/PoseDataService.java b/backend/src/main/java/com/shadowfit/service/exercise/PoseDataService.java index ca027905..65b86e9b 100644 --- a/backend/src/main/java/com/shadowfit/service/exercise/PoseDataService.java +++ b/backend/src/main/java/com/shadowfit/service/exercise/PoseDataService.java @@ -21,6 +21,7 @@ import org.springframework.jdbc.core.BatchPreparedStatementSetter; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronizationManager; @@ -87,8 +88,19 @@ public class PoseDataService { * 저장 전 다운샘플(위치 B: Spring, pose-ingest-downsampling.md §3-B) — 라이브 분석 * (DTW·sync·rep 감지)은 이 저장 이전 FastAPI에서 이미 끝난 값이라 저장본을 줄여도 영향 없고, * 영향받는 건 리포트 시계열 해상도뿐(같은 문서 §1 안전판). + * + *

격리수준 READ COMMITTED (#276, 2026-09-24 사용자 confirm). 기본 RR 에서는 중복 키 + * 한 건(재전송이 원본과 겹칠 때)이 {@code PRIMARY} 의 파티션 끝(supremum)에 X 락을 잡아, 커밋까지 + * 같은 파티션의 모든 신규 삽입을 세우고 두 재전송이 겹치면 데드락이 된다. RC 에서는 그 락이 + * 안 생긴다 — 결정적 재현과 동시 부하(워커 8, 중복)에서 RR 45.9% → RC 0/960 + * ({@code loadtest/results/r276-lock-trace-2026-09-24/}). 중복 검사가 uk 원본 레코드에 잡는 + * next-key 락은 RC 에서도 남아 한 방향 대기는 생길 수 있다 — 세션 키가 {@code session_id} + * 로 묶여 있어 순환이 안 닫힌다는 것이 근거이고, 한 트랜잭션이 여러 세션 키를 섞게 되면 다시 볼 것. + * 아래 세션 조회는 잠금 없는 읽기 한 번이라 RC 로 바뀌어도 보장이 달라지지 않는다. + * 데드락 재시도({@code ExerciseGrpcService})는 다른 원인에 대한 그물로 그대로 둔다. + * 분기와 기각된 대안: docs/decisions/r276-lock-root-cause-fix.md */ - @Transactional + @Transactional(isolation = Isolation.READ_COMMITTED) public void savePoseDataBatch(Long sessionId, List grpcList) { if (grpcList == null || grpcList.isEmpty()) return; diff --git a/backend/src/test/java/com/shadowfit/service/exercise/PoseDataResendDeadlockRaceTest.java b/backend/src/test/java/com/shadowfit/service/exercise/PoseDataResendDeadlockRaceTest.java new file mode 100644 index 00000000..97a33459 --- /dev/null +++ b/backend/src/test/java/com/shadowfit/service/exercise/PoseDataResendDeadlockRaceTest.java @@ -0,0 +1,166 @@ +package com.shadowfit.service.exercise; + +import com.shadowfit.grpc.PoseDataRequest; +import com.shadowfit.model.exercise.Category; +import com.shadowfit.model.exercise.Exercise; +import com.shadowfit.model.exercise.Session; +import com.shadowfit.model.exercise.Status; +import com.shadowfit.model.member.Member; +import com.shadowfit.model.member.UserRole; +import com.shadowfit.repository.exercise.CategoryRepository; +import com.shadowfit.repository.exercise.ExercisesRepository; +import com.shadowfit.repository.exercise.SessionRepository; +import com.shadowfit.repository.member.MemberRepository; +import com.shadowfit.support.MySqlContainerSupport; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.dao.PessimisticLockingFailureException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * 재전송이 원본과 겹칠 때의 데드락 (#276) — {@code savePoseDataBatch} 를 RC 로 돌린 처방의 회귀 가드. + * + *

무엇을 고정하나. 기본 RR 에서는 중복 키 한 건이 {@code PRIMARY} 의 파티션 끝(supremum)에 + * X 락을 잡고, 서로 다른 세션의 재전송이 동시에 겹치면 그 X 를 둘이 쥔 채 서로의 insert intention 을 + * 기다려 데드락이 된다(결정적 재현: {@code loadtest/results/r276-lock-trace-2026-09-24/}). 같은 모양을 + * 서비스 경로로 건다 — 세션 {@value #SESSIONS} 개가 각자 같은 배치를 {@value #RESENDS} 번(첫 번만 신규, + * 나머지는 전부 중복) 동시에 보낸다. + * + *

왜 «0» 을 단언할 수 있나. 같은 모양의 SQL 판에서 RR 은 문장당 약 46%, RC 는 0/960 이었다. + * 이 테스트는 {@value #SESSIONS} × {@value #RESENDS} 번을 부르므로 RR 로 되돌리면 사실상 반드시 걸린다 + * (2026-09-24, 격리수준을 빼고 돌려 실패하는 것을 확인했다). 데드락 재시도는 {@code ExerciseGrpcService} + * 에 있고 이 테스트는 서비스를 직접 부르므로, 여기서 잡히는 것은 재시도로 가려지기 전의 데드락이다. + * + *

H2 는 이 잠금을 재현하지 못한다 — 실 MySQL({@link MySqlContainerSupport})에서만 돈다. + */ +@SpringBootTest +@ActiveProfiles("race") +@DisplayName("pose 재전송 데드락 (#276)") +class PoseDataResendDeadlockRaceTest extends MySqlContainerSupport { + + private static final int SESSIONS = 8; + private static final int RESENDS = 20; + private static final int FRAME_COUNT = 50; // DOWNSAMPLE_WINDOW=5 → 10행 + + @Autowired private PoseDataService poseDataService; + @Autowired private ExercisesRepository exercisesRepository; + @Autowired private CategoryRepository categoryRepository; + @Autowired private MemberRepository memberRepository; + @Autowired private SessionRepository sessionRepository; + @Autowired private JdbcTemplate jdbcTemplate; + + private Long memberId; + private final List sessionIds = new ArrayList<>(); + + @AfterEach + void tearDown() { + for (Long id : sessionIds) { + jdbcTemplate.update("DELETE FROM pose_data WHERE session_id = ?", id); + jdbcTemplate.update("DELETE FROM exercise_sessions WHERE id = ?", id); + } + if (memberId != null) { + jdbcTemplate.update("DELETE FROM users WHERE id = ?", memberId); + } + } + + @Test + @DisplayName("서로 다른 세션의 중복 재전송이 동시에 겹쳐도 데드락이 나지 않고, 멱등은 유지된다") + void concurrentDuplicateResendsDoNotDeadlock() throws Exception { + seedSessions(); + List batch = frames(); + + ExecutorService pool = Executors.newFixedThreadPool(SESSIONS); + CountDownLatch start = new CountDownLatch(1); + AtomicInteger deadlocks = new AtomicInteger(); + ConcurrentLinkedQueue others = new ConcurrentLinkedQueue<>(); + + for (Long sessionId : sessionIds) { + pool.submit(() -> { + start.await(); + for (int i = 0; i < RESENDS; i++) { + try { + poseDataService.savePoseDataBatch(sessionId, batch); + } catch (PessimisticLockingFailureException e) { // 데드락·락 대기 초과의 공통 조상 + deadlocks.incrementAndGet(); + } catch (Throwable t) { + others.add(t); + } + } + return null; + }); + } + start.countDown(); + pool.shutdown(); + assertThat(pool.awaitTermination(120, TimeUnit.SECONDS)).as("시간 안에 끝나야 한다").isTrue(); + + assertThat(others).as("데드락 외 예외는 없어야 한다").isEmpty(); + assertThat(deadlocks.get()) + .as("RR 로 되돌리면 중복 한 건이 파티션 끝을 잠가 여기서 걸린다 — %d/%d", + deadlocks.get(), SESSIONS * RESENDS) + .isZero(); + + for (Long sessionId : sessionIds) { + Integer rows = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM pose_data WHERE session_id = ?", Integer.class, sessionId); + assertThat(rows).as("세션 %d: 재전송 %d번이 한 벌로 접혀야 한다", sessionId, RESENDS) + .isEqualTo(FRAME_COUNT / 5); + } + } + + private void seedSessions() { + Member member = memberRepository.saveAndFlush(Member.builder() + .email("resend-deadlock@test.com").username("재전송데드락").password("dummy") + .role(UserRole.USER).build()); + memberId = member.getId(); + + Category category = categoryRepository.findByName("LOWER") + .orElseGet(() -> categoryRepository.save(Category.builder().name("LOWER").build())); + Exercise exercise = exercisesRepository.saveAndFlush(Exercise.builder() + .name("스쿼트").category(category).expectedDurationMinutes(15) + .syncThresholdBeginner(new BigDecimal("60.00")) + .syncThresholdAdvanced(new BigDecimal("85.00")) + .build()); + + // 시작 시각을 같게 둔다 — created_at 이 세션 시작 시각이라 전부 같은 파티션(같은 달)에 들어간다. + // 동시에 운동 중인 사용자는 정의상 같은 달이므로 이것이 실사용 조건이다. + LocalDateTime startTime = LocalDateTime.now().withNano(0); + for (int i = 0; i < SESSIONS; i++) { + Session session = sessionRepository.saveAndFlush(Session.builder() + .member(member).exercise(exercise).startTime(startTime) + .status(Status.IN_PROGRESS).totalReps(0).difficultyLevel(1).build()); + sessionIds.add(session.getId()); + } + } + + private List frames() { + List frames = new ArrayList<>(); + for (int i = 0; i < FRAME_COUNT; i++) { + frames.add(PoseDataRequest.newBuilder() + .setTimestampSec(i * 0.1) + .setJointCoordinates("{}") + .setSyncRate(72.5) + .setRepNumber(1) + .setSmoothedKneeAngle(120.0) + .setFeedbackMessage("ok") + .build()); + } + return frames; + } +} diff --git a/docs/18-testing-guide.md b/docs/18-testing-guide.md index cb9ba164..5a1e804e 100644 --- a/docs/18-testing-guide.md +++ b/docs/18-testing-guide.md @@ -60,7 +60,8 @@ Windows PowerShell: | Docker 없을 때 | — | `@Testcontainers(disabledWithoutDocker = true)` → **건너뜀**. 강제로 끄려면 `-Dmysql.container=false` | 쓰는 클래스: `FlywayMigrationValidationTest`(마이그레이션↔엔티티 정합 — 이 프로파일이 존재하는 첫 번째 이유), -`PoseDataOrphanRaceTest`(FK 없는 파티션 표), `SignupUsernameRaceTest`(벤더 제약명), `WeeklySummaryBLayerRaceTest`(`JSON_TABLE`). +`PoseDataOrphanRaceTest`(FK 없는 파티션 표), `SignupUsernameRaceTest`(벤더 제약명), `WeeklySummaryBLayerRaceTest`(`JSON_TABLE`), +`PoseDataResendDeadlockRaceTest`(InnoDB 잠금 — 재전송 데드락 #276, 격리수준 RC 의 회귀 가드). `PoseDataOrphanWindowTest` 는 같은 프로파일이지만 **측정 장치**라 `-Dmeasure.orphan.window=true` 로만 돈다(4분+, 2026-09-11 에는 30스레드 구간이 InnoDB 데드락으로 끝났다 — 미검증, 박스가 다른 컨테이너와 동거 중이었다). 새 테스트가 «H2 로는 원리상 안 된다» 면 `MySqlContainerSupport` 를 상속하고 `@ActiveProfiles("race")` 를 붙인다 — 그 외에는 기본 프로파일에 둔다(컨테이너 기동 비용). diff --git a/docs/architecture/ai-backend-changelog.md b/docs/architecture/ai-backend-changelog.md index b62c5de1..c7899ddb 100644 --- a/docs/architecture/ai-backend-changelog.md +++ b/docs/architecture/ai-backend-changelog.md @@ -334,6 +334,12 @@ - **바꾼 것.** `AbstractOutboxPublisher.dispatchOne` 이 예외의 출처를 가른다 — `dispatch()` 예외 = RETRY(재시도 기록 + 백오프, 상한 초과 시 `onGivenUp` → `FAILED`), 결과 기록 단계 예외 = 예전대로 회수 대기. 예외 뒤 재시도 기록마저 실패하면 후자로 떨어져 행을 잃지 않는다. - **결합면 영향.** `StopAnalysis`·`ReattachAnalysis` 경로는 gRPC 실패를 이미 RETRY 로 분류해 돌려주므로(예외를 안 던지는 계약) 보통 경로는 그대로다. 달라지는 건 그 분류 밖에서 새는 예외(DB 조회 실패·gRPC 밖 런타임 예외)뿐이다 — 이제 재시도 상한을 소진한다. 갱신 트리거 2번(전달 보장)·4번(실패 처리). +### #276 — fix(pose): `savePoseDataBatch` 를 READ COMMITTED 로 (2026-09-24) ⭐ 실패 처리 변경, Spring 단독 + +- **무엇이 문제였나.** AI 재전송(`spring_client.py`, 실패 시 3회)이 원본과 겹치면 RR 에서 중복 키 한 건이 `pose_data` 의 파티션 끝(`PRIMARY` supremum)에 X 락을 잡아, 서로 다른 세션의 재전송이 동시에 겹칠 때 데드락이 됐다(동시 부하 45.9%). 지금까지는 Spring 쪽 데드락 재시도(상한 5)가 가렸고, 그 소진분은 `INTERNAL` → AI 재전송으로 되돌아가는 **두 겹** 구조였다. +- **바꾼 것.** 이 트랜잭션만 RC — supremum 락이 안 생긴다(0/960, [`loadtest/results/r276-lock-trace-2026-09-24/`](../../loadtest/results/r276-lock-trace-2026-09-24/README.md)). 데드락 재시도는 그물로 남긴다. 분기: [`../decisions/r276-lock-root-cause-fix.md`](../decisions/r276-lock-root-cause-fix.md) +- **결합면 영향.** RPC·proto·응답 계약은 그대로다. 달라지는 것은 «재전송이 `INTERNAL` 을 받을 확률» — 측정한 모양(다세션 중복, 워커 8)에서는 데드락이 0 이었다 — 다른 동시성·다른 삽입 모양에서의 0 은 안 쟀다. 갱신 트리거 4번(실패 처리). AI 코드 변경 없음. + --- ## 5. 결합 요소별 변경 시점 diff --git a/docs/decisions/r276-lock-root-cause-fix.md b/docs/decisions/r276-lock-root-cause-fix.md index 44f74d80..6a15abd6 100644 --- a/docs/decisions/r276-lock-root-cause-fix.md +++ b/docs/decisions/r276-lock-root-cause-fix.md @@ -1,7 +1,7 @@ # #276 근본 처방 — 재시도가 아니라 «중복 하나가 파티션 끝을 잠그는» 자리를 없앤다 작성: 2026-09-24 -상태: **분기점 — 사용자 결정 대기** (후보와 트레이드오프까지만. 채택은 confirm 뒤 별도로 박제) +상태: **ㄴ(RC) 채택 · 적용 (2026-09-24 사용자 confirm)** — ㄷ(자연키 PK) 비용 측정 착수는 미결정 근거: [`loadtest/results/r276-lock-trace-2026-09-24/`](../../loadtest/results/r276-lock-trace-2026-09-24/README.md) 관련: [#276](https://github.com/Shadowfit/init/issues/276) · [`r276-retry-followup.md`](./r276-retry-followup.md) · [`pose-batch-idempotency-vs-partition.md`](./pose-batch-idempotency-vs-partition.md) · [`online-ddl-vs-blocking-alter.md`](./online-ddl-vs-blocking-alter.md) @@ -71,10 +71,13 @@ RR 에서 **중복 키 한 건**이 `PRIMARY` 의 파티션 끝(supremum)에 `X` ## 4. 미결정 (사용자 confirm 필요) -1. ㄴ 을 적용할지 +1. ~~ㄴ 을 적용할지~~ → ✅ 적용(아래 결정 로그) 2. ㄷ 의 비용 측정(§3 순서)을 착수할지 3. #276 에 이 판 결과를 코멘트로 남기고, 이슈 상태 블록을 갱신할지 ## 결정 로그 - 2026-09-24 — 문서 작성. 결정 없음 +- 2026-09-24 — **ㄴ 채택 (사용자 confirm).** `PoseDataService.savePoseDataBatch` 에 `@Transactional(isolation = READ_COMMITTED)`. + 회귀 가드 `PoseDataResendDeadlockRaceTest`(실 MySQL, 세션 8 × 재전송 20): **RR 로 되돌리면 데드락 63/160 으로 실패, RC 에서 0 으로 통과**를 둘 다 확인했다. + 데드락 재시도(상한 5)는 그대로 둔다(§3)