채팅 데이터 캐싱을 통한 성능 향상

Dltmd202·2024년 8월 24일
post-thumbnail

실시간 채팅 애플리케이션에서는 사용자가 자주 최근 채팅 기록을 조회하게 됩니다. 때문에 최근 N개의 채팅 데이터를 빠르게 조회할 수 있도록 캐싱 전략을 도입하기로 했다.

캐싱된 채팅 데이터의 관리

저희는 최근 N개의 데이터를 캐싱하기 위해 Sorted Set 자료구조를 선택했다.
Sorted Set은 각 데이터에 점수를 부여해 정렬된 상태로 데이터를 유지한다.
이 점수는 메시지 ID(MySQL상의 PK)로 설정되며, 이를 통해 가장 최근의 N개의 채팅 데이터를 관리한다

이 점수는 메시지 ID로 설정되며, 이를 통해 가장 최근의 N개의 데이터를 손쉽게 관리할 수 있습니다. 다만, Sorted Set 자체는 N개를 유지하면서 자동으로 오래된 데이터를 삭제하는 기능은 제공하지 않으므로, 이를 구현하기 위해 추가적인 작업이 필요했습니다. 예를 들어, 새로운 데이터를 추가한 후, ZREMRANGEBYRANK 명령어를 통해 N개를 초과하는 오래된 데이터를 삭제하여 최신 데이터만을 유지하는 방법을 사용했습니다. [여기서는 Sorted Set의 데이터 구조와 동작 방식을 설명하는 다이어그램을 추가할 수 있습니다.]

캐싱된 채팅 N개 이하로 유지하기

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))
}
  1. 새로생긴 채팅을 저장한다.
    ZADD 연산이며 저장을 위한 시간복잡도는 정렬된 위치를 탐색하기 위해 O(log(N))의 비용이 소모된다.
  2. 인덱스기반으로 범위 밖의 채팅을 삭제한다.
    ZREMRANGEBYRANK 연산이며 삭제를 위한 시간복잡도는 O(log(N) + M)의 비용이 소모된다.
    현재 인덱스를 기반으로 0번째부터 -(N + 1)번째 범위까지의 채팅을 삭제함으로 Sorted Set에 저장되는 데이터를 N개로 유지할 수 있다.

커서 기반 N개의 채팅 조회

캐시에서 범위 검색을 통해 조회하려니 문제가 있었다. 캐시가 반환한 빈값이 두 가지로 해석될 수 있다는 것이다.

  1. 해당 조회요청이 너무 예전 채팅 데이터를 조회하는 요청이기 때문에 이미 캐시에서 삭제된 값이기 때문에 빈값을 반환받을 수 있다.

  2. 범위 검색을 위해 커서를 받았는데, 해당 커서에 대한 데이터가 아직 생성되기 이전의 데이터일 경우 빈값을 반환받을 수 있다.

때문에 두 가지 경우를 나눠서 처리할 수 있는 로직이 필요했다.

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)
        }
}
  1. 해당 채팅방의 가장 최근 채팅을 검사
    해당 채팅방의 가장 최근 채팅방의 채팅 아이디가 커서보다 작다면 해당 요청은 없는 데이터에 대한 요청이기 때문에 list를 반환하여 상위 계층에서 DB에 요청을 보내도 되지 않음을 알려준다.
  2. 그 외의 경우에는 채팅 범위 조회 요청
    ZRANGEBYSCORE연산이며 조회를 위한 시간복잡도는 O(log(N) + M)의 비용이 소모된다.
    cursor보다 큰 score의 데이터를 count개수만큼 가져온다.
    만약 조회할 데이터가 없다면 null을 반환하기 때문에 상위 모듈에서 cash-miss로 판단하고 DB에 조회요청을 날린다.

채팅 성능 비교

k6를 사용해서 성능을 테스트하였다.
히트율은 85%로 설정하였고, 캐싱되는 채팅은 100개로 설정하였다.

기존의 DB만을 통한 성능

     ✓ 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% 더 성능이 향상되었다.

0개의 댓글