Skip to main content
Septvean's Documents
Toggle Dark/Light/Auto mode Toggle Dark/Light/Auto mode Toggle Dark/Light/Auto mode Back to homepage

PostgreSQL 多表查询

多表查询的核心,是根据表与表之间的关系,把不同表中的行组合成新的结果集。

例如:

股票基本信息 + 每日行情 + 公司资料 + 题材概念

PostgreSQL 主要通过以下方式实现:

  • JOIN:横向组合字段
  • 子查询:使用另一个查询的结果
  • EXISTS:判断关联记录是否存在
  • LATERAL:为左表每行执行关联子查询
  • UNION:纵向合并多组结果
  • CTE:拆分复杂查询步骤

官方教程见 PostgreSQL 18 多表连接


一、示例表关系

本文使用五张表:

erDiagram
    SECURITY ||--o| SECURITY_PROFILE : "一对一"
    SECURITY ||--o{ DAILY_QUOTE : "一对多"
    SECURITY ||--o{ SECURITY_CONCEPT : "关联"
    CONCEPT ||--o{ SECURITY_CONCEPT : "关联"

    SECURITY {
        bigint security_id PK
        varchar code
        text name
    }
    SECURITY_PROFILE {
        bigint security_id PK,FK
        text primary_business
    }
    DAILY_QUOTE {
        bigint security_id PK,FK
        date trade_date PK
        numeric close_price
    }
    CONCEPT {
        bigint concept_id PK
        text name
    }
    SECURITY_CONCEPT {
        bigint security_id PK,FK
        bigint concept_id PK,FK
    }

关系类型:

关系 示例
一对一 一只股票对应一份公司资料
一对多 一只股票对应多天行情
多对多 一只股票属于多个题材,一个题材包含多只股票

二、JOIN 基本语法

SELECT
    1.字段,
    2.字段
FROM 1
JOIN 2
    ON 1.关联字段 = 2.关联字段;

查询股票及其行情:

SELECT
    s.code,
    s.name,
    q.trade_date,
    q.close_price
FROM market.security AS s
JOIN market.daily_quote AS q
    ON q.security_id = s.security_id;

这里:

  • s:股票基本信息
  • q:日线行情
  • security_id:两张表的关联字段
  • 一只股票有多条行情,所以股票名称会在结果中重复出现

多表查询中,建议始终使用表别名和限定字段名:

s.code
q.close_price

避免直接写:

code
close_price

这样即使以后多张表出现同名字段,SQL 仍然清晰。


三、JOIN 类型对比

JOIN 类型 返回结果
INNER JOIN 只返回左右两边匹配的记录
LEFT JOIN 返回左表全部记录,以及匹配的右表记录
RIGHT JOIN 返回右表全部记录,以及匹配的左表记录
FULL JOIN 返回两边全部记录
CROSS JOIN 返回左右表所有组合
Self Join 同一张表与自己关联

四、INNER JOIN:内连接

SELECT
    s.code,
    s.name,
    q.trade_date,
    q.close_price,
    q.change_rate
FROM market.security AS s
INNER JOIN market.daily_quote AS q
    ON q.security_id = s.security_id
WHERE q.trade_date = DATE '2026-09-14'
ORDER BY q.change_rate DESC;

INNER JOIN 只返回:

股票表中存在
并且
行情表中也存在

如果某只股票在指定交易日没有行情,它不会出现在结果中。

INNER 可以省略:

JOIN market.daily_quote AS q

等价于:

INNER JOIN market.daily_quote AS q

五、LEFT JOIN:左连接

查询全部正常股票,以及指定日期的行情:

SELECT
    s.code,
    s.name,
    q.trade_date,
    q.close_price,
    q.change_rate
FROM market.security AS s
LEFT JOIN market.daily_quote AS q
    ON q.security_id = s.security_id
   AND q.trade_date = DATE '2026-09-14'
WHERE s.is_active IS TRUE
ORDER BY s.code;

结果包括:

  • 当天有行情的股票
  • 当天没有行情的股票

没有行情的股票对应字段为:

q.trade_date   = NULL
q.close_price  = NULL
q.change_rate  = NULL

LEFT JOIN 适合:

  • 查询所有股票,包括无行情股票
  • 查询所有用户,包括无订单用户
  • 查询所有题材,包括没有成分股的题材
  • 查找缺失数据

六、ONWHERE 的重要区别

下面两条语句看起来相似,结果却不同。

保留没有行情的股票

SELECT
    s.code,
    s.name,
    q.close_price
FROM market.security AS s
LEFT JOIN market.daily_quote AS q
    ON q.security_id = s.security_id
   AND q.trade_date = DATE '2026-09-14';

日期条件位于 ON,表示:

只关联指定日期的行情,但仍保留左表中的所有股票。


过滤掉没有行情的股票

SELECT
    s.code,
    s.name,
    q.close_price
FROM market.security AS s
LEFT JOIN market.daily_quote AS q
    ON q.security_id = s.security_id
WHERE q.trade_date = DATE '2026-09-14';

没有行情的股票,其 q.trade_dateNULL,会被 WHERE 过滤掉。

结果实际上接近:

INNER JOIN

原则:

  • 表之间如何匹配:放在 ON
  • 最终结果如何筛选:放在 WHERE
  • LEFT JOIN 中需要保留左表未匹配行时,右表条件通常放在 ON

PostgreSQL 官方文档明确说明,外连接中条件放在 ONWHERE 会产生不同结果。JOIN 表达式


七、找出没有关联数据的记录

方法一:LEFT JOIN

查询指定日期没有行情的股票:

SELECT
    s.security_id,
    s.code,
    s.name
FROM market.security AS s
LEFT JOIN market.daily_quote AS q
    ON q.security_id = s.security_id
   AND q.trade_date = DATE '2026-09-14'
WHERE q.security_id IS NULL;

之所以检查:

q.security_id IS NULL

是因为该字段在真实行情记录中定义为 NOT NULL


方法二:NOT EXISTS

SELECT
    s.security_id,
    s.code,
    s.name
FROM market.security AS s
WHERE NOT EXISTS (
    SELECT 1
    FROM market.daily_quote AS q
    WHERE q.security_id = s.security_id
      AND q.trade_date = DATE '2026-09-14'
);

NOT EXISTS 更直接地表达:

不存在满足条件的关联行情。

在只需要判断存在与否、不需要返回右表字段时,通常优先考虑 EXISTSNOT EXISTS


八、RIGHT JOIN:右连接

SELECT
    s.code,
    s.name,
    i.name AS imported_name
FROM market.security AS s
RIGHT JOIN staging.security_import AS i
    ON i.exchange_code = s.exchange_code
   AND i.code = s.code;

它保留右表 security_import 的全部记录。

多数情况下可以交换表顺序,改写为更容易理解的 LEFT JOIN

SELECT
    s.code,
    s.name,
    i.name AS imported_name
FROM staging.security_import AS i
LEFT JOIN market.security AS s
    ON s.exchange_code = i.exchange_code
   AND s.code = i.code;

实际项目中 LEFT JOIN 通常比 RIGHT JOIN 更常用。


九、FULL JOIN:全连接

FULL JOIN 返回:

  • 两边都匹配的数据
  • 只存在于左表的数据
  • 只存在于右表的数据

适合核对两个数据源。

SELECT
    coalesce(s.exchange_code, i.exchange_code) AS exchange_code,
    coalesce(s.code, i.code) AS code,

    s.name AS database_name,
    i.name AS imported_name,

    CASE
        WHEN s.security_id IS NULL
            THEN '仅导入表存在'

        WHEN i.code IS NULL
            THEN '仅正式表存在'

        WHEN s.name IS DISTINCT FROM i.name
            THEN '数据不一致'

        ELSE '数据一致'
    END AS compare_status

FROM market.security AS s
FULL JOIN staging.security_import AS i
    ON i.exchange_code = s.exchange_code
   AND i.code = s.code

ORDER BY
    exchange_code,
    code;

这里使用:

IS DISTINCT FROM

可以正确比较包含 NULL 的字段。

使用 FULL JOIN 核对数据前,应保证两边业务键唯一。否则一边存在重复代码时,结果可能成倍增加。


十、CROSS JOIN:交叉连接

SELECT *
FROM table_a
CROSS JOIN table_b;

如果:

table_a 有 100 行
table_b 有 200 行

结果为:

100 × 200 = 20,000 行

这就是笛卡尔积。


实际用途:生成应有行情组合

假设交易日历表:

market.trading_calendar
├── trade_date
└── is_open

生成“所有正常股票 × 所有交易日”:

SELECT
    s.security_id,
    s.code,
    c.trade_date
FROM market.security AS s
CROSS JOIN market.trading_calendar AS c
WHERE s.is_active IS TRUE
  AND c.is_open IS TRUE;

继续关联行情,查找缺失数据:

SELECT
    s.code,
    c.trade_date
FROM market.security AS s
CROSS JOIN market.trading_calendar AS c
LEFT JOIN market.daily_quote AS q
    ON q.security_id = s.security_id
   AND q.trade_date = c.trade_date
WHERE s.is_active IS TRUE
  AND c.is_open IS TRUE
  AND q.security_id IS NULL
ORDER BY
    c.trade_date,
    s.code;

交叉连接必须评估结果规模:

5000只股票 × 2500个交易日 = 1250万行

十一、自连接:同一张表关联自己

比较两个交易日的行情:

SELECT
    s.code,
    s.name,

    previous.close_price AS previous_close,
    current.close_price AS current_close,

    current.close_price / nullif(previous.close_price, 0) - 1
        AS calculated_change_rate

FROM market.daily_quote AS current

JOIN market.daily_quote AS previous
    ON previous.security_id = current.security_id
   AND previous.trade_date = DATE '2026-09-11'

JOIN market.security AS s
    ON s.security_id = current.security_id

WHERE current.trade_date = DATE '2026-09-14'

ORDER BY calculated_change_rate DESC;

虽然 currentprevious 都来自 daily_quote,但别名使它们成为两个不同的查询角色。

如果要自动寻找每条记录的上一个交易日,窗口函数 lag() 通常比自连接更合适。


十二、ONUSINGNATURAL

1. ON

最明确、最灵活:

JOIN market.daily_quote AS q
    ON q.security_id = s.security_id

多字段业务键:

JOIN staging.security_import AS i
    ON i.exchange_code = s.exchange_code
   AND i.code = s.code

2. USING

当两张表中的关联字段名称相同时:

SELECT
    security_id,
    s.code,
    q.trade_date,
    q.close_price
FROM market.security AS s
JOIN market.daily_quote AS q
USING (security_id);

它等价于:

ON s.security_id = q.security_id

区别是 USING 在结果中只保留一个 security_id

多字段:

JOIN another_table
USING (exchange_code, code)

3. 不建议 NATURAL JOIN

SELECT *
FROM market.security
NATURAL JOIN market.daily_quote;

NATURAL JOIN 会自动使用两张表中所有同名字段关联。

如果以后两张表都增加:

created_at
updated_at

查询关联条件可能在没有修改 SQL 的情况下改变。

如果两张表没有同名字段,NATURAL JOIN 甚至会表现得像 CROSS JOIN。因此生产代码应明确使用 ONUSING


十三、同时连接多张表

查询股票、公司资料和指定日期行情:

SELECT
    s.code,
    s.name,
    s.industry,

    p.company_feature,
    p.primary_business,

    q.trade_date,
    q.close_price,
    q.change_rate

FROM market.security AS s

LEFT JOIN market.security_profile AS p
    ON p.security_id = s.security_id

LEFT JOIN market.daily_quote AS q
    ON q.security_id = s.security_id
   AND q.trade_date = DATE '2026-09-14'

WHERE s.is_active IS TRUE

ORDER BY s.code;

这里:

  • security 是主表
  • security_profile 是可选的一对一资料
  • daily_quote 是指定日期的一条行情
  • 两个 LEFT JOIN 保证资料或行情缺失时仍保留股票

十四、多对多查询

股票与题材通过中间表关联:

security
security_concept
concept

查询“机器人”题材股票:

SELECT
    s.security_id,
    s.code,
    s.name,
    c.name AS concept_name

FROM market.security AS s

JOIN market.security_concept AS sc
    ON sc.security_id = s.security_id

JOIN market.concept AS c
    ON c.concept_id = sc.concept_id

WHERE c.name = '机器人'
  AND s.is_active IS TRUE

ORDER BY s.code;

中间表负责保存:

security_id + concept_id

不要把题材保存成:

机器人,人工智能,算力

这种逗号分隔字符串不适合关系查询。


查询一只股票的所有题材

SELECT
    s.code,
    s.name,
    c.name AS concept_name
FROM market.security AS s
JOIN market.security_concept AS sc
    ON sc.security_id = s.security_id
JOIN market.concept AS c
    ON c.concept_id = sc.concept_id
WHERE s.exchange_code = 'SSE'
  AND s.code = '600519'
ORDER BY c.name;

聚合成一行

SELECT
    s.security_id,
    s.code,
    s.name,

    string_agg(
        c.name,
        '、'
        ORDER BY c.name
    ) AS concepts

FROM market.security AS s

LEFT JOIN market.security_concept AS sc
    ON sc.security_id = s.security_id

LEFT JOIN market.concept AS c
    ON c.concept_id = sc.concept_id

WHERE s.exchange_code = 'SSE'
  AND s.code = '600519'

GROUP BY
    s.security_id,
    s.code,
    s.name;

返回类似:

600519 | 贵州茅台 | 国企、白酒、消费

聚合为数组:

coalesce(
    array_agg(
        c.name
        ORDER BY c.name
    ) FILTER (
        WHERE c.concept_id IS NOT NULL
    ),
    ARRAY[]::text[]
) AS concepts

同时属于两个题材

查询同时属于“机器人”和“人工智能”的股票:

SELECT
    s.security_id,
    s.code,
    s.name
FROM market.security AS s
JOIN market.security_concept AS sc
    ON sc.security_id = s.security_id
JOIN market.concept AS c
    ON c.concept_id = sc.concept_id
WHERE c.name IN ('机器人', '人工智能')
GROUP BY
    s.security_id,
    s.code,
    s.name
HAVING count(DISTINCT c.name) = 2;

如果查询三个指定题材,应相应改为:

HAVING count(DISTINCT c.name) = 3

十五、只判断是否存在:EXISTS

查询属于“机器人”题材的股票,但不需要返回题材字段:

SELECT
    s.security_id,
    s.code,
    s.name
FROM market.security AS s
WHERE EXISTS (
    SELECT 1
    FROM market.security_concept AS sc
    JOIN market.concept AS c
        ON c.concept_id = sc.concept_id
    WHERE sc.security_id = s.security_id
      AND c.name = '机器人'
);

使用普通 JOIN 时,一只股票匹配多个关联记录,就会出现多行;EXISTS 只回答“有没有”,不会因为右表有多条记录而重复左表。

不要为了消除这种重复就机械地添加:

SELECT DISTINCT

应先判断业务真正需要的是:

  • 返回关联明细:使用 JOIN
  • 判断关联是否存在:使用 EXISTS

PostgreSQL 子查询表达式


十六、查询每只股票最新行情

方法一:DISTINCT ON

WITH latest_quote AS (
    SELECT DISTINCT ON (q.security_id)
        q.security_id,
        q.trade_date,
        q.close_price,
        q.change_rate,
        q.volume
    FROM market.daily_quote AS q
    ORDER BY
        q.security_id,
        q.trade_date DESC
)
SELECT
    s.code,
    s.name,
    l.trade_date,
    l.close_price,
    l.change_rate,
    l.volume
FROM market.security AS s
LEFT JOIN latest_quote AS l
    ON l.security_id = s.security_id
WHERE s.is_active IS TRUE
ORDER BY s.code;

方法二:窗口函数

WITH ranked_quote AS (
    SELECT
        q.*,
        row_number() OVER (
            PARTITION BY q.security_id
            ORDER BY q.trade_date DESC
        ) AS row_num
    FROM market.daily_quote AS q
)
SELECT
    s.code,
    s.name,
    q.trade_date,
    q.close_price,
    q.change_rate
FROM market.security AS s
LEFT JOIN ranked_quote AS q
    ON q.security_id = s.security_id
   AND q.row_num = 1
ORDER BY s.code;

注意 q.row_num = 1 放在 ON 中,才能保留没有行情的股票。


方法三:LATERAL

SELECT
    s.code,
    s.name,
    latest.trade_date,
    latest.close_price,
    latest.change_rate
FROM market.security AS s

LEFT JOIN LATERAL (
    SELECT
        q.trade_date,
        q.close_price,
        q.change_rate
    FROM market.daily_quote AS q
    WHERE q.security_id = s.security_id
    ORDER BY q.trade_date DESC
    LIMIT 1
) AS latest ON true

WHERE s.is_active IS TRUE
ORDER BY s.code;

LATERAL 允许右侧子查询引用左侧当前股票:

q.security_id = s.security_id

它适合:

  • 每只股票最新一条行情
  • 每个用户最后一笔订单
  • 每个分类排名前3的商品
  • 每个父记录的局部 Top N

每只股票最近3条行情

SELECT
    s.code,
    s.name,
    recent.trade_date,
    recent.close_price
FROM market.security AS s

LEFT JOIN LATERAL (
    SELECT
        q.trade_date,
        q.close_price
    FROM market.daily_quote AS q
    WHERE q.security_id = s.security_id
    ORDER BY q.trade_date DESC
    LIMIT 3
) AS recent ON true

WHERE s.is_active IS TRUE

ORDER BY
    s.code,
    recent.trade_date DESC;

十七、多表聚合中的重复放大

这是多表查询最常见、也最隐蔽的错误。

假设一只股票有:

100条行情
5个题材

直接连接:

FROM security AS s
JOIN daily_quote AS q
    ON q.security_id = s.security_id
JOIN security_concept AS sc
    ON sc.security_id = s.security_id

结果可能产生:

100 × 5 = 500行

如果再执行:

sum(q.amount)

成交额可能被重复计算5次。


错误示例

SELECT
    s.code,
    sum(q.amount) AS total_amount,
    count(sc.concept_id) AS concept_count
FROM market.security AS s
JOIN market.daily_quote AS q
    ON q.security_id = s.security_id
JOIN market.security_concept AS sc
    ON sc.security_id = s.security_id
GROUP BY s.code;

两个一对多关系互相相乘。


正确方法:分别聚合后再关联

WITH quote_summary AS (
    SELECT
        security_id,
        sum(amount) AS total_amount,
        count(*) AS quote_count
    FROM market.daily_quote
    GROUP BY security_id
),
concept_summary AS (
    SELECT
        security_id,
        count(*) AS concept_count
    FROM market.security_concept
    GROUP BY security_id
)
SELECT
    s.code,
    s.name,

    coalesce(q.total_amount, 0) AS total_amount,
    coalesce(q.quote_count, 0) AS quote_count,
    coalesce(c.concept_count, 0) AS concept_count

FROM market.security AS s

LEFT JOIN quote_summary AS q
    ON q.security_id = s.security_id

LEFT JOIN concept_summary AS c
    ON c.security_id = s.security_id

ORDER BY s.code;

核心原则:

连接前先确认每个查询结果的粒度。需要“一只股票一行”,就先把行情和题材分别聚合到“一只股票一行”,再进行连接。


十八、COUNT(*) 与外连接

统计每只股票的行情条数:

SELECT
    s.code,
    s.name,
    count(q.security_id) AS quote_count
FROM market.security AS s
LEFT JOIN market.daily_quote AS q
    ON q.security_id = s.security_id
GROUP BY
    s.security_id,
    s.code,
    s.name;

这里必须使用:

count(q.security_id)

如果使用:

count(*)

即使股票没有行情,LEFT JOIN 仍会产生一条左表保留行,所以可能得到:

quote_count = 1

count(q.security_id) 只统计右表非空匹配记录,没有行情时正确返回零。


十九、子查询与 JOIN 的选择

JOIN

适合:

  • 需要返回右表字段
  • 需要返回关联明细
  • 需要聚合关联记录
SELECT
    s.code,
    q.trade_date,
    q.close_price
FROM market.security AS s
JOIN market.daily_quote AS q
    ON q.security_id = s.security_id;

EXISTS

适合:

  • 只判断右表是否存在
  • 不希望左表因多条匹配而重复
WHERE EXISTS (...)

标量子查询

适合返回单个值:

SELECT
    s.code,
    (
        SELECT max(q.trade_date)
        FROM market.daily_quote AS q
        WHERE q.security_id = s.security_id
    ) AS last_trade_date
FROM market.security AS s;

标量子查询如果返回多行,会报错。

LATERAL

适合为左表每行查询:

  • 最新一条
  • 前N条
  • 单独聚合结果

选择方式应以查询语义和执行计划为准,不要简单认为 JOIN 或子查询一定更快。


二十、JOIN 与 UNION 的区别

操作 组合方向 作用
JOIN 横向 把不同表的字段组合在一行
UNION 纵向 把多个查询结果上下合并

JOIN

code | name | close_price

UNION

第一组股票
第二组股票
第三组股票

示例:

SELECT
    code,
    name,
    '沪市主板' AS source
FROM market.security
WHERE code LIKE '60%'

UNION ALL

SELECT
    code,
    name,
    '深市主板' AS source
FROM market.security
WHERE code LIKE '00%';

UNION 会去重:

UNION

UNION ALL 保留重复:

UNION ALL

如果不需要去重,优先使用 UNION ALL,避免额外的排序或哈希去重开销。

两边查询必须满足:

  • 字段数量相同
  • 对应字段数据类型兼容

PostgreSQL 集合查询


二十一、关联字段中的 NULL

普通关联:

ON a.industry = b.industry

如果两边都是 NULL,也不会被认为匹配,因为:

NULL = NULL

结果是未知,而不是 TRUE

如果业务明确要求两个 NULL 匹配,可以使用:

ON a.industry IS NOT DISTINCT FROM b.industry

但主外键关系通常应该:

  • 使用稳定ID
  • 字段类型一致
  • 外键列尽量 NOT NULL

不要随意使用:

ON coalesce(a.key, 0) = coalesce(b.key, 0)

这可能把原本毫无关系的空值错误地关联到一起。


二十二、关联字段类型必须一致

推荐:

security.security_id         bigint
daily_quote.security_id      bigint
security_concept.security_id bigint

不推荐:

一个表 bigint
一个表 varchar

然后查询时转换:

ON s.security_id::text = q.security_id

问题包括:

  • 类型语义混乱
  • 可能影响索引使用
  • 每次查询都需要转换
  • 更容易出现非法值

如果当前代码字段使用 char(6),另一张表使用 varchar(6),不要长期依赖:

ON btrim(a.code) = btrim(b.code)

更好的方式是清洗数据并统一表结构。


二十三、旧式隐式关联不推荐

旧式写法:

SELECT
    s.code,
    q.close_price
FROM
    market.security AS s,
    market.daily_quote AS q
WHERE q.security_id = s.security_id;

虽然结果可以与 INNER JOIN 相同,但关联条件和筛选条件混杂。

推荐:

SELECT
    s.code,
    q.close_price
FROM market.security AS s
JOIN market.daily_quote AS q
    ON q.security_id = s.security_id;

如果旧式写法漏掉关联条件,就会意外产生笛卡尔积。显式 JOIN ... ON 更容易阅读和审查。


二十四、多表查询索引设计

股票主表

PRIMARY KEY (security_id)

根据交易所和代码查询:

UNIQUE (exchange_code, code)

日线行情

PRIMARY KEY (security_id, trade_date)

支持:

WHERE security_id = ?
ORDER BY trade_date DESC

按交易日查询全市场:

CREATE INDEX idx_daily_quote_trade_security
ON market.daily_quote (trade_date, security_id);

股票题材中间表

主键:

PRIMARY KEY (security_id, concept_id)

支持查询一只股票有哪些题材。

反向查询某个题材有哪些股票:

CREATE INDEX idx_security_concept_concept_security
ON market.security_concept (
    concept_id,
    security_id
);

题材表

UNIQUE (name)

支持根据题材名称定位 concept_id

外键不会自动为引用方创建普通索引。因此应根据关联方向,为子表外键建立合适索引。


二十五、PostgreSQL 的三种主要 JOIN 算法

PostgreSQL 执行计划中常见:

算法 常见适用情况
Nested Loop 外层结果较少,内层关联字段有索引
Hash Join 较大数据集的等值连接
Merge Join 两边已排序或能有效排序的连接

例如:

Nested Loop
  -> Index Scan
  -> Index Scan

或者:

Hash Join
  -> Seq Scan
  -> Hash
       -> Seq Scan

查询中写的是逻辑关系:

JOIN ... ON ...

具体采用什么算法,由优化器根据以下信息选择:

  • 表大小
  • 过滤选择性
  • 索引
  • 字段统计信息
  • 内存参数
  • 预计成本

通常不应强制指定 JOIN 算法,应先确保表结构、统计信息、索引和 SQL 语义正确。


二十六、使用 EXPLAIN 分析多表查询

EXPLAIN
SELECT
    s.code,
    s.name,
    q.trade_date,
    q.close_price
FROM market.security AS s
JOIN market.daily_quote AS q
    ON q.security_id = s.security_id
WHERE s.exchange_code = 'SSE'
  AND q.trade_date = DATE '2026-09-14';

执行并查看真实统计:

EXPLAIN (
    ANALYZE,
    BUFFERS,
    VERBOSE
)
SELECT
    s.code,
    s.name,
    q.trade_date,
    q.close_price
FROM market.security AS s
JOIN market.daily_quote AS q
    ON q.security_id = s.security_id
WHERE s.exchange_code = 'SSE'
  AND q.trade_date = DATE '2026-09-14';

重点查看:

  • 使用了哪种 JOIN 算法
  • 是否使用目标索引
  • estimated rowsactual rows 是否接近
  • loops 是否过大
  • 是否扫描大量无效行
  • 是否产生大排序
  • 是否出现临时磁盘文件
  • Buffer 命中和读取情况
  • JOIN 后行数是否异常放大

EXPLAIN ANALYZE 会真正执行查询。PostgreSQL EXPLAIN


二十七、多表查询性能原则

  1. 主表使用主键或唯一键关联。
  2. 关联字段类型保持一致。
  3. 高频关联方向建立合适索引。
  4. 只查询需要的字段,避免多表 SELECT *
  5. 尽早过滤不需要的数据。
  6. 先确认每张表和子查询的数据粒度。
  7. 多个一对多表不要直接同时聚合。
  8. 只判断存在时使用 EXISTS
  9. 每组最新或前N条可以使用 LATERAL
  10. 不用 DISTINCT 掩盖错误关联。
  11. 避免在关联字段上执行类型转换和函数。
  12. 避免不必要的 CROSS JOIN
  13. EXPLAIN (ANALYZE, BUFFERS) 验证。
  14. 保持统计信息及时更新:
ANALYZE market.security;
ANALYZE market.daily_quote;
ANALYZE market.security_concept;

二十八、Python psycopg 3 多表查询

import psycopg


sql = """
SELECT
    s.code,
    s.name,
    q.trade_date,
    q.close_price,
    q.change_rate
FROM market.security AS s
JOIN market.daily_quote AS q
    ON q.security_id = s.security_id
WHERE s.exchange_code = %s
  AND s.code = %s
  AND q.trade_date >= %s
  AND q.trade_date < %s
ORDER BY q.trade_date DESC
"""


params = (
    "SSE",
    "600519",
    "2026-01-01",
    "2027-01-01",
)


with psycopg.connect(
    "postgresql://postgres:password@localhost:5432/stock"
) as connection:
    with connection.cursor() as cursor:
        cursor.execute(sql, params)

        for row in cursor:
            print(row)

不能拼接用户输入:

## 不推荐
sql = f"""
SELECT ...
WHERE s.code = '{code}'
"""

必须使用参数化查询。


二十九、SQLAlchemy 2.0 多表查询

from sqlalchemy import select


statement = (
    select(
        Security.code,
        Security.name,
        DailyQuote.trade_date,
        DailyQuote.close_price,
        DailyQuote.change_rate,
    )
    .join(
        DailyQuote,
        DailyQuote.security_id == Security.security_id,
    )
    .where(
        Security.exchange_code == "SSE",
        Security.code == "600519",
    )
    .order_by(
        DailyQuote.trade_date.desc(),
    )
)

rows = session.execute(statement).all()

左连接:

statement = (
    select(
        Security.code,
        Security.name,
        DailyQuote.close_price,
    )
    .outerjoin(
        DailyQuote,
        (DailyQuote.security_id == Security.security_id)
        & (DailyQuote.trade_date == trade_date),
    )
)

注意日期条件应放在 outerjoin() 的关联条件中,才能保留当天无行情的股票。


三十、完整实战:股票、最新行情、均线和题材

WITH quote_metrics AS (
    SELECT
        q.security_id,
        q.trade_date,
        q.close_price,
        q.volume,
        q.change_rate,

        avg(q.close_price) OVER (
            PARTITION BY q.security_id
            ORDER BY q.trade_date
            ROWS BETWEEN 4 PRECEDING AND CURRENT ROW
        ) AS ma5,

        avg(q.close_price) OVER (
            PARTITION BY q.security_id
            ORDER BY q.trade_date
            ROWS BETWEEN 19 PRECEDING AND CURRENT ROW
        ) AS ma20,

        avg(q.close_price) OVER (
            PARTITION BY q.security_id
            ORDER BY q.trade_date
            ROWS BETWEEN 59 PRECEDING AND CURRENT ROW
        ) AS ma60,

        count(*) OVER (
            PARTITION BY q.security_id
            ORDER BY q.trade_date
            ROWS BETWEEN 59 PRECEDING AND CURRENT ROW
        ) AS ma60_count,

        row_number() OVER (
            PARTITION BY q.security_id
            ORDER BY q.trade_date DESC
        ) AS row_num

    FROM market.daily_quote AS q
),
concept_summary AS (
    SELECT
        sc.security_id,

        string_agg(
            c.name,
            '、'
            ORDER BY c.name
        ) AS concepts

    FROM market.security_concept AS sc
    JOIN market.concept AS c
        ON c.concept_id = sc.concept_id

    GROUP BY sc.security_id
)
SELECT
    s.exchange_code,
    s.code,
    s.name,
    s.board,
    s.industry,

    q.trade_date,
    q.close_price,
    q.volume,
    q.change_rate,

    round(q.ma5, 4) AS ma5,
    round(q.ma20, 4) AS ma20,

    CASE
        WHEN q.ma60_count = 60
        THEN round(q.ma60, 4)
        ELSE NULL
    END AS ma60,

    coalesce(c.concepts, '') AS concepts

FROM market.security AS s

JOIN quote_metrics AS q
    ON q.security_id = s.security_id
   AND q.row_num = 1

LEFT JOIN concept_summary AS c
    ON c.security_id = s.security_id

WHERE s.is_active IS TRUE
  AND s.is_st IS FALSE

ORDER BY
    q.change_rate DESC NULLS LAST,
    s.code;

这条查询先分别把数据整理到正确粒度:

quote_metrics   → 每只股票选最新一行
concept_summary → 每只股票聚合为一行
security        → 每只股票一行

然后再连接,因此不会产生行情与题材相乘造成的重复统计。


三十一、多表查询检查清单

执行前检查:

  • 每张表的一行代表什么?
  • 表之间是一对一、一对多还是多对多?
  • 关联字段是否为主键、唯一键或外键?
  • 关联字段类型是否完全一致?
  • 是否需要 INNER JOIN 还是 LEFT JOIN
  • 右表筛选条件应该放在 ON 还是 WHERE
  • 是否需要保留没有关联记录的左表数据?
  • 一个左表行会匹配多少个右表行?
  • 多个一对多关系是否会相乘?
  • 是否应该先聚合再关联?
  • 只是判断存在时是否应该使用 EXISTS
  • 是否误用了 NOT IN
  • 是否使用了危险的 NATURAL JOIN
  • 是否意外产生 CROSS JOIN
  • 是否用 DISTINCT 掩盖关联错误?
  • 外键引用方是否有合适索引?
  • 是否只返回需要的字段?
  • EXPLAIN 预估行数和实际行数是否接近?
  • JOIN 后结果行数是否符合业务预期?

核心原则:

多表查询最重要的不是记住多少种 JOIN,而是先明确每张表的数据粒度,再明确“哪些行应该匹配、未匹配的行是否保留”。粒度正确,关联才不会重复;关系明确,结果才不会失真。