사전 write-through
패턴

write-through

gabury1

캐시에 쓸 때 원본에도 함께 쓰는 방식입니다. 양쪽에 다 닿아야 그 쓰기가 끝난 것으로 봅니다. 그래서 캐시에는 원본에 아직 못 간 내용이 남지 않습니다. 캐시를 굴리는 쪽이 고르는 정책이지 캐시와 원본이 서로 합의하는 규칙이 아닙니다.

상세

쓰기를 어디까지 밀고 나서 완료라 부를지가 캐시 쓰기 정책을 가릅니다. write-through 는 그 선을 원본까지로 잡습니다. 리눅스 커널의 블록 계층 캐시에서 writethrough 를 고르면, 캐시된 블록에 대한 쓰기는 오리진 장치와 캐시 장치 양쪽에 닿기 전에는 완료되지 않습니다. 그리고 깨끗한 블록은 깨끗한 채로 남아야 합니다.

sequenceDiagram
    autonumber
    participant 앱
    participant 캐시
    participant 원본
    앱->>캐시: 쓰기
    앱->>원본: 같은 쓰기
    캐시-->>앱: 닿음
    원본-->>앱: 닿음
    Note over 캐시: 깨끗한 채로 남는다

같은 쓰기가 두 자리에 들어갑니다. 둘 중 하나만 닿은 상태는 완료가 아닙니다. 그래서 캐시에 원본과 다른 내용이 얹혀 있는 구간이 생기지 않습니다. 같은 계층의 인자 설명은 이것을 더 짧게 못 박습니다. writethrough 인자는 캐시 블록의 내용이 오리진 블록의 내용과 달라지는 것을 금지하는 write through 캐싱이라고 적혀 있습니다. 이 인자를 안 주면 기본 동작은 캐시 블록의 내용을 나중에 원본으로 되쓰는 것이고, 그때는 둘이 달라질 수 있습니다.

기본값 쪽과 견주면 선이 더 뚜렷해집니다. 그 계층의 동작 모드 셋은 writeback · writethrough · passthrough 입니다. 기본값인 writeback 을 고르면 캐시된 블록에 대한 쓰기는 캐시로만 가고 그 블록은 메타데이터에서 dirty 로 표시됩니다. write-through 에는 그 dirty 표시가 생기지 않습니다. 되쓸 것이 없기 때문입니다.

애플리케이션 캐시에서도 완료의 선은 같습니다. 다만 원본으로 가는 그 쓰기를 누가 내보내는지가 갈립니다. 한쪽은 애플리케이션이 두 곳에 각각 씁니다. 데이터베이스에 데이터를 쓸 때마다 캐시에 데이터를 더하거나 갱신하는 것이 write-through 전략이라는 정의입니다. 쓰기를 하는 쪽이 캐시와 원본 둘을 함께 움직입니다. 캐시가 채워지는 자리도 쓰기 경로입니다. 읽다가 캐시 미스가 나서 채우는 cache-aside 나 lazy loading 과 갈리는 자리가 여기입니다.

다른 쪽은 캐시가 원본으로 보냅니다. 애플리케이션은 캐시에만 쓰고, 캐시에 붙은 writer 부품이 그 쓰기를 시스템 오브 레코드까지 통과시킵니다. read-through 와 write-through 를 CacheLoaderWriter 라는 인터페이스 하나 뒤에 합쳐 두고 메서드를 두 묶음으로 가르는 구현이 그 꼴입니다. load(K) 와 loadAll(Iterable) 이 read-through 쪽이고, write(K, V) · writeAll(Iterable) · delete(K) · deleteAll(Iterable) 이 write-through 쪽입니다. 캐시에 쓰는 호출이 시스템 오브 레코드까지 통과하는 것이 write-through 라는 뜻입니다. 두 정의가 가리키는 선은 같고, 다른 것은 그 두 번째 쓰기를 애플리케이션이 내보내나 캐시가 내보내나입니다.

이름은 한 계층의 것이 아닙니다. CPU(Central Processing Unit, 중앙 처리 장치) 의 메모리 타입 이름표, 블록 계층 캐시의 설정값, 애플리케이션 캐시의 전략 이름에서 같은 이름이 같은 뜻으로 되풀이됩니다.

대가

얻는 것

  • 캐시의 데이터가 낡지 않습니다. 데이터베이스에 쓸 때마다 캐시도 갱신되므로 캐시에 있는 데이터는 늘 현재 값입니다.
  • 캐시와 원본이 달라지는 구간이 없습니다. 쓰기가 양쪽에 닿기 전에는 완료가 아니고, 되쓸 것이 없으니 dirty 표시도 생기지 않습니다.

