{"id":"IL-0456","org":"Inflab","date":"2022-01-03","docs":[],"title":{"en":"Inflearn went down six times during a January 2022 free-course event as slow queries piled up on Aurora PostgreSQL","ko":"100% 할인 이벤트 기간에 인프런 서비스가 Aurora PostgreSQL 병목으로 여섯 번 멈췄다"},"domain":["database","aws"],"impact":{"scope":"Inflearn was unusable at peak hours on 3, 4, 7 and 17 January 2022, six outages in total, including 1 h 30 min and 2 h on 3 to 4 January and 3 h on the morning of 4 January","users":"unknown","region":"unknown"},"review":{"written_by":"session","reviewed_at":null,"reviewed_by":null},"lessons":{"en":"Because every database bottleneck appeared as slow queries on the reader DBs, watching only the readers hid the root cause on the writer. In PostgreSQL, frequent updates to a table with large columns produce large dead tuples in bulk and slow every query on that table. Before the second event the team could not run a meaningful performance test because the first week showed that almost any page could fail under load. The event exposed high sensitivity to failure in every area, one table's failure spreading to the whole service, hard-to-tune table and cache structures, unintuitive SQL code, code without types or tests, no stable batch environment, and many hidden failure points not yet found.","ko":"그동안 데이터베이스 병목이 모두 읽기 DB 의 슬로우 쿼리로 보여 팀은 읽기 DB 만 모니터링했다. 이 때문에 근본 원인인 쓰기 DB 강의 테이블의 Dead Tuple 을 늦게 찾았다. 원인 쿼리는 빨리 찾았지만, 무분별한 쿼리 추상화와 타입과 테스트 코드가 없는 코드 때문에 수정과 배포가 늦어졌다. 그만큼 장애 시간도 길어졌다. 첫 주 장애를 돌아보면 어느 페이지가 문제가 될지 예상할 수 없었고, 대부분의 페이지가 트래픽이 몰리면 언제든 장애가 날 수 있는 상태였다. 팀은 조직의 구조와 성숙도가 갖춰지지 않은 상태에서 마이크로서비스 아키텍처인 MSA 로 바로 옮기면 더 큰 문제가 생긴다고 보았다. 그래서 모놀리식 구조를 알맞은 선에서 유지하면서 일부 도메인만 분리하기로 했다."},"related":[],"sources":[{"url":"https://tech.inflab.com/202201-event-postmortem/","kind":"blog","archived":"https://web.archive.org/web/20251216094516/https://tech.inflab.com/202201-event-postmortem/"}],"summary":{"en":"Inflearn runs as a monolithic Node.js project on ECS Fargate, 10 to 30 instances depending on traffic, with Aurora PostgreSQL 10.16 as one writer and 2 to 7 readers. Most reads go to the readers and writes to the writer, but because services do not have their own databases, a problem in one part spreads to the whole service. The event, 125 courses worth 5,016,000 won in total offered for free, went far beyond expectations: more than 10,000 retweets, shares on many IT communities, traffic 5 to 20 times normal and 76,730 new sign-ups in 7 days. The first outages were handled with quick fixes: an index for the interested-companies query, a changed cache reset and regeneration interval, moving read queries off the writer, index-friendly rewrites or switching features off, dropping the course description column from select * queries, and splitting IN lists into batches of 20 IDs run with Promise.all.\n\nThe 17 January outage, slow queries on a table of under 10,000 rows, led the team to check tuple statistics on the writer DB. The course table had 99% dead tuples. Autovacuum should have been working on it, so the team ran VACUUM by hand and then watched the dead tuple rate, which climbed again very fast. The course table kept the number of students as a column, and every enrollment ran an UPDATE that added 1 to it. Because PostgreSQL's MVCC writes a new tuple on every update and leaves the old one as a dead tuple, and the course table's rows carried large columns such as the HTML course description, the frequent updates produced large dead tuples in bulk, made worse by the surge of students enrolling in 100 or more courses at once. That bottleneck slowed every query on the course table and was the root cause throughout the event.\n\nThe team chose to move the large course description column into a separate table rather than move the student count column, which would have meant working out its effect on the other existing features. After that, the event caused no further outages.","ko":"인프랩은 2022년 1월 3일부터 17일까지, 이어서 18일부터 24일까지 강의 125개를 0원에 등록하는 100% 할인 이벤트를 열었다. 이벤트가 트위터와 여러 IT 커뮤니티에 퍼지며 평소 대비 5~20배의 트래픽이 들어왔고, 7일 만에 76,730명이 새로 가입했다. 인프런은 모놀리식 NodeJS 프로젝트 하나를 ECS Fargate 에서 10대에서 30대로 돌린다. 데이터베이스는 쓰기 1대와 읽기 2~7대로 이루어진 Aurora PostgreSQL 10.16 이다. 서비스별로 DB 가 나뉘어 있지 않아, 한 기능의 장애가 전체 서비스 장애로 번지는 구조였다.\n\n1월 3일에는 관심회사 조회 API 의 인덱스 없는 쿼리가 Node 프로세스의 커넥션을 고갈시켜 1시간 30분 동안 서비스가 멈췄다. 팀은 인덱스를 추가해 이 장애를 넘겼다. 같은 날 밤에는 쓰기 DB CPU 가 90% 넘게 올라 서비스가 2시간 동안 멈췄다. 모든 캐시를 담은 용량이 큰 캐시 행 1건의 초기화와 재생성, 그리고 쓰기 DB 에서 돌던 조회 쿼리 때문이었다. 팀은 캐시 주기를 바꾸고 조회 쿼리를 읽기 DB 로 옮겼다. 1월 4일 오전에는 강의 조회 슬로우 쿼리로 서비스가 3시간 동안 멈췄다. DB 를 r5.8xlarge 8대로 늘려도 몇십 분만 버텼다. 팀은 인덱스를 못 타는 쿼리를 고치고, select * 에서 강의 소개 컬럼을 뺐다. 원인 쿼리는 30분 안에 찾았다. 그러나 쿼리가 함수, Hook, 커스텀 이벤트로 감싸여 추상화돼 있었고 코드에 타입과 테스트가 없어 고치는 데 3시간이 걸렸다. 1월 7일에는 ID 가 100개를 넘는 IN 절이 풀 스캔을 했다. 팀은 ID 를 20개씩 나눠 보내게 고쳤다. 두 번째 이벤트 전에는 어느 페이지가 문제가 될지 몰라 성능 테스트를 할 수 없었다. 그래서 PostgreSQL 을 11.13 으로 올리고, 쿼리 타임아웃 5초를 걸고, Max Connection 을 늘리고, 2초 넘는 쿼리를 개선했다.\n\n1월 17일 오후 10시 30분에 여섯 번째 장애가 났다. 행이 1만 건도 안 되는 강의 테이블만 읽는 쿼리도 느렸다. 쓰기 DB 의 Tuple 정보를 보니 강의 테이블의 Dead Tuple 이 99% 였다. 수동 vacuum 뒤에도 Dead Tuple Rate 는 매우 빠르게 다시 올랐다. 강의 테이블에는 수강생 수 컬럼이 있었고, 수강 신청이 들어올 때마다 이 값을 1 올리는 UPDATE 가 실행됐다. PostgreSQL 은 MVCC 때문에 UPDATE 때마다 기존 행을 남겨 두고 새 행을 쓴다. 10KB 짜리 강의 소개 HTML 컬럼을 가진 행이 계속 갱신되면서 용량이 큰 Dead Tuple 이 대량으로 쌓였다. autovacuum 이 이를 따라가지 못해 강의 테이블을 읽는 모든 쿼리가 병목에 걸렸다. 팀은 강의 소개 컬럼을 별도 테이블로 분리했고, 그 뒤로 이벤트로 인한 장애는 더 나지 않았다."},"trigger":{"en":"A 100% discount event on 125 courses, from 3 to 17 January and 18 to 24 January 2022, went viral and brought 5 to 20 times the usual traffic.","ko":"2022년 1월 강의 125개를 0원에 등록할 수 있는 100% 할인 이벤트가 크게 퍼지면서 평소 대비 5~20배의 트래픽이 들어왔다."},"severity":"unknown","symptoms":["service-unavailable","database-overloaded","high-latency"],"timeline":{"detected_after":null,"resolved_after":null},"date_kind":"incident","detection":{"en":"The team followed the outages through hourly transaction counts, RDS metrics, monitoring and slow query alerts.","ko":"모니터링과 슬로우 쿼리 알람으로 문제를 알아챘고, 문제가 되는 쿼리를 30분 안에 찾았다. 1월 17일에는 오후 10시부터 RDS 의 여러 수치가 요동치는 것을 보았다."},"components":["database","autovacuum"],"root_cause":"capacity","remediation":[{"text":{"en":"Added an index to the interested-companies query on the course page","ko":"관심회사 조회 API 의 쿼리 일부가 인덱스를 쓰지 않는 것을 로그와 실행계획으로 확인하고 바로 인덱스를 추가했다."}},{"text":{"en":"Changed how often the cache data stored in the database was reset and regenerated","ko":"용량이 큰 캐시 행의 캐시 초기화와 재생성 주기를 바꿨다."}},{"text":{"en":"Moved the read queries that loaded the writer DB to the reader DBs","ko":"쓰기 DB 에 부하를 준 조회용 쿼리부터 읽기 DB 로 옮겼다."}},{"text":{"en":"Scaled the database to 8 r5.8xlarge instances, which bought only a few tens of minutes","ko":"시간을 벌려고 데이터베이스를 r5.8xlarge 8대로 늘렸다."}},{"text":{"en":"Rewrote slow course queries or added indexes so they use indexes, and switched off features whose queries could not be fixed","ko":"느린 강의 조회 쿼리에 인덱스를 추가하거나 쿼리를 바꿔 인덱스를 쓰게 했고, 개선할 수 없는 쿼리를 쓰는 기능은 껐다."}},{"text":{"en":"Removed the large course description column from the select * course queries","ko":"select * 로 읽던 강의 조회 쿼리에서 실제로 쓰지 않는 고용량 강의 소개 컬럼을 모두 뺐다."}},{"text":{"en":"Split the IDs of the IN clause into batches of 20 run with Promise.all","ko":"IN 절에 담는 강의 ID 를 20개씩 나눠 Promise.all 로 처리하게 바꿔, 쿼리가 다시 인덱스를 쓰게 했다."}},{"text":{"en":"Upgraded PostgreSQL from 10.16 to 11.13","ko":"PostgreSQL 을 10.16 에서 11.13 으로 올렸다."}},{"text":{"en":"Set a 5-second query timeout","ko":"5초 넘게 도는 쿼리를 강제로 끝내도록 쿼리 타임아웃을 5초로 걸었다."}},{"text":{"en":"Raised max connections","ko":"Max Connection 을 늘렸다."}},{"text":{"en":"Ran VACUUM by hand on the course table","ko":"강의 테이블에 수동으로 vacuum 을 돌려 Dead Tuple 을 정리했다."}},{"text":{"en":"Moved the large course description column into a separate table","ko":"용량이 큰 강의 소개 컬럼을 강의 테이블에서 떼어 별도 테이블로 옮겼다."}}],"action_items":[{"text":{"en":"Change the structure in which one table's failure spreads to the whole service","ko":"한 테이블의 장애가 전체 서비스의 장애로 번지는 구조를 개선한다."}},{"text":{"en":"Improve the table and cache structures that are hard to tune","ko":"성능 개선이 쉽지 않은 테이블과 캐시 구조를 개선한다."}},{"text":{"en":"Make the SQL code easier to follow","ko":"직관적이지 않은 SQL 코드를 개선한다."}},{"text":{"en":"Improve code that gives no confidence to change, such as code without types or test code","ko":"타입과 테스트 코드가 없어 안심하고 고치기 어려운 코드를 개선한다."}},{"text":{"en":"Build a stable batch environment","ko":"배치 작업을 안정적으로 돌릴 환경을 갖춘다."}},{"text":{"en":"Keep a monolith of reasonable size and separate the domains through which failures spread most","ko":"모놀리식 구조는 알맞은 선에서 유지하고, 장애가 잘 번지는 일부 도메인만 따로 분리한다."}}],"contributing":["inefficient-query","monitoring-blind-spot","blast-radius-too-wide"],"recurrence_of":null,"symptoms_text":{"en":"During a 100% discount event that made 125 courses free, Inflearn received 5 to 20 times its normal traffic, and on four days, 3, 4, 7 and 17 January 2022, the service became unusable around 9 a.m. and 10 p.m. There were six outages in all.\n\nThey showed as large numbers of slow queries on the Aurora PostgreSQL 10.16 database and, at times, writer CPU above 90%.\n\nOn 3 January, from 20:30 to 22:00, slow queries from the course pages' interested-companies lookup, part of which had no index, quickly used up the connections of the Node processes; later requests waited until the processes were killed, and although ECS added new processes, every other query those processes were running was cancelled, so the whole service stopped. From 23:30 to 01:30 the writer DB's CPU stayed above 90%: cache data moved from Redis into the database lived in a single large JSON row, cache resets and regeneration under the higher load hit that row at once and led to deadlocks, and read queries not yet moved to the readers added load.\n\nOn 4 January from 09:00 to 12:00, and again in the afternoon, course lookups were the slow queries, all of the top nine. Most could not use indexes, using lower(\"column\") = lower(parameter) or negative conditions such as != and NOT IN, so tens to hundreds of full table scans ran at once, and select * pulled a 10 KB course description column of HTML into every course query. Scaling the database to 8 r5.8xlarge instances bought only a few tens of minutes. Finding the slow queries took under 30 minutes, but fixing them took 3 hours because every query was wrapped in functions, hooks and custom events, and the code had no types and no tests. On 7 January from 21:00 a course query with where id in (...) ran full table scans once the list passed 100 IDs, as students put all 125 courses in the cart and checked out at once.\n\nBefore the second event the team upgraded PostgreSQL from 10.16 to 11.13, set a 5-second query timeout, raised max connections and tuned queries over 2 seconds, and the busiest time of that week, 09:00 on Saturday 15 January, passed without trouble. At 22:00 on 17 January RDS metrics began to swing, and at 22:30 the sixth outage started, with slow queries even on a course table of fewer than 10,000 rows. Because every bottleneck so far had shown up as slow queries on the reader DBs, the team had been watching only the readers.","ko":"2022년 1월 3일부터 17일까지 이어진 100% 할인 이벤트 기간에, 인프런은 1월 3일, 4일, 7일, 17일 오전 9시와 오후 10시 무렵마다 쓸 수 없을 만큼 느려졌다. 트래픽은 평소의 5~20배였고, 매번 데이터베이스의 슬로우 쿼리와 부하가 함께 나타났다.\n\n1월 3일 오후 8시 30분부터 10시까지는 모든 강의 소개 페이지가 부르는 관심회사 조회 API 에서 슬로우 쿼리가 대량으로 났다. 쿼리가 밀리자 Node 프로세스의 커넥션이 빠르게 고갈됐고, 뒤이은 요청이 모두 대기하다가 Node 프로세스가 강제 종료됐다. ECS 가 프로세스를 다시 채웠지만, 종료된 프로세스가 처리하던 다른 쿼리도 전부 취소돼 서비스 전체가 멈췄다.\n\n1월 3일 오후 11시 30분부터 1월 4일 오전 1시 30분까지는 쓰기 DB 의 CPU 가 90% 이상으로 올라 전체 서비스가 멈췄다. 모든 캐시 데이터를 JSON 하나로 담은 용량이 큰 캐시 행 1건에 캐시 초기화와 재생성 요청이 겹쳐 데드락이 났다. 아직 읽기 DB 로 옮기지 못한 조회 쿼리도 쓰기 DB 의 부하를 키웠다.\n\n1월 4일 오전 9시부터 12시까지는 강의 조회 쿼리가 슬로우 쿼리 상위 9개를 모두 차지했다. lower(\"컬럼명\") = lower(쿼리파라미터) 처럼 컬럼을 변형하거나 !=, NOT IN 같은 부정 조건을 써서 인덱스를 타지 못하는 쿼리가 많았다. 그 결과 테이블 풀 스캔이 동시에 수십에서 수백 개씩 돌았다. 값 하나가 10KB 인 강의 소개 컬럼까지 select * 로 읽는 쿼리는 READ IO 병목을 일으켰다. 데이터베이스를 r5.8xlarge 8대로 늘렸지만 몇십 분을 버텼을 뿐 장애를 해소하지는 못했다.\n\n1월 7일 오후 9시부터는 수강바구니, 구매완료, 결제페이지에서 강의 정보를 가져오는 where id in (ID) 쿼리가 ID 를 100개 넘게 받아 인덱스를 쓰지 못하고 테이블 풀 스캔을 했다. 이벤트 강의 125개를 한 번에 담아 일괄 결제하는 수강생이 급증했기 때문이었다.\n\n1월 17일 오후 10시부터 RDS 의 여러 수치가 요동쳤고, 오후 10시 30분에 1월의 여섯 번째 장애가 시작됐다. 행이 1만 건도 안 되는 강의 테이블만 읽는 쿼리조차 느려졌다. 그때까지 병목은 모두 읽기 DB 의 슬로우 쿼리로 보였고, 팀은 읽기 DB 만 모니터링하고 있었다."},"updated_at":"2026-11-14T12:34:10.509947Z","url":"https://benchlet.com/incidentlib/app/cases/IL-0456","org_slug":"inflab"}