- Postgres LISTEN/NOTIFY의 전역 배타적 잠금은 단순 구현의 처리량을 제한하지만, 알림을 버퍼링해 일괄 전송하면 단일 서버에서 초당 최대 6만 건의 스트림 쓰기를 처리할 수 있음
- NOTIFY를 호출한 트랜잭션은 알림의 커밋 순서를 보장하기 위해 커밋과
fsync()가 끝날 때까지 전역 잠금을 유지하며, 이로 인해 커밋이 직렬화되고 그룹 커밋도 활용하지 못함 - 스트림 테이블의 모든 쓰기마다 트리거로 NOTIFY를 호출한 초기 구현은 낮은 지연 시간을 제공했지만, CPU·메모리·IOPS를 충분히 사용하지 못한 채 초당 2,900건에서 병목을 맞음
- 알림이 아닌 데이터베이스 테이블을 진실의 원천으로 삼고, 메모리에 모은 알림을 주기적으로 하나의 트랜잭션에서 전송하면 잠금 획득 횟수를 크게 줄일 수 있음
- 프로세스 장애로 버퍼의 알림이 유실될 가능성은 낮은 빈도의 폴링으로 보완하며, 동시 읽기 환경에서도 15~100ms의 지연 시간과 기존 대비 20배의 처리량을 달성함
Postgres로 구현한 저지연 스트림
- Postgres 기반 스트림은 각 스트림 조각을
streams테이블의 새 행으로 저장하며, LLM 응답 토큰도 하나의 조각이 될 수 있음 - 읽기 측에서는 다음 조각이 언제 도착할지 알 수 없어 단순 조회만으로 효율적으로 대기하기 어려움
- 주기적 폴링은 간격이 길면 온라인 채팅 같은 대화형 용도에서 지연 시간이 커지고, 짧으면 동시 폴러가 데이터베이스를 압도함
- LISTEN/NOTIFY를 사용하면 읽기 프로세스가 차단 상태로 기다리다가 새 조각이 기록됐다는 알림과 함께 즉시 깨어나므로 불필요한 폴링을 피할 수 있음
쓰기마다 보낸 NOTIFY의 병목
- 초기 구현에서는
streams테이블에 새 조각이 기록될 때마다 트리거가 함수를 실행해 NOTIFY를 한 번씩 전송하고, 읽기 프로세스는 알림을 기다린 뒤 새 조각을 읽음 - 정확성과 낮은 지연 시간은 확보했지만, 큰 Postgres 데이터베이스에서도 초당 2,900건을 넘는 스트림 쓰기를 지속하지 못함
- 병목이 발생하는 동안 CPU·메모리·IOPS 사용량은 눈에 띄게 높아지지 않았으며, 원인은 NOTIFY 커밋 경로의 전역 잠금이었음
커밋 순서를 보장하는 전역 잠금
- NOTIFY를 호출한 트랜잭션은 커밋 시작 시 전역 배타적 잠금을 획득하고, 완전히 커밋되어 내용이
fsync()로 디스크에 기록될 때까지 해제하지 않음 - Postgres는 알림이 트랜잭션 커밋 순서대로 전달되도록 보장하며, 모든 발신 알림을 커밋 순서와 정확히 일치해야 하는 전역 내부 큐에 저장함
- 알림을 큐에 추가하는 작업도 커밋의 일부로 트랜잭션 방식으로 처리해야 하지만, 트랜잭션마다 커밋 시간이 달라 커밋 완료 전에는 순서를 정할 수 없음
- 전역 잠금은 알림을 포함한 트랜잭션의 커밋을 직렬화해 순서를 미리 확정하고, 내부 알림 큐에도 같은 순서로 추가되도록 함
커밋 직렬화가 처리량을 제한하는 방식
- 모든 스트림 쓰기가 트리거를 통해 NOTIFY를 호출하므로 각 쓰기 트랜잭션은 커밋 전체와 디스크 플러시 동안 전역 잠금을 보유함
- 트랜잭션이 차례로 커밋되면서 여러 트랜잭션을 한 번의
fsync()로 처리하는 Postgres의 그룹 커밋을 활용할 수 없음 - 처리량은 Postgres가 개별 트랜잭션을 커밋하는 속도를 넘을 수 없으며, 작업들이 잠금에서 대기해 CPU와 디스크도 충분히 사용되지 않음
- Postgres 19에 포함될 패치는 전역 잠금을 제거하지 않으므로 이 병목을 해소하지 못함
- 대신 다수의 알림 채널이 있고 각 리스너가 특정 채널 하나만 기다리는 제한적인 사례를 최적화함
알림 버퍼링과 일괄 전송
- 스트림을 비롯한 여러 LISTEN/NOTIFY 용도에서 알림은 진실의 원천이 아니라 실제 데이터가 저장된 테이블을 확인하라는 신호에 불과함
- 이런 구조에서는 알림 자체가 완벽한 전역 순서나 완전한 내구성을 갖출 필요가 없어, 메모리에 버퍼링한 뒤 주기적으로 하나의 배치 트랜잭션에서 전송할 수 있음
- 전역 잠금은 개별 스트림 쓰기마다가 아니라 버퍼를 비울 때만 획득함
- 개별 쓰기가 백그라운드 알림 전송과 분리되어 빠르게 진행되며, 그룹 커밋 같은 Postgres 최적화를 활용해 처리량을 높일 수 있음
알림 유실을 보완하는 저빈도 폴링
- 알림이 메모리에 남아 있는 동안 프로세스가 중단되면 해당 알림이 전달되지 않을 수 있음
- 읽기 프로세스는 알림을 기다리는 동시에 데이터베이스를 주기적으로 조회해 알림 없이 기록된 스트림 데이터가 있는지 확인함
- 이 폴링은 유실된 알림만 복구하는 보조 수단이므로 낮은 빈도로 실행할 수 있고, 성능에 미치는 영향도 크지 않음
처리량과 지연 시간
- 최적화된 구현은 동시 읽기 프로세스가 있는 환경에서 초당 최대 6만 건의 스트림 쓰기를 처리해 초기 구현보다 20배 높은 처리량을 기록함
- 처리량을 높인 상태에서도 지연 시간은 15~100ms 범위를 유지함
- 최대 처리량에서는 Postgres CPU가 완전히 사용되어 잠금 경합이 아니라 데이터베이스 자체의 실제 포화 상태에 도달했음을 보여줌
- 전체 벤치마크 코드는 dbos-postgres-benchmark에서 확인할 수 있음