사용자 프로필 페이지 로딩이 느립니다

현재 사용 중인 소프트웨어에서 Discourse로 사용자 게시글을 가져오는 작업을 완료했습니다. 그런데 일부 사용자 페이지가 매우 느리게 로드되는 것(수 초 이상)을 발견했는데, 이는 느린 쿼리 때문으로 보입니다. 이를 어떻게 디버깅하고 병목 현상을 파악할 수 있을까요?

우리 환경에 대한 추가 정보입니다:

서버 사양: 8코어, 32GB RAM, ZFS가 적용된 NVMe SSD 저장장치

보드에는 약 2,500만 개의 게시글이 있습니다. 이 중 대부분은 개인 대화이며, 약 1,050만 개가 공개 게시글입니다.

수많은 메가토픽(저희는 글쓰기 사이트이므로 개별 이야기들이 시간이 지남에 따라 천천히 발전하면서 게시글이 많이 쌓임)가 수천 개의 게시글을 포함하고 있어 이것이 원인의 일부일 수 있지만, 이를 확인하는 방법을 모르겠습니다.

다음은 문제가 되는 것으로 보이는 쿼리 중 하나입니다. 내장된 로드 분석기에서 5,000ms 이상을 기록한 것을 확인했습니다:

SELECT "topic_links"."id" AS t0_r0, "topic_links"."topic_id" AS t0_r1, "topic_links"."post_id" AS t0_r2, "topic_links"."user_id" AS t0_r3, "topic_links"."url" AS t0_r4, "topic_links"."domain" AS t0_r5, "topic_links"."internal" AS t0_r6, "topic_links"."link_topic_id" AS t0_r7, "topic_links"."created_at" AS t0_r8, "topic_links"."updated_at" AS t0_r9, "topic_links"."reflection" AS t0_r10, "topic_links"."clicks" AS t0_r11, "topic_links"."link_post_id" AS t0_r12, "topic_links"."title" AS t0_r13, "topic_links"."crawled_at" AS t0_r14, "topic_links"."quote" AS t0_r15, "topic_links"."extension" AS t0_r16, "topics"."id" AS t1_r0, "topics"."title" AS t1_r1, "topics"."last_posted_at" AS t1_r2, "topics"."created_at" AS t1_r3, "topics"."updated_at" AS t1_r4, "topics"."views" AS t1_r5, "topics"."posts_count" AS t1_r6, "topics"."user_id" AS t1_r7, "topics"."last_post_user_id" AS t1_r8, "topics"."reply_count" AS t1_r9, "topics"."featured_user1_id" AS t1_r10, "topics"."featured_user2_id" AS t1_r11, "topics"."featured_user3_id" AS t1_r12, "topics"."deleted_at" AS t1_r13, "topics"."highest_post_number" AS t1_r14, "topics"."like_count" AS t1_r15, "topics"."incoming_link_count" AS t1_r16, "topics"."category_id" AS t1_r17, "topics"."visible" AS t1_r18, "topics"."moderator_posts_count" AS t1_r19, "topics"."closed" AS t1_r20, "topics"."archived" AS t1_r21, "topics"."bumped_at" AS t1_r22, "topics"."has_summary" AS t1_r23, "topics"."archetype" AS t1_r24, "topics"."featured_user4_id" AS t1_r25, "topics"."notify_moderators_count" AS t1_r26, "topics"."spam_count" AS t1_r27, "topics"."pinned_at" AS t1_r28, "topics"."score" AS t1_r29, "topics"."percent_rank" AS t1_r30, "topics"."subtype" AS t1_r31, "topics"."slug" AS t1_r32, "topics"."deleted_by_id" AS t1_r33, "topics"."participant_count" AS t1_r34, "topics"."word_count" AS t1_r35, "topics"."excerpt" AS t1_r36, "topics"."pinned_globally" AS t1_r37, "topics"."pinned_until" AS t1_r38, "topics"."fancy_title" AS t1_r39, "topics"."highest_staff_post_number" AS t1_r40, "topics"."featured_link" AS t1_r41, "topics"."reviewable_score" AS t1_r42, "topics"."image_upload_id" AS t1_r43, "posts"."id" AS t2_r0, "posts"."user_id" AS t2_r1, "posts"."topic_id" AS t2_r2, "posts"."post_number" AS t2_r3, "posts"."raw" AS t2_r4, "posts"."cooked" AS t2_r5, "posts"."created_at" AS t2_r6, "posts"."updated_at" AS t2_r7, "posts"."reply_to_post_number" AS t2_r8, "posts"."reply_count" AS t2_r9, "posts"."quote_count" AS t2_r10, "posts"."deleted_at" AS t2_r11, "posts"."off_topic_count" AS t2_r12, "posts"."like_count" AS t2_r13, "posts"."incoming_link_count" AS t2_r14, "posts"."bookmark_count" AS t2_r15, "posts"."score" AS t2_r16, "posts"."reads" AS t2_r17, "posts"."post_type" AS t2_r18, "posts"."sort_order" AS t2_r19, "posts"."last_editor_id" AS t2_r20, "posts"."hidden" AS t2_r21, "posts"."hidden_reason_id" AS t2_r22, "posts"."notify_moderators_count" AS t2_r23, "posts"."spam_count" AS t2_r24, "posts"."illegal_count" AS t2_r25, "posts"."inappropriate_count" AS t2_r26, "posts"."last_version_at" AS t2_r27, "posts"."user_deleted" AS t2_r28, "posts"."reply_to_user_id" AS t2_r29, "posts"."percent_rank" AS t2_r30, "posts"."notify_user_count" AS t2_r31, "posts"."like_score" AS t2_r32, "posts"."deleted_by_id" AS t2_r33, "posts"."edit_reason" AS t2_r34, "posts"."word_count" AS t2_r35, "posts"."version" AS t2_r36, "posts"."cook_method" AS t2_r37, "posts"."wiki" AS t2_r38, "posts"."baked_at" AS t2_r39, "posts"."baked_version" AS t2_r40, "posts"."hidden_at" AS t2_r41, "posts"."self_edits" AS t2_r42, "posts"."reply_quoted" AS t2_r43, "posts"."via_email" AS t2_r44, "posts"."raw_email" AS t2_r45, "posts"."public_version" AS t2_r46, "posts"."action_code" AS t2_r47, "posts"."locked_by_id" AS t2_r48, "posts"."image_upload_id" AS t2_r49 FROM "topic_links" INNER JOIN "topics" ON "topics"."id" = "topic_links"."topic_id" AND ("topics"."deleted_at" IS NULL) INNER JOIN "posts" ON "posts"."id" = "topic_links"."post_id" AND ("posts"."deleted_at" IS NULL) WHERE (posts.post_type IN (1,2,3,4)) AND ("topics"."deleted_at" IS NULL) AND (topics.archetype <> 'private_message') AND "topics"."visible" = TRUE AND (topics.category_id IS NULL OR topics.category_id IN (SELECT id FROM categories WHERE NOT read_restricted OR id IN (3,4))) AND "topic_links"."user_id" = 7237 AND "topic_links"."internal" = FALSE AND "topic_links"."reflection" = FALSE AND "topic_links"."quote" = FALSE ORDER BY clicks DESC, topic_links.created_at DESC LIMIT 6

