🏠 总目录📚 本教程 附录A · 速查与三库差异 ←
📑 本页目录(点开跳转)

附录 A · 速查与三库差异对照

⏱ 54 分钟 | ⭐ 一页纸:语法速查 + SQLite / Postgres / MySQL 差在哪 + 哪个特性要哪个版本

📌 Ctrl+F 搜。不要通读。


🎯 一句话

三个库的 SQL 语义高度一致,分歧集中在两个地方:函数名(换个写法就行)和几条会让你【算错】的默认值(不换写法,直接错)。 ⭐ 第三节那张「会让你算错的八条」是这一页里唯一值得读一遍的部分,其余按需搜。

⚠️ 本页的标记约定(和 00 章一致):

标记 含义
实测 在 Windows 11 · Python 3.13.14 · SQLite 3.50.4 上跑过,输出抄回来的
🗓️ 未实跑 —— sqlite 无此语法 Postgres / MySQL 特有,本机跑不了,只给形状不给输出

⭐ 第五节把「本机到底支不支持」的逐条探测输出整个贴出来了 —— 那一节全部是实测。


📋 一、执行顺序(全教程最该背下来的一行)

FROM / JOIN  →  WHERE  →  GROUP BY  →  HAVING  →  SELECT  →  DISTINCT  →  ORDER BY  →  LIMIT
                                                     ↑
                                       窗口函数在这一步之后算

由此直接推出的四条规则(01 章展开讲):

规则 为什么
WHERE 里不能用 SELECT 起的别名 WHERE 比 SELECT 早跑,那时别名还不存在
ORDER BY 里可以用别名 它比 SELECT 晚跑
WHERE 里不能放聚合函数,要用 HAVING WHERE 在分组之前,那时还没有「组」这个东西
WHERE 里不能放窗口函数 窗口函数在 SELECT 之后才算,比 WHERE 晚太多

⭐ 过滤发生在三个不同的时刻,选错就是另一个意思:

写在哪 什么时候过滤 典型用途
ON 后面 ⭐ 连接的时候(外连接里:不匹配的行仍然保留,只是右侧全 NULL) 限制怎么连
WHERE 连接之后、分组之前 挑哪些行
HAVING 分组之后 挑哪些组

⚠️ 左连接里把条件从 ON 挪到 WHERE,会把左连接悄悄变回内连接 —— 02 章的题目。


🧰 二、按「你要干什么」查写法

你要干的 写法 ⚠️ 坑 / 章
数行 vs 数非空值 COUNT(*) vs COUNT(col) 后者跳过 NULL;03
条件计数 SUM(CASE WHEN 条件 THEN 1 ELSE 0 END) ⭐ 不动 WHERE,所以能和别的指标并排;10
条件计数(更短) COUNT(*) FILTER (WHERE 条件) SQLite / PG 有,⚠️ MySQL 没有
去重计数 COUNT(DISTINCT col) ⚠️ 不数 NULL;SQLite 不支持 COUNT(DISTINCT a, b)
排除某几个值 col NOT IN ('a','b') 💀 列表里一旦有 NULL,整个结果是空集;04
空值兜底 COALESCE(col, 0) 三库通用,优先用它而不是 IFNULL
把某个值变成 NULL NULLIF(col, 0) 常用于防除零:a / NULLIF(b, 0)
判两值是否「真的不同」(含 NULL) a IS DISTINCT FROM b SQLite 3.39+ / PG;MySQL 写 NOT (a <=> b)
每组取 Top-N ROW_NUMBER() OVER (PARTITION BY g ORDER BY x DESC) 外层筛 = 1 07;PG 另有 DISTINCT ON
排名(并列怎么处理) RANK() 跳号 / DENSE_RANK() 不跳号 / ROW_NUMBER() 强行分先后 07
累计求和 SUM(x) OVER (ORDER BY d ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) ⭐ 不写帧的默认帧不是这个;08
最近 N 行 ROWS BETWEEN N PRECEDING AND CURRENT ROW 数行;08
最近 N 天 RANGE BETWEEN N PRECEDING AND CURRENT ROW(ORDER BY 要是数值) 数值,有缺口时和上一行不是一回事;08
和上一行比(环比) x - LAG(x) OVER (ORDER BY d) LAG / LEAD 第三个参数是缺省值
行转列 SUM(CASE WHEN k='a' THEN v END) AS a, SUM(CASE WHEN k='b' THEN v END) AS b ⭐ 三库通用;别找 PIVOT,那是 SQL Server 的
找重复 GROUP BY 键 HAVING COUNT(*) > 1 10
两表差集 SELECT ... EXCEPT SELECT ... 或 NOT EXISTS (...) ⚠️ 别用 NOT IN(见上面那条 💀)
递归展开层级 / 血缘 WITH RECURSIVE x AS (锚点 UNION ALL 递归步) SELECT ... ⭐ 有环必须带 depth 刹车;06
游标分页 WHERE (updated_at, id) < (?, ?) ORDER BY updated_at DESC, id DESC LIMIT 20 行值比较;05
看执行计划 EXPLAIN QUERY PLAN <查询> SQLite 专有拼法,三家不同(见第六节);09

