Home SCAN
Post
Cancel

SCAN

1. 글을 작성하게 된 계기


레디스의 SCAN 명령어를 학습하는 과정에서 알게 된 내용을 기록하기 위해 글을 작성하게 되었습니다.







2. SCAN


레디스 서버는 멀티 쓰레드로 동작하지만 코어는 싱글 쓰레드로 동작합니다. KEYS 명령어를 사용하면 쓰레드가 전체 키를 검색하는 동안 다른 작업을 처리할 수 없기 때문에, 키가 많을 경우 병목현상이 발생할 수 있습니다.

1
2
3
4
5
6
7
8
9
root@a8f1125e221d:/data# ps -eLF | egrep "PID|redis-server"
UID          PID    PPID     LWP  C NLWP    SZ   RSS PSR STIME TTY          TIME CMD
redis          1       0       1  0    6 33481 11032   6 Dec29 ?        00:05:23 redis-server 0.0.0.0:7101 [cluster]
redis          1       0      17  0    6 33481 11032   4 Dec29 ?        00:00:00 redis-server 0.0.0.0:7101 [cluster]
redis          1       0      18  0    6 33481 11032   3 Dec29 ?        00:00:00 redis-server 0.0.0.0:7101 [cluster]
redis          1       0      19  0    6 33481 11032   4 Dec29 ?        00:00:00 redis-server 0.0.0.0:7101 [cluster]
redis          1       0      20  0    6 33481 11032   1 Dec29 ?        00:00:00 redis-server 0.0.0.0:7101 [cluster]
redis          1       0      21  0    6 33481 11032   1 Dec29 ?        00:00:00 redis-server 0.0.0.0:7101 [cluster]
root         215      23     215  0    1   831  1456   2 19:15 pts/0    00:00:00 grep -E PID|redis-server







따라서 전체 키를 한 번에 조회하는 KEYS 명령어 대신, 일정 개수를 나누어 조회하는 SCAN 명령어를 사용해야 하는데, 이 과정에서 고려할 점에 대해 살펴보겠습니다.

SCAN이 어떻게 동작하는지에 대해서는 Redis의 SCAN은 어떻게 동작하는가?에 상세히 나와있습니다.







3. 적절한 SCAN 개수


SCAN을 사용할 때, 한 번에 몇 개의 키를 조회할지 개수를 지정해야 합니다. 이때, 너무 작은 값을 주면 잦은 네트워크 통신이 발생하며, 너무 큰 값을 주면 많은 메모리 비용, 네트워크 부하가 발생합니다.

image







따라서 한 노드에 적정량의 데이터를 저장하고, 실험을 통해 한 번에 조회할 키 개수를 찾아야 합니다. 이는 서버 스펙, 메모리 용량 등 각자가 처한 상황에 따라 다르므로, 자신의 상황에 맞는 적절한 값을 찾도록 합니다. 이번 예제에서는 10만 개의 키를 저장하고 1개, 500개, 1,000개씩 키를 조회하며 성능을 비교해 보겠습니다. 전체 코드는 다음과 같습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
class RedisService(
    private val redisTemplate: RedisTemplate<String, Any>
) : RedisSaveUseCase, RedisSearchUseCase, RedisConfigurationUseCase {

    private val log: Logger = logger()

    override fun searchKey(
        key: String,
        pattern: String
    ): Set<Any>? {
        return try {
            val options = ScanOptions.scanOptions()
                .count(1000)
                .match("$pattern*")
                .build()
            redisTemplate.execute { connection ->
                val keys = HashSet<String>()
                val cursor = connection.keyCommands()
                    .scan(options)
                while (cursor.hasNext()) {
                    keys.add(String(cursor.next()))
                }
                keys
            }
        } catch (exception: Exception) {
            log.error("Error Message: ${exception.message}")
            null
        }
    }

    override fun searchKeys(keys: List<String>): List<Any?> {
        return try {
            redisTemplate.opsForValue()
                .multiGet(keys) ?: emptyList()
        } catch (exception: Exception) {
            log.error("Error Message: ${exception.message}")
            emptyList()
        }
    }

    override fun save(
        key: String,
        value: Any
    ) {
        try {
            redisTemplate.opsForValue()
                .set(key, value)
        } catch (exception: Exception) {
            log.error("Error Message: ${exception.message}")
        }
    }

    override fun saveAll(map: MutableMap<String, String>) {
        redisTemplate.opsForValue()
            .multiSet(map)
    }

    override fun flushAll() {
        redisTemplate.execute { connection ->
            connection.serverCommands()
                .flushAll()
        }
    }
}







