You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

PostgreSQL分组获取订阅用户最新50条帖子的查询优化

问题背景

现有用户、帖子(blog_posts)、订阅(subscriptions)三张表,用户可订阅其他用户并查看其最新帖子。原查询语句如下:

SELECT "blog_posts".* 
FROM "blog_posts" 
WHERE (user_id IN (SELECT subscribed_to_id FROM subscriptions WHERE subscriber_id = ?)) 
ORDER BY "blog_posts"."created_at" LIMIT 50

该查询用于获取当前用户订阅对象的所有帖子,再按created_at排序取前50条。但随着帖子数量增长(测试库含10000名用户,每人4000条帖子),当用户订阅多名其他用户时,查询计划会加载所有订阅用户的帖子后进行内存排序,性能随数据量增大持续下降。

尝试添加包含created_at的索引后未被PostgreSQL使用,改用CROSS JOIN LATERAL查询:

SELECT b.*
FROM   subscriptions s
CROSS JOIN LATERAL (
   SELECT *
   FROM blog_posts b
   WHERE  s.subscriber_id = 100 
   AND b.user_id = s.subscribed_to_id
   ORDER  BY created_at DESC LIMIT 50
   ) b
ORDER BY created_at DESC LIMIT 50

虽减少了帖子表的读取行数,速度有所提升,但会全表扫描subscriptions表,查询成本极高。

现寻求纯SQL优化方案,在保证获取最新50条帖子的同时,控制读取行数。


表结构

Posts表

CREATE TABLE blog_posts (
    id bigint DEFAULT nextval('blog_posts_id_seq'::regclass) PRIMARY KEY,
    message character varying(280) NOT NULL,
    user_id bigint NOT NULL REFERENCES users(id),
    created_at timestamp(6) without time zone NOT NULL,
    updated_at timestamp(6) without time zone NOT NULL
);

-- 索引 -------------------------------------------------------

CREATE UNIQUE INDEX blog_posts_pkey ON blog_posts(id int8_ops);
CREATE INDEX index_posts_on_user_id ON blog_posts(user_id int8_ops);
CREATE INDEX idx_posts_user_created ON blog_posts(user_id int8_ops,created_at timestamp_ops DESC);

(注:原代码中重复了created_at timestamp_ops DESC);,已修正)

Users表

CREATE TABLE users (
    id BIGSERIAL PRIMARY KEY,
    email text NOT NULL,
    created_at timestamp(6) without time zone NOT NULL,
    updated_at timestamp(6) without time zone NOT NULL
);

-- 索引 -------------------------------------------------------

CREATE UNIQUE INDEX users_pkey ON users(id int8_ops);

Subscriptions表

CREATE TABLE subscriptions (
    id BIGSERIAL PRIMARY KEY,
    subscriber_id bigint REFERENCES users(id),
    subscribed_to_id bigint REFERENCES users(id),
    created_at timestamp(6) without time zone NOT NULL,
    updated_at timestamp(6) without time zone NOT NULL
);

-- 索引 -------------------------------------------------------

CREATE UNIQUE INDEX subscriptions_pkey ON subscriptions(id int8_ops);
CREATE INDEX index_subscriptions_on_subscribed_to_id ON subscriptions(subscribed_to_id int8_ops);
CREATE UNIQUE INDEX index_subscriptions_on_subscriber_id_and_subscribed_to_id ON subscriptions(subscriber_id int8_ops,subscribed_to_id int8_ops);
CREATE INDEX index_subscriptions_on_subscriber_id ON subscriptions(subscriber_id int8_ops);

原查询执行计划
Limit  (cost=74084.78..74084.90 rows=50 width=57) (actual time=119.681..119.696 rows=50 loops=1)
  Buffers: shared hit=16 read=28029 written=3
  ->  Sort  (cost=74084.78..74134.70 rows=19971 width=57) (actual time=119.679..119.687 rows=50 loops=1)
          Sort Key: blog_posts.created_at
          Sort Method: top-N heapsort Memory: 37kB
        Buffers: shared hit=16 read=28029 written=3
        ->  Nested Loop  (cost=51.68..73421.35 rows=19971 width=57) (actual time=1.389..112.820 rows=28000 loops=1)
              Buffers: shared hit=16 read=28029 written=3
              ->  Index Only Scan using index_subscriptions_on_subscriber_id_and_subscribed_to_id on subscriptions subscriptions  (cost=0.29..4.38 rows=5 width=8) (actual time=0.535..0.549 rows=7 loops=1)
                      Index Cond: (subscriptions.subscriber_id = 9999)
                    Buffers: shared hit=1 read=2

              ->  Bitmap Heap Scan on blog_posts blog_posts  (cost=51.39..14643.46 rows=3994 width=57) (actual time=1.295..15.097 rows=4000 loops=7)
                      Recheck Cond: (blog_posts.user_id = subscriptions.subscribed_to_id)
                      Heap Blocks: exact=28000
                    Buffers: shared hit=15 read=28027 written=3
                    ->  Bitmap Index Scan on index_posts_on_user_id  (cost=0.00..50.39 rows=3994 width=0) (actual time=0.693..0.693 rows=4000 loops=7)
                            Index Cond: (blog_posts.user_id = subscriptions.subscribed_to_id)
                          Buffers: shared hit=7 read=35