🔀 三、⭐ 会让你算错的八条差异

⚠️ 这八条不是「换个函数名」,是同一条 SQL 在三个库里给出【不同的数】。 换库迁移时,这八条要逐条对一遍。

① NULL 默认排在哪一头

实测(SQLite):
  ORDER BY v        → [None, 10, 20, 30]     ← NULL 在最前
  ORDER BY v DESC   → [30, 20, 10, None]     ← NULL 在最后
库 ORDER BY x ASC 时 NULL 在
SQLite 最前(实测)
Postgres 🗓️ 最后(NULL 被当成比任何值都大)
MySQL 🗓️ 最前

⭐ 一律显式写 ORDER BY x ASC NULLS LAST(SQLite 3.30+ / PG 支持,实测 SQLite 可用)。 🗓️ MySQL 没有 NULLS LAST,要用 ORDER BY (x IS NULL), x。

② 整数除法

实测(SQLite):SELECT 5/2, 5.0/2  →  (2, 2.5)
库 5/2
SQLite 2(实测,整数除整数取整)
Postgres 🗓️ 2(同上)
MySQL 🗓️ 2.5(⚠️ / 永远返回小数;要取整用 DIV)

💀 这条最容易在迁移时静默改变结果:一条算比例的 SQL 从 MySQL 搬到 Postgres, 成功数/总数 会从 0.83 变成 0,而且不报错。⭐ 保险写法:分子乘 1.0, 100.0 * a / b。

③ 除以零

实测(SQLite):SELECT 1/0, 1.0/0  →  (None, None)
库 1/0
SQLite NULL(实测)
Postgres 🗓️ 报错:division by zero
MySQL 🗓️ NULL(带一条 warning)

⭐ 三家都能用的写法:a / NULLIF(b, 0) —— 分母是 0 就变 NULL,不报错也不算错。

④ 字符串拼接符

实测(SQLite):SELECT 'a' || 'b'       →  'ab'
实测(SQLite):SELECT CONCAT('a','b')  →  'ab'      (3.44+ 才有 CONCAT)

⚠️ 两个竖线在 MySQL 里默认是逻辑「或」,不是拼接(除非开了 PIPES_AS_CONCAT)。 🗓️ 未实跑。⭐ 要三家通吃就用 CONCAT()。

⚠️ 但 CONCAT() 和竖线拼接对 NULL 的处理不一样:

实测(SQLite):SELECT 'a' || NULL       →  None      ← 一个 NULL 毁掉整串
实测(SQLite):SELECT CONCAT('a', NULL)  →  'a'       ← NULL 被当空串跳过

💀 拼主键的时候这条会咬人:a || '|' || b 里 b 是 NULL,整个键变 NULL, 那一行在后续 GROUP BY 里独自成组且看不出原因。⭐ 拼键之前一律 COALESCE。

