
실시간 채팅 애플리케이션에서는 사용자가 자주 최근 채팅 기록을 조회하게 됩니다. 때문에 최근 N개의 채팅 데이터를 빠르게 조회할 수 있도록 캐싱 전략을 도입하기로 했다.
저희는 최근 N개의 데이터를 캐싱하기 위해 Sorted Set 자료구조를 선택했다.
Sorted Set은 각 데이터에 점수를 부여해 정렬된 상태로 데이터를 유지한다.
이 점수는 메시지 ID(MySQL상의 PK)로 설정되며, 이를 통해 가장 최근의 N개의 채팅 데이터를 관리한다
이 점수는 메시지 ID로 설정되며, 이를 통해 가장 최근의 N개의 데이터를 손쉽게 관리할 수 있습니다. 다만, Sorted Set 자체는 N개를 유지하면서 자동으로 오래된 데이터를 삭제하는 기능은 제공하지 않으므로, 이를 구현하기 위해 추가적인 작업이 필요했습니다. 예를 들어, 새로운 데이터를 추가한 후, ZREMRANGEBYRANK 명령어를 통해 N개를 초과하는 오래된 데이터를 삭제하여 최신 데이터만을 유지하는 방법을 사용했습니다. [여기서는 Sorted Set의 데이터 구조와 동작 방식을 설명하는 다이어그램을 추가할 수 있습니다.]
Sorted Set을 사용한다고 해도 자료구조 상에서 자동으로 채팅 하나가 추가되면 하나가 삭제되도록 할 수는 없다.
fun saveChat(
chatRoomId: Long,
chat: ChatWithUserCache<*>,
) {
redisChatTemplate
.opsForZSet()
.add(key(chatRoomId), timeObjectMapper.writeValueAsString(chat), chat.id.toDouble())
redisChatTemplate
.opsForZSet()
.removeRange(key(chatRoomId), 0, -(CHAT_COUNT + 1))
}
ZADD 연산이며 저장을 위한 시간복잡도는 정렬된 위치를 탐색하기 위해 O(log(N))의 비용이 소모된다.ZREMRANGEBYRANK 연산이며 삭제를 위한 시간복잡도는 O(log(N) + M)의 비용이 소모된다.0번째부터 -(N + 1)번째 범위까지의 채팅을 삭제함으로 Sorted Set에 저장되는 데이터를 N개로 유지할 수 있다.캐시에서 범위 검색을 통해 조회하려니 문제가 있었다. 캐시가 반환한 빈값이 두 가지로 해석될 수 있다는 것이다.
해당 조회요청이 너무 예전 채팅 데이터를 조회하는 요청이기 때문에 이미 캐시에서 삭제된 값이기 때문에 빈값을 반환받을 수 있다.
범위 검색을 위해 커서를 받았는데, 해당 커서에 대한 데이터가 아직 생성되기 이전의 데이터일 경우 빈값을 반환받을 수 있다.
때문에 두 가지 경우를 나눠서 처리할 수 있는 로직이 필요했다.
fun findRangeChat(
chatRoomId: Long,
cursor: Long,
count: Int,
): ChatWithUserCache<*>? {
val firstChat = redisChatTemplate
.opsForZSet()
.range(key(chatRoomId), 0, 0)
?.firstOrNull()
if(firstChat == null || firstChat.id < cursor) return emptyList()
return redisChatTemplate
.opsForZSet()
.rangeByScore(key(chatRoomId), (cursor + 1).toDouble(), Double.POSITIVE_INFINITY, 0, count.toLong())
?.map { chat ->
timeObjectMapper.readValue(chat, ChatWithUserCache::class.java)
}
}
ZRANGEBYSCORE연산이며 조회를 위한 시간복잡도는 O(log(N) + M)의 비용이 소모된다.cursor보다 큰 score의 데이터를 count개수만큼 가져온다.null을 반환하기 때문에 상위 모듈에서 cash-miss로 판단하고 DB에 조회요청을 날린다.k6를 사용해서 성능을 테스트하였다.
히트율은 85%로 설정하였고, 캐싱되는 채팅은 100개로 설정하였다.
✓ success
checks.........................: 100.00% ✓ 73000 ✗ 0
data_received..................: 506 MB 2.5 MB/s
data_sent......................: 50 MB 247 kB/s
http_req_blocked...............: avg=27.8µs min=0s med=2µs max=158.82ms p(90)=4µs p(95)=5µs
http_req_connecting............: avg=21.76µs min=0s med=0s max=132.79ms p(90)=0s p(95)=0s
✓ http_req_duration..............: avg=79.62ms min=355µs med=104.63ms max=513.52ms p(90)=145.87ms p(95)=161.48ms
{ expected_response:true }...: avg=80.65ms min=1.07ms med=106.26ms max=513.52ms p(90)=146.08ms p(95)=161.84ms
http_req_failed................: 1.27% ✓ 1461 ✗ 112762
http_req_receiving.............: avg=66.18µs min=5µs med=50µs max=17.67ms p(90)=135µs p(95)=168µs
http_req_sending...............: avg=20.9µs min=1µs med=9µs max=192.7ms p(90)=23µs p(95)=28µs
http_req_tls_handshaking.......: avg=0s min=0s med=0s max=0s p(90)=0s p(95)=0s
http_req_waiting...............: avg=79.53ms min=312µs med=104.56ms max=513.45ms p(90)=145.78ms p(95)=161.4ms
http_reqs......................: 114223 565.982793/s
iteration_duration.............: avg=124.94ms min=3.49ms med=122.64ms max=2m21s p(90)=154.6ms p(95)=184.51ms
iterations.....................: 73000 361.720003/s
vus............................: 150 min=0 max=150
vus_max........................: 150 min=150 max=150
running (3m21.8s), 000/150 VUs, 73000 complete and 0 interrupted iterations
default ✓ [======================================] 150 VUs 1m0s
기존의 DB만을 사용한 채팅 조회는 564TPS가 나왔다.
✓ success
checks.........................: 100.00% ✓ 96117 ✗ 0
data_received..................: 653 MB 4.1 MB/s
data_sent......................: 60 MB 372 kB/s
http_req_blocked...............: avg=15.4µs min=0s med=1µs max=126.86ms p(90)=2µs p(95)=3µs
http_req_connecting............: avg=13.68µs min=0s med=0s max=87.7ms p(90)=0s p(95)=0s
✓ http_req_duration..............: avg=56.95ms min=319µs med=78.87ms max=564.99ms p(90)=97.52ms p(95)=122.94ms
{ expected_response:true }...: avg=57.56ms min=684µs med=79.12ms max=564.99ms p(90)=97.75ms p(95)=123.75ms
http_req_failed................: 1.06% ✓ 1464 ✗ 135852
http_req_receiving.............: avg=37.55µs min=4µs med=31µs max=11.38ms p(90)=74µs p(95)=112µs
http_req_sending...............: avg=5.51µs min=1µs med=3µs max=25.83ms p(90)=10µs p(95)=14µs
http_req_tls_handshaking.......: avg=0s min=0s med=0s max=0s p(90)=0s p(95)=0s
http_req_waiting...............: avg=56.91ms min=282µs med=78.81ms max=564.93ms p(90)=97.46ms p(95)=122.83ms
http_reqs......................: 137316 855.703744/s
iteration_duration.............: avg=94.46ms min=1.15ms med=85.38ms max=1m40s p(90)=133.1ms p(95)=161.8ms
iterations.....................: 96117 598.966448/s
vus............................: 150 min=0 max=150
vus_max........................: 150 min=150 max=150
running (2m40.5s), 000/150 VUs, 96117 complete and 0 interrupted iterations
default ✓ [======================================] 150 VUs 1m0s
캐싱을 했을 때는 855TPS로 51.6% 성능이 향상되었음을 볼 수 있다.
캐싱을 통해 성능을 많이 높였지만 아쉬운 점이 많았다. 아무래도 캐싱과정이 복잡하여 레디스에 많은 요청을 보내는 만큼 네트워크 트래픽때문에 만족할만한 성능을 못 낸 것 같았다.
그래서 Lua 스크립트로 요청을 파이프라이닝하여 응답받았다.
@Repository
class ChatPipelinedQueryRepository(
private val redisChatTemplate: RedisTemplate<String, String>,
private val timeObjectMapper: ObjectMapper,
) {
companion object {
const val PREFIX = "chatRoom:"
fun key(chatRoomId: Long) = "$PREFIX$chatRoomId"
const val AFTER_CHAT_SCRIPT =
"""
local key = KEYS[1]
local cursor = tonumber(ARGV[1])
local count = tonumber(ARGV[2])
local firstResult = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
if #firstResult > 0 then
local firstChat = firstResult[1]
local firstScore = tonumber(firstResult[2]) -- 첫 번째 요소의 score 값을 가져옴
if firstScore > cursor then
local result = redis.call('ZRANGEBYSCORE', key, cursor + 1, math.huge, 'LIMIT', 0, count)
if #result > 0 then
return table.concat(result, ",")
else
return ""
end
else
return firstChat
end
else
return nil
end
"""
}
fun findAfterChats(
chatRoomId: Long,
cursor: Long,
count: Int,
): List<ChatWithUserCache<*>>? {
val result: List<String> =
redisChatTemplate.execute(
RedisScript.of(AFTER_CHAT_SCRIPT.trimIndent(), List::class.java as Class<List<String>>),
listOf(key(chatRoomId)),
cursor.toString(),
count.toString(),
)
return result.map { chat ->
timeObjectMapper.readValue(chat, ChatWithUserCache::class.java)
}
}
}
기존 테스트와 동일하게 히트율을 85%로 설정하고 100개의 채팅을 캐싱했다
✓ success
checks.........................: 100.00% ✓ 157480 ✗ 0
data_received..................: 164 MB 916 kB/s
data_sent......................: 86 MB 478 kB/s
http_req_blocked...............: avg=5.04µs min=0s med=1µs max=32.05ms p(90)=2µs p(95)=4µs
http_req_connecting............: avg=3.27µs min=0s med=0s max=8.8ms p(90)=0s p(95)=0s
✓ http_req_duration..............: avg=2.65ms min=319µs med=1.28ms max=298.82ms p(90)=3.51ms p(95)=5.76ms
{ expected_response:true }...: avg=2.66ms min=607µs med=1.29ms max=298.82ms p(90)=3.53ms p(95)=5.8ms
http_req_failed................: 0.73% ✓ 1458 ✗ 196999
http_req_receiving.............: avg=34.85µs min=4µs med=30µs max=5.84ms p(90)=66µs p(95)=93µs
http_req_sending...............: avg=5.62µs min=1µs med=4µs max=30.93ms p(90)=9µs p(95)=15µs
http_req_tls_handshaking.......: avg=0s min=0s med=0s max=0s p(90)=0s p(95)=0s
http_req_waiting...............: avg=2.61ms min=283µs med=1.24ms max=298.73ms p(90)=3.47ms p(95)=5.69ms
http_reqs......................: 198457 1109.297128/s
iteration_duration.............: avg=57.77ms min=778.7µs med=57.58ms max=1m58s p(90)=75.94ms p(95)=85.67ms
iterations.....................: 157480 880.2517/s
vus............................: 150 min=0 max=150
vus_max........................: 150 min=150 max=150
running (2m58.9s), 000/150 VUs, 157480 complete and 0 interrupted iterations
default ✓ [======================================] 150 VUs 1m0s
그 결과 1109TPS로 기존 캐싱 기준 29.71% 더 성능이 향상되었다.