내주는 것

  • 쓰기 한 번이 두 번의 이동이 됩니다. 모든 쓰기가 캐시에 한 번, 데이터베이스에 한 번 두 번을 오가며 그것이 과정에 지연을 더합니다. 원본이 응답할 때까지 그 쓰기는 안 끝난 것이므로, 쓰기 지연은 원본 매체가 정합니다.
  • 쓰기를 모아서 나중에 처리할 여지를 내줍니다. 블록 계층의 기본 동작은 캐시 블록의 내용을 나중에 되쓰는 것이고 그 이유가 성능입니다. write-through 인자는 그 미루기를 금지합니다.
  • 읽히지 않을 데이터가 캐시로 들어옵니다. 이것을 cache churn 이라 부릅니다. 대부분의 데이터는 한 번도 읽히지 않으므로 그만큼이 자원의 낭비입니다. 쓰기가 캐시를 채우는 구조라서 캐시의 내용이 읽기 수요가 아니라 쓰기 수요를 따라갑니다.
  • 새로 띄운 노드는 비어 있습니다. 노드 장애 때문이든 확장 때문이든 새 노드를 띄우면 데이터가 없습니다. 그 데이터는 데이터베이스에서 다시 쓰이거나 갱신될 때까지 계속 없습니다. lazy loading 을 write-through 와 함께 구현하면 이것을 줄일 수 있습니다.

예시

dm-cache 의 테이블 인자

cache <metadata dev> <cache dev> <origin dev> <block size> <#feature args> [<feature arg>]* <policy> <#policy args> [policy args]*

디바이스 매퍼 타깃을 만드는 줄입니다. <#feature args> 는 뒤에 몇 개의 기능 인자를 넘기는지 적는 자리이고, 그 뒤 <feature arg> 자리에 들어갈 수 있는 값이 writethrough 또는 passthrough 입니다. 커널 문서는 기본값이 writeback 이라고 괄호로 적어 둡니다. 즉 이 인자를 한 개 넣는 것이 write-through 를 켜는 최소 형태입니다. <metadata dev> 는 영속 메타데이터를 담고 <cache dev> 는 캐시된 데이터 블록을 담습니다. 커널 문서는 이 둘을 원본 데이터 블록을 담는 <origin dev> 보다 빠른 장치로 적습니다.

bcache 의 cache_mode

/sys/block/<bdev>/bcache/cache_mode

블록 계층 캐시 bcache 는 백킹 장치마다 sysfs 파일로 모드를 노출합니다. 자리는 /sys/block/<bdev>/bcache 와 /sys/block/bcache*/bcache 이고, 붙어 있으면 /sys/fs/bcache/<cset-uuid>/bdev* 에도 있습니다. cache_mode 가 가질 수 있는 값은 writethrough · writeback · writearound · none 넷입니다. 커널 문서는 writethrough 와 writeback 캐싱을 둘 다 지원하고 writeback 은 기본이 꺼짐이며 런타임에 임의로 켜고 끌 수 있다고 적습니다.

x86 메모리 타입 WT

wb   write-back
uc   uncached
wc   write-combined
wt   write-through
uc-  uncached minus

PAT(Page Attribute Table, 페이지 속성 테이블) 문서가 싣고 있는 메모리 속성 이름표입니다. PAT 는 페이지 단위로 메모리 속성을 정하게 해 주고, 이 다섯이 그중 흔히 쓰이는 값입니다. 캐시 정책의 이름이 CPU 가 읽는 페이지 속성으로 그대로 내려와 있는 자리입니다. 그보다 오래된 MTRR(Memory Type Range Register, 메모리 타입 범위 레지스터) 쪽에도 같은 이름이 남아 있습니다.

static char *mtrr_strings[MTRR_NUM_TYPES] = { "uncachable", "write-combining", "?", "?", "write-through", "write-protect", "write-back", };

MTRR 은 물리 주소 구간 단위로 메모리 타입을 정하는 옛 방식이고, 커널 문서는 현대 x86 하드웨어 에서는 이 쓰임이 PAT 로 대체됐다고 적습니다.

Ehcache 의 CacheLoaderWriter 등록

Java
CacheManager cacheManager = CacheManagerBuilder.newCacheManagerBuilder().build(true);
Cache<Long, String> writeThroughCache = cacheManager.createCache("writeThroughCache",
    CacheConfigurationBuilder.newCacheConfigurationBuilder(Long.class, String.class, ResourcePoolsBuilder.heap(10))
        .withLoaderWriter(new SampleLoaderWriter<>(singletonMap(41L, "zero")))
        .build());
assertThat(writeThroughCache.get(41L), is("zero"));
writeThroughCache.put(42L, "one");
assertThat(writeThroughCache.get(42L), equalTo("one"));
cacheManager.close();

withLoaderWriter 로 CacheLoaderWriter 를 캐시에 등록합니다. 등록한 것은 41L 이 "zero" 에 대응한다는 매핑을 아는 샘플입니다. 캐시에는 아직 내용이 없으므로 get(41L) 은 CacheLoaderWriter 에 위임되고, 돌아온 매핑이 캐시를 채우면서 호출한 쪽으로도 돌아갑니다. 여기까지가 read-through 쪽입니다. put(42L, "one") 으로 캐시 매핑을 만들 때는 CacheLoaderWriter 가 불려 그 매핑을 시스템 오브 레코드에 씁니다. 이 절반이 write-through 입니다.

실패

깨지는 자리는 캐시 장치 쪽입니다. 원본에는 같은 값이 이미 갔거나 함께 가고 있으므로, 캐시 쪽 사고가 데이터 자체를 없애지는 않습니다.