사용자 페이지를 로드하는 동안 서버를 모니터링해 보면, 쿼리가 실행되는 동안 postgresql 프로세스의 CPU 사용량이 급격히 상승하는 것 같습니다. 가져오기 프로세스를 위해 MySQL 지원을 추가한 것 외에는 설치에 대한 설정 변경을 하지 않았습니다. 다음 단계에 대한 조언을 주시면 감사하겠습니다!

7개의 좋아요

참고로 여기에 남겨 둡니다. 데이터베이스에 4개의 인덱스를 추가했는데, 성능이 크게 향상되었습니다. 다만 아직 더 개선할 여지가 있다고 생각합니다. 추가한 내용은 다음과 같습니다:

CREATE INDEX index_topic_links_on_clicks_and_created ON public.topic_links USING btree (clicks, created_at);
CREATE INDEX index_posts_on_like_count_and_created ON public.posts USING btree (like_count, created_at);
CREATE INDEX index_topic_links_on_clicks_and_created_desc ON public.topic_links USING btree (clicks DESC, created_at DESC);
CREATE INDEX index_posts_on_like_count_and_created_desc ON public.posts USING btree (like_count DESC, created_at DESC, user_id) WHERE deleted_at IS NULL AND post_number > 1 AND (post_type = ANY ('{1,2,3,4}'::integer[]));
6개의 좋아요

음, 여기 인덱스가 몇 개 빠진 건 아닌가요 @tgxworld?

2개의 좋아요

@Ghan 님,

해당 인덱스들을 정말 감사드립니다. 저희도 같은 문제를 꽤 오랫동안 겪고 있었으나 해결하지 못했습니다.
해당 인덱스를 적용한 후 사용자 프로필 로딩이 크게 개선되었습니다. 최대 30초까지 걸리던 최악의 경우 프로필들이 이제 약 5초 만에 표시됩니다.

