write-behind
쓰기를 캐시에서 먼저 끝내고 원본에는 나중에 반영하는 방식입니다. 애플리케이션은 캐시가 받아 준 순간 다음 일로 넘어갑니다. 원본에 옮겨 적는 일은 뒤에서 따로 돌아갑니다. 자리에 따라 write-back 이라는 이름으로도 부릅니다.
상세
캐싱에서 쓰기 방식은 원본에 언제 닿느냐로 갈립니다. write-through 는 원본과 캐시 양쪽에 다 닿기 전에는 그 쓰기를 완료로 돌려주지 않습니다. write-behind 는 그 기다림을 걷어냅니다. 시스템 오브 레코드(system of record)로 가는 쓰기의 시점을 바꾸는 것이 이 패턴입니다. 갱신하는 스레드가 기다리는 동안 시스템 오브 레코드에 쓰는 write-through 와 달리, write-behind 는 나중에 쓸 데이터로 큐에 넣습니다. 그래서 사용자 스레드가 더 일찍 다음 일로 넘어갑니다.
블록 계층으로 내려가도 정의가 같습니다. 리눅스의 장치 매퍼 캐시는 동작 모드를 writeback · writethrough · passthrough 셋으로 두고 기본값이 writeback 입니다. 이 모드를 고르면 캐시된 블록에 대한 쓰기가 캐시로만 갑니다. 그 블록은 메타데이터에서 dirty 로 표시됩니다. writethrough 를 고르면 원본 장치와 캐시 장치 양쪽에 닿기 전에는 쓰기가 완료되지 않습니다. clean 블록은 계속 clean 으로 남습니다.
쓰기 경로는 이렇게 갈라집니다.
sequenceDiagram
autonumber
participant 앱
participant 캐시
participant 큐
participant 원본
앱->>캐시: 쓰기
캐시->>큐: 나중에 쓸 것으로 넣기
캐시-->>앱: 완료
Note over 캐시: 블록을 dirty 로 표시
큐->>원본: 시간이 지난 뒤 반영
앱이 캐시에 씁니다. 캐시는 그 값을 나중에 쓸 것으로 큐에 넣고 곧바로 완료를 돌려줍니다. 블록 캐시 쪽이면 같은 순간에 그 블록이 dirty 로 표시됩니다. 원본에 실제로 적히는 것은 그 뒤입니다.
애플리케이션 캐시에서는 래퍼가 이 일을 맡습니다. 사용자가 제공한 CacheLoaderWriter 구현 둘레에
래퍼를 등록하라고 시스템에 알리는 것이 write-behind 를 켜는 방식입니다. 쓰기는 백킹 시스템 오브
레코드로 비동기로 나갑니다. 등록하고 나면 쓰기의 배칭과 병합에 관한 설정이 딸려 옵니다.
대가
얻는 것
- 원본에 값이 적히기를 호출한 쪽이 기다리지 않습니다. 갱신하는 스레드가 기다리는 동안 원본에 쓰는 write-through 와 달리, 나중에 쓸 데이터로 큐에 넣고 완료를 돌려줍니다. 사용자 스레드가 더 일찍 다음 일로 넘어갑니다.
- 같은 키에 거듭 쓴 것이 원본에는 한 번만 닿습니다. 병합(coalescing)은 배칭할 때 키마다 마지막
변경만
CacheLoaderWriter로 보내는 동작입니다. 같은 키에 세 번 썼어도 시스템 오브 레코드로 나가는 것은 하나입니다.
내주는 것
- 쓰기가 완료로 돌아온 시점과 원본에 값이 적히는 시점이 벌어집니다. 사용자 스레드가 더 일찍 진행하는 대가가 이것입니다. 시스템 오브 레코드가 갱신되기까지 얼마간의 시간 지연이 생깁니다.
- 캐시가 아직 옮기지 않은 데이터를 들고 있게 됩니다. 블록 계층에서 디스크 위 메타데이터는 flush 나 FUA(Force Unit Access) bio 가 쓰일 때마다 커밋되고, 그런 요청이 없으면 1초마다 커밋됩니다. 그래서 캐시가 휘발성 쓰기 캐시를 단 물리 디스크처럼 동작합니다.
- 병합을 켜면 중간 값이 원본에 남지 않습니다. 키마다 마지막 변경만 나가므로, 같은 키를 거쳐 간 중간 값들은 시스템 오브 레코드에 기록되지 않습니다.
- 밀린 쓰기의 최대 개수를 미리 정해 두어야 합니다. in-flight 쓰기의 최대치는 사실상 동시성 수준 곱하기 큐 크기입니다. 배칭을 쓰면 거기에 배치 크기가 한 번 더 곱해집니다. 그 곱이 아직 원본에 안 닿은 채로 처리 중일 수 있는 쓰기의 최대 개수입니다.
예시
Ehcache 의 write-behind 설정
Cache<Long, String> writeBehindCache = cacheManager.createCache("writeBehindCache",
CacheConfigurationBuilder.newCacheConfigurationBuilder(Long.class, String.class,
ResourcePoolsBuilder.heap(10))
.withLoaderWriter(new SampleLoaderWriter<>(singletonMap(41L, "zero")))
.withService(WriteBehindConfigurationBuilder
.newBatchedWriteBehindConfiguration(1, TimeUnit.SECONDS, 3)
.queueSize(3)
.concurrencyLevel(1)
.enableCoalescing())
.build());
withLoaderWriter 가 먼저 있어야 합니다. write-behind 에는 설정된 CacheLoaderWriter 가
필요합니다. 그 위에 WriteBehindConfigurationBuilder 로 만든 write-behind 설정을 캐시에
등록합니다. newBatchedWriteBehindConfiguration(1, TimeUnit.SECONDS, 3) 은 배치 크기 3 과 최대
쓰기 지연 1초를 정합니다. queueSize(3) 은 write-behind 큐의 최대 크기입니다.
concurrencyLevel(1) 은 시스템 오브 레코드를 비동기로 갱신하는 쓰기 스레드를 몇 개 둘지
정합니다. enableCoalescing() 은 배치마다 키당 한 번의 갱신만 시스템 오브 레코드에 닿게 합니다.
Ehcache 문서는 이 배치 설정이 클러스터 캐시에서는 적용되지 않는다고 적습니다.
병합이 무엇을 하는지는 같은 문서의 쓰기 세 줄에서 드러납니다.
writeBehindCache.put(42L, "one");
writeBehindCache.put(43L, "two");
writeBehindCache.put(42L, "this goes for the record");
키 42L 에 두 번 썼습니다. 캐시에서 읽으면 "this goes for the record" 가 나옵니다. 병합을 켜 둔
배치에서 그 키로 시스템 오브 레코드에 도달하는 것도 마지막 값 하나입니다.
bcache 의 cache_mode
cache_mode
can be one of either writethrough, writeback, writearound or none.
bcache 의 백킹 장치 sysfs 속성입니다. 네 값 중 writeback 을 고르면 그 백킹 장치가 이 방식으로
돕니다. bcache 문서는 writeback 캐싱이 캐시의 대부분을 쓰기 버퍼링에 쓸 수 있다고 적습니다. dirty
데이터를 백킹 장치로 내려보내는 일은 언제나 순차로, 인덱스의 처음부터 끝까지 훑으며 이뤄집니다.
실패
| 조건 | 그때 벌어지는 일 |
|---|---|
| 전원이 나간다 | dm-cache 문서는 캐시가 휘발성 쓰기 캐시를 단 물리 디스크처럼 동작한다고 적습니다. 최근 쓰기 일부를 잃을 수 있습니다 |
| 캐시가 clean 이 되기 전에 캐시로 쓰는 장치의 볼륨을 줄인다 | dm-cache 문서는 절대 그러지 않도록 주의하라고 적습니다. writeback 모드를 쓸 때 특히 중요하다고 적습니다 |
| 원본 쓰기가 실패한다 | Ehcache 는 write-behind 래퍼 수준에서 실패한 쓰기의 재시도를 지원하지 않습니다 |
| 밀린 쓰기가 큐 크기에 닿는다 | 캐시 연산 쪽으로 백프레셔가 걸립니다 |
전원 손실 자리를 먼저 봅니다. dm-cache 는 디스크 위 메타데이터를 flush 나 FUA bio 가 쓰일 때마다, 그런 요청이 없으면 1초마다 커밋합니다. 그 커밋 사이에 전원이 나가면 최근 쓰기 일부를 잃을 수 있습니다. writethrough 와 passthrough 모드는 캐시를 이미 clean 으로 유지하므로 이 자리에 서지 않습니다.
원본 쓰기가 실패하는 자리는 구현이 손을 뗍니다. Ehcache 문서는 재시도가 언제 어떻게 일어나야
하는지는 애플리케이션 개발자와 시스템 오브 레코드 주인이 더 잘 안다고 적습니다. 그 기능이
필요하면 CacheLoaderWriter 구현에 직접 넣으라고 적습니다. 래퍼는 실패한 쓰기를 대신 다시 밀어
주지 않습니다.
큐가 차면 아래쪽이 아니라 위쪽이 밀립니다. Ehcache 의 queue size 는 캐시 연산에 백프레셔를 걸기 전까지 밀린 쓰기 연산이 몇 개까지 있을 수 있는지를 가리킵니다. 배칭을 쓰면 그 수는 사실상 밀린 배치의 개수가 됩니다.
같은 자리에서 다르게 도는 구현도 있습니다. bcache 문서는 자기가 데이터를 지키려고 애쓴다고 적습니다. unclean shutdown 을 안정적으로 다룬다고 적습니다. 쓰기가 stable storage 에 올라가기 전에는 완료로 돌려주지 않습니다. 그래서 clean shutdown 이라는 개념 자체를 두지 않는다고 적습니다.
운영
Ehcache 는 write-behind 를 켜면서 만질 손잡이 다섯을 이름으로 정의합니다.
| 개념 | 무엇을 정하나 |
|---|---|
| queue size | 캐시 연산에 백프레셔를 걸기 전까지 밀린 쓰기 연산이 몇 개까지 있을 수 있나 |
| concurrency level | write-behind 를 위한 병렬 처리 스레드와 큐를 몇 벌 둘 것인가. in-flight 쓰기의 최대치는 사실상 이 값 곱하기 큐 크기입니다 |
| batching and batch size | 변경 연산을 배치 크기 단위로 묶어 CacheLoaderWriter 에 보냅니다. 배칭할 때 큐 크기는 사실상 밀린 배치의 개수가 됩니다 |
| coalescing | 배칭할 때 키마다 마지막 변경만 CacheLoaderWriter 로 보냅니다 |
| maximum write delay | 배칭할 때 덜 찬 배치를 얼마까지 기다리나. 이 시간이 지나면 배치가 덜 찼어도 처리됩니다 |
블록 계층에서는 켜고 끄는 손잡이가 따로 있습니다. bcache 에서 writeback 은 기본이 꺼짐입니다.
런타임에 임의로 켜고 끌 수 있습니다. 백킹 장치의 sysfs 에서 cache_mode 로 고릅니다. 볼 자리는
dirty_data 입니다. 이 백킹 장치의 캐시에 들어 있는 dirty 데이터의 양을 알려 줍니다. detach 에
쓰면 캐시 셋에서 뗍니다. 캐시에 dirty 데이터가 있으면 먼저 flush 합니다.
커널 자신의 페이지 캐시도 같은 방식으로 돌고 손잡이가 셋입니다.
| 설정 키 | 무엇을 정하나 |
|---|---|
dirty_background_ratio |
백그라운드 커널 flusher 스레드가 dirty 데이터를 쓰기 시작하는 지점. 사용 가능한 전체 메모리에 대한 백분율입니다 |
dirty_expire_centisecs |
dirty 데이터가 얼마나 오래됐을 때 쓰기 대상이 되나. 100분의 1초 단위입니다 |
dirty_writeback_centisecs |
flusher 스레드가 깨어나는 간격. 100분의 1초 단위입니다. 0 으로 두면 주기적 writeback 이 꺼집니다 |
dirty_background_ratio 가 말하는 사용 가능한 전체 메모리는 시스템 전체 메모리와 같지 않습니다.
비어 있는 페이지와 회수 가능한 페이지를 담은 양입니다.
관련 항목
원본 반영 시점을 놓고 겨루는 캐싱 전략
write-through · read-through · cache-aside · cache-as-sor · passthrough · write-around
이것을 실제로 구현·채택한 제품·표준
Ehcache · dm-cache · bcache · 커널 · Oracle Coherence · PAT · MTRR · x86
이것이 거치는 처리 단계
dirty 데이터 · FUA · flush · 메타데이터 · 인덱스
이것을 이루는 구성 요소
다른 이름: write-back · writeback · write behind · 지연 쓰기