테스트 코드는 다음과 같습니다. 테스트 멱등성을 위해 테스트 전 10만개의 키를 넣고, 테스트가 끝난 후 이를 모두 초기화 시켜줍니다. 더 정확한 테스트를 위해서는 각 케이스마다 도커 컨테이너를 내렸다 올리면 됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
@SpringBootTest
@ActiveProfiles("test")
class KeySearchTest(
    @Autowired
    private val redisSaveUseCase: RedisSaveUseCase,

    @Autowired
    private val redisSearchUseCase: RedisSearchUseCase,

    @Autowired
    private val redisConfigurationUseCase: RedisConfigurationUseCase
) {

    private val log = logger()

    @BeforeEach
    fun setUp() {
        val map = mutableMapOf<String, String>()
        for (index in 1..100_000) {
            val key = "user:string:$index"
            val value = key
            map[key] = value
        }
        redisSaveUseCase.saveAll(map)
    }

    @AfterEach
    fun init() {
        redisConfigurationUseCase.flushAll()
    }

    @Test
    @DisplayName("1 ~ 10만 까지 사용자를 user:string:{userId}로 저장한 후 user:string:7*패턴으로 검색하면 11111개 key가 반환된다.")
    fun keySearchTest() {
        val startTime = System.currentTimeMillis()
        val value = redisSearchUseCase.searchKey("user", "user:string:7")

        assertEquals(11111, value!!.size)

        val endTime = System.currentTimeMillis()
        log.info("Execution time: ${endTime - startTime} ms")
    }
}







3-1. 1개씩 찾아오는 경우

10만 개의 키를 저장하고 1개씩 키를 찾아오는 경우입니다. 약 72초가 걸렸습니다.

image







실행된 명령어는 다음과 같습니다.

1
2
3
4
5
6
7
8
9
10
11
1703972973.528219 [0 172.24.0.1:62528] "SCAN" "53247" "MATCH" "user:string:7*" "COUNT" "1"
1703972973.532700 [0 172.24.0.1:62528] "SCAN" "77823" "MATCH" "user:string:7*" "COUNT" "1"
1703972973.534116 [0 172.24.0.1:62528] "SCAN" "45055" "MATCH" "user:string:7*" "COUNT" "1"
1703972973.535580 [0 172.24.0.1:62528] "SCAN" "94207" "MATCH" "user:string:7*" "COUNT" "1"

......

1703972973.542863 [0 172.24.0.1:62528] "SCAN" "81919" "MATCH" "user:string:7*" "COUNT" "1"
1703972973.544281 [0 172.24.0.1:62528] "SCAN" "98303" "MATCH" "user:string:7*" "COUNT" "1"
1703972973.545716 [0 172.24.0.1:62528] "SCAN" "131071" "MATCH" "user:string:7*" "COUNT" "1"
1703972973.583515 [0 172.24.0.1:62528] "FLUSHALL"







3-2. 500개씩 찾아오는 경우

10만 개의 키를 저장하고 500개씩 키를 찾아오는 경우입니다. 약 0.35초가 걸렸습니다.

image







실행된 명령어는 다음과 같습니다.

1
2
3
4
5
6
7
8
9
10
11
1703967913.513892 [0 172.24.0.1:61210] "SCAN" "0" "MATCH" "user:string:7*" "COUNT" "500"
1703967913.517610 [0 172.24.0.1:61210] "SCAN" "43648" "MATCH" "user:string:7*" "COUNT" "500"
1703967913.520463 [0 172.24.0.1:61210] "SCAN" "116032" "MATCH" "user:string:7*" "COUNT" "500"
1703967913.523387 [0 172.24.0.1:61210] "SCAN" "110528" "MATCH" "user:string:7*" "COUNT" "500"

......

1703967913.888664 [0 172.24.0.1:61210] "SCAN" "23615" "MATCH" "user:string:7*" "COUNT" "500"
1703967913.890252 [0 172.24.0.1:61210] "SCAN" "12735" "MATCH" "user:string:7*" "COUNT" "500"
1703967913.891805 [0 172.24.0.1:61210] "SCAN" "38783" "MATCH" "user:string:7*" "COUNT" "500"
1703967913.937880 [0 172.24.0.1:61210] "FLUSHALL"







3-2. 1,00개씩 찾아오는 경우

10만 개의 키를 저장하고 1,000개씩 키를 찾아오는 경우입니다. 약 0.25초가 걸렸습니다.

