bundis 0.1.0 + 핫 캐시 + TIER1 하드닝 (commit
1b62336+ 미커밋) · 측정일 2026-06-10 주: A–E·G·I 수치는 캐시 레이어 시점 측정이고, F3·H는 TIER1 리퍼/SPOP 픽스 후 재측정값이다. write-through 버그 수정(plain SET이 이제 캐시 채움) 이후 SET 경로에 fill 비용 ~1%가 추가됐으나 GET 적중 수치(read-fill 경로와 동일)에는 영향 없다. 원시 데이터:bench/results.json· 계획/방법론:bench/PLAN.md· 재현:bun run bench/run.ts
측정 구성 = 실제 제품 기본 구성: 파일 기반 SQLite + WAL, 핫 캐시 ON(64MB = 메모리 예산 256MB의 25%, write-through + 적응형 TTI + LRU), 별도 서버 프로세스, 순정 Bun.RedisClient, loopback TCP, 만료 리퍼 100ms.
| 지표 | 값 |
|---|---|
| GET 처리량 (핫 워킹셋, 캐시 적중) | 321,824 ops/s (B2) · 동일 케이스 on/off 차분 2.67× (I1: 332,261 vs 124,600) |
| SET 처리량 (덮어쓰기, write-through) | 69,105 ops/s (캐시 off: 69,562 — 오버헤드 ≈ 0) |
| GET service time (단건, p50 / p99) | 29.5µs / 39.1µs (I1, n=20k — §I 표 참조. §A의 n=50k 측정은 p99 65.5µs로 1회성 스파이크 포함) |
| SET service time (단건, p50 / p99) | 33.4µs / 54.6µs |
| 16KB 값 GET 대역폭 (캐시 적중) | 3,345 MB/s |
| 256KB 값 GET 대역폭 (워킹셋 > 캐시 → 비적중 경로) | 826 MB/s |
| MSET 일괄 쓰기 (100키/호출) | 291,370 keys/s |
| Pub/Sub 팬아웃 전달량 (구독자 50) | 462,192 msgs/s |
| 콜드 스타트 (100k키 DB → 첫 응답) | 약 13ms (기동 11.3ms + 첫 GET 2.1ms) |
| 지속 삽입 (200k키, WAL 체크포인트 포함) | 39.6k–45.3k ops/s, 톱니 없음 |
환경: Apple M5 (10코어) · 24GB RAM · macOS (Darwin 25.5.0) · Bun 1.3.14
설계 단계 3-렌즈 방법론 비판 패널, 하니스 적대적 코드 리뷰(확정 결함 17건 수정), 캐시 레이어 적대적 리뷰(확정 6건 수정 — 캐시 포이즈닝 critical 포함)를 거친 측정. 핵심 규칙:
- 서버는 항상 별도 프로세스(
spawnServer) — 측정 클라이언트와 이벤트루프 비공유. embed 비교(G1)만 예외. - 파일+WAL + 핫 캐시 ON이 기본 (실제 기본 설정). 캐시 기여분은 I1의 on/off 차분으로 분리.
:memory:수치는 E1 비교 레그에만. - 케이스마다 새 서버+새 DB. 워밍업은 측정과 동일 워크로드 — 단, 캐시 적중 경로의 콜드 구간(~10만 op)이 기본 워밍업(1만 op)보다 길어 처리량 케이스의 첫 반복에 잔존하며, 중앙값이 이를 걸러낸다(한계 1). 타이밍 루프 내 할당 0.
- 일부 비교에 캐시 도입 전 런의 수치를 인용함 — 그 수치들은 현재
results.json에 없고 본문에 "(캐시 전 런)"으로 표기. - "service time"은 동시성 1 닫힌 루프 수치 — 부하 시 지연시간 아님(coordinated omission). 부하 중 꼬리지연은 F3 open-loop로만.
- 처리량 = sliding window, 3회 반복 중앙값(반복별 수치 results.json 공개).
- 대표 처리량 케이스(B2, C1, D1, I1)에서 bench/서버 %CPU 샘플링. 나머지는 미샘플.
알려진 한계:
- 캐시 적중 경로의 콜드 구간: 프로세스 기동 후 첫 ~10만 op 동안 ~90k ops/s, 이후 320k+로 전환 (B2 반복값 [89.8k, 325.6k, 321.8k], I1도 동일 패턴 [91.9k, 336.5k, 332.3k]). 원인 미확정 — 캐시 off 런에는 콜드 반복이 없으므로([123.5k, 124.6k, 124.9k]) 캐시 경로 특유의 JIT/자료구조 워밍업으로 추정만 함. 중앙값이 이를 흡수하지만, 짧게 살고 죽는 프로세스는 워밍업 후 수치를 못 본다.
- F3 open-loop 절대치는 스케줄러 타이머 해상도로
1ms 수준 부풀려짐 — 베이스라인 레그(p50 871µs, p99 2.6ms)는 상당 부분이 이 아티팩트다. 처닝 레그의 ms수백ms 꼬리에선 상대적으로 작아지므로, 레그 간 배율은 보수적 하한으로 읽을 것. - D1 다중 클라이언트는 단일 bench 프로세스 구동(서버 CPU 89–104%로 서버 병목 확인됨), F2 구독자 동일 프로세스.
max는 일회성 이벤트 복권 — 진단용 (예: A2 max 19.8ms는 5만 샘플 중 1회짜리 스파이크).- 실제 Redis 차분 비교 없음 (redis-server 미설치).
- INCR "회귀" 규명 완료 (
bench/incr-probe.ts로 변수 분리, 각 3회 중앙값): 캐시 도입 탓이 아니다.- 캐시 레이어 비용: ~1% (100k 프리로드에서 on 57.0k vs off 57.6k; 카운터 키가 캐시돼 매 INCR이 무효화를 적중해도 동일; 400k 행에서도 on/off 동일).
- PRAGMA cache_size 변경(2MB→128MB) 영향: 0.
- 키스페이스 효과가 지배: 빈 DB 74.6k vs 100k행 56k (−25%,
keysB-tree upsert 깊이) — 캐시 전·후 런이 똑같이 지불한 비용이라 회귀가 아님. 100k→400k 행은 추가 영향 없음. - 캐시 전 런 61.9k와의 잔여 격차: 동일 조건(캐시 off + 구 pragma)을 오늘 재측정해도 56–57k → 런 간 환경 드리프트. 풀 스위트의 50.6k는 스위트 문맥(직전 케이스들이 남긴 서버 힙 상태) 추가분.
- 부수 수정:
incrBy/incrByFloat/hIncrBy핫루프의 호출별TextEncoder할당 제거 (+2.3%, 55.7k→57.0k).
| 명령 | p50 | p95 | p99 | mean |
|---|---|---|---|---|
| GET (hit, 캐시 적중) | 29.5µs | 36.3µs | 65.5µs | 32.3µs |
| GET (miss) | 22.5µs | 26.6µs | 30.3µs | 22.9µs |
| SET (64B 덮어쓰기) | 33.4µs | 39.3µs | 54.6µs | 34.3µs |
| INCR | 36.5µs | 42.5µs | 50.7µs | 38.0µs |
| DEL | 44.8µs | 53.5µs | 75.0µs | 47.8µs |
| HSET (신규 필드) | 37.1µs | 48.0µs | 62.0µs | 40.1µs |
| HGET | 25.5µs | 29.3µs | 33.3µs | 25.9µs |
| SADD | 34.0µs | 42.6µs | 52.4µs | 36.4µs |
| SISMEMBER | 25.5µs | 29.0µs | 32.3µs | 25.6µs |
| EXPIRE | 35.2µs | 45.0µs | 59.4µs | 38.6µs |
단건 수치는 loopback RTT가 지배해 캐시 유무 영향이 작다 (I1: on 29.5µs vs off 26.4µs — 캐시 관리 비용 +3µs 수준). 캐시의 가치는 처리량에서 나온다(B·I).
| 케이스 | ops/s | 비고 |
|---|---|---|
| GET (캐시 적중) | 321,824 | 서버 CPU 91% (bench 42%) |
| SET (덮어쓰기) | 69,105 | write-through, 캐시 off와 동일 |
| SET (신규 삽입) | 37,074 | 두 B-tree 실제 INSERT 경로 |
| INCR (단일 키) | 50,611 | 회귀 아님 — 한계 6의 규명 결과 참고 (단독 측정 시 57k) |
| MGET(10키) | 139,411 호출/s = 1,394,110 keys/s | 캐시 적중 다건 조회 |
파이프라인 깊이 스케일링 (GET, 캐시 적중):
| depth | 1 | 10 | 100 | 1000 |
|---|---|---|---|---|
| ops/s | 51,494 | 265,053 | 324,360 | 325,856 |
| 크기 | SET ops/s | GET ops/s | GET MB/s | 비고 |
|---|---|---|---|---|
| 1KB | 50,163 | 327,703 | 336 | 워킹셋 1MB — 전부 캐시 적중 |
| 16KB | 20,561 | 204,149 | 3,345 | 워킹셋 16MB < 64MB 캐시 — 적중 |
| 256KB | 1,854 | 3,152 | 826 | 워킹셋 256MB > 64MB 캐시 — LRU 순환으로 사실상 전부 미스 (캐시 전 런의 821MB/s와 일치) |
256KB 행이 캐시 경계 효과를 그대로 보여준다: 워킹셋이 --cache-mb를 넘으면 순차 순환 접근에서 적중률 ~0이 되고 SQLite 경로 성능(이전 측정과 동일한 826MB/s)으로 떨어진다. 워킹셋 크기에 맞춰 --cache-mb를 설정하는 것이 가장 큰 성능 레버다.
D1 — GET 처리량 vs 클라이언트 수 (총 인플라이트 ≈128, 캐시 적중):
| 클라이언트 | 1 | 4 | 16 | 64 |
|---|---|---|---|---|
| ops/s | 320,281 | 329,737 | 312,045 | 241,472 |
| 서버 CPU | 104% | 89% | 90% | 94% |
~320–330k에서 플랫 — 단일 스레드 설계의 코어 1개 천장 (캐시로 천장 자체가 125k→330k로 올라감). 64 클라이언트에서 소켓당 오버헤드로 하락.
D2 — 혼합, 16 클라이언트: 80/20 = 166,316 · 50/50 = 100,788 · 20/80 = 67,363 ops/s. 읽기 비중이 높을수록 캐시 이득 (80/20은 94k(캐시 전 런) → 166k).
E1 — 파일 WAL vs :memory::
| SET ops/s | GET ops/s | |
|---|---|---|
| 파일 + WAL | 68,364 | 343,849 |
| :memory: | 122,811 | 338,755 |
읽기에서 파일/메모리 차이가 소멸 — 핫 캐시가 양쪽 모두의 읽기를 흡수하기 때문. 영속성의 비용은 이제 쓰기에만 남는다 (1.8×).
E2 — 키스페이스 크기: 1k 키 368,713 vs 100k 키 344,398 ops/s — 둘 다 캐시에 들어가므로 차이 거의 소멸 (174k vs 111k, 캐시 전 런).
E3 — 콜드 스타트 (100k키 파일 DB, 반복마다 새 파일 복사본, 5회): 기동 11.3ms + 첫 GET 2.1ms ≈ 13ms. 페이지 지연 로드 구조라 기동 시 데이터 재생 없음 (100k키 단일 지점 측정). 단, 캐시는 재시작 시 비어 있으므로 한계 1의 JIT 워밍업 + 캐시 재충전 전까지는 비적중 성능(~125k).
E4 — 지속 삽입 200k키: 청크별 39,625–45,254 ops/s, 체크포인트 톱니 없음. 종료 시 DB 25MB / WAL 2.4MB.
F1 — 100-SET 묶음 전략 (20,000 SET): MSET 291,370 > MULTI/EXEC 136,293 > 파이프라인 107,044 > 순차 37,082 SETs/s. MULTI/EXEC가 개별 파이프라인보다 빠른 이유: EXEC가 100개 쓰기를 SQLite 트랜잭션 1개로 커밋 (개별 SET은 건마다 BEGIN/COMMIT).
F2 — Pub/Sub 팬아웃 (전달 msgs/s): 구독자 1 = 155,738 · 10 = 412,301 · 50 = 462,192.
F3 — 부하 중 꼬리지연 (open-loop GET @5k/s, TTL 처닝) — 캐시 도입 후에도 동일한 최중요 운영 지표:
리퍼 배치 상한(틱당 LIMIT 1000) 적용 후 측정:
| 레그 | p50 | p95 | p99 | max | (상한 도입 전 p99) |
|---|---|---|---|---|---|
| 베이스라인 | 803µs | 1.4ms | 2.6ms | 7.7ms | 2.6ms |
| 50k키 분산 만료 (~4초 창, PEXPIREAT) | 891µs | 15.6ms | 21.4ms | 30.0ms | 24.1ms |
| 50k키 일괄 만료 (한 시점) | 833µs | 9.0ms | 14.1ms | 23.4ms | 377ms |
일괄 만료 p99가 377ms → 14.1ms (27배 개선). 리퍼가 틱(100ms)당 최대 1,000키만 회수하므로 5만 키 동시 만료가 한 틱에 몰리지 않고 여러 틱에 분산된다 — 한 번의 동기 스윕이 서버를 수백 ms 점유하던 문제 해소. (베이스라인 레그 절대치는 스케줄러 아티팩트 ~1ms — 한계 2.) 분산 만료 꼬리가 일괄보다 약간 긴 것은 만료가 측정 창 전체에 퍼져 매 틱 회수가 GET과 계속 경합하기 때문이고, 둘 다 이제 두 자릿수 ms로 안전 영역. 남은 한계: 단일 키 CASCADE가 대형 컬렉션을 끌고 가는 경우는 행이 아닌 키 단위라 여전히 상한 밖 — TIER 3의 O(1) DEL로 해결 예정.
F5 — 만료 키 GET (lazy-expiry): p50 40.1µs vs 일반 miss 22.5µs — DELETE-on-read 비용 +78%, 1회성.
F6 — 에러 경로: 정상 GET(단일 키, 캐시 적중) 384,755 vs WRONGTYPE GET 192,816 vs INCR 비숫자 140,324 ops/s. 캐시 도입으로 역전 — 에러 경로는 캐시를 타지 못해 이제 정상 경로의 절반. 여전히 병목은 아님. (단일 키 반복이라 B2와 절대치 비교 불가.)
F7 — APPEND 누적 (1KB×2,000): 첫 윈도 194µs → 마지막 윈도 3,473µs (17.9배) — BLOB 전체 재기록. 반복 APPEND 패턴 회피.
| spawn | embed | |
|---|---|---|
| GET ops/s (캐시 적중) | 347,906 | 282,095 (−19%) |
| SET ops/s | 67,903 | 64,349 (−5%) |
| GET service time p50 | 29.4µs | 29.3µs |
embed = 호스트 앱과 이벤트루프 공유 비용. 처리량 중요하면 spawn.
| 카디널리티 | HGETALL | SMEMBERS |
|---|---|---|
| 1k | 0.4ms | 0.2ms |
| 10k | 3.5ms | 1.5ms |
| 100k | 57.1ms | 17.2ms |
SQL 랜덤 선택 적용 후 SRANDMEMBER(1) = 1.7ms / SPOP(1) = 1.7ms @100k 셋 (도입 전 4.6ms / 5.8ms — 약 3.4배 개선). 전체 멤버를 JS로 끌어오던 것을 단건 경로는 COUNT(*) + LIMIT 1 OFFSET random(균등 보장)으로 교체. 컬렉션 통째 DEL: 10k 5ms · 100k 45–55ms — 여전히 동기 정지(TIER 3 O(1) DEL 대상). HGETALL/SMEMBERS 자체는 전체 조회라 카디널리티 비례 유지.
| 캐시 ON (64MB) | 캐시 OFF | 배율 | |
|---|---|---|---|
| GET 처리량 | 332,261 | 124,600 | 2.67× |
| SET 처리량 | 69,016 | 69,562 | 0.99× (오버헤드 없음) |
| GET service time p50 / p99 | 29.5µs / 39.1µs | 26.4µs / 47.3µs | p50 +3.1µs (Map 관리 비용) |
| 서버 CPU (GET 중) | 93% | 93% | 같은 포화, 2.67배 일 |
캐시 정책: write-through(쓴 즉시 적중) + 적응형 TTI(히트 누적 시 idle 허용 로그 성장, 8× 캡, 스위프마다 반감) + LRU 바이트 상한. 데이터 정합성: SQLite가 항상 진실 — 캐시는 순수 읽기 가속이라 재시작/영속성 의미론 무변화 (전체 테스트 141개 통과, 이 중 계약 테스트 73개는 순정 클라이언트가 캐시 ON 기본 서버를 경유).
| # | 특성 | 영향 | 회피/개선 |
|---|---|---|---|
| 1 | 핫 캐시 적중 = 2.67× 읽기, 쓰기 무비용 (I1) | 워킹셋 ≤ cache-mb일 때 | 워킹셋에 맞춰 --cache-mb 설정 |
| 2 | 워킹셋 > 캐시면 적중률 붕괴 (C1 256KB) | 비적중 = 기존 SQLite 성능 | 캐시 증설 또는 기대치 조정 |
| 3 | 배치 상한으로 p99 377ms→14ms (F3) | TTL 지터 권장은 유지 | |
| 4 | 캐시 적중 경로 콜드 구간 ~10만 op (B2/I1, 원인 미확정) | 기동 직후 ~90k ops/s | 장수 프로세스 전제, 워밍업 트래픽 |
| 5 | SQL 랜덤 선택으로 5.8ms→1.7ms (H) | — | |
| 6 | APPEND 전체 재기록 (F7) | 194µs→3.5ms @2MB | 반복 APPEND 회피 |
| 7 | 대량 쓰기 MSET ≫ MULTI > 파이프라인 (F1) | 최대 2.7× | 클라이언트 패턴 선택 |
| 8 | 단일 코어 천장: 캐시로 125k→330k GET/s 상승 (D1) | 코어 추가 무효 | 설계상 의도 |
| 9 | 콜드 스타트 13ms, 단 캐시 재충전 필요 (E3) | 재시작 직후 읽기 ~125k | — |
bun run bench/run.ts # 전체 (~3분)
bun run bench/run.ts --quick # 축소 스모크
bun run bench/run.ts --only I1 # 캐시 on/off 차분만결과는 bench/results.json (메타데이터: 커밋, Bun 버전, 하드웨어, 측정 구성 포함).