좋은 서비스를 만드는 CS — 교과서 개념을 측정으로 다시 배운다
끼어들기 순서로 캐시의 거짓말을 잰다
목표
결정적 시뮬레이터로 캐시 쓰기·읽기 전략 여섯 개를 같은 끼어들기 순서에 넣어 보고, 시나리오마다 오래된 읽기 수와 최대 오래됨(ms) 을 잽니다. 끝에는 영영 고쳐지지 않는 조합을 가려내고, 숫자를 근거로 전략 하나를 고릅니다.
왜 중요한가
캐시 불일치는 재현이 어렵습니다. 몇 ms 차이로 겹친 요청 두 개가 만들고, 다음 날 다시 돌리면 사라집니다. 그래서 '쓰기 후 삭제면 괜찮다', 'TTL 이 있으니 최대 TTL 만큼만 틀린다' 같은 믿음이 검증 없이 남습니다. 연산이 끝나는 시각을 정수 ms 로 못박은 시뮬레이터에서는 같은 순서를 몇 번이고 다시 돌릴 수 있고, 전략만 바꿔 같은 순서에 넣어 볼 수 있습니다. 패턴 설명은 'Redis 와 캐싱' 코스에 있으니, 여기서는 순서와 숫자만 봅니다.
재료
/opt/fixtures/svccs/cache/ 아래에 있습니다. 읽기만 하세요.
sim.py 시뮬레이터. 맨 위 설명에 원시 연산·제너레이터 규칙·명령줄 사용법이 있다
example_strategies.py 시작 틀 — write_update 와 read_aside
scenarios/*.json 시나리오 다섯. name · about(설명) · initial · warm(미리 채운 키) · ttl_ms · delay_ms ·
actors[{name, kind(read|write), key, value, start, lat{연산: ms}}]
명령줄: python3 /opt/fixtures/svccs/cache/sim.py <시나리오.json> <전략파일.py> <전략이름> > 기록.json
전략 이름은 update·delete_after·delete_before·double_delete·cas·ttl 이고, 각각 쓰는 함수는 sim.py 의 STRATEGIES 표에 있습니다(ttl 은 write_delete_after + read_aside 에 시나리오의 ttl_ms 를 줍니다).
기록의 모양
events(연산이 끝난 순서, seq·t·actor·op …), commits(seq·key·version·value·t = 커밋 시각·writer), reads(seq = 그 읽기의 마지막 연산 seq · actor · key · start · t = 끝난 시각 · value · version), final_db, final_cache(키마다 value·version·set_t·expires 또는 null) 입니다.
측정 규칙
오래된 읽기 r 보다 작은 seq 로 커밋된 같은 키의 버전 중 가장 큰 것이 r.version 보다 크다
오래됨(ms) r.t − (버전 r.version+1 커밋의 t) ← 쓰기를 시작한 시각이 아니다
stuck 끝났을 때 final_cache 의 항목이 expires 가 null 이고 version 이 final_db 보다 작다
돌려줄 것 {"stale_reads": 정수, "max_stale_ms": 정수 또는 null, "stuck": 불리언}
stuck 이면 max_stale_ms 는 null(상한 없음), 오래된 읽기가 없으면 0
단계
/root/svccs/cache/를 만들고example_strategies.py를/root/svccs/cache/strategies.py로 복사한 뒤,s1-two-writers시나리오를update전략으로 돌린 기록을/root/svccs/cache/s1-update.log.json에 저장합니다.strategies.py에measure(log)를 '측정 규칙' 대로 만들고, 1단계 기록을 잰 결과를/root/svccs/cache/s1-update.measure.json에 저장합니다. 채점기는 숨은 시나리오 기록들로 measure 를 다시 부릅니다.write_delete_after(커밋 → 캐시 삭제)와write_delete_before(캐시 삭제 → 커밋)를 만듭니다. 쓰기 함수는(key, value, opts)를 받는 제너레이터입니다.is_newer(cached, new)(캐시가 비었거나 new 가 엄격히 클 때만 참),write_cas(커밋 → 받은 버전으로cache_set_if),read_aside_cas(read_aside 와 같되 채울 때cache_set_if)를 만듭니다.cache_set_if의 마지막 인자로is_newer를 넘깁니다.write_double_delete(캐시 삭제 → 커밋 →("sleep", opts["delay_ms"])→ 캐시 삭제)를 만듭니다.- 시나리오 다섯 × 전략 여섯을 모두 돌려 measure 한 결과를
/root/svccs/cache/results.json에{시나리오 name: {전략 이름: measure 결과}}로 저장합니다. ttl전략의 시나리오별 max_stale_ms 를 모아/root/svccs/cache/ttl.json에ttl_ms(시나리오들의 ttl_ms, 정수)·max_stale_ms_by_scenario·exceeds_ttl(max_stale_ms 가 ttl_ms 보다 큰 시나리오 이름, 가나다순 목록)을 적습니다. 채점기는 여러분의ttl전략도 숨은 시나리오에서 다시 돌립니다./root/svccs/cache/choice.json에stuck(전략마다 stuck 이 참이었던 시나리오 이름 목록, 정렬)·safe(어느 시나리오에서도 stuck 이 아닌 전략, 정렬)·chosen(safe 가운데 시나리오별 max_stale_ms 의 최댓값이 가장 작은 것, 같으면 stale_reads 합이 작은 것, 그래도 같으면 이름순)·chosen_worst_ms(그 최댓값)를 적습니다.
참고
- 제너레이터에서
x = yield ("db_read", key)라고 쓰면 x 에 연산 결과가 들어옵니다. 읽기 전략은 읽은 값을return합니다. - 채점기는 여러분의 전략을 숨은 시나리오에서 돌려 연산 기록을 한 줄씩 기준과 대조합니다. 단계에 적힌 순서와 인자를 그대로 지키세요 — 연산 하나를 더하거나 빼도 기록이 달라집니다.
strategies.py는 채점기가 불러옵니다. 결과 파일을 만드는 코드는 별도 스크립트나if __name__ == "__main__":아래에 두세요.- 흔한 실수: 오래됨을 쓰기 시작 시각부터 재는 것, stuck 인데 max_stale_ms 에 관측한 최댓값을 적는 것(상한이 없습니다), is_newer 의 비교 방향을 뒤집는 것.
시뮬레이터를 한 번 돌린다
example_strategies.py 를 /root/svccs/cache/strategies.py 로 복사하고, s1-two-writers.json 을 update 전략으로 돌린 기록을 /root/svccs/cache/s1-update.log.json 에 저장한다.
sim.py 는 표준 출력으로 기록 JSON 을 냅니다. > 로 파일에 받으세요. 기록의 events 를 훑어 W1 의 늦은 cache_set 이 W2 보다 뒤에 도착하는 줄을 찾아보세요.
오래됨을 커밋 기준으로 잰다
strategies.py 에 measure(log) 를 측정 규칙대로 만들고, 1단계 기록을 잰 결과를 /root/svccs/cache/s1-update.measure.json 에 저장한다. 채점기는 숨은 시나리오 기록들로 measure 를 다시 부른다.
읽기의 seq 와 커밋의 seq 를 비교해 '읽기가 끝나기 전에 커밋된 것' 만 봅니다. 오래됨의 기준은 돌려준 버전 바로 다음 버전이 커밋된 시각입니다. 캐시에 만료 없는 옛 값이 남았다면 관측한 최댓값이 얼마든 상한은 없습니다.
쓰기 후 삭제, 삭제 후 쓰기
strategies.py 에 write_delete_after(커밋 → 캐시 삭제)와 write_delete_before(캐시 삭제 → 커밋)를 만든다. 채점기가 숨은 시나리오에서 두 전략의 연산 기록을 기준과 대조한다.
쓰기 함수는 (key, value, opts) 를 받는 제너레이터이고 연산 두 개를 yield 합니다. 결과를 쓰지 않는 연산도 yield 해야 시뮬레이터가 시간을 흘립니다.
버전을 비교해서만 덮는다
strategies.py 에 is_newer(cached, new)·write_cas·read_aside_cas 를 만든다. 채점기는 is_newer 를 직접 부르고, cas 전략을 숨은 시나리오에서 기준과 대조한다.
is_newer 는 캐시에 있는 버전(cached, 없으면 None)과 넣으려는 버전(new)을 받습니다. 옛 값이 새 값을 덮지 못하게 하려면 어느 쪽이 커야 할까요. write_cas 는 db_write 가 돌려준 버전을 씁니다.
지연 이중 삭제
strategies.py 에 write_double_delete(캐시 삭제 → 커밋 → ("sleep", opts["delay_ms"]) → 캐시 삭제)를 만든다. 채점기가 숨은 시나리오에서 기준과 대조한다.
sleep 은 결과가 없는 연산이지만 yield 해야 그만큼 시간이 흐릅니다. 기다리는 시간은 시나리오마다 다르므로 opts 에서 읽으세요.
다섯 시나리오 × 여섯 전략
시나리오 다섯을 전략 여섯으로 모두 돌려 measure 한 결과를 /root/svccs/cache/results.json 에 {시나리오 name: {전략 이름: measure 결과}} 로 저장한다.
sim.py 를 import 해 sim.run_strategy(시나리오, 전략모듈, 이름) 을 부르면 기록 dict 가 바로 나옵니다. 시나리오 이름은 파일 안의 name 입니다.
TTL 은 오래됨을 어디까지 묶나
ttl 전략의 시나리오별 max_stale_ms 를 모아 /root/svccs/cache/ttl.json 에 ttl_ms·max_stale_ms_by_scenario·exceeds_ttl 을 적는다. 채점기는 여러분의 ttl 전략도 숨은 시나리오에서 다시 돌린다.
TTL 은 캐시에 넣은 시각부터 셉니다. 커밋보다 한참 뒤에 옛 값이 들어오면 무엇이 되나요. ttl 전략은 read_aside 가 opts["ttl_ms"] 를 set 에 넘겨야 동작합니다.
숫자로 전략을 고른다
results.json 으로 /root/svccs/cache/choice.json 에 stuck·safe·chosen·chosen_worst_ms 를 적는다.
한 시나리오에서라도 stuck 이면 그 전략은 오래됨에 상한이 없으므로 safe 가 아닙니다. 어떤 시나리오에서 오래된 읽기가 0 이었다는 것은 안전의 근거가 되지 못합니다.