Planning:
  Buffers: shared hit=24 read=14
Execution time: 119.728 ms

CROSS JOIN LATERAL查询执行计划
Limit  (cost=10163257.73..10163257.85 rows=50 width=57) (actual time=31.224..31.238 rows=50 loops=1)
  Buffers: shared hit=741
  ->  Sort  (cost=10163257.73..10169483.10 rows=2490150 width=57) (actual time=31.222..31.230 rows=50 loops=1)
          Sort Key: b.created_at DESC
          Sort Method: top-N heapsort Memory: 35kB
        Buffers: shared hit=741
        ->  Nested Loop  (cost=0.56..10080536.74 rows=2490150 width=57) (actual time=0.296..31.139 rows=300 loops=1)
              Buffers: shared hit=741
              ->  Seq Scan on subscriptions s  (cost=0.00..914.03 rows=49803 width=16) (actual time=0.007..4.748 rows=49803 loops=1)
                    Buffers: shared hit=416
              ->  Limit  (cost=0.56..201.39 rows=50 width=57) (actual time=0.000..0.000 rows=0 loops=49803)
                    Buffers: shared hit=325
                    ->  Result  (cost=0.56..16042.46 rows=3994 width=57) (actual time=0.000..0.000 rows=0 loops=49803)
                          Buffers: shared hit=325
                          ->  Index Scan using idx_posts_user_created on blog_posts b  (cost=0.56..16042.46 rows=3994 width=57) (actual time=0.007..0.041 rows=50 loops=6)
                                  Index Cond: (b.user_id = s.subscribed_to_id)
                                Buffers: shared hit=325
Execution time: 31.267 ms

优化方案

方案1:修正LATERAL查询,避免全表扫描订阅表

原LATERAL查询未过滤当前订阅者,导致全表扫描subscriptions。调整后先通过索引快速获取当前用户的订阅列表,再关联子查询:

SELECT b.*
FROM   (SELECT subscribed_to_id FROM subscriptions WHERE subscriber_id = ?) s
CROSS JOIN LATERAL (
   SELECT *
   FROM blog_posts b
   WHERE  b.user_id = s.subscribed_to_id
   ORDER  BY created_at DESC LIMIT 50
) b
ORDER BY b.created_at DESC LIMIT 50;

该版本利用订阅表索引快速定位目标订阅对象,针对每个对象取最新50条帖子,最后全局排序取前50,兼顾性能与准确性。

方案2:窗口函数+复合索引优化(订阅用户较多场景)

若订阅用户数量大,LATERAL查询开销较高,可结合窗口函数与现有复合索引:

WITH ranked_posts AS (
    SELECT 
        bp.*,
        ROW_NUMBER() OVER (PARTITION BY bp.user_id ORDER BY bp.created_at DESC) AS rn
    FROM blog_posts bp
    JOIN subscriptions s ON bp.user_id = s.subscribed_to_id
    WHERE s.subscriber_id = ?
)
SELECT * FROM ranked_posts 
WHERE rn <= 50
ORDER BY created_at DESC LIMIT 50;

利用idx_posts_user_created索引快速按用户分组排序,窗口函数限制每个用户仅取最新50条,最后全局排序取结果,减少无效数据读取。

方案3:强制索引引导查询计划

若PostgreSQL未自动选择最优索引,可尝试强制使用复合索引或改写JOIN语句:

-- 强制使用复合索引
SELECT "blog_posts".* 
FROM "blog_posts" 
WHERE user_id IN (SELECT subscribed_to_id FROM subscriptions WHERE subscriber_id = ?) 
ORDER BY "blog_posts"."created_at" DESC LIMIT 50
INDEX idx_posts_user_created;

或改写为JOIN形式引导优化器:

SELECT bp.*
FROM subscriptions s
JOIN blog_posts bp ON s.subscribed_to_id = bp.user_id
WHERE s.subscriber_id = ?
ORDER BY bp.created_at DESC LIMIT 50;

这种写法更易让优化器选择按created_at排序的索引,降低排序开销。


内容的提问来源于stack exchange,提问作者Seanvm

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.30 23:18:09