같은 조회가 반복되는 곳이 있어서 MySQL 앞에 Redis 를 두기로 했다. 넣는 것은 쉬웠다.
$key = "user:{$id}:profile";
if ($cached = $redis->get($key)) {
return json_decode($cached, true);
}
$data = $db->fetchProfile($id);
$redis->setex($key, 300, json_encode($data));
return $data;
조회가 빨라졌는데 며칠 뒤 문제가 나왔다.
Table of contents
Open Table of contents
바꿨는데 안 바뀐다
사용자가 정보를 수정했는데 화면에 옛 값이 나온다. 당연했고 user:{id}:profile 키를 안 지웠다.
수정하는 곳에 삭제를 넣었다.
$db->updateProfile($id, $data);
$redis->del("user:{$id}:profile");
그런데 grep 으로 훑어 보니 수정하는 곳이 한 군데가 아니었다.
사용자가 직접 수정
관리자가 수정
배치가 일괄 수정
다른 기능의 부수효과로 수정
네 곳을 다 찾아 del 을 넣어야 했다. 하나 빠뜨리면 그 경로로 수정했을 때만 옛 값이 남는다.
넣는 곳과 지우는 곳이 멀었다
이것이 캐시의 근본적인 어려움이었다. get 을 부르는 곳은 조회 코드고 del 을 불러야 하는 곳은 수정 코드라 서로 멀다.
새 수정 경로를 만드는 사람이 캐시의 존재를 모르면 안 지운다. 그리고 안 지워도 당장은 아무 일도 안 생기고 만료될 때까지만 이상하다.
시간이 지나 저절로 맞으니 문제가 안 보인다. 오류로도 안 나타난다.
만료 시간으로 완화했다
완벽하게 지우는 것을 포기하고 setex 의 만료를 짧게 가져갔다.
5분 → 30초
del 을 빠뜨려도 최대 30초만 이상하다. 캐시 효과는 줄어들지만 같은 요청이 초당 여러 번 오는 곳이라 30초로도 효과가 컸다.
정확성과 효과의 교환인데 이 경우 정확성이 중요했다.
판단 기준 — 무엇을 캐시할지
전부 캐시하려던 것을 줄였다.
자주 읽고 드물게 바뀜 설정·코드 목록·분류 정보 → 길게
자주 바뀜 사용자 상태·재고 → 짧게 하거나 안 함
돈과 관련된 것 안 함
캐시하면 빨라진다가 아니라 옛 값이 보여도 되는가로 판단했다. 금액처럼 잠깐도 틀리면 안 되는 것은 대상에서 뺐다.
캐시가 죽었을 때와 몰려오는 요청
한 번은 Redis 가 잠깐 죽었는데 전체가 멈췄다.
$cached = $redis->get($key); // 여기서 예외
캐시가 없으면 MySQL 로 가야 하는데 예외가 나서 요청이 죽는다. 캐시는 없어도 동작해야 한다.
try {
$cached = $redis->get($key);
} catch (Exception $e) {
$cached = null; // 캐시 실패는 조회 실패가 아니다
log_message('warning', 'cache read failed: ' . $e->getMessage());
}
대신 로그는 남긴다. 캐시가 계속 죽어 있으면 알아야 하니까.
담아 둔 값과 못 찾은 것이 구분이 안 되는 자리도 있다. 키가 없으면 false 를 돌려주는데 값 자체가 false 이거나 빈 문자열이면 같은 것이 오고 매뉴얼도 이 경우를 결과 코드로 갈라야 한다고 적고 있다.
조회 결과가 0건인 것을 그대로 담아 두면 매번 못 찾은 것으로 읽혀 MySQL 까지 간다. 캐시를 둔 자리가 오히려 가장 자주 새는 자리가 되므로 없음을 나타내는 표시를 따로 담아 두게 했다.
setex 만료가 되는 순간 여러 요청이 동시에 MySQL 로 가는 것도 있었다. 평소엔 캐시가 막아 주다가 만료되는 순간 다 통과하고 인기 있는 데이터일수록 심하다.
$ttl = 30 + rand(0, 10);
만료 시간에 무작위를 섞으니 여러 키가 동시에 만료되지 않는다.
정리
- 캐시는 넣기 쉽고 지우기 어렵다
- 값을 바꾸는 경로를 전부 찾아야 한다
- 하나 빠뜨리면 그 경로에서만 옛 값이 남는다
- 읽는 곳과 지우는 곳이 멀어 새 코드가 캐시를 모른다
- 안 지워도 만료될 때까지만 이상해서 문제가 안 보인다
- 완벽한 무효화 대신 짧은 만료로 완화할 수 있다
- 빨라지는지가 아니라 옛 값이 보여도 되는지로 대상을 고른다
- 캐시가 죽어도 동작해야 하고 캐시 실패는 조회 실패가 아니다
- 대신 로그를 남겨 계속 죽어 있는 것을 알게 한다
- 담긴
false와 못 찾은 것이 같은 값으로 오므로 없음을 따로 표시한다 - 만료 시간에 무작위를 섞어 동시 만료를 막는다