게시글이 100만 개 이상인 대형 포럼, 검색 기능은 어떻게 작동하나요?

최근 사용자 수가 급증하고 트래픽이 폭증하면서 동시에 수행되는 검색도 늘었습니다.

약 120만 개의 게시물에서 검색 타임아웃 현상이 나타나고 있습니다.

대형 포럼에서는 검색 측면에서 어떤 조치를 취하고 계신가요? 디스코어의 기본 검색 기능을 벗어나 다른 솔루션을 도입했나요, 아니면 여전히 잘 작동하고 있나요? 검색 알고리즘이나 쿼리를 더 제한적이거나 구체적으로 업데이트했거나, DB 튜닝을 수행한 것일까요?

댓글 부탁드립니다!

서버 사양은 어떤가요? 그리고 어떤 리소스가 가장 부족해 보이나요?

약 2,000만 개의 게시글(공개 게시글 + 개인 대화)이 있는 보드를 이전하는 것을 고려하고 있어, 대규모 보드에서 발생할 수 있는 성능 문제에 대해 알아보고 싶습니다.

게시물 100만 개에서 2,000만 개까지의 격차는 매우 크므로, 두 분 모두에게 적용할 수 있는 조언은 거의 없습니다. 참고로, 저는 i7-7500U @ 2.7Ghz CPU와 16GB RAM을 갖춘 서버에서 900만 개의 게시물로 양호한 성능을 유지하고 있는 사이트를 운영하고 있습니다. 같은 서버에는 트래픽이 낮은 다른 사이트들도 몇 개 있습니다.

반면, AWS 데이터베이스를 사용하고 RAM이 8GB인 사이트는 게시물 400만 개를 보유하고 있는데, 특히 검색 기능에서 다소 어려움을 겪고 있습니다.

@Jumanji 님, 서버 사양이 어떻게 되나요?

저희는 AWS를 사용하고 있습니다. 포럼은 4개의 인스턴스에서 실행되고 있으며, t3.large 인스턴스인 것 같습니다. 즉, 4 vCPU와 16GB RAM을 보유하고 있습니다. 커스텀 Docker 이미지를 사용하여 Kubernetes를 실행하고 있습니다. DB는 자체 인스턴스(m4.large)에서 실행되며, 이는 2 vCPU와 8GB RAM에 해당합니다. DB 인스턴스의 RAM이 병목 현상의 원인일 수 있습니다.

타임아웃이 발생하는 쿼리는 일반적으로 더 일반적인 단어, 즉 단어를 포함하는 경우입니다. 60초 이상 실행된 경우도 있었습니다. 최근 한 달 동안 웹사이트로 유입되는 트래픽이 급증했습니다. 지난달에는 전월 대비 트래픽이 40% 증가했습니다. 그 기간 동안 v1.9에서 v2.4로 업그레이드했으며, 1.9 이후 Search에 자동완성 검색 기능이 추가되었으므로 DB 서버에 더 많은 부하가 걸리는 것으로 추정됩니다.

쿼리 분석을 수행했으며, 다음 쿼리는 20~60초 사이에서 실행되었습니다:

