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을 사용할 때, 한 번에 몇 개의 키를 조회할지 개수를 지정해야 합니다. 이때, 너무 작은 값을 주면 잦은 네트워크 통신이 발생하며, 너무 큰 값을 주면 많은 메모리 비용, 네트워크 부하가 발생합니다.
따라서 한 노드에 적정량의 데이터를 저장하고, 실험을 통해 한 번에 조회할 키 개수를 찾아야 합니다. 이는 서버 스펙, 메모리 용량 등 각자가 처한 상황에 따라 다르므로, 자신의 상황에 맞는 적절한 값을 찾도록 합니다. 이번 예제에서는 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초가 걸렸습니다.
실행된 명령어는 다음과 같습니다.
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초가 걸렸습니다.
실행된 명령어는 다음과 같습니다.
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초가 걸렸습니다.
실행된 명령어는 다음과 같습니다.
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 개수를 정하는 과정에서 추가로 고려해야 할 점은 메모리 용량과 네트워크 비용입니다.
- 메모리 용량
- 네트워크 비용
4-1. 메모리 용량
적절한 SCAN 크기 설정을 위해서는 각 노드의 저장 용량을 알아야 합니다. 즉, 각 노드에 저장할 수 있는 최대 데이터양과 키 개수를 고려해, 이에 맞는 SCAN 처리량을 결정하는 것입니다. 이는 CLI 또는 모니터링 툴을 통해 파악할 수 있습니다.
4-2. 네트워크 비용
또 다른 고려할 점은 네트워크 비용 입니다. 본문 크기가 클 수록 대역폭 사용량 증가, 지연 시간 증가, 네트워크 혼잡, 재전송 비용 증가 등과 같은 문제가 발생할 수 있기 때문에, 한 번에 조회할 적절한 SCAN 개수를 고려해야 합니다.
대역폭 사용량 증가: 많은 데이터를 전송해야 하므로 네트워크 대역폭 사용량이 증가합니다.지연 시간 증가: 큰 패킷은 처리/전송하는 데 더 많은 시간이 소요됩니다.네트워크 혼잡 발생: 대용량 데이터 전송은 네트워크 혼잡을 일으킬 수 있으며, 이로 인해 다른 트래픽의 성능이 저하될 수 있습니다.재전송 비용 증가: 큰 패킷은 손실된 경우, 재전송할 때 더 많은 비용이 발생합니다.
아래는 데이터를 1개, 10,000개씩 조회할 때 발생하는 네트워크 비용을 WireShark로 캡처했는데, 본문 크기만 보더라도 10,000개씩 조회할 때 훨씬 큰 비용이 발생합니다.
값의 차이를 크게 준 것은, 조금 더 극단적으로 어느 정도의 네트워크 비용이 발생하는지 보기 위함입니다.
4-2-1. 1건
1개씩 조회한 경우 수십 ~ 수백 단위의 length를 볼 수 있습니다.
4-2-1. 10,000건
10,000개씩 조회한 경우 만 단위의 length를 볼 수 있습니다.
5. 정리
SCAN 명령어를 사용할 때 고려할 점들에 대해 살펴보았습니다. 크게 적절한 SCAN 개수, 메모리 용량, 네트워크 비용 등이 있었으며, 여기에는 정답이 없으므로 자신의 환경에 맞는 적절한 수치를 찾아 키를 잘 관리하도록 합니다.
- 적절한 SCAN 개수
- 메모리 용량
- 네트워크 비용