⑤ 双引号

实测(SQLite):SELECT COUNT(*) FROM t WHERE g = "a"   →  能跑,返回 2 行
库 "a" 是什么
SQLite 先当列名,找不到就退化成字符串(实测,所以上面那句能跑)
Postgres 🗓️ 永远是列名,找不到就报错
MySQL 🗓️ 默认是字符串(除非开 ANSI_QUOTES)

⭐ 规则很简单:字符串一律单引号,标识符一律不引(真要引,SQLite 三种引号都吃: "x"、`x`、[x] 全部实测能跑,但那只是兼容包袱,别用)。

⑥ LIKE 认不认大小写

实测(SQLite):SELECT COUNT(*) FROM t WHERE g LIKE 'A'   →  2   ← 匹配上了小写的 'a'
实测(SQLite):ILIKE  →  OperationalError: near "ILIKE": syntax error
库 LIKE 'A' 匹配 'a' 吗
SQLite ✅ 匹配(实测)。⚠️ 但只对 ASCII 生效,非 ASCII 字符照旧区分大小写
Postgres 🗓️ ❌ 不匹配,要用 ILIKE
MySQL 🗓️ 看列的排序规则(collation),默认那套 ..._ai_ci 是不区分的

💀 这条是「同一条 SQL 在两个库返回不同行数」的最常见来源,而且两边都不报错。 ⭐ 想要确定行为就自己动手:WHERE LOWER(g) = LOWER('A') (⚠️ 代价是这样写用不上 g 上的索引 —— 09 章的题目)。

⑦ GROUP BY 后面能不能跟没聚合的列

实测(SQLite):SELECT g, v, COUNT(*) FROM t GROUP BY g   →  [('a', 10, 2), ('b', 30, 2)]

⚠️ v 既没在 GROUP BY 里也没被聚合,SQLite 照跑不误,随便挑组里某一行的值给你。

库 行为
SQLite 允许,返回组内任意一行的值(实测)
Postgres 🗓️ 报错(除非那列函数依赖于分组键)
MySQL 🗓️ 5.7 起默认开 ONLY_FULL_GROUP_BY,报错;关掉就和 SQLite 一样

⭐ Postgres 的严格是对的:那个值没有意义。要「组内某一行的完整信息」, 正确写法是窗口函数 ROW_NUMBER() ... = 1,不是裸列。

⑧ 类型:声明了长度也不管用

实测(SQLite):CREATE TABLE small(x VARCHAR(3));
              INSERT INTO small VALUES('abcdefgh');   ← 不报错
              SELECT x, LENGTH(x) FROM small  →  ('abcdefgh', 8)

⚠️ SQLite 是动态类型 + 类型亲和性:VARCHAR(3) 里的 3 完全不生效。 🗓️ Postgres 会报 value too long;MySQL 严格模式报错、非严格模式截断加 warning。

⭐ SQLite 3.37+ 可以建 STRICT 表(实测可用)把类型管起来:

CREATE TABLE t(a INT, b TEXT) STRICT;

⚠️ 另一个相关的坑:

实测(SQLite):SELECT '1' = 1, '1' = '1', 1 = 1.0   →  (0, 1, 1)

'1' = 1 是假 —— 文本和数字属于不同的存储类,文本永远「大于」数字。 🗓️ Postgres 和 MySQL 在这里会把字面量转成数字,结果是真。 ⭐ 所以「把数字存成字符串」这件事,在 SQLite 上会以查不出行的形式发作,而不是报错。

🧯 还有一条不算「算错」但值得知道:LENGTH 数的是什么

实测(SQLite):SELECT LENGTH('北京'), LENGTH(CAST('北京' AS BLOB))  →  (2, 6)

SQLite / Postgres 的 LENGTH 数字符;🗓️ MySQL 的 LENGTH() 数字节, 要数字符得用 CHAR_LENGTH()。⚠️ 一个存中文的字段,MySQL 上 LENGTH 会是三倍。


