Skip to content

Add index on battles.tx_id for GetBattleByTxId - #193

Open
ipdae wants to merge 1 commit into
mainfrom
yang/battles-tx-id-index
Open

ipdae wants to merge 1 commit into
mainfrom
yang/battles-tx-id-index

Conversation

@ipdae

@ipdae ipdae commented Oct 8, 2026

Copy link
Copy Markdown
Contributor

배경 (2026-10-08 장애)

odin / heimdall 아레나 DB(odin_arena_db, heimdall_arena_db)의 디스크 읽기가 약 8000 blocks/s까지 올라갔다. 원인은 battles 테이블(약 950MB)을 배틀 1건당 약 2번씩 풀 seq-scan 한 것이다.

  • BattleRepository.GetBattleByTxId → Battles.SingleOrDefaultAsync(b => b.TxId == txId && b.Id != battleId)
  • BattleProcessor.ValidateUsedTxId 에서 배틀 1건당 2번 호출된다.
  • tx_id 로 시작하는 인덱스가 없었다. 기존 ix_battles_id_tx_id (id, tx_id) 는 선두 컬럼이 id 라서 이 조회에 쓰이지 않는다.

수정

  • Battle 모델에 [Index(nameof(TxId))] 를 추가했다. snake_case 규칙에 따라 ix_battles_tx_id 가 생성되고, 이름은 dotnet ef 로 확인했다.
  • dotnet ef migrations add 로 AddBattleTxIdIndex 를 생성했다. 모델 스냅샷에는 인덱스 1개만 추가됐다.
  • 생성된 CreateIndex / DropIndex 는 멱등 SQL로 바꿨다.
    • Up: CREATE INDEX CONCURRENTLY IF NOT EXISTS ix_battles_tx_id ON battles (tx_id); (suppressTransaction: true)
    • Down: DROP INDEX CONCURRENTLY IF EXISTS ix_battles_tx_id; (suppressTransaction: true)

프로덕션 상태

odin / heimdall 에는 이미 수동으로 만들어 두었다 (2026-10-08, CREATE INDEX CONCURRENTLY ix_battles_tx_id ON battles (tx_id);). 그 뒤로 디스크 읽기는 약 8000 → 약 10 blocks/s 로 떨어졌다. thor 에는 아직 만들지 않았다.

  • 이 저장소의 마이그레이션은 앱 기동 시 자동으로 적용되지 않는다. CONTRIBUTING.md 에 따라 dotnet ef database update 로 수동 적용한다.
  • odin / heimdall 에서 적용하면 IF NOT EXISTS 때문에 인덱스는 건너뛰고 __EFMigrationsHistory 에 기록만 남는다.
  • thor 를 포함한 나머지 환경에서는 CONCURRENTLY 로 만들기 때문에 테이블 쓰기를 막지 않는다. 그래서 테이블 크기와 무관하게 안전하다.

검증

  • dotnet build ArenaService.sln: 오류 0
  • dotnet test: ArenaService.Tests 39/39, ArenaService.BackOffice.Tests 44/44 통과
  • dotnet ef migrations has-pending-model-changes: "No changes have been made to the model since the last migration."
  • dotnet ef migrations script "20250620050806_Add RoundIndex": CREATE INDEX 문은 트랜잭션 밖에서 실행되고, 그 다음 트랜잭션에서 history INSERT 가 실행되는 것을 확인했다.
  • DB 에는 접속하지 않았다.

리스크

  • CONCURRENTLY 빌드가 도중에 실패하면 INVALID 인덱스가 남는다. 그 상태로 다시 실행하면 IF NOT EXISTS 가 그 인덱스를 건너뛰므로, 적용 전후에 pg_index.indisvalid 를 확인해야 한다. 프로덕션에 수동으로 만든 인덱스도 valid 인지 확인할 것을 권장한다.
  • 쓰기 시 인덱스 유지 비용이 조금 늘어난다. tx_id 컬럼 하나짜리 인덱스라 부담은 작다.

후속 (이 PR 범위 밖)

  • ix_battles_battle_status_reviewed 와 ix_battles_id_tx_id 는 쓰이지 않거나 중복으로 보인다. id 가 PK 라서 (id, tx_id) 는 PK 와 기능이 겹친다. 프로덕션에는 아직 남아 있으므로 이 PR 에서는 삭제하지 않았다. pg_stat_user_indexes 로 확인한 뒤 별도 PR 에서 정리한다.

🤖 Generated with Claude Code

BattleProcessor.ValidateUsedTxId looks up battles by tx_id twice per
battle, and no index starts with tx_id, so each lookup seq-scanned the
whole battles table. The index already exists in production (created
manually with CREATE INDEX CONCURRENTLY), so the migration uses
CREATE INDEX CONCURRENTLY IF NOT EXISTS to stay idempotent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant