# 106 — 조건을 먼저 정한 TPC-C: 묶어 커밋하는 SQLite, 창고 넷 2026-09-30. [외부 리뷰 004](../outside-feedback/feedback-004.md)의 넷째 권고에서 시작한다. [092](092-tpcc.md)의 비교는 창고 하나(W=1)였고, SQLite는 트랜잭션마다 커밋했다. 리뷰는 SD 카드에서의 약 90배가 대부분 그룹 커밋과 로그 쓰기 구조에서 온다고 읽었다. 그래서 SQLite도 묶어 커밋하게 하고, 창고를 넷으로 늘려 다시 잰다. 아래 착수 기준은 조율과 측정 행렬을 돌리기 전에 정해 커밋했다(`7f71c85`, 하니스를 확인하려고 5초짜리 시험을 몇 번 돌린 뒤다). 결과를 본 뒤에 고치지 않았다. 착수 기준: - **바꿀 결정.** 1. **SD 카드에서의 주장.** SQLite도 묶어 커밋할 때 Pure와의 처리량 차이가 2배 안으로 줄면, 092의 SD 비교를 "묶어 커밋하는 구조를 앱 코드 없이 얻는다"로 고쳐 쓴다. 2배 넘게 남으면, 거래당 장치에 쓴 바이트로 차이가 어디서 오는지 적는다. 2. **소유자 하나.** W=4에서 Pure의 소유자 스레드가 코어 하나의 90% 이상을 쓰는데도, 64터미널의 처리량이 16터미널보다 10% 넘게 늘지 않으면 소유자가 병목이다. 그러면 키(창고)로 나눈 소유자 여럿을 다음 설계로 올린다. 아니면 지금은 하지 않는다. 3. **체크포인트.** SD 카드에서 W=4의 Pure 처리량이 W=1의 절반 아래면, 목록의 이미지를 통째로 다시 쓰는 체크포인트가 다음 일이다(092의 "덧붙는 목록의 이미지"). - **정합성 통과 조건.** W마다 시간 잰 실행 전에, 한 씨앗에서 뽑은 같은 거래 2만 건을 Pure, SQLite, 묶어 커밋하는 SQLite에 돌린다. 모든 표의 모든 행이 같아야 하고(시각 제외), 실패한 거래가 없어야 한다(되돌린 New-Order는 규격대로 성공이다). 시간 잰 실행마다 그 뒤에 clause 3.3.2.1–4가 성립해야 한다. 하나라도 어긋난 실행은 결과로 싣지 않는다. - **허용 메모리.** 실행이 모두 끝난 뒤 Pure의 상주 메모리가 상태 바이트의 2.5배 이하여야 한다([104](104-where-memory-goes.md)에서 최고가 약 2배였다). 넘으면 그 차이가 어디로 가는지가 다음 일이다. W는 RAM(tmpfs)에서 저장소와 데이터베이스까지 메모리에 들고도 1 GB가 남게 고른다. 이 Pi(8 GB, 쓸 수 있는 것 약 4.8 GB)에서는 W=4다. - **저장 완료와 지연.** 두 쪽 모두 커밋이 장치에 닿은 뒤 답한다. Pure는 저장마다 로그에 쓰고 `fdatasync`한다. SQLite는 WAL, `synchronous=FULL`이다. 지연은 같은 수의 터미널이 기다릴 때(16, 64) 비교한다. - **SQLite 설정.** 신중한 엔지니어가 고를 설정으로 겨룬다. 행렬 전에 조율 실행으로 고른다. RAM에서는 기본값과 `mmap_size` 1 GiB, 연결마다 캐시 64 MiB, `temp_store=MEMORY`(SAVEPOINT의 하위 저널을 메모리에)를 견준다. SD 카드에서는 페이지 크기 4,096 B와 1,024 B를 견준다. 묶어 커밋하는 실행의 처리량이 높은 쪽을 쓴다. ## 쉬운 말로 - **SD 카드에서의 차이는 90배에서 4–11배로 줄었지만, 2배를 훨씬 넘게 남았다.** - 092의 SQLite는 기본 설정으로 트랜잭션마다 커밋했다. 이번에는 SQLite도 Pure처럼 기다리는 거래를 묶어 한 번에 커밋하고, 카드에 맞게 조율했다(페이지 1 KiB, 체크포인트는 WAL 40 MB마다). SQLite가 스스로 10배 넘게 빨라졌다. - 그래도 Pure가 같은 터미널 수에서 4.2–10.8배다(W=1 64터미널: 4,980건 대 739건). - 남은 차이는 거래당 장치에 쓰는 바이트에서 온다. SQLite는 행 하나를 바꿔도 그 행이 든 페이지를 통째로 쓴다(거래당 7.6–15 KB). Pure는 바뀐 필드와 더한 행만 기록한다(0.7–1.3 KB). 8–18배다. - **RAM에서는 Pure가 3.2–3.5배다.** 조율한 SQLite의 최고(10,357건)에 대해 Pure는 36,567건(W=1)이다. RAM에서는 아낄 동기화가 없어서, 묶어 커밋해도 SQLite가 빨라지지 않는다. Pure는 HTTP와 JSON까지 하면서도 거래당 CPU가 SQLite의 절반이다. - **창고가 넷이어도 맞았다.** 같은 거래 2만 건 뒤 세 쪽(Pure, SQLite, 묶어 커밋하는 SQLite)의 모든 표가 같았다. 원격 재고와 원격 결제가 들어간다. 모든 실행 뒤 일관성 조건이 성립했고, 실패한 거래는 없었다. - **RAM에서 Pure의 한계는 소유자 스레드 하나다.** W=4에서 터미널을 16에서 64로 늘려도 처리량이 늘지 않았고, 소유자가 코어의 94%를 썼다. 서버의 세 코어 가운데 1.74개만 썼다. 소유자의 바쁜 시간 가운데 약 3분의 1은 스냅숏이 공유한 노드를 복사하고 놓는 데 간다. SD 카드에서는 소유자가 코어의 7–15%만 쓴다(카드가 한계다). - **메모리는 기준 안이다.** Pure는 모든 상태를 메모리에 든다. 상주가 상태 바이트의 1.6–2.2배였다(기준 2.5배). 같은 실행에서 SQLite는 최고 상주가 190 MiB 아래였고, 데이터는 파일(최대 124 MiB)에 있다. 메모리를 더 쓰는 것이 Pure의 값이다. - 아직 모르는 것: 레코드를 기록하는 다른 저장소(RocksDB 같은 LSM)와는 견주지 않았다. SD 카드에서의 이점은 레코드 로그가 페이지 WAL보다 적게 쓰는 데서 오므로, 그런 저장소와의 차이는 더 작을 것이다. ## 방법 [`benchmarks/tpcc/run.py`](../../benchmarks/tpcc/run.py)와 [`sqlite-tpcc.c`](../../benchmarks/tpcc/sqlite-tpcc.c)에 SQLite의 묶음 모드를 더했다. - **묶어 커밋하는 SQLite.** 터미널들이 쓰는 거래(New-Order, Payment, Delivery)를 작성자 스레드 하나에 넘긴다. 작성자는 기다리는 것을 모두 한 트랜잭션(`BEGIN IMMEDIATE`)으로 돌리고, 거래마다 `SAVEPOINT`를 둔다. 실패하거나 되돌릴 거래는 제 SAVEPOINT까지만 되돌린다. 한 번의 `COMMIT`(동기화 한 번) 뒤에 각 터미널을 깨운다. - **읽기 거래.** Order-Status와 Stock-Level은 각 터미널이 제 연결에서 마지막 커밋을 읽는다. pure-serve가 보기를 소유자에게 넘기지 않고 연결 스레드에서 마지막 저장의 스냅숏으로 답하는 것과 같다. - **잰 것.** 실행마다 초당 거래, 종류별 p50/p99, 거래당 CPU(Pure는 서버 프로세스, SQLite는 제 프로세스), 소유자 스레드가 쓴 코어의 몫(pure-serve의 소유자 스레드 이름을 `pure-owner`로 붙였다), 메모리(Pure는 서버의 상주, SQLite는 최고 상주), 거래당 쓴 바이트(프로세스의 `write_bytes`, 그리고 장치의 쓴 섹터)다. 끝에 Pure의 상태 바이트와 두 쪽의 파일 크기를 적는다. - **행렬.** RAM(`/dev/shm`)과 SD 카드(ext4), 각각 W=1과 W=4. Pure 16·64터미널, SQLite 1·4스레드, 묶어 커밋하는 SQLite 16·64터미널. RAM은 20초, SD 카드는 30초씩이다. SD 카드는 상태에 따라 흔들리므로 세 번 돌려 범위를 적는다. - 서버와 SQLite는 코어 0–2, Pure의 부하 생성기(HTTP/1.1, JSON)는 코어 3이다. SQLite는 프로세스 안에서 돈다. - 실행마다 앞에서 `sync`로 쌓인 쓰기를 비운다(적재와 복사가 남긴 것이 먼저 도는 쪽의 몫이 되지 않게). Pure는 제 실행이 끝나면 멈춘다. 그 체크포인트가 SQLite의 실행 동안 장치를 쓰지 않게 하려는 것이다. - 같은 거래 2만 건의 확인은 W마다 RAM에서 한 번 했다. 장치와 상관없는 확인이고, SD 카드에서 한 터미널로 거래마다 동기화하며 돌리면 SQLite 쪽만 몇 분이 걸린다. SD 카드의 실행도 끝날 때마다 clause 3.3.2를 확인했다. ## SQLite 설정 고르기 W=1에서 SQLite만 돌렸다([원시 결과](../../research/experiments/022-tpcc-grouped/), `tune-`로 시작하는 파일). **RAM, 20초, 초당 거래.** | SQLite | 1스레드 | 4스레드 | 묶음 16 | 묶음 64 | |---|---:|---:|---:|---:| | 기본값 | 9,390 | 9,083 | 7,872 | 8,555 | | mmap 1 GiB, 캐시 64 MiB, `temp_store=MEMORY` | 9,886 | 9,470 | 9,680 | 10,230 | | 여기에 체크포인트를 WAL 40 MB마다 | 10,361 | 10,272 | 9,890 | 10,211 | 기본값의 묶음은 데이터베이스가 RAM에 있는데도 거래당 7.6 KB(16터미널)와 1.9 KB(64터미널)를 장치에 썼다. SAVEPOINT의 하위 저널이 64 KiB를 넘으면 임시 파일로 넘치는데, 그 파일이 SD 카드의 `/var/tmp`에 생긴다. `temp_store=MEMORY`가 이것을 막는다. **SD 카드, 30초, 초당 거래(거래당 장치에 쓴 바이트).** | SQLite | 1스레드 | 묶음 16 | 묶음 64 | |---|---:|---:|---:| | 페이지 4,096 B | 72 (60.7 KB) | 162 (41.8 KB) | 139 (29.3 KB) | | 페이지 1,024 B | 132 (31.9 KB) | 203 (22.5 KB) | 263 (17.9 KB) | | 1,024 B, 체크포인트를 WAL 40 MB마다 | 87 (40.9 KB) | 694 (10.7 KB) | 594 (7.7 KB) | - 기준대로 SD 카드에서는 페이지를 1,024 B로 한다. SQLite는 행 하나를 바꿔도 그 행이 든 페이지를 통째로 WAL에 쓰므로, 페이지가 작을수록 덜 쓴다. - **체크포인트 간격은 기준에 없던 조율이다.** SQLite에 유리한 쪽이라 더했다. 기본값은 1,000페이지마다다. 1 KiB 페이지라면 WAL 1 MB마다 체크포인트가 데이터베이스 파일의 페이지를 되쓴다. 40 MB마다로 늘리면 한 페이지를 여러 번 바꿔도 한 번만 되쓴다. 묶음 16의 처리량이 3.4배가 됐다. RAM에서도 1스레드의 New-Order p99가 1.2 ms에서 0.2 ms가 됐다(체크포인트가 그 꼬리였다). 그래서 두 장치 모두 이 간격을 쓴다([`run.py`](../../benchmarks/tpcc/run.py)의 `tuned`). - 1스레드는 이 간격에서 오히려 느려졌다(132 → 87건). 원인은 재지 않았다. WAL이 자라는 동안에는 커밋마다 파일 크기까지 동기화해야 하는데(ext4 저널), 그 몫일 수 있다. 묶음은 그 비용을 거래들이 나눠 진다. 판정은 묶음으로 한다. ## 결과 원시 결과는 [022-tpcc-grouped](../../research/experiments/022-tpcc-grouped/)에 있다. 명령은 그 안의 `runs.sh`이고, 아래 표는 `tables.py`가 만든다. **정합성.** W=1과 W=4 모두, 같은 거래 2만 건 뒤 Pure와 SQLite, SQLite와 묶어 커밋하는 SQLite의 아홉 표가 행마다 같았다 (되돌린 New-Order도 W=1 90건, W=4 93건으로 같다). 실패한 거래는 없었다. 조율까지 시간 잰 실행 70번 모두, 끝난 뒤 clause 3.3.2.1–4가 성립했고 실패한 거래가 없었다. **RAM(tmpfs), 20초.** 칸마다 W=1 / W=4다. | | 초당 거래 | New-Order p99 (ms) | 거래당 CPU (µs) | 소유자의 코어 몫 | |---|---:|---:|---:|---:| | Pure, 16터미널 | 35,672 / 32,765 | 1.1 / 1.0 | 45.6 / 48.1 | 0.83 / 0.86 | | Pure, 64터미널 | 36,567 / 32,255 | 4.4 / 3.6 | 48.9 / 53.9 | 0.90 / 0.94 | | SQLite, 1스레드 | 10,357 / 10,272 | 0.2 / 0.2 | 96.5 / 97.1 | | | SQLite, 4스레드 | 10,219 / 9,780 | 0.3 / 0.3 | 98.4 / 103.9 | | | SQLite 묶음, 16터미널 | 9,843 / 8,828 | 4.4 / 8.2 | 134.0 / 147.2 | | | SQLite 묶음, 64터미널 | 10,271 / 8,876 | 12.3 / 17.4 | 131.2 / 147.3 | | - Pure는 조율한 SQLite의 최고보다 3.5배(W=1), 3.2배(W=4)다. 같은 터미널 수의 묶음 SQLite보다는 3.6–3.7배이고, New-Order p99는 2.8–8.2배 짧다. - 거래당 CPU는 Pure가 45.6–53.9 µs(서버 프로세스 전체, HTTP와 JSON 포함), SQLite가 96.5–147.3 µs(프로세스 안, HTTP 없음)다. - RAM에서는 묶어 커밋해도 SQLite가 빨라지지 않는다. 동기화가 거의 공짜라 아낄 것이 없고, 작성자에게 넘기고 SAVEPOINT를 두는 값(거래당 35–43 µs)만 든다. - W=4에서 Pure는 W=1보다 8–12%, 묶음 SQLite는 10–14% 느리다. **SD 카드(ext4), 30초, 세 판의 중앙값(범위).** SQLite의 페이지는 1 KiB다. | | 초당 거래, W=1 | 초당 거래, W=4 | New-Order p99 (ms) | 거래당 장치 바이트 | |---|---:|---:|---:|---:| | Pure, 16터미널 | 2,138 (2,136–2,672) | 2,247 (1,893–2,314) | 59 / 59 | 1,324 / 1,245 | | Pure, 64터미널 | 4,980 (4,864–6,083) | 4,590 (4,324–5,540) | 78 / 94 | 680 / 682 | | SQLite, 1스레드 | 110 (96–124) | 84 (80–89) | 86 / 115 | 38,982 / 40,111 | | SQLite, 4스레드 | 267 (152–293) | 161 (159–222) | 78 / 90 | 18,110 / 25,886 | | SQLite 묶음, 16터미널 | 505 (485–520) | 335 (333–546) | 475 / 590 | 10,871 / 15,001 | | SQLite 묶음, 64터미널 | 739 (578–853) | 426 (302–612) | 1,245 / 1,770 | 7,647 / 12,067 | (p99와 바이트는 W=1 / W=4다.) - 같은 터미널 수에서 Pure는 묶음 SQLite의 4.2배(W=1, 16터미널), 6.7배(W=1, 64), 6.7배(W=4, 16), 10.8배(W=4, 64)다. New-Order p99는 8–19배 짧다. - **거래당 장치에 쓴 바이트가 차이를 설명한다.** 묶음 SQLite는 7.6–15.0 KB, Pure는 0.68–1.32 KB를 썼다(8–18배). - SQLite의 WAL은 바뀐 행이 든 페이지를 통째로 쓰고, 체크포인트가 그 페이지를 데이터베이스 파일에 한 번 더 쓴다. - 창고가 넷이면 거래들이 더 흩어진 페이지를 건드린다. 한 커밋 안에서 겹치는 페이지가 줄어, SQLite는 거래당 W=1의 1.4–1.6배를 쓴다. - Pure는 바뀐 필드(수 하나에 약 7 B)와 더한 행(주문 줄 약 24 B)을 기록하므로 W와 거의 상관없다. - 장치가 받은 양으로 보면, 묶음 SQLite(64터미널)는 초당 5.1–5.7 MB를 썼다. 이 카드의 지속 쓰기(초당 3–8 MB, [092](092-tpcc.md)) 안쪽이라, 바이트가 한계로 보인다. Pure는 초당 3.1–3.4 MB를 쓰면서 거래는 6.7–10.8배를 넣었다. - Pure의 SD 처리량은 W=4가 W=1의 105%(16터미널), 92%(64터미널)다. 체크포인트는 W=1에서 두 실행을 합쳐 8–12번, W=4에서 3–5번 돌았다. 목록이 크면 그 이미지를 다시 쓰는 기준도 크다. **메모리.** Pure는 모든 상태를 메모리에 든다. 같은 행을 적재한 직후, Pure의 상주는 38 MiB(W=1)와 104 MiB(W=4)이고 SQLite의 파일은 13.8 MiB와 52.8 MiB다. SQLite는 필요한 페이지만 읽어 든다. | | Pure 상주 | 상태 바이트 | 배수 | Pure 저장소 | SQLite 최고 상주 | SQLite 파일(직접 / 묶음) | |---|---:|---:|---:|---:|---:|---:| | RAM, W=1 | 835 MiB | 375 MiB | 2.23 | 434 MiB | 41–124 MiB | 84 / 82 MiB | | RAM, W=4 | 846 MiB | 402 MiB | 2.10 | 411 MiB | 60–188 MiB | 124 / 118 MiB | | SD, W=1 | 150–179 MiB | 73–85 MiB | 2.06–2.09 | 70–87 MiB | 17–89 MiB | 15–16 / 19–21 MiB | | SD, W=4 | 209–239 MiB | 130–142 MiB | 1.61–1.68 | 104–128 MiB | 23–136 MiB | 54–55 / 56–58 MiB | Pure의 값은 Pure의 실행이 모두 끝난 뒤다. 상태는 거의 모두 실행이 더한 주문 줄이다(RAM W=1에서 144만 건의 거래로 22 MiB에서 375 MiB). SQLite의 최고 상주는 실행마다의 범위다(SD는 세 판의 중앙값). **소유자가 하는 일(RAM, W=1, 64터미널).** Pure만 90초 돌리며 gdb로 소유자 스레드의 스택을 80번 떴다 ([`owner-samples.py`](../../research/experiments/022-tpcc-grouped/owner-samples.py)). 붙을 때마다 프로세스가 멈추므로 그동안의 처리량은 33,376건이었다. 표본마다 스택에 든 함수로 나눴다. | 소유자가 하던 일 | 표본 | |---|---:| | 항목 찾기(목록의 B+트리를 내려간다, `pure_list_seek`) | 24 (30%) | | 요청 기다리기 | 19 (24%) | | 스냅숏이 공유한 노드를 고치기 전에 복사하기(`own`, `new_node`, `retire`) | 15 (19%) | | 스냅숏 게시와 놓은 노드 해제(`publish`) | 5 (6%) | | 해석기(`run`, `dispatch`) | 5 (6%) | | 항목 바꾸기, 항목 읽기, 답 넘기기 | 각 3 (4%) | | 저장(`pure_app_save`)과 그 밖 | 3 (4%) | - 바쁜 시간(표본 61개)의 39%가 찾기, 33%가 스냅숏에 딸린 복사와 해제다. 저장은 1%다. - pure-serve는 저장마다 스냅숏을 게시한다. 읽기가 방금 답한 쓰기를 보게 하려는 것이다. 게시한 뒤 처음 고치는 노드는 그 스냅숏과 공유되어 있어 복사한다. RAM에서는 저장이 초당 약 1,000번이라, 무작위로 흩어진 항목(재고, 고객)을 고칠 때마다 거의 매번 그 잎을 복사한다. ## 판정 1. **SD 카드에서의 주장: 2배 넘게 남았다.** 같은 터미널 수에서 4.2–10.8배다. 기준대로 차이가 어디서 오는지 적었다. 거래당 장치에 쓴 바이트가 8–18배 다르다. - 092의 90배 가운데, 묶어 커밋하기와 조율이 SQLite를 10–15배 빠르게 했다(50건에서 505–739건). 남은 4–11배가 레코드를 기록하는 로그의 몫이다. - Pure는 묶어 커밋하는 구조를 앱 코드 없이 준다. SQLite에서는 작성자 스레드와 SAVEPOINT를 손으로 짰다. - README와 CHECKPOINT의 SD 비교를 이 비교로 바꿨다. 90배는 기본 설정으로 거래마다 커밋하는 SQLite에 대한 값이었다. 2. **소유자 하나: 병목이다.** W=4 RAM, 64터미널에서 소유자가 코어의 0.94를 썼고, 처리량은 16터미널보다 1.6% 적었다. 서버는 코어 셋 가운데 1.74개만 썼다. 기준대로 키로 나눈 소유자를 다음 설계로 올린다. [084](084-parallel-syncs.md)는 "앱의 적용이 CPU 하나를 넘을 때 소유자를 나누되 동기화는 장치마다 한 줄로 모은다"고 했다. TPC-C는 RAM에서 그 조건에 닿았다. - 설계는 표본이 보인 것과 함께 본다. 소유자 시간의 3분의 1이 스냅숏 때문에 드는 복사와 해제다. 이것을 줄이면 키로 나눌 수 없는 앱(한 키에 모이는 상태)까지 모두 빨라진다. - 나누기는 창고를 넘는 거래를 두 소유자가 함께 해야 하는 문제를 새로 만든다. New-Order의 약 10%가 다른 창고의 재고를 쓰고, Payment의 15%가 다른 창고의 고객에게 간다. - SD 카드에서는 소유자가 코어의 7–15%만 쓴다. 이 장치에서 쓰기를 늘리는 길은 여전히 거래당 바이트와 동기화다(084 그대로). 3. **체크포인트: 다음 일이 아니다.** SD 카드에서 W=4가 W=1의 92–105%다(기준은 절반 아래). 4. **메모리: 기준 안이다.** Pure의 상주가 상태 바이트의 1.61–2.23배다(기준 2.5배). ## 한계와 다음 - W=4까지다. RAM에서 저장소와 데이터베이스까지 메모리에 들어야 해서다. SD 카드의 실행은 30초다. W=4에서 Pure의 체크포인트는 두 실행을 합쳐 3–5번이라, 더 긴 실행에서 체크포인트가 어떻게 겹치는지는 재지 않았다. - SQLite는 프로세스 안에서 돌고 HTTP도 JSON도 없다. Pure의 수에는 그것이 들어 있다. CPU 비교는 SQLite에 유리하다. - 규격의 생각 시간, 창고당 터미널 10개, 텍스트 필드는 없다(092와 같다). 공식 tpmC가 아니다. - 1스레드 SQLite가 큰 WAL에서 느려진 까닭은 재지 않았다. - 소유자 표본은 80개다. 한 칸이 ±5%포인트쯤 흔들린다. gdb가 붙을 때마다 프로세스가 멈춘다. - 레코드를 기록하는 저장소(LSM 같은)와는 견주지 않았다. 다음은 **소유자의 시간**이다. 설계 노트에서 두 길을 함께 다룬다. 스냅숏이 공유한 잎을 고칠 때 덜 복사하는 길(모든 앱에 듣는다)과, 키로 나눈 소유자가 한 저장소의 그룹 커밋을 함께 쓰는 길(코어를 더 쓴다)이다. 덜 복사하는 쪽을 먼저 잰다. 그 첫걸음은 [107](107-snapshot-nodes-reused.md)이다. 복사의 몫 대부분이 할당이었고, 스냅숏이 놓은 노드를 되써 없앴다.