본문 바로가기
Concepts/Cloud Native

Sharding

by ocwokocw 2024. 1. 13.

- 개요

개발자가 샤딩이라는 용어를 접할 수 있는 가장 일반적인 분야는 DB인것 같다. DB 장비(instance) 1대가 감당할 수 있는 수준을 넘어서는 규모의 데이터를 처리해본적이 있다면 샤딩이라는 단어를 들어봤을것이다. 샤딩을 적용하면 전체 데이터가 여러 대의 장비로 분산되며, 이에 따라 자연스럽게 1대의 DB 서버가 받는 부하도 분산된다.

 

그런데 샤딩이 꼭 DB에만 샤딩이 적용되는것은 아니다. 공유 데이터에 대한 쓰기나 읽기 락 경합을 완화하기 위해 사용되기도 한다. Golang 에서 Map 을 사용한다고 가정해보자. 어떤 데이터를 DB로 부터 조회한 후, Map에 취합한다. 그런데 데이터가 커짐에 따라 속도가 생각만큼 나오질 않아 goroutine을 생성하고 취합을 빠르게 하려고 한다. CPU를 많이 사용하고 goroutine의 수만 늘리면 속도는 선형적으로 무조건 빨라질까? Map은 쓰기 및 읽기시 Lock을 걸어야 하므로, goroutine이 많아질수록 경합과정이 많이 발생하게 된다. 게다가 Map에 쓰고 읽는 작업은 작업을 요청해놓고 기다리는 network 나 파일, DB 연산처럼 다른 서버에 작업을 미루는 연산이 아니라 지금 프로그램이 실행되고 있는 Host가 그대로 연산 부하를 감당해야 한다. 따라서 goroutine 수에 따른 효과를 선형적으로 얻을 수 없다. 이를 해결하기 위해서는 Sharded Map이 필요하다.


- Sharded Map (Concurrent Map)

Sharded Map은 논리적으로는 1차원의 key-value 쌍의 map 이라서 사용자는 일반 map 처럼 사용하지만 내부적으로는 2차원(map of map)으로 구성하여 락 경합 문제를 완화한다. key에 대한 hash값을 추출하여 해당 key에 대한 값이 어떤 map에 저장될지를 결정한다. 마치 hash map에서 key에 대한 hash 값을 추출하여 O(1)로 접근하기 위한 장치와 같다.

 

위의 설명처럼 Sharded Map을 구성하면 단일 Map이 감당하던 여러개의 goroutine에서 발생하는 읽기/쓰기 경합과정을 선언한 2차원 Map의 수만큼 경합과정 발생확률이 1/N로 줄어들게 된다.


- Golang Example

golang concurrent map을 검색하면 가장 쉽게 접할 수 있는 github은 https://github.com/orcaman/concurrent-map 일것이다. 해당 라이브러리 설치를 위해 아래 명령어로 설치해준다.

go get "github.com/orcaman/concurrent-map/v2"

 

10개의 goroutine으로 2000만개의 data를 map에 취합하는 시나리오를 구현해보자. 우선 ShardedMap이 아닌 일반 Map인 SyncMap으로 수행해본다. SyncMap은 일반 Map에 Mutex 처리가 되어 Thread-Safe한 Map이다.

const (
	numOfWorker = 10
	numOfData   = 20_000_000
)

func Test_SyncMap(t *testing.T) {
	m := sync.Map{}

	process := func(dataChan <-chan int) <-chan struct{} {
		done := make(chan struct{})
		go func() {
			for data := range dataChan {
				m.Store(data, data)
			}
			close(done)
		}()
		return done
	}

	dataChan := make(chan int)
	doneChans := make([]<-chan struct{}, 0, numOfWorker)
	for i := 0; i < numOfWorker; i += 1 {
		doneChans = append(doneChans, process(dataChan))
	}

	for i := 0; i < numOfData; i += 1 {
		dataChan <- i
	}
	close(dataChan)

	wg := sync.WaitGroup{}
	wg.Add(numOfWorker)
	for _, doneChan := range doneChans {
		doneChan := doneChan
		go func() {
			<-doneChan
			wg.Done()
		}()
	}
	wg.Wait()
}

 

전반적인 동작과정은 아래와 같다.

  • Process 함수를 통해 Worker (map에 취합하는 역할)를 설정된 10개의 goroutine만큼 수행한다.
  • 그 후 2,000만개의 데이터를 channel을 통해 보낸다.
  • 데이터를 모두 보내고 곧바로 송신 channel을 close 하고 바로 프로그램을 종료하는게 아니라 map에 취합이 끝날 때 까지 대기해야 함에 주의한다. 따라서 done 채널을 통해 완료 신호를 모두 완료될때까지 대기한다.

SyncMap을 실제로 돌려보면 17초 정도가 나온다. 

 

이를 concurrent map으로 실행하면 아래와 같다. 소스는 모두 동일하고 map 선언부와 취합 부분만 변경한다. 해당 라이브러리에서 기본 샤드값은 512로 설정되어 있다. 실제로 실행해보면 5초 정도내에 취합을 모두 완료한다.

func Test_CMap(t *testing.T) {
	cMap := cmap.New[int]()

	process := func(dataChan <-chan int) <-chan struct{} {
		done := make(chan struct{})
		go func() {
			for data := range dataChan {
				cMap.Set(string(rune(data)), data)
			}
			close(done)
		}()
		return done
	}
    
    ...

'Concepts > Cloud Native' 카테고리의 다른 글

Fan-out, Fan-in  (0) 2023.11.12
Timeout  (1) 2023.11.11
Throttling  (0) 2023.11.04
Retry  (0) 2023.10.31
Debounce  (1) 2023.10.30

댓글