🔧 四、换个写法就行的差异(语法糖对照)

⚠️ 这一节的非 SQLite 列全部 🗓️ 未实跑 —— sqlite 无此语法,只给形状。

要干的事 SQLite(实测) Postgres 🗓️ MySQL 🗓️
分页 LIMIT 20 OFFSET 40(也吃 LIMIT 40, 20) LIMIT 20 OFFSET 40 LIMIT 40, 20 或 LIMIT 20 OFFSET 40
当前时间 CURRENT_TIMESTAMP(⚠️ 没有 NOW()) NOW() / CURRENT_TIMESTAMP NOW() / CURRENT_TIMESTAMP
日期减 7 天 date(d, '-7 days') d - INTERVAL '7 day' DATE_SUB(d, INTERVAL 7 DAY)
截到月 strftime('%Y-%m', d) date_trunc('month', d) DATE_FORMAT(d, '%Y-%m')
两日期相差几天 julianday(a) - julianday(b) a::date - b::date DATEDIFF(a, b)
类型转换 CAST(x AS INT) CAST(x AS INT) 或 x::int CAST(x AS SIGNED)
聚合成一串 GROUP_CONCAT(x, ',') 或 STRING_AGG(x, ',') STRING_AGG(x, ',') GROUP_CONCAT(x SEPARATOR ',')
中位数 ⚠️ 没有,用窗口函数 + LIMIT/OFFSET 绕 PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY x) ⚠️ 也没有,同样要绕
正则匹配 ⚠️ 默认没有 REGEXP 函数(宿主程序可自己注册) x ~ '模式' / x ~* '模式'(后者忽略大小写) x REGEXP '模式'
插入或更新 INSERT ... ON CONFLICT(k) DO UPDATE SET ... 同左 INSERT ... ON DUPLICATE KEY UPDATE ...
写完把行返回 ... RETURNING id(3.35+,实测) ... RETURNING id ⚠️ 没有
每组取一行的捷径 ⚠️ 没有,用 ROW_NUMBER() = 1 DISTINCT ON (g) ... ⚠️ 没有,同样用 ROW_NUMBER()
看有哪些列 PRAGMA table_info(t) information_schema.columns information_schema.columns 或 DESCRIBE t
看有哪些表 SELECT name FROM sqlite_master WHERE type='table' information_schema.tables 或 \dt SHOW TABLES
自增主键 INTEGER PRIMARY KEY(可选 AUTOINCREMENT) GENERATED ALWAYS AS IDENTITY 或 SERIAL INT AUTO_INCREMENT

⭐ 一条经验:迁移时先搜日期函数。它是三家分歧最密集的地方,而且几乎每个报表都用到。


🧪 五、本机到底支不支持:逐条探测(全部实测)

⭐ 这一节不是从文档抄的,是在 SQLite 3.50.4 上逐条跑出来的:能跑就打印结果, 不能跑就打印它抛的错。你换个版本重跑一遍,就知道自己那台机器的边界在哪。

