부하테스트 ‐ (10) 게시글 댓글 목록 페이지 조회 성능 개선(댓글수 비정규화, 쿼리 튜닝) - ttasjwi/board-system GitHub Wiki

개요

  • 사실 지난 글에서 한 성능 튜닝(인덱스 추가)만으로 충분히 좋은 성능을 보이는 것을 확인했다.
  • 그런데 여기서 좀 더 성능을 개선할 수 있는 여지가 있을 것 같았고, 이를 시도해봤다.

테스트

import http from 'k6/http';
import {sleep} from 'k6';

export const options = {
  // 부하를 생성하는 단계(stages)를 설정
  stages: [
    // 10분에 걸쳐 vus(virtual users, 가상 유저수)가 1000에 도달하도록 설정
    {duration: '10m', target: 1000}
  ],
};

export default function () {
  // API 주소로 GET 요청
  const articleId = 24000000
  const page = 100
  http.get(`http://3.35.168.50/api/v1/article-comments?articleId=${articleId}&page=${page}&pageSize=50`);
  sleep(1);
}
  • 테스트에서, 높은 OFFSET 에서의 성능차를 확인하기 위해 성능 테스트를 변경했다.
  • articleId = 240000000, page = 100 고정

기존 방식 성능테스트

image

  • vus 181, 최대 tsp 167.4 추정
  • 그 이후에서는 증가가 정체됨.

image

  • 데이터베이스쪽에서 병목이 발생(CPU 사용률 98.65%)

댓글수 조회

mysql> WITH cte AS (
    ->     SELECT article_comment_id
    ->     FROM article_comments
    ->     WHERE article_id = 24000000
    ->     LIMIT 12000001
    -> )
    -> SELECT COUNT(*)
    -> FROM cte;

+----------+
| COUNT(*) |
+----------+
| 12000001 |
+----------+
1 row in set (38.32 sec)
  • 기존 댓글수 조회 방식이다.
  • 실제 article_comments 테이블에서 count(*) 쿼리를 통해 댓글 수를 조회해오고 있었다.
  • 댓글 수가 늘어날 수록, limit 값이 늘어나서 쿼리 성능은 크게 악화될 여지가 있다.
mysql> SELECT comment_count
    -> FROM article_comment_counts
    -> WHERE article_id = 24000000;
+---------------+
| comment_count |
+---------------+
|      12000828 |
+---------------+
1 row in set (0.01 sec)
  • 근데, article_comment_counts 테이블을 따로 만들어서 게시글별 댓글 수를 비정규화해놨다. 이를 통해 조회해오는 것이 몇 배는 빠르다.
  • 그래서, 애플리케이션 로직에서 댓글수 조회 로직을 article_comment_counts 테이블을 통해 조회해오는 식으로 변경했다.

게시글 댓글 페이지 번호 제약 추가

  • 게시글 댓글 페이지 번호에 제약을 추가했다.
  • 최대 100 페이지까지 가져올 수 있게 한다.
  • 이러면 게시글 댓글에 대한 offset 은 최대 4950 정도까지 제한을 둘 수 있다.
  • 보통 서비스에서 어떤 게시글에 대한 댓글이 4950개를 넘을 일은 그렇게 많지 않고, 설령 그런 일이 일어나더라도 그 때 가서 페이지 번호 제한을 조금 완화하는 것을 고려해봐도 충분하다.
  • 그리고 보통 사용자가 100페이지 넘는 댓글을 전부 찾아 접근하는 일도 드물다.

댓글 조회 쿼리 수정

mysql> explain analyze
    -> SELECT
    ->     article_comment_id,
    ->     content,
    ->     article_id,
    ->     root_parent_comment_id,
    ->     writer_id,
    ->     writer_nickname,
    ->     parent_comment_writer_id,
    ->     parent_comment_writer_nickname,
    ->     delete_status,
    ->     created_at,
    ->     modified_at
    -> FROM article_comments
    -> WHERE article_id = 24000000
    -> ORDER BY root_parent_comment_id, article_comment_id
    -> LIMIT 50 OFFSET 12000000;