다시 한번 감사드리며, 이 부분을 더 빠르게 개선할 여지가 있기를 바랍니다.

감사합니다.
Sascha

P.S. /about 페이지도 로딩에 최대 약 20초가 걸립니다. 이 부분도 속도 개선이 가능할 수 있습니다.

1개의 좋아요

다시 묻습니다. @tgxworld @sam 이 인덱스가 빠져 있나요?

2개의 좋아요

해당 쿼리들의 실행 계획을 Meta에서 확인해 보겠습니다.

Meta에서는 쿼리가 상당히 빠르게 실행되지만, 성능이 낮은 데이터베이스에서는 성능이 크게 저하될 수 있다는 점을 이해할 수 있습니다. 우리는 ActiveRecord를 사용하고 있으며 코드 자체는 꽤 깔끔하지만, 생성되는 SQL은 다소 지저분합니다.

SQL을 수동으로 작성하면 기존 인덱스를 활용하여 쿼리를 크게 최적화할 수 있습니다:

최적화 전

최적화 후

최적화된 쿼리에서는 해당 사용자의 게시글만 가져오도록 하여, 조인(join)이 너무 늦게 발생하는 게시글 루프를 제한하고 있습니다.

따라서 인덱스 오버헤드를 추가하지 않고도 더 나은 성능을 얻을 수 있다고 생각합니다.

7개의 좋아요

재작성된 버전을 ActiveRecord 코드로 작성할 수 있나요, 아니면 여기에 수동 쿼리 문자열을 전달해야 하나요?

그렇게 되기를 바랍니다. 현재 Ruby 코드는 정말 아름답거든요. 그렇지 않다면, 앱에서 SQL을 처리하는 아주 단순한 패턴이 이미 널리 사용되고 있습니다.

1개의 좋아요

사용자 요약 페이지에서 실행되는 많은 쿼리들에 인덱스가 누락된 것 같습니다. 이 쿼리들을 하나씩 최적화해야 할 것 같습니다.

@Falco 본인이 이 작업을 담당하기로 표시하셨는데, 계속 진행하시겠습니까? 여기 어려운 점은 이 쿼리들이 대부분 테스트가 안 된 상태라, 다른 부분에 문제가 생기지 않도록 주의해야 한다는 것입니다.

3개의 좋아요

눈을 감고 진행하지 않도록, 직렬화 출력을 테스트하기 위한 초안 커밋을 여기에 추가했습니다:

그 후, 여기에서 했던 작업을 다시 적용해 보았지만, 결과적으로 빠지긴 하지만 정확하지 않은 추가 절(clause)을 붙이고 있었던 것으로 드러났습니다.

가장 많이 좋아요 받은 항목/좋아요를 누른 사용자 등 이 모든 정보를 guardian을 통해 전달한다는 것은 정보가 항상 매우 정확하고 시청자(사용자)에 맞게 맞춤화되어 있다는 것을 의미하지만, 여러 사용자를 대상으로 할 때 캐싱이 도움이 되지 못하게 만듭니다.

최소한 이 엔드포인트에 더 긴 캐시 시간을 추가할 수 있을 것 같습니다. 제 생각에는 요약 정보의 내용이 자주 변경되지 않으므로 1시간 캐시가 적절할 것입니다.

4개의 좋아요

좋습니다. 그렇게 처리하겠습니다!

1개의 좋아요

캐시를 PR 브랜치에 push했습니다:

추가로 디버깅하던 중, 여기서도 N+1 문제가 몇 군데 있다는 것을 발견했습니다. 썸네일(Thumbnails)과 Solved가 토픽 목록에서 N+1을 유발하고 있습니다. @david @tgxworld 혹시 아이디어가 있으신가요?

2개의 좋아요

캐시 작업을 병합했습니다.

N+1 문제는 아직 작업 중입니다.

7개의 좋아요

사용자 요약 페이지에서 토픽 목록에 어떤 직렬화기를 사용하고 있나요? 전체 페이지 검색의 경우, 검색 직렬화기에서 thumbnails 속성을 제거하여 최근 N+1 문제를 해결했습니다. 해당 속성은 전혀 사용되지 않기 때문입니다.

3개의 좋아요

PERF: Preload topic thumbnails for all topic lists - Pull Request #11238 - discourse/discourse - GitHub 와 PERF: Simplify topic serialization for user summary page - Pull Request #11236 - discourse/discourse - GitHub 에서 수정되었습니다.

4개의 좋아요