sqlite: 3.50.4
  OK   窗口函数 ROW_NUMBER            -> [(1, 1), (2, 2), (3, 1)]
  FAIL 窗口 QUALIFY                 -> OperationalError: near "ROW_NUMBER": syntax error
  OK   FILTER (WHERE ...)         -> [(2,)]
  OK   NULLS LAST                 -> [(10,), (20,), (30,)]
  OK   IS DISTINCT FROM           -> [(3,)]
  OK   FULL OUTER JOIN            -> [(5,)]
  OK   RIGHT JOIN                 -> [(2,)]
  OK   行值比较 (a,b)<(?,?)           -> [(2,)]
  OK   WITH RECURSIVE             -> [(3,)]
  OK   CTE MATERIALIZED           -> [(1,)]
  OK   GROUP_CONCAT               -> [('a|a|b|b',)]
  OK   STRING_AGG                 -> [('a|a|b|b',)]
  FAIL DISTINCT ON                -> OperationalError: near "ON": syntax error
  FAIL ILIKE                      -> OperationalError: near "ILIKE": syntax error
  OK   LIKE 大小写(ASCII)            -> [(2,)]
  OK   MySQL 式 LIMIT 1,2          -> [(2,), (3,)]
  OK   整数除法 5/2                   -> [(2, 2.5)]
  OK   CONCAT()                   -> [('ab',)]
  OK   双引号当字符串                    -> [(2,)]
  OK   '1' = 1 ?                  -> [(0, 1, 1)]
  OK   布尔字面量 TRUE                 -> [(1, 0, 2)]
  OK   裸列跟着 GROUP BY              -> [('a', 10, 2), ('b', 30, 2)]
  OK   RETURNING                  -> [(9,)]
  OK   UPSERT ON CONFLICT         -> []
  OK   EXPLAIN QUERY PLAN         -> [(2, 0, 216, 'SCAN t')]
  FAIL EXPLAIN ANALYZE            -> OperationalError: near "SELECT": syntax error
  OK   date/strftime              -> [('2026-07-04', '2026-07')]
  OK   日期加减 modifier              -> [('2026-06-27',)]
  FAIL INTERVAL '7 day'           -> OperationalError: no such column: INTERVAL
  FAIL NOW()                      -> OperationalError: no such function: NOW
  OK   CURRENT_TIMESTAMP          -> [('2026-08-11 12:09:49',)]
  OK   RANGE 帧 + 数值偏移             -> [(10,), (30,), (60,)]
  FAIL RANGE INTERVAL 帧           -> OperationalError: near "'2 day'": syntax error
  OK   STRICT 表                   -> []
  FAIL COUNT(DISTINCT a,b)        -> OperationalError: wrong number of arguments to function COUNT()
  FAIL pg 风格 ::int 转换             -> OperationalError: unrecognized token: ":"
  FAIL LATERAL                    -> OperationalError: near "SELECT": syntax error
  FAIL generate_series            -> OperationalError: no such table: generate_series
  FAIL array_agg                  -> OperationalError: no such function: array_agg
  FAIL information_schema         -> OperationalError: no such table: information_schema.columns
  FAIL PERCENTILE_CONT            -> OperationalError: near "(": syntax error
  FAIL REGEXP                     -> OperationalError: no such function: REGEXP

⭐ 值得单独拎出来的三条:

  1. 布尔字面量 TRUE -> (1, 0, 2) —— TRUE + TRUE = 2。SQLite 里布尔就是整数 1 / 0,没有独立的布尔类型。所以 SUM(条件) 直接就能当条件计数用。
  2. RANGE 帧 + 数值偏移 能跑,RANGE INTERVAL 帧 不能 —— SQLite 的 RANGE 只吃数值偏移。想按「最近 7 天」开窗,得先把日期转成数字(julianday(d))再 ORDER BY 它。 🗓️ Postgres 才支持 RANGE BETWEEN INTERVAL '7 day' PRECEDING AND CURRENT ROW。08 章有完整例子。
  3. QUALIFY 三家都没有 —— 它是 Snowflake / BigQuery / DuckDB 的方言。 在这三个库里筛窗口函数结果,只能外面套一层子查询。

🗓️ 六、哪个特性要哪个版本

⚠️ 「起始版本」一列取自各库的发行说明,不是实测;本机 SQLite 3.50.4 上, 表里标 ✅ 的 SQLite 项全部实测能跑(输出见第五节)。