flowchart TD
    A["writethrough 쓰기"] --> B{"캐시 쓰기가 에러인가"}
    B -->|아니오| C["양쪽에 닿음. 완료"]
    B -->|예| D["그 LBA 의 캐시 데이터를 무효화"]
    D --> E["캐시를 우회한 쓰기와 같은 처리"]

bcache 문서는 캐시 장치로 오가는 입출력 에러를 정상 동작에 영향을 주지 않고 처리하려 한다고 적습니다. writethrough 쓰기에서 캐시 쪽 쓰기가 에러를 내면 그 LBA(Logical Block Address, 논리 블록 주소)의 캐시 데이터를 무효화하는 쪽으로 바꿉니다. 캐시를 우회하는 쓰기에 하는 것과 같은 처리입니다. writeback 쓰기는 다릅니다. 그 에러를 파일시스템과 유저스페이스로 그대로 올려보낸다고 적혀 있습니다.

조건 그때 벌어지는 일
캐시 쪽 쓰기가 에러를 낸다 그 LBA 의 캐시 데이터를 무효화하고 넘어갑니다. 캐시를 우회한 쓰기와 같은 처리입니다
캐시 장치의 에러가 임계를 넘는다 bcache 가 캐시 장치를 내리고 붙어 있던 백킹 장치를 전부 passthrough 모드로 돌립니다. 임계는 설정할 수 있고 기본값은 0 입니다
캐시 장치를 통째로 잃는다 캐시가 writethrough 모드면 캐시 장치를 그냥 버려도 데이터를 잃지 않습니다
캐시가 dirty 인 채로 캐시용 볼륨을 줄인다 write-through 는 이 자리에 안 걸립니다. dm-cache 문서는 writethrough 와 passthrough 모드가 이미 깨끗한 캐시를 유지한다고 적습니다

커널이 없는 자리에서 데이터를 되찾는 절차도 같은 결을 보입니다. bcache 문서는 백킹 장치의 파일시스템이 8KiB 오프셋에 그대로 있으므로 losetup -o 8192 로 루프 장치를 만들어 열 수 있다고 적습니다. 그리고 캐시가 writethrough 모드면 캐시 장치를 안전하게 버려도 데이터를 잃지 않는다고 덧붙입니다.

운영

모드를 켜고 끄는 손잡이는 계층마다 이름이 다릅니다. bcache 는 sysfs 의 cache_mode 파일이고, 값은 writethrough · writeback · writearound · none 입니다. writeback 은 기본이 꺼짐이고 런타임에 임의로 켜고 끌 수 있습니다. dm-cache 는 타깃을 만드는 줄의 기능 인자 자리에서 정합니다.

모드를 옮기는 자리에는 조건이 붙습니다. dm-cache 문서는 passthrough 를 캐시 내용이 오리진 장치와 일관되다고 알려지지 않았을 때 쓸모 있다고 적습니다. passthrough 에서는 모든 읽기가 오리진 장치에서 처리되고 모든 쓰기가 오리진 장치로 넘어가며, 쓰기 적중은 캐시 블록 무효화를 일으킵니다. passthrough 모드를 켜려면 캐시가 깨끗해야 합니다. 그 뒤에 일관성이 확인되거나 invalidate_cblocks 메시지로 세워지면, 캐시가 아직 warm 인 채로 writethrough 나 writeback 으로 옮길 수 있습니다.

줄이기 전에 볼 것이 있습니다. dm-cache 문서는 캐시용 장치로 쓰는 볼륨을 캐시가 깨끗해지기 전에는 절대 줄이지 말라고 적습니다. writeback 모드를 쓸 때 특히 중요하다고 덧붙이고, writethrough 와 passthrough 모드는 이미 깨끗한 캐시를 유지한다고 적습니다.

애플리케이션 캐시 쪽 손잡이는 TTL(Time To Live, 생존 시간)입니다. Amazon ElastiCache 문서는 각 쓰기에 TTL 값을 붙이면 두 전략의 이점을 함께 가질 수 있다고 적습니다. lazy loading 은 낡은 데이터를 허용하지만 빈 노드에서 실패하지 않고, write-through 는 데이터가 늘 새것임을 보장하지만 빈 노드에서 실패할 수 있고 군더더기 데이터로 캐시를 채울 수 있다는 것이 그 이유입니다.

관련 항목

이것을 실제로 구현·채택한 제품

dm-cache · bcache · Ehcache · Amazon ElastiCache · CacheLoaderWriter

이것을 대신할 수 있는 다른 수단

cache-aside · read-through · write-behind · passthrough · writearound

이 이름이 그대로 쓰이는 CPU 캐시 계층

CPU · x86 · PAT · MTRR

이것이 지키는 성질과 원칙

write policy · 캐싱 · no-write allocate · dirty · 무효화

이것이 치르는 대가

지연 · 성능 · 노드

이것이 쓰기를 미는 저장소

데이터베이스 · 메타데이터 · 테이블

굴릴 때 조정하는 운영 손잡이

TTL · 메시지 · passthrough

다른 이름: write through · writethrough · 라이트 스루