{"id":"IL-0432","org":"Hello Inc.","date":"2026-10-27","docs":[],"title":{"en":"AutoReserve notifications silently went unsent as Memorystore for Redis 3.2 rejected writes with OOM below its limit","ko":"Memorystore for Redis 가 메모리 단편화로 쓰기를 거부해 AutoReserve 예약 알림 일부가 조용히 빠졌다"},"domain":["database","gcp"],"impact":{"scope":"One kind of AutoReserve reservation notification was intermittently not sent to users","users":"unknown","region":"unknown"},"review":{"written_by":"session","reviewed_at":null,"reviewed_by":null},"lessons":{"en":"The logs and the instance page metrics did not show the cause; it took looking inside Redis with INFO memory to see physical memory far above logical memory. A cache is a place for values that can be rebuilt if lost and whose writes may fail, so the sent status, which had to persist and be decided once under concurrent processing, belonged in the relational database.","ko":"보낸 상태를 판정하는 데 중요한 것은 속도가 아니라 값이 사라지지 않는 것과 여러 처리가 동시에 와도 결과가 하나로 정해지는 것이었다. 캐시는 사라져도 되살릴 수 있는 값을 두는 곳이고, 처음부터 쓰기가 실패할 수 있다고 보고 만든 것이다. 그러므로 값의 성질에 맞춰 둘 곳과 다루는 방식을 정해야 한다. 로그와 인스턴스 화면 메트릭처럼 겉으로 보이는 정보만으로는 원인을 잡지 못했고, Redis 안까지 들어가 봐야 했다."},"related":[],"sources":[{"url":"https://tech.hello.ai/entry/redis-memory-fragmentation","kind":"blog","archived":null}],"summary":{"en":"Respo and AutoReserve are one Ruby on Rails application that share the relational database and Redis, run on Google Cloud with Memorystore for Redis as the cache. Because one notification can be sent through several paths, the application recorded a sent status in Redis with Rails.cache.write using unless_exist: true and a 3-minute expiry, and skipped sending when the write did not succeed. Rails.cache.write returns false when the key already exists and nil when the write itself fails, and Rails does not raise an exception on a failed cache write. The code did not handle nil, so every failed write was treated as already sent and logged \"Notification already scheduled\". The author had assumed an exception would be raised, which slowed the investigation.\n\nTwo checks decide whether memory is short on Memorystore. Redis looks at its logical memory use, the total of the values it holds, and when that reaches its limit it evicts keys with an expiry and only then rejects writes. Memorystore looks at the physical memory of the Redis process plus the OS and management processes, and rejects writes regardless of Redis's own limit or eviction. The physical figure is not on the instance page and is visible with INFO memory. The 6.71 GiB on the instance page was logical use; physical use was 8.81 GiB, 1.31 times the logical figure. Redis saw free room and evicted nothing, while the instance's physical memory moved back and forth across the threshold, so writes were rejected now and then. The gap was memory fragmentation: jemalloc can return memory to the OS only in whole pages, freed space is reused only by values of the same size, and cache values of varying sizes kept adding pages. Google Cloud's documentation states that fragmentation can leave a Memorystore instance out of memory even when the used-memory ratio is low.\n\nThe team first scaled the instance from 8 GiB to 16 GiB, but writes were still rejected, because the cached data grew with the limit and the fragmentation grew with it. The rejections stopped when Redis 3.2 was upgraded to 7.2: logical use went from 12.45 GiB to 10.81 GiB, physical use from 17.45 GiB to 10.94 GiB, and the ratio from 1.40 to 1.01, because the new process laid the data out again. In about four months after the upgrade no write rejection was recorded, including periods when logical use reached the limit and keys were evicted, and the ratio stayed between 1.0 and 1.2. The team guesses that faster removal of expired keys in Redis 6.0 changed how free space appears, but has not confirmed what made the difference. The sent status was also moved from Redis to the relational database, using an UPDATE that sets notified_at only on a row where it is empty, so the one process whose update succeeds sends the notification.","ko":"Hello Inc. 의 AutoReserve 와 Respo 는 같은 Ruby on Rails 애플리케이션이고, Google Cloud 위에서 캐시로 Memorystore for Redis 를 함께 썼다. 한 종류의 예약 알림은 보내는 경로가 여럿이라 같은 알림이 두 번 나가지 않도록 보낸 상태를 Redis 에 기록했다. 코드는 Rails.cache.write 에 unless_exist: true 를 넘겨 키가 없을 때만 쓰고, 키가 있으면 false 를 받아 보내지 않았다. Redis 장애로 쓰기 자체가 실패하면 결과가 nil 인데 코드가 이 경우를 다루지 않았다. Rails 는 캐시 스토어 장애로 앱이 죽지 않도록 쓰기 실패에 예외를 던지지 않는다. 그래서 Redis 가 쓰기를 거부하는 동안 알림은 나가지 않았는데 보낸 것으로 판정되었고, 로그에는 Notification already scheduled 가 찍혔다.\n\nRedis 는 버전 3.2, 8GiB 인스턴스였다. Memorystore 에서는 두 곳이 따로 쓰기 거부를 판정한다. Redis 자체는 값의 합계인 논리 메모리 사용량으로 판정하고, Memorystore 는 Redis 프로세스의 물리 메모리에 OS 와 관리 프로세스 몫을 더한 인스턴스 전체 사용량으로 판정한다. 메모리 할당자 jemalloc 은 페이지 단위로만 OS 에 메모리를 반환할 수 있어서, 크기가 다른 값이 섞여 쓰이면 빈 자리가 메워지지 않은 채 페이지만 늘어난다. 이 단편화로 물리 메모리 사용량이 논리 메모리 사용량의 1.31배가 되었다. Redis 가 보기에는 여유가 있어 오래된 키를 버리지 않았고, 인스턴스 전체로는 임계값을 넘나들어 쓰기가 띄엄띄엄 거부되었다.\n\n팀은 처음에 메모리가 부족하다고 보고 8GiB 를 16GiB 로 늘렸지만, 캐시 데이터와 단편화도 함께 늘어 쓰기 거부가 멈추지 않았다. Redis 3.2 를 7.2 로 업그레이드하자 새 프로세스가 데이터를 다시 배치하면서 물리 메모리 사용량이 17.45GiB 에서 10.94GiB 로 줄었고, 비율은 1.40배에서 1.01배가 되었다. 글을 쓴 시점까지 업그레이드 뒤 4개월쯤 동안 쓰기 거부는 한 번도 기록되지 않았다. 팀은 Redis 가 언제 다시 쓰기를 거부할지 통제할 수 없다고 보고, 보낸 상태를 RDB 에서 관리하도록 옮겼다."},"trigger":null,"severity":"unknown","symptoms":["writes-rejected","memory-pressure"],"timeline":{"detected_after":null,"resolved_after":null},"date_kind":"published","detection":{"en":"A bug report from AutoReserve said that some reservation notifications were not arriving.","ko":"AutoReserve 에서 예약 관련 알림 일부가 도착하지 않는다는 버그 보고가 들어왔다."},"components":["cache"],"root_cause":"capacity","remediation":[{"text":{"en":"Scaled the Memorystore for Redis instance from 8 GiB to 16 GiB, which did not stop the write rejections","ko":"메모리가 부족하다고 보고 Redis 인스턴스를 8GiB 에서 16GiB 로 늘렸지만, 쓰기 거부는 멈추지 않았다."}},{"text":{"en":"Upgraded Redis from 3.2 to 7.2, which removed the fragmentation and stopped the write rejections","ko":"Redis 3.2 를 7.2 로 업그레이드했고, 그때 쓰기 거부가 멈췄다."}},{"text":{"en":"Moved the notification sent status from Redis to the relational database","ko":"Redis 가 다시 쓰기를 거부해도 알림이 빠지지 않도록 보낸 상태를 Redis 가 아니라 RDB 에서 관리하게 바꿨다."}}],"action_items":[{"text":{"en":"Consider enabling active defrag if physical memory use rises above 1.3 times logical memory use","ko":"물리 메모리 사용량이 논리 메모리 사용량의 1.3배를 넘기 시작하면, Redis 를 멈추지 않고 단편화를 해소하는 active defrag 를 켜는 것을 검토한다."}}],"contributing":["monitoring-blind-spot","unsafe-failure-mode","assumption-unverified"],"recurrence_of":null,"symptoms_text":{"en":"A bug report said that some reservation notifications in the AutoReserve restaurant reservation service never arrived. Of the several kinds of notification sent as a reservation progresses, only one kind sometimes arrived and sometimes did not.\n\nThe team first suspected the notification platform, but other notifications for the same reservation had arrived. The next suspicion was the restaurant's contract plan, but the send history showed mixed results within the same plan. The idea that the notification job was lost when the application restarted during a deploy was also checked and ruled out. The logs looked normal: they showed \"Notification already scheduled\" for the notifications that were never sent.\n\nThe missing notification was the only one whose processing used the Redis cache. A test write to Redis was refused with OOM command not allowed under OOM prevention, and the refusals came and went. The Redis instance was a Memorystore for Redis 8 GiB instance running version 3.2, and the memory usage on the instance page was 6.71 GiB, below the 8 GiB limit.","ko":"레스토랑 예약 서비스 AutoReserve 에서 예약 관련 알림 일부가 도착하지 않는다는 버그 보고가 들어왔다. 여러 종류의 알림 가운데 한 종류만 갔다 안 갔다 했고, 로그에는 정상으로 보이는 Notification already scheduled 가 찍혔다.\n\n팀은 먼저 알림 발송 인프라의 장애를 의심했지만, 같은 예약의 다른 알림은 도착했으므로 이 가능성을 뺐다. 다음으로 매장의 계약 플랜 차이를 의심했지만 같은 플랜에서도 결과가 제각각이었다. 배포 때 재시작으로 알림 작업이 사라졌다는 가설도 확인했지만 맞지 않았다.\n\n도착하지 않는 알림의 처리에만 Redis 캐시가 쓰인다는 것을 알아챈 뒤 Redis 에 직접 써 보자 OOM command not allowed under OOM prevention 오류로 쓰기가 거부되었다. 앱 쪽에서는 Rails.cache.write 가 예외를 던지지 않고 nil 을 반환했고, 코드는 이를 이미 보낸 것으로 판정했다.\n\n인스턴스 화면의 메모리 사용량 메트릭은 8GiB 상한에 못 미치는 6.71GiB 였다. 이 값은 논리 메모리 사용량이었고, 인스턴스 화면 메트릭에 나오지 않는 물리 메모리 사용량을 INFO memory 로 확인하니 8.81GiB 였다. 물리 메모리 사용량이 임계값 앞뒤를 오가면서 쓰기 거부가 띄엄띄엄 났다."},"updated_at":"2026-11-14T12:34:08.216183Z","url":"https://benchlet.com/incidentlib/app/cases/IL-0432","org_slug":"hello-inc"}