特性 SQLite Postgres MySQL
WITH / CTE ✅ 3.8.3 ✅ 8.4 ⚠️ 8.0 起,5.7 没有
WITH RECURSIVE ✅ 3.8.3 ✅ 8.4 ⚠️ 8.0 起
窗口函数 ✅ 3.25 ✅ 8.4 ⚠️⚠️ 8.0 起
行值比较 (a,b) < (?,?) ✅ 3.15 ✅ ✅
FILTER (WHERE ...) ✅ 3.30 ✅ 9.4 ❌ 没有
NULLS FIRST / LAST ✅ 3.30 ✅ ❌ 用 ORDER BY (x IS NULL), x
UPSERT ✅ 3.24 ✅ 9.5 ✅(ON DUPLICATE KEY UPDATE)
RETURNING ✅ 3.35 ✅ ❌ 没有
CTE MATERIALIZED 提示 ✅ 3.35 ✅ 12 ❌ 没有
STRICT 表 ✅ 3.37 —(本来就强类型) —(本来就强类型)
FULL OUTER JOIN ✅ 3.39 ✅ ❌ 要用 UNION 拼
IS DISTINCT FROM ✅ 3.39 ✅ ❌ 用 NOT (a <=> b)
CONCAT() / STRING_AGG() ✅ 3.44 ✅ / ✅ ✅ / ❌

💀 最常撞的一条是窗口函数在 MySQL 5.7 上不存在。 一份在本地 SQLite 上写得好好的报表 SQL,扔到还没升级的 MySQL 5.7 上会直接语法错 —— 这是少数几个会当场报错的差异,反而是好事。


🚦 七、三家的执行计划怎么看

库 命令 特点
SQLite EXPLAIN QUERY PLAN <查询>(实测) ⚠️ 不真跑,只给形状;EXPLAIN ANALYZE 不存在(实测报错)
Postgres 🗓️ EXPLAIN (ANALYZE, BUFFERS) <查询> 加 ANALYZE 会真跑一遍,给真实耗时和真实行数
MySQL 🗓️ EXPLAIN <查询> / EXPLAIN ANALYZE <查询>(8.0.18+) FORMAT=JSON 给更细的成本估算

⚠️ 三家计划里最该盯的两处是一样的:扫的是索引还是全表、估算行数和真实行数差多少。 ⭐ 而「计划里写着索引名,它照样可能是全表扫」是 09 章整章的题目 —— 这里不重复。

⭐ EXPLAIN ANALYZE 会真的执行查询(Postgres / MySQL)。 ⚠️ 在生产库上对一条 UPDATE 或 DELETE 用它,语句真的会生效。 要看写操作的计划,包在事务里跑完 ROLLBACK。


🔒 八、这一页不讲的:SQL 注入

⭐ SQL 注入整块归 《AI 全栈》06b 列表接口与分页,本教程一个字不重复。

去那边的理由(那一章真的讲了这些): 排序字段被直接拼进 ORDER BY 是最常见的注入面(ORDER BY 后面能放子查询、能放 CASE), 而且 ⚠️ 参数化占位符 ? 在这里救不了你 —— ? 只能替换「值」,不能替换列名和关键字, ORDER BY ? 不报错但静默失效(参数被当常量,返回插入序 —— 比报语法错更糟)。没有参数化退路,只能靠白名单。


🔗 这一页连到哪里

相关的地方 为什么
00 怎么用这份教程 本页的两个标记(实测 / 🗓️)在那里定义,还有为什么整套教程跑在 SQLite 上
01 一条查询是怎么跑的 第一节那行执行顺序的完整展开,以及它怎么解释一半的报错
09 计划里的连接与索引失效 第七节只列了三家的 EXPLAIN 拼法,「计划该怎么读、写法怎么废掉索引」在那一章
AI 全栈 · 06 关系数据库 ⭐ 本页只讲查询语法,建表 / 建索引 / 迁移 / 连接池全在那边
AI 全栈 · 06b 列表接口与分页 ⭐ SQL 注入、COUNT(*) 的成本、游标分页在接口层怎么用
AI 全栈 · 附录B 技术栈对照 同样是「换一套技术栈要改什么」的对照表,那边是 Web 栈这边是数据库

🗓️ 这一页会过期的部分:第六节的版本号、第四节的语法糖对照。 ⭐ 不会过期的部分:第一节的执行顺序、第三节那八条语义差异 —— 它们是标准和实现取舍决定的,比任何一个版本号活得久。

下一节 👉 回到首页

打卡记录保存在你的浏览器里,首页能看到总进度