`SELECT "posts".*
FROM "posts"
JOIN (
    SELECT *, row_number() over() row_number
    FROM (
        SELECT topics.id, min(posts.post_number) post_number
        FROM "posts"
        INNER JOIN "post_search_data" ON "post_search_data"."post_id" = "posts"."id"
        INNER JOIN "topics" ON "topics"."id" = "posts"."topic_id" AND ("topics"."deleted_at" IS NULL)
        LEFT JOIN categories ON categories.id = topics.category_id
        WHERE 
            ("posts"."deleted_at" IS NULL)
            AND "posts"."post_type" IN (1, 2, 3)
            AND (topics.visible)
            AND (topics.archetype <> 'private_message')
            AND (post_search_data.search_data @@ TO_TSQUERY('english', '''a'':*ABD & ''price'':*ABD'))
            AND (categories.id NOT IN (SELECT categories.id WHERE categories.search_priority = 1))
            AND ((categories.id IS NULL) OR (NOT categories.read_restricted))
        GROUP BY topics.id
        ORDER BY
            MAX((
                TS_RANK_CD(
                    post_search_data.search_data,
                    TO_TSQUERY('english', '''a'':*ABD & ''price'':*ABD'),
                    1|32
                ) * (
                    CASE categories.search_priority
                    WHEN 2
                    THEN 0.6
                    WHEN 3
                    THEN 0.8
                    WHEN 4
                    THEN 1.2
                    WHEN 5
                    THEN 1.4
                    ELSE
                    CASE
                        WHEN topics.closed
                        THEN 0.9
                        ELSE 1
                        END
                    END
                )
            )) DESC,
            topics.bumped_at DESC
        LIMIT 6
        OFFSET 0
    ) xxx
) x ON x.id = posts.topic_id AND x.post_number = posts.post_number
WHERE ("posts"."deleted_at" IS NULL)
ORDER BY row_number;

---

 Sort  (cost=495214.55..495214.56 rows=1 width=1177) (actual time=20529.725..20529.727 rows=6 loops=1)
   Sort Key: (row_number() OVER (?))
   Sort Method: quicksort  Memory: 29kB
   ->  Nested Loop  (cost=495164.08..495214.54 rows=1 width=1177) (actual time=20525.899..20529.703 rows=6 loops=1)
         ->  WindowAgg  (cost=495163.65..495163.80 rows=6 width=16) (actual time=20520.976..20521.078 rows=6 loops=1)
               ->  Limit  (cost=495163.65..495163.66 rows=6 width=24) (actual time=20520.969..20521.063 rows=6 loops=1)
                     ->  Sort  (cost=495163.65..495232.24 rows=27438 width=24) (actual time=20520.967..20520.969 rows=6 loops=1)
                           Sort Key: (max((ts_rank_cd(post_search_data.search_data, '''price'':*ABD'::tsquery, 33) * (CASE categories.search_priority WHEN 2 THEN 0.6 WHEN 3 THEN 0.8 WHEN 4 THEN 1.2 WHEN 5 THEN 1.4 ELSE CASE WHEN topics.closed THEN 0.9 ELSE '1'::numeric END END)::double precision))) DESC, topics.bumped_at DESC
                           Sort Method: top-N heapsort  Memory: 25kB
                           ->  GroupAggregate  (cost=493642.90..494671.83 rows=27438 width=24) (actual time=19082.214..20506.763 rows=32951 loops=1)
                                 Group Key: topics.id
                                 ->  Sort  (cost=493642.90..493711.50 rows=27438 width=400) (actual time=19082.184..19283.907 rows=191436 loops=1)
                                       Sort Key: topics.id
                                       Sort Method: external merge  Disk: 77632kB
                                       ->  Hash Left Join  (cost=36655.60..486646.69 rows=27438 width=400) (actual time=1562.696..18611.724 rows=191436 loops=1)
                                             Hash Cond: (topics.category_id = categories.id)
                                             Filter: (((categories.id IS NULL) OR (NOT categories.read_restricted)) AND (NOT (SubPlan 1)))
                                             ->  Gather  (cost=36645.63..486471.60 rows=58991 width=400) (actual time=1562.623..18249.349 rows=191436 loops=1)
                                                   Workers Planned: 2
                                                   Workers Launched: 2
                                                   ->  Nested Loop  (cost=35645.63..479572.50 rows=24580 width=400) (actual time=1556.547..18541.793 rows=63812 loops=3)
                                                         ->  Hash Join  (cost=35645.20..328688.61 rows=157831 width=25) (actual time=1551.912..13356.416 rows=285279 loops=3)
                                                               Hash Cond: (posts_1.topic_id = topics.id)
                                                               ->  Parallel Seq Scan on posts posts_1  (cost=0.00..286245.92 rows=504559 width=12) (actual time=0.280..11249.160 rows=404770 loops=3)
                                                                     Filter: ((deleted_at IS NULL) AND (post_type = ANY ('{1,2,3}'::integer[])))
                                                                     Rows Removed by Filter: 21884
                                                               ->  Hash  (cost=33938.80..33938.80 rows=92912 width=17) (actual time=1549.103..1549.103 rows=80351 loops=3)
                                                                     Buckets: 65536  Batches: 2  Memory Usage: 2557kB
                                                                     ->  Seq Scan on topics  (cost=0.00..33938.80 rows=92912 width=17) (actual time=0.010..1492.606 rows=80351 loops=3)
                                                                           Filter: ((deleted_at IS NULL) AND visible AND ((archetype)::text <> 'private_message'::text))
                                                                           Rows Removed by Filter: 216751
                                                         ->  Index Scan using posts_search_pkey on post_search_data  (cost=0.43..0.96 rows=1 width=383) (actual time=0.017..0.017 rows=0 loops=855836)
                                                               Index Cond: (post_id = posts_1.id)
                                                               Filter: (search_data @@ '''price'':*ABD'::tsquery)
                                                               Rows Removed by Filter: 1
                                             ->  Hash  (cost=9.43..9.43 rows=43 width=9) (actual time=0.059..0.059 rows=43 loops=1)
                                                   Buckets: 1024  Batches: 1  Memory Usage: 10kB
                                                   ->  Seq Scan on categories  (cost=0.00..9.43 rows=43 width=9) (actual time=0.007..0.045 rows=43 loops=1)
                                             SubPlan 1
                                               ->  Result  (cost=0.00..0.01 rows=1 width=4) (actual time=0.000..0.000 rows=0 loops=191436)
                                                     One-Time Filter: (categories.search_priority = 1)
         ->  Index Scan using index_posts_on_topic_id_and_post_number on posts  (cost=0.43..8.45 rows=1 width=1169) (actual time=1.435..1.435 rows=1 loops=6)
               Index Cond: ((topic_id = topics.id) AND (post_number = (min(posts_1.post_number))))
               Filter: (deleted_at IS NULL)
 Planning time: 3.508 ms
 Execution time: 20541.411 ms
(46 rows)`

데이터베이스 전체 크기가 8GB 미만인가요? 데이터를 RAM에 최대한 많이 적재하는 것이 좋습니다.

이것은 느릴 것입니다. 그리고 디스크가 네트워크 마운트인 경우 훨씬 더 나빠질 수 있습니다.

또한 위쪽의 제 답변을 확인하여 튜닝 방법을 살펴보세요.

@Jumanji discourse로 운영되는 높은 트래픽의 포럼 사이트 링크를 몇 개 공유해 주실 수 있을까요? 트래픽이 수백만 이상인 사이트요.

저는 Discourse 팀의 일원은 아니지만, 검색을 통해 이 내용을 찾았습니다.

저희는 24GB입니다. 인스턴스에 할당된 RAM보다 확실히 큽니다.

일부 경우, 대량의 데이터 이동 이후 vacuum analyze를 실행하면 성능이 향상될 수 있습니다.

또한 AWS를 사용 중이라면 RDS를 사용하고 계신가요? 아니라면… 왜 그런가요?

감사합니다. 네, 해당 내용을 실행했고 현재 RDS를 사용 중입니다.