Sync.Map은 잠금 없는 읽기 및 잠금 작업 간의 경쟁을 최소화하기 위해 신중하게 분리된 이중 맵 아키텍처를 사용합니다. 이 구조는 읽기 전용 맵(read)에 대한 원자적 포인터를 유지하여 entry 구조체에 대한 원자적 포인터로 항목을 저장하며, 이 레이어에 키가 존재할 때 잠금 없는 조회를 허용합니다. 읽기 맵에서 쓰기 또는 캐시 미스가 발생하면 최근 쓰기를 포함한 키의 슈퍼셋을 포함하는 mutex로 보호된 dirty 맵으로 대체됩니다. 이 두 레이어 간의 전환을 관리하는 중요한 승격 휴리스틱이 있습니다: 원자적 misses 카운터(읽기에서 실패한 조회를 추적)가 dirty 맵의 길이를 초과할 때, 런타임은 전체 더러운 맵을 원자적으로 새 읽기 맵으로 승격시킵니다.
내부 구현은 이러한 원자적 작업을 가능하게 하는 특수 구조체를 사용합니다:
type readOnly struct { m map[any]*entry amended bool // 읽기에 없는 키가 dirty에 존재하는 경우 true } type entry struct { p atomic.Pointer[any] // 실제 값 또는 삭제된 경우 nil }
이러한 구조체는 런타임이 맵을 원자적으로 교환할 수 있도록 하여 동시 goroutine에 안전한 접근을 유지하고, 승격 임계값은 이중 조회 비용이 많은 접근에 걸쳐 고르게 분산되도록 합니다.
우리의 분산 시스템 팀은 100k+ QPS를 처리하는 고속 메타데이터 서비스에서 심각한 지연 스파이크를 경험했습니다. 이 서비스는 UUID로 키가 지정된 구성 객체를 캐싱하였으며, 95%의 트래픽이 5%의 핫 키에 집중되었고, 백그라운드 goroutine은 지속적으로 새로 배포된 서비스에 대한 새 구성 요소를 추가했습니다.
솔루션 1: sync.RWMutex와 맵
초기 구현은 sync.RWMutex로 보호된 표준 맵을 사용했습니다. 개념적으로 간단했지만, 이 접근 방식은 모든 읽기 goroutine이 mutex의 내부 상태 단어에 대한 캐시 라인을 놓고 경쟁하는 고도 병렬성 하에서 심각한 경쟁을 겪었습니다. 백그라운드 작성기가 새 구성을 추가하기 위해 쓰기 잠금을 획득하면 모든 읽기 작업이 차단되어 캐시 새로 고침 주기 동안 p99 지연이 500ms를 초과했습니다.
솔루션 2: 분할 잠금 접근
그 후 우리는 해시 기반 키 분배로 256개의 sync.RWMutex 인스턴스를 사용하는 분할 맵을 프로토타입했습니다. 이 설계는 다른 캐시 라인과 별도의 mutex를 통해 부하를 분산시켜 경쟁을 줄였습니다. 그러나 크기 조정 중 일관된 해싱을 유지하는 데 상당한 복잡성을 도입했으며, 피할 수 없는 핫 키가 불균형 분할을 만들어 여전히 꼬리 지연 스파이크를 겪었습니다.
솔루션 3: sync.Map
우리는 궁극적으로 프로파일링이 명확한 접근 패턴을 확인한 후 sync.Map을 채택했습니다: 읽기는 안정적이고 장기적인 키를 목표로 하였고, 쓰기는 덧붙여진 새로운 키를 도입했습니다. 읽기 경로의 잠금 없는 원자적 로드는 전체 캐시 라인 바운싱을 제거하였고, 자동 승격 휴리스틱은 우리의 특정 작업 부하 특성에 최적화되었습니다. 단일 스레드 처리량은 일반 맵보다 약 20% 낮았지만, mutex 경쟁의 제거로 인해 높은 쓰기 폭증 동안 p99 지연이 5ms 미만으로 줄어들었습니다.
배포 결과는 꼬리 지연 안정성이 100배 개선되었고 구성 새로 고침 중 goroutine의 정체가 완전히 제거되었습니다. 서비스 가용성은 피크 트래픽 기간 동안 99.9%에서 99.99%로 증가했으며, 한 달 이상 운영되는 기간 동안 메모리 누수는 전혀 관찰되지 않았습니다.
*왜 sync.Map은 값을 직접 interface{} 값이 아닌 entry 포인터로 저장하며, 이것이 잠금 없는 삭제를 어떻게 가능하게 합니까?
read 맵은 원자적 삭제를 가능하게 하기 위해 원시 interface{} 값이 아닌 *entry 구조체를 저장하여 맵 구조를 수정하지 않습니다. 키를 삭제할 때, sync.Map은 원자적 비교-교환 작업을 사용하여 항목의 내부 포인터를 nil로 원자적으로 교환하여 슬롯을 비어있다고 표시하면서 맵 항목은 그대로 둡니다. 삭제 중 읽기 전용 맵 구조의 불변성은 동시 읽기자가 잠금 없이 작업할 수 있도록 하지만, 삭제된 키는 다음 승격 주기가 이를 정리할 때까지 메모리를 소모합니다.
sync.Map은 더러운 맵을 읽기로 승격할 시점을 어떻게 결정하며, 이 특정 임계값이 성능에 왜 중요한가요?
승격은 원자적 misses 카운터가 읽기 전용 맵에서 실패한 조회 중 증가하여 dirty 맵의 길이를 초과할 때 발생합니다. 이 임계값은 이중 조회 패널티의 비용이 전체 dirty 맵을 read 원자적 포인터로 복사하는 비용을 초과하도록 보장합니다. 트리거되면 dirty 맵은 원자적으로 read로 승격되고, dirty 맵은 nil로 설정되며, misses는 0으로 재설정되어 많은 실패한 조회에서 승격 비용을 고르게 분산시킵니다.
더러운 맵을 읽기로 원자적으로 승격하는 동안 동시 읽기자가 부분적으로 업데이트된 맵 상태를 관찰하지 않고 계속 작업할 수 있게 해주는 메커니즘은 무엇인가요?
승격하는 동안 코드에서는 read 필드의 원자적 포인터 교환을 수행하여 이전의 dirty 맵을 가리킵니다. Go의 메모리 모델은 모든 goroutine에 원자적으로 보이도록 보장합니다. 동시 읽기자는 이전의 read 맵이나 새로 승격된 맵 중 하나를 관찰하지만, 유효하지 않거나 부분적으로 구축된 상태는 결코 관찰하지 않습니다. 맵 할당은 포인터 교환 전에 완료되므로, 이전의 read 맵은 Go의 가비지 수집기가 참조가 모두 삭제된 후에만 회수되므로 비행 중 읽기자에게 도달 가능합니다. 이는 sync.Map이 가비지 수집을 활용하여 잠금 없는 구조 전환을 수행하는 방법을 보여줍니다.