+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| EXPLAIN                                                                                                                                                                                                                                                                                                         |
+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| -> Limit/Offset: 50/12000000 row(s)  (cost=1.66e+6 rows=0) (actual time=90411..90411 rows=50 loops=1)
    -> Index lookup on article_comments using idx_article_id_root_parent_comment_id_article_comment_id (article_id=24000000)  (cost=1.66e+6 rows=11.6e+6) (actual time=0.0792..89914 rows=12e+6 loops=1)
 |
+-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
1 row in set (1 min 30.43 sec)
  • 기존 댓글 조회 로직이다.
  • index look up 을 통해 접근하는데 이 과정에서 사실 1200만 offset 에 도달할 떄까지 disk i/o 가 발생한다.
mysql> EXPLAIN ANALYZE
    -> WITH cte(article_comment_id) AS (
    ->     SELECT ac.article_comment_id
    ->     FROM article_comments ac
    ->     WHERE ac.article_id = 24000000
    ->     ORDER BY ac.root_parent_comment_id, ac.article_comment_id
    ->     LIMIT 50 OFFSET 12000000
    -> )
    -> SELECT
    ->     ac.article_comment_id AS articleCommentId,
    ->     ac.content AS content,
    ->     ac.article_id AS articleId,
    ->     ac.root_parent_comment_id AS rootParentCommentId,
    ->     ac.writer_id AS writerId,
    ->     ac.writer_nickname AS writerNickname,
    ->     ac.parent_comment_writer_id AS parentCommentWriterId,
    ->     ac.parent_comment_writer_nickname AS parentCommentWriterNickname,
    ->     ac.delete_status AS deleteStatus,
    ->     ac.created_at AS createdAt,
    ->     ac.modified_at AS modifiedAt
    -> FROM article_comments ac
    ->          JOIN cte ON cte.article_comment_id = ac.article_comment_id;
+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| EXPLAIN                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                |
+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| -> Nested loop inner join  (cost=12.6e+6 rows=0) (actual time=18586..18586 rows=50 loops=1)
    -> Table scan on cte  (cost=1.2e+6..1.2e+6 rows=0) (actual time=18584..18584 rows=50 loops=1)
        -> Materialize CTE cte  (cost=1.2e+6..1.2e+6 rows=0) (actual time=18584..18584 rows=50 loops=1)
            -> Limit/Offset: 50/12000000 row(s)  (cost=1.2e+6 rows=0) (actual time=18584..18584 rows=50 loops=1)
                -> Covering index lookup on ac using idx_article_id_root_parent_comment_id_article_comment_id (article_id=24000000)  (cost=1.2e+6 rows=11.6e+6) (actual time=0.022..18106 rows=12e+6 loops=1)
    -> Single-row index lookup on ac using PRIMARY (article_comment_id=cte.article_comment_id)  (cost=0.981 rows=1) (actual time=0.0312..0.0312 rows=1 loops=50)
 |
+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
1 row in set (18.61 sec)
  • 새로 개선한 쿼리는 커버링 인덱스를 활용한 방식이다.
  • 1200만번째 offset에 도달할 때까지 인덱스만을 사용하여 필터링하기 때문에(커버링 인덱스) 성능이 더 우수하다.
  • 물론 낮은 offset에서는 기존 로직과는 별 차이가 없는 것처럼 느껴지거나, 오히려 더 성능이 느리게 느껴질 수도 있다.

튜닝 후 성능 테스트

image

  • vus 314, tps 303.2
  • 기존 최대 tps는 167.4였는데, page = 100 일 경우 1.81배 성능이 향상됐다.

image

  • rds 의 cpu 사용률은 22%대로 다시 낮아지고, EC2가 병목지점이 됐다.