写这篇PostgreSQL查询笔记的起因,是前几天在群里看到有人问:select *能不能直接用,为什么有时候慢得让人抓狂,有时候又没问题。紧接着又有人贴了一行 create table tstb_user_bak_202606 as select * from tstb_user;,问这条语句能不能把表结构和数据一起备份。你看,绕来绕去又回到了SELECT上。作为一个几乎每天和PostgreSQL打交道的使用者,我想认真梳理一下SELECT查询的基础语法和条件筛选,把这些年踩过的坑、总结过的经验一并写下来。不管是刚接触PostgreSQL的新手,还是写过一阵子SQL但偶尔被条件筛选细节卡住的人,这篇文章都适合你。
1. SELECT查询的骨架:先搞清楚SQL的执行顺序和书写顺序
很多初学者一开始背SELECT语法,总是记不住各个子句的前后关系,其实是因为没有分清楚“书写顺序”和“执行顺序”是两回事。SELECT的完整形态大概是下面这个样子:
sql复制SELECT
[DISTINCT] 列名或表达式
FROM
表名
[WHERE 条件]
[GROUP BY 分组字段]
[HAVING 分组后的过滤条件]
[ORDER BY 排序字段]
[LIMIT 行数或OFFSET偏移量];
方括号里的部分都可以省略,但顺序不能乱。也就是说,你写SQL的时候,必须按照 SELECT -> FROM -> WHERE -> GROUP BY -> HAVING -> ORDER BY -> LIMIT 这个顺序来写。但是数据库引擎真正执行的时候,并不是这个顺序。
PostgreSQL的逻辑执行顺序是这样的:先执行 FROM,找到数据来源表,然后 WHERE 逐行过滤,接着 GROUP BY 分组,分组之后用 HAVING 过滤分组,再到 SELECT 计算投影列,最后才轮到 ORDER BY 排序和 LIMIT 截断。这个差异很关键,因为它解释了为什么你在 WHERE 里不能直接用 SELECT 里定义的别名,而在 ORDER BY 里却可以。举个例子:
sql复制-- 下面的写法会报错,因为WHERE执行时,别名alias_name还不存在
SELECT
user_name AS alias_name
FROM
t_user
WHERE
alias_name = '张三';
-- 下面的写法可以正常执行,因为ORDER BY在SELECT之后执行
SELECT
user_name AS alias_name
FROM
t_user
ORDER BY
alias_name;
从最小可用查询入手也很重要。最简单的SELECT语句,甚至可以不跟表产生关系,直接这样写:
sql复制SELECT 1; -- 返回一列一行的数字1
SELECT NOW(); -- 返回当前数据库时间
SELECT version(); -- 返回PostgreSQL版本号
这种写法经常用来验证数据库连接是否正常、当前会话是否可用。你写程序时的数据库连接池预热脚本,通常就是这种最简单的SELECT。
接下来就是正规用法,从表里取数据。我第一次在项目里用PostgreSQL的时候,发现很多人特别依赖 SELECT *,因为省事。我理解这种省事,但在生产环境里,这其实是个要慎重对待的习惯。
SELECT * 意味着把表的所有列都查出来。如果表只有三五个字段,什么问题都没有;但如果表里有几十个字段,其中还夹杂着重大的 TEXT 类型字段,特别是那些存了大段JSON或日志内容的列,查询会返回大量不必要的数据。对于数据库和应用程序之间传输的数据量、内存占用、网络带宽,都是浪费。更隐蔽的问题是,一旦你对表做了 ALTER TABLE ADD COLUMN 操作,SELECT * 的结果集会悄悄多出一列,如果你的应用代码是按位置取列的,字段顺序一变,程序拿到的数据就串了。
正确姿势是:只列出你真正需要的字段,并给它们起清晰易读的别名。
sql复制-- 实际开发中推荐这种写法
SELECT
user_id,
user_name,
create_time AS reg_time
FROM
t_user
WHERE
status = 1;
表名太长、多表连接时容易混淆,这时候用表别名能让SQL清爽很多。比如:
sql复制SELECT
u.user_id,
o.order_no
FROM
t_user AS u
JOIN t_order AS o ON u.user_id = o.user_id;
AS 关键字可以省略,直接写 t_user u 也可以,但我个人推荐保留 AS,可读性更好,尤其在复杂SQL里,表别名和列别名混在一起时不会看花眼。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. WHERE条件筛选的核心写法:比较运算符、模糊匹配与逻辑组合
掌握了SELECT骨架以后,真正影响查询质量的是条件筛选。把不需要的行过滤掉,让数据库只返回你关心的数据,这就是 WHERE 子句存在的意义。PostgreSQL的WHERE条件写法非常丰富,但核心无非三类:比较运算、逻辑组合、模糊匹配。
2.1 比较运算:等值、范围与集合归属
最基本的筛选就是比较运算。等值比较用单个等号,这和很多编程语言不一样。SQL里 = 是比较,不是赋值。如果你写 WHERE user_id = 100,就是找user_id等于100的记录。
sql复制-- 等值比较
SELECT
user_name
FROM
t_user
WHERE
user_id = 100;
-- 不等值比较
SELECT
user_name
FROM
t_user
WHERE
user_id <> 100;
-- 范围比较
SELECT
user_name,
age
FROM
t_user
WHERE
age BETWEEN 18 AND 30;
这里重点说说 BETWEEN AND。它其实是 age >= 18 AND age <= 30 的语法糖,是包含边界值的。如果你想要的是半开区间,比如“18到30岁,但不包含30”,那必须老老实实写成 age >= 18 AND age < 30。这个细节在统计年龄分段、订单金额区间时特别容易踩坑,尤其是报表场景,边界值重复统计会导致数据对不上。
还有一个非常高频的写法是 IN,用于判断某个字段的值是否落在给定的集合里。
sql复制SELECT
user_name,
status
FROM
t_user
WHERE
status IN ('active', 'pending');
要注意 IN 列表里的元素过多时会有性能问题。我见过有人把几千个ID放在 IN 后面,SQL文长到编辑器都卡。PostgreSQL对于这种场景虽然优化过,但列表特别大时,解析成本和哈希匹配成本都会涨上去。如果确实要传几千个ID,可以先建临时表,或者用 JOIN 的方式关联,性能会好很多。这是后话,先放在这。
2.2 模糊匹配:LIKE、ILIKE 与通配符的细节
模糊匹配是另一类高频条件。PostgreSQL里最常用的是 LIKE,配合两个通配符:百分号 % 代表任意长度的任意字符,下划线 _ 代表任意单个字符。
sql复制-- 名字以"张"开头的用户
SELECT
user_name
FROM
t_user
WHERE
user_name LIKE '张%';
-- 名字长度为2,且第二个字是"三"的用户
SELECT
user_name
FROM
t_user
WHERE
user_name LIKE '_三';
特别提醒一个细节:PostgreSQL的 LIKE 是大小写敏感的。如果你查的是英文内容,LIKE 'john%' 是查不到 John 开头的行的。这时候有两条路:
- 用
ILIKE,这是PostgreSQL特有的、大小写不敏感的模糊匹配; - 用
LOWER(字段) LIKE LOWER('目标')。
ILIKE 写起来最简单,但它意味着字段上的普通索引大概率用不上,因为在索引列上做大小写转换后,索引失效。数据量大的时候,这种查询会是全表扫描。如果业务确实需要频繁做大小写不敏感的模糊查询,可以考虑用表达式索引,比如 CREATE INDEX idx_user_name_lower ON t_user (LOWER(user_name));。不过这是另一个话题了,基础阶段先知道这个瓶颈在哪即可。
LIKE 匹配时还有一种需要处理的场景:字段值本身就包含 % 或 _ 这两个字符。比如查一个包含百分号的描述信息,不能直接写 LIKE '100%',因为 % 被当成通配符了。正确做法是用 ESCAPE 指定转义字符:
sql复制-- 查询描述里包含"100%"的记录,反斜杠是转义符
SELECT
description
FROM
t_order
WHERE
description LIKE '%100\%%' ESCAPE '\';
这段SQL里第一个 % 和第三个 % 都代表任意字符,中间 100\% 表示字面量“100%”。很多人第一次用ESCAPE时容易数错通配符的位置,建议写完之后先跑一条 SELECT 验证结果,再放到正式查询里。
2.3 逻辑组合:AND、OR、NOT 的优先级陷阱
多个条件放一起,就会涉及到逻辑运算符。AND 表示必须同时满足,OR 表示满足其一即可,NOT 表示取反。三者的优先级从高到低是:NOT > AND > OR。这句话很多教程都会写,但实际写SQL时,人的直觉和这个优先级往往不一致。
来看一个典型的坑:
sql复制-- 想查"VIP用户 或 活跃用户"中的男性
-- 但下面这个写法和你想的不一样
SELECT
user_name,
gender,
is_vip,
is_active
FROM
t_user
WHERE
gender = 'male'
OR is_vip = true
AND is_active = true;
由于 AND 优先级高于 OR,这条SQL实际解析成的是 gender = 'male' OR (is_vip = true AND is_active = true),也就是“男性,或者既是VIP又是活跃用户”。这跟原本想表达的“VIP或活跃用户,并且还是男性”不是一回事。
正确写法必须加括号:
sql复制SELECT
user_name,
gender,
is_vip,
is_active
FROM
t_user
WHERE
(is_vip = true OR is_active = true)
AND gender = 'male';
我的经验是:只要同时出现 AND 和 OR,一律用括号把逻辑分组显式标出来,不要依赖优先级约定。这不只是为了正确性,更是为了让以后维护你SQL的同事(包括三个月后的你自己)能一眼看清查询意图。你在复杂WHERE条件里去猜优先级,是非常折磨人的事情。
3. 基于列类型选择正确的条件写法:日期/时间、布尔值和枚举字段的坑
初级阶段的SELECT查询往往把注意力放在语法正确上,但真正在工作中遇到“查出来的结果不对,或者连报错都看不懂”的情况,根源几乎都出在数据类型的匹配上。PostgreSQL是强类型数据库,它的类型约束比MySQL严格得多,这既是优点也是门槛。这一节我只挑最高频的几个场景讲。
3.1 日期与时间字段:用DATE_TRUNC还是字符串比较?
日期时间条件筛选是SQL里绕不过去的重点。很多人习惯直接用字符串去和日期字段比较,比如:
sql复制SELECT
order_id,
order_time
FROM
t_order
WHERE
order_time = '2026-06-01';
如果 order_time 是 timestamp 类型,这条SQL通常能执行,但结果往往出乎意料。因为字符串 '2026-06-01' 会被隐式转换成 timestamp 类型,也就是 2026-06-01 00:00:00,它只精确到当天零时零分零秒。而实际业务订单的 order_time 大概率是 2026-06-01 09:30:25 这样的完整时间戳。于是你以为查的是6月1日全天数据,实际只查到6月1日零点那一刻的订单,结果基本是空的,或者只有极少数临界数据。
正确的当日查询写法应该是:
sql复制-- 方式一:范围写法,推荐,索引友好
SELECT
order_id,
order_time
FROM
t_order
WHERE
order_time >= '2026-06-01'
AND order_time < '2026-06-02';
-- 方式二:用DATE函数做类型转换,直观但注意索引问题
SELECT
order_id,
order_time
FROM
t_order
WHERE
order_time::date = '2026-06-01';
方式一的执行效率通常更高。因为当字段被 order_time >= ... AND order_time < ... 包裹时,如果这个字段上有B-tree索引,优化器可以直接走索引范围扫描。而方式二先用 ::date 把字段做了类型转换,相当于在索引列上套了一层函数,普通索引会失效,数据量大了就会变成全表扫描。
这里补充一个非常实用的日期截断函数 DATE_TRUNC,它在按天、按月、按年汇总时特别好用:
sql复制-- 统计每天的下单数
SELECT
DATE_TRUNC('day', order_time) AS order_day,
COUNT(*) AS order_cnt
FROM
t_order
WHERE
order_time >= '2026-06-01'
AND order_time < '2026-07-01'
GROUP BY
DATE_TRUNC('day', order_time)
ORDER BY
order_day;
DATE_TRUNC 可以把时间戳归到指定精度。比如 DATE_TRUNC('month', order_time) 会把 2026-06-15 10:30:00 归到 2026-06-01 00:00:00。按月做报表统计时,配合 GROUP BY 非常顺手。
3.2 布尔值筛选的三态逻辑:TRUE、FALSE和NULL
PostgreSQL的布尔类型写作 boolean,取值是 TRUE、FALSE,还有 NULL。很多人写条件时只考虑前两个值,忽略了 NULL 的存在,导致查询结果少了一部分数据。
比如表里有一个字段 is_active,标注用户是否激活。常规写法可能是:
sql复制-- 查已激活用户
SELECT
user_name
FROM
t_user
WHERE
is_active = TRUE;
这时候如果某些行的 is_active 是 NULL,这些行不会被返回。如果你想同时找出“明确不是激活状态”的用户(也就是 is_active = FALSE 或 is_active IS NULL),不能直接写 WHERE is_active <> TRUE,因为SQL的三值逻辑里,与 NULL 比较的结果是 NULL,而 WHERE 只接受 TRUE 的结果,NULL 会被当作不满足条件。
正确写法:
sql复制SELECT
user_name
FROM
t_user
WHERE
is_active IS NOT TRUE;
在PostgreSQL里,IS NOT TRUE 会返回 FALSE 和 NULL 两种情况的结果,语义是“不是真”。这和 = FALSE 并不完全等价,后者只返回 FALSE,不包括 NULL。这是初学者特别容易踩的坑,我见过不止一次因为这里漏数据而对不上报表的情况。
3.3 枚举和状态码字段:别用错引号
如果某个字段是 varchar 类型,值可能是 'active'、'disabled',那条件肯定要写引号。但如果是整数类型的状态码,比如 1 表示启用,0 表示停用,有的人习惯性加了引号,写 WHERE status = '1'。PostgreSQL会尝试把字符串 '1' 隐式转换成数字来做比较,多数情况下能执行,但一旦传入不可转换的字符串,就会报错。而且这种隐式转换会带来计划生成时的额外开销,也容易掩盖程序里类型不匹配的问题。
我在实际开发中给自己的规矩是:字段是数字就写数字字面量,字段是字符串就写字符串字面量,拒绝隐式类型转换的便利,换取明确性和稳定性。尤其是写查询接口参数时,WHERE id = '100' 虽然能跑,但 WHERE id = 100 才是符合字段类型的写法,且能避免因为字符串里混入不可见字符导致的诡异问题。
4. 从基础查询到常用扩展:DISTINCT去重、ORDER BY排序与LIMIT分页
SELECT的基础语法里还有几个高频扩展:去重、排序、分页。它们单独看非常基础,但组合起来用的时候,也有不少细节值得说。
4.1 DISTINCT去重:搞清楚“去重”是按什么维度
DISTINCT 的作用是去除查询结果中完全重复的行。注意,这里的“完全重复”指的是结果集中所有列的值都一样。
sql复制-- 列出所有不重复的用户状态
SELECT DISTINCT
status
FROM
t_user;
如果你关心的是“不同状态下各有多少用户”,那更合适的写法是 GROUP BY status 再加 COUNT(*),而不是先 DISTINCT 再数。从执行效率上讲,DISTINCT 和 GROUP BY 在某些场景下可以表达类似语义,但 GROUP BY 更容易配合聚合函数。
还有一个容易误解的点:SELECT DISTINCT 作用于整行,而不是单独某一列。比如:
sql复制SELECT DISTINCT
status,
is_vip
FROM
t_user;
这里是按 (status, is_vip) 组合去重,而不是只按 status 去重。如果你想看“唯一的status列表”,同时带一个示例的 is_vip,这种写法并不合适,因为结果里同一个 status 可能出现多次。这时候要用 GROUP BY status 配合 MIN、MAX 之类的聚合函数来选值。
4.2 ORDER BY排序: NULLS FIRST/LAST 和按表达式排序
排序是查询里非常自然的诉求。PostgreSQL的 ORDER BY 默认升序,用 ASC 表示,降序则用 DESC。但有一个新手很容易忽略的细节:NULL值在排序时的位置。
升序排列时,PostgreSQL默认让NULL排在最后;降序排列时,默认让NULL排在最前。这个行为虽然符合SQL标准里的普遍约定,但不见得总是符合业务意图。比如在电商订单列表里,你想把未发货的订单(发货时间为NULL)排在最前面,升序默认会把它们放在最后,你根本看不到。
此时需要显式控制NULL的位置:
sql复制SELECT
order_id,
ship_time
FROM
t_order
ORDER BY
ship_time NULLS FIRST;
业务逻辑若明确要求“没有发货时间的排前面”,一定要加上 NULLS FIRST,否则数据排序结果很可能不符合预期。这一点在报表和列表页翻页时尤其重要,排序规则不稳定会导致数据在不同页之间出现错乱。
ORDER BY 还支持按表达式排序。比如用户表里的 last_login_time 是 NULL 表示从未登录过,你想让近期活跃的排前面、从未登录的排最后,可以这样写:
sql复制SELECT
user_name,
last_login_time
FROM
t_user
ORDER BY
last_login_time DESC NULLS LAST;
4.3 LIMIT分页的两种方式:OFFSET翻页与键集分页
分页几乎是每个应用都会用到的功能。PostgreSQL的经典写法是:
sql复制-- 第1页,每页20条
SELECT
user_id,
user_name
FROM
t_user
ORDER BY
user_id
LIMIT 20 OFFSET 0;
-- 第2页
SELECT
user_id,
user_name
FROM
t_user
ORDER BY
user_id
LIMIT 20 OFFSET 20;
这种写法的逻辑非常直观,还有一个更简洁的等价写法:LIMIT 20 OFFSET 20 可以写成 LIMIT 20, 20,但PostgreSQL并不推荐后者,可读性也差。建议坚持使用 LIMIT ... OFFSET ...。
不过OFFSET翻页有个隐患:翻到比较靠后的页码时,数据库仍然需要扫描并跳过前面的大量行。比如你第10000页,每页20条,OFFSET就是20万,数据库需要读取20万行再丢弃掉,才能拿到目标数据。数据量越大,越到后面的页面越慢。解决办法一般是两层:
- 第一层,物理删除或条件过滤掉历史不需要的数据,让总数据量可控;
- 第二层,使用“键集分页”,即基于排序字段的记录位置来翻页。
键集分页的写法大概长这样:
sql复制-- 假设上一页最后一条的 user_id 是 10086
SELECT
user_id,
user_name
FROM
t_user
WHERE
user_id < 10086
ORDER BY
user_id DESC
LIMIT 20;
它用WHERE条件代替OFFSET,跳过不需要的数据时不需要物理扫描那么多行。只要排序字段上有索引,这种查询在深分页场景下比OFFSET快得多。代价是逻辑上比OFFSET稍微复杂一点,前端翻页时要带上上一页最后一条的锚点值。
如果你的业务数据量不大,像几千行几万行的内部系统,OFFSET完全够用,没必要为了炫技引入键集分页。但如果你的查询要应付百万级以上的数据量,而且用户的翻页深度可能很大,建议尽早设计成键集分页,免得后期推翻重来。
5. SELECT进阶中容易忽略的隐患:NULL三值逻辑、隐式类型转换与SQL注入风险
这一节更像是我在实际项目中积累的“悔过书”。很多问题不是一开始就会遇到的,而是在踩了几次坑、看了故障报告之后才刻骨铭心。
5.1 NULL的三值逻辑:为什么WHERE name <> '张三'查不到“名字为空”的人
我在接手的很多老系统里发现,有相当一部分查询逻辑会用 WHERE user_name <> '张三' 来排除某个人。写这条SQL的人大概率是想表达“只要不是张三就行”。但如果 user_name 是空值,也就是 NULL,那么 NULL <> '张三' 的结果不是 TRUE,而是一个 NULL。WHERE 子句只保留结果为 TRUE 的行,所以名字为 NULL 的用户被漏掉了。
这在数据清洗阶段特别常见。你写个排查脚本,想找出“所有名字非空且不是张三”的用户,结果发现人数对不上,最后定位到是NULL默默地“隐身”了。
处理NULL的基本思路就是要意识到这个世界有三种状态:是、否、未知。SQL用 IS NULL 和 IS NOT NULL 来判断NULL,不能用 = NULL 或 <> NULL。= NULL 永远返回NULL,不会报错,但也不会返回任何行。很多新手写 WHERE name = NULL 发现查不到数据,还以为表是空的,其实就是这个原因。
5.2 隐式类型转换:方便背后的不确定性
PostgreSQL在表结构设计合理的情况下,一般会尽量避免隐式类型转换,但它也不是完全没有。比如 varchar 字段和 integer 字面量比较时,数据库通常会把字符串转成整数,或者反过来根据字段类型决定转换方向。当数据量很大、转换复杂时,索引使用会受影响。
举一个最常见的场景,字段 phone 存的是手机号字符串,但你在条件里写 WHERE phone = 13800138000 而不是 WHERE phone = '13800138000'。PostgreSQL可能会把右侧的数字转成字符串来做比较。如果该字段有索引,这种比较方式仍然可能命中索引,但存在变数。更稳妥、更专业的做法仍然是类型匹配。也就是说,你在代码里写SQL之前,先看一眼字段的类型定义,然后决定字面量的格式。这个习惯一旦养成,很多莫名其妙的查询问题会自动消失。
5.3 条件筛选里的SQL注入风险
SELECT查询不是只有数据库面对的事情,应用程序里动态拼接SQL时,条件常量部分很容易成为注入入口。比如常见的登录模块,如果代码是这样拼出来的:
sql复制-- 假设直接拼接字符串得到下面的SQL
SELECT
user_name
FROM
t_user
WHERE
user_name = 'admin' -- 用户输入的内容被原样拼进来
AND password = 'xxx';
如果用户在用户名输入框里输入 ' OR '1'='1,拼出来的SQL就变成了:
sql复制SELECT
user_name
FROM
t_user
WHERE
user_name = '' OR '1'='1'
AND password = 'xxx';
这条SQL的执行结果就是所有用户都能被查出来,等于登录校验被绕过了。这只是最基础的注入方式,更复杂的还有利用注释符号截断SQL、利用 UNION SELECT 从别的表里捞数据等。
解决办法完全不在SQL层面,而是在程序层面:养成使用参数化查询的习惯。在Java里用 PreparedStatement,在Python的 psycopg2 里用 %s 占位符,在Node.js的 pg 库里也用参数数组,而不是手动拼字符串。
python复制import psycopg2
conn = psycopg2.connect(
host="localhost",
dbname="testdb",
user="postgres",
password="postgres"
)
cur = conn.cursor()
cur.execute(
"SELECT user_name FROM t_user WHERE user_name = %s",
("admin",)
)
rows = cur.fetchall()
这段代码里,用户输入的内容是通过 %s 占位符传进去的,数据库会把它当纯数据而不是SQL片段处理。这是我在写任何涉及外部输入的条件查询时给自己定的硬规矩。就算你的系统只是内部工具,也应该这么做。因为内部系统不等于没有风险,一旦攻击者摸到了入口,危害是一样的。
6. 从热搜问题看SELECT常见误区:备份表、去重、大小写和慢查询的纠结
前面几节讲的是语法和逻辑层面,这一节我想借几个我看到的热搜词来展开,因为这些问题恰恰是SELECT查询在真实场景中最常被问到的变体。
6.1 建表备份和SELECT *:到底能不能这样用?
有人问:create table tstb_user_bak_202606 as select * from tstb_user; 可不可以用来做备份?从功能上说,这条语句确实能创建一张新表,并把旧表的数据拷贝进去。但它创建出来的是一张普通表,不会复制旧表的索引、约束、主键、自增序列等结构信息。
举个例子,如果原表 tstb_user 的主键是 id,并且 id 是 BIGSERIAL 自增列,那么 CREATE TABLE ... AS SELECT 生成的新表里,id 列的数据会和原表一致,但不带默认值,也没有序列绑定。你往新表里插数据的时候,如果不手动指定 id,就会触发“null value in column id violates not-null constraint”这样的错误。
所以,如果你只是想快速导出一份数据做临时分析,这条语句够用。但如果你要的是完整备份,必须用 pg_dump 或者在创建后用 ALTER TABLE 手动补索引和约束。再有就是,如果你的目的是在整表数据量很大的情况下做备份,这种写法会一次性占掉大量磁盘空间和事务时间,生产环境要非常谨慎。我曾见过有人拿它备份一张几千万行的表,结果把磁盘打满了。
做这种操作之前,最好先看一眼当前表的大小:
sql复制SELECT
pg_size_pretty(pg_total_relation_size('tstb_user')) AS total_size;
6.2 SELECT查询时大小写到底区不区分?
这个问题在热搜里反复出现:“select原表数据字段时候不区分大小写么?”答案要分两方面看。
PostgreSQL对标识符的处理方式是:如果创建表时字段名没有加双引号,那么字段名会被自动折叠成小写存储。比如你写:
sql复制CREATE TABLE t_user (
UserName TEXT
);
实际存进去的字段名是 username,不是 UserName。这时候你用:
sql复制SELECT "UserName" FROM t_user; -- 报错,找不到字段
因为双引号会让PostgreSQL区分大小写,它去查找名为 UserName 的字段,但表里没有。
当然你也可以在创建表时给字段名加双引号来强制保留大小写:
sql复制CREATE TABLE t_user (
"UserName" TEXT
);
这时候 SELECT username FROM t_user; 反而是错的,必须写 SELECT "UserName" FROM t_user; 才能查到。
但有个坑是:加双引号的字段名用起来非常麻烦,你写任何SQL都要准确匹配大小写,一旦写错就报“column does not exist”。所以我的建议是:不要让这种字段出现在你的数据库设计里。建表时全部用统一的小写加下划线命名风格,例如 user_name、create_time。这样写SQL时,无论你大写还是小写,PostgreSQL都会正常识别,因为不带的引号的标识符都会被折叠成小写。这也是PostgreSQL官方推荐的习惯。
至于数据值的大小写,那是另一回事。所有字符串比较都默认区分大小写,除非你显式用 ILIKE、LOWER() 或者指定了 COLLATION。
6.3 慢查询和SELECT的关系:为什么同一个查询时快时慢
“慢查询日志”也是热搜里频繁出现的词。很多人以为慢查询一定是SQL写得不好,其实不尽然。PostgreSQL的查询计划器会根据表统计信息来选择执行计划。同样的SQL,表统计信息过期后,优化器选错执行计划,就会从毫秒级变成秒级。
要排查这类问题,基本思路是用 EXPLAIN ANALYZE 看执行计划:
sql复制EXPLAIN ANALYZE
SELECT
user_id,
user_name
FROM
t_user
WHERE
status = 'active'
ORDER BY
create_time DESC
LIMIT 20;
输出结果里会显示实际启动时间、执行时间、扫描了多少行、返回了多少行,以及是否用了索引。我平时排查慢查询的第一件事就是跑这个命令,看是不是出现了“Seq Scan”全表扫描,以及估计行数和实际行数相差是否悬殊。如果差的太多,很可能是统计信息过旧,执行 ANALYZE t_user; 就能改善。
另外一个非常常见的慢查询原因是:在查询条件字段上使用了函数或表达式,导致索引失效。比如:
sql复制SELECT
user_id,
user_name
FROM
t_user
WHERE
DATE_TRUNC('day', create_time) = '2026-06-01';
如果 create_time 上有索引,DATE_TRUNC 包裹后,普通的B-tree索引大概率帮不上忙。改成范围查询就好很多:
sql复制SELECT
user_id,
user_name
FROM
t_user
WHERE
create_time >= '2026-06-01'
AND create_time < '2026-06-02';
从热搜词里能看到,“慢查询日志”和“select”经常被放在一起搜索,说明大家在实际性能排查中,第一反应就是检查SQL。方向是对的,但不要忽略了表统计信息和执行计划这两个因素。
7. 写在最后:给新手的三个排查建议和一段个人体会
这篇文章到这里,核心内容已经讲得差不多了。SELECT是SQL世界的大门,但也是最容易在细节上翻车的地方。我再整理三条针对新手的排查建议,感觉对日常开发比较有用。
第一条,拿到一条SELECT,先看清字段类型。无论你是写WHERE条件,还是ORDER BY或GROUP BY,都要先知道这个字段是数字、字符串、日期还是布尔,再决定怎么写,能省掉你后面排查隐式转换、查询结果不符合预期的大量时间。
第二条,条件里只用你能确定的运算符。不确定优先级就加括号,不确定NULL就补 IS NULL,不确定大小写就先用 EXPLAIN ANALYZE 验证一下。宁可多写几行,不要猜。
第三条,写任何带外部输入的查询时,第一时间用参数化查询,不要等到出了问题再补。这不是性能问题,是安全问题。安全问题的修复成本往往远高于你省下的几分钟。
我自己在实际开发中还有一个习惯:每次写完一段带WHERE的SELECT,会下意识用 EXPLAIN ANALYZE 看一眼执行计划和扫描方式。这么做不是形式主义,是因为它能让我在问题发生之前就意识到索引是否被正确使用、数据量增长后是否会出现性能瓶颈。很多老程序员说“多跑一下EXPLAIN”,不是没有道理,它确实能帮你建立对查询代价的直觉。
最后分享一个小技巧:PostgreSQL的 SELECT 支持在事务里用 SELECT ... FOR UPDATE 来锁定查询到的行,这在做并发扣减库存、防止超卖时非常有用。它是SELECT语法的一个分支,但已经超出了基础语法的范畴。如果你在理解了前面所有内容之后,开始思考并发场景下的查询行为,那么你离进阶不远了。这篇笔记就先停在这里,希望这些基础却又关键的细节,能帮你少踩一些我当年踩过的坑。
