본문 바로가기
db

[TIL] Redis Scan 개선

by marble25 2023. 10. 21.

Primary Database로 in-memory db, 그 중에서도 redis를 사용하고 있다.

Redis를 사용하는 이유는 disk io가 많이 발생하지 않고, 적재하는 데이터의 양이 크지 않기 때문에 in-memory state로도 충분하기 때문이다.

하지만 관계형 데이터베이스만 쓰다가 처음 in-memory database를 사용하니 rdb에서는 매우 간단하게 했던 select, join하는 작업이 쉽지 않았다. select를 잘못 구현해서 문제가 발생하고 해결한 과정에 대해서 적어보고자 한다.

Context

다음 sql을 redis에서 수행하려고 한다.

select * from <TABLE>;

이를 위해 Scan method와 HScan method을 사용했다.

Redis에는 다음과 같이 저장되어 있다.

<TABLE>:<ID1>, key1, value1, key2, value2, key3, value3...
<TABLE>:<ID2>, key1, value1, key2, value2, key3, value3...

AS-IS

Scan은 패턴 스캔을 통해 <TABLE>:* 의 키를 모두 가져오고, HScan은 해당 Hashtabe에 저장된 key, value들을 모두 가져온다.

HGetAll을 사용하지 않고, Scan을 사용해서 blocking되는 시간을 줄였고, HScan은 파이프라인 을 이용해서 Round-Trip 횟수를 줄였다.

func (r *RedisClient) PrevScanAll(match string) (values map[string]map[string]string, err error) {
	ctx := context.Background()
	values = make(map[string]map[string]string)

	cursor := uint64(0)
	hashKeys := []string{}
	for {
		keys := []string{}
		keys, cursor, err = r.client.Scan(ctx, cursor, match, MaxCount).Result()
		if err != nil {
			if err == redis.Nil {
				return values, nil
			}
			return values, err
		}

		hashKeys = append(hashKeys, keys...)
		for _, key := range keys {
			values[key] = make(map[string]string)
		}

		if cursor == 0 {
			break
		}
	}

	// Redis 파이프라인 시작
	cmds, err := r.client.Pipelined(ctx, func(p redis.Pipeliner) error {
		for _, hashKey := range hashKeys {
			// pipelining을 위해 max count를 1000으로 설정.
			// pipeline에서 iteration을 지원하지 않기 때문
			// 더 많은 데이터가 필요하다면 단순히 count를 늘리자.
			p.HScan(ctx, hashKey, 0, "", MaxCount).Result()
		}
		return nil
	})
	if err != nil {
		return values, nil
	}

	for idx, cmd := range cmds {
		keys, _ := cmd.(*redis.ScanCmd).Val()

		var key string
		for _, k := range keys {
			if key == "" {
				key = k
			} else {
				values[hashKeys[idx]][key] = k
				key = ""
			}
		}
	}

	return values, nil
}

Problem

Scan 메소드를 잘못 이해하고 있었다.

Scan이 모든 key를 스캔해서 해당 패턴을 감지하기 때문에 db full scan이 일어나게 된다. 우리의 경우 내가 목표로 하는 <TABLE> 의 경우 100개의 row도 안되지만, Request 데이터는 수십만개의 row가 존재한다. 다행히, Request 데이터는 scan해야 하는 경우는 없다.

다행히 아직까지는 1000여건의 Request 데이터가 있어서 크게 latency issue가 발생하지는 않았지만, 실사용 환경에서는 수십만건의 데이터를 읽어오는 과정에서 엄청나게 시간을 많이 소요할 것이다. 뿐만 아니라, scan하는 operation이 자주 일어나는 연산이다 보니, 그 효과는 엄청 클 것으로 판단했다.

TO-BE

Scan 메소드를 SScan으로 바꾼다.

SScan의 경우 Set에서 데이터를 스캔해오는 메소드이다.

// Set
<TABLE>: {<TABLE>:<ID1>, <TABLE>:<ID2>, <TABLE>:<ID3>, <TABLE>:<ID4>}

// Hash Table
<TABLE>:<ID1>, key1, value1, key2, value2, key3, value3...
<TABLE>:<ID2>, key1, value1, key2, value2, key3, value3...

각각의 테이블별로 set을 만들고, 그 set에 key들을 넣는다.

select를 해야 하는 경우 Set에서 1차 scan해서 key를 찾고, hscan으로 실제 디테일 데이터를 가져온다.

func (r *RedisClient) ScanAll(key, match string) (values map[string]map[string]string, err error) {
	ctx := context.Background()
	values = make(map[string]map[string]string)

	cursor := uint64(0)
	hashKeys := []string{}
	for {
		keys := []string{}
		keys, cursor, err = r.client.SScan(ctx, key, cursor, match, MaxCount).Result()
		if err != nil {
			if err == redis.Nil {
				return values, nil
			}
			return values, err
		}

		hashKeys = append(hashKeys, keys...)
		for _, key := range keys {
			values[key] = make(map[string]string)
		}

		if cursor == 0 {
			break
		}
	}

	// Redis 파이프라인 시작
	cmds, err := r.client.Pipelined(ctx, func(p redis.Pipeliner) error {
		for _, hashKey := range hashKeys {
			// pipelining을 위해 max count를 1000으로 설정.
			// pipeline에서 iteration을 지원하지 않기 때문
			// 더 많은 데이터가 필요하다면 단순히 count를 늘리자.
			p.HScan(ctx, hashKey, 0, "", MaxCount).Result()
		}
		return nil
	})
	if err != nil {
		return values, nil
	}

	for idx, cmd := range cmds {
		keys, _ := cmd.(*redis.ScanCmd).Val()

		var key string
		for _, k := range keys {
			if key == "" {
				key = k
			} else {
				values[hashKeys[idx]][key] = k
				key = ""
			}
		}
	}

	return values, nil
}

1000여건의 데이터에도 0.2s → 0.1s로 응답시간이 줄었다. 만약 데이터 양이 더 클 경우 훨씬 영향이 클 것이다. 개선 전의 경우 데이터의 개수와 linear하게 응답시간이 증가하지만, 개선 후의 경우는 거의 constant한 응답시간을 기대할 수 있다. (scan할 데이터는 100건이 채 안되기 때문이다.)

'db' 카테고리의 다른 글

Database partition in postgresql  (0) 2024.01.29
[TIL] Postgresql에서 one-row size 구하기  (0) 2023.12.11
Clickhouse 알아보기  (0) 2022.05.11