image







실행된 명령어는 다음과 같습니다.

1
2
3
4
5
6
7
8
9
10
11
1703971980.825560 [0 172.24.0.1:57854] "SCAN" "0" "MATCH" "user:string:7*" "COUNT" "1000"
1703971980.847951 [0 172.24.0.1:57854] "SCAN" "50928" "MATCH" "user:string:7*" "COUNT" "1000"
1703971980.850989 [0 172.24.0.1:57854] "SCAN" "72" "MATCH" "user:string:7*" "COUNT" "1000"
1703971980.853587 [0 172.24.0.1:57854] "SCAN" "56872" "MATCH" "user:string:7*" "COUNT" "1000"

......

1703971981.093850 [0 172.24.0.1:57854] "SCAN" "86559" "MATCH" "user:string:7*" "COUNT" "1000"
1703971981.096427 [0 172.24.0.1:57854] "SCAN" "863" "MATCH" "user:string:7*" "COUNT" "1000"
1703971981.099478 [0 172.24.0.1:57854] "SCAN" "93887" "MATCH" "user:string:7*" "COUNT" "1000"
1703971981.196378 [0 172.24.0.1:57854] "FLUSHALL"







한 번에 검색하는 키의 개수를 늘릴 수록 조회 성능이 좋아지는 것을 볼 수 있습니다. 하지만 너무 많은 키를 한 번에 조회하면 KEYS 명령어와 유사한 효과가 나타나 메모리 부족, 네트워크 부하성능 저하가 발생할 수 있으므로 주의합니다.

메모리 용량과 네트워크 비용을 고려해 적절한 값을 찾도록 합니다.







4. 추가로 고려할 점


적절한 SCAN 개수를 정하는 과정에서 추가로 고려해야 할 점은 메모리 용량네트워크 비용입니다.

  1. 메모리 용량
  2. 네트워크 비용





4-1. 메모리 용량

적절한 SCAN 크기 설정을 위해서는 각 노드의 저장 용량을 알아야 합니다. 즉, 각 노드에 저장할 수 있는 최대 데이터양키 개수를 고려해, 이에 맞는 SCAN 처리량을 결정하는 것입니다. 이는 CLI 또는 모니터링 툴을 통해 파악할 수 있습니다.

Redis can handle up to 2^32 keys, and was tested in practice to handle at least 250 million keys per instance.







4-2. 네트워크 비용

또 다른 고려할 점은 네트워크 비용 입니다. 본문 크기가 클 수록 대역폭 사용량 증가, 지연 시간 증가, 네트워크 혼잡, 재전송 비용 증가 등과 같은 문제가 발생할 수 있기 때문에, 한 번에 조회할 적절한 SCAN 개수를 고려해야 합니다.

  1. 대역폭 사용량 증가: 많은 데이터를 전송해야 하므로 네트워크 대역폭 사용량이 증가합니다.
  2. 지연 시간 증가: 큰 패킷은 처리/전송하는 데 더 많은 시간이 소요됩니다.
  3. 네트워크 혼잡 발생: 대용량 데이터 전송은 네트워크 혼잡을 일으킬 수 있으며, 이로 인해 다른 트래픽의 성능이 저하될 수 있습니다.
  4. 재전송 비용 증가: 큰 패킷은 손실된 경우, 재전송할 때 더 많은 비용이 발생합니다.





아래는 데이터를 1개, 10,000개씩 조회할 때 발생하는 네트워크 비용을 WireShark로 캡처했는데, 본문 크기만 보더라도 10,000개씩 조회할 때 훨씬 큰 비용이 발생합니다.

값의 차이를 크게 준 것은, 조금 더 극단적으로 어느 정도의 네트워크 비용이 발생하는지 보기 위함입니다.





4-2-1. 1건

1개씩 조회한 경우 수십 ~ 수백 단위의 length를 볼 수 있습니다.

image





4-2-1. 10,000건

10,000개씩 조회한 경우 만 단위의 length를 볼 수 있습니다.

image







5. 정리


SCAN 명령어를 사용할 때 고려할 점들에 대해 살펴보았습니다. 크게 적절한 SCAN 개수, 메모리 용량, 네트워크 비용 등이 있었으며, 여기에는 정답이 없으므로 자신의 환경에 맞는 적절한 수치를 찾아 키를 잘 관리하도록 합니다.

  1. 적절한 SCAN 개수
  2. 메모리 용량
  3. 네트워크 비용

This post is licensed under CC BY 4.0 by the author.