上一章把两个查询结果放进同一个集合时,我们反复强调一件事:对应位置的列必须能放在一起。一个查询返回数字,另一个查询返回日期,数据库不会因为“看起来都能显示”就认为它们表达的是同一种东西。到了这一章,我们把镜头拉近,看看单个值进入表达式之后会发生什么。
小满商店的表里保存的是原始字段:product_name 是商品名,unit_price 是单价,order_date 是下单时间,phone 可能还是 NULL。业务页面真正需要的却往往是“清洗后的商品名”“折后小计”“上海时间的下单日期”“缺少手机号时使用邮箱”。从原始值到这些可用信息,中间靠的就是函数、运算符和数据类型。

函数并不神秘。你可以先把它理解成一台逐行工作的小机器:每读到一行,就接收这一行的若干值,按照明确规则产生一个新值。真正容易出错的地方不在函数名,而在四个问题:输入是什么类型,输出是什么类型,遇到 NULL 怎么办,表达式放进筛选条件后还能不能利用索引。
本章示例以 MySQL 8.4 为主,并在容易迁移出错的地方说明 PostgreSQL 的差异。不要把某个数据库里的“能运行”误当成 SQL 的通用保证,尤其是隐式类型转换、字符串拼接和时间类型。
小满商店已经在前面的章节建立好 9 张表。本章不另造一套商品或学生数据,只使用同一批固定记录。最常出现的四张表仍然是 customers、products、orders 和 order_items。
先把字段角色对齐:
这里故意把 products.unit_price 和 order_items.unit_price 都保留下来。商品表里的价格是当前售价,订单明细里的价格是下单那一刻的成交基准价。以后商品涨价,旧订单仍然应该按历史单价对账。字段名称相似,不代表业务含义相同。
先看几条订单明细,确认后续表达式的输入:
SELECT
oi.order_item_id,
oi.order_id,
p.product_name,
oi.quantity,
oi.unit_price,
oi.discount_rate
FROM order_items AS oi
JOIN products AS p
ON p.product_id = oi.product_id
WHERE oi.order_item_id <= 4
ORDER BY oi.order_item_id;+---------------+----------+----------------------+----------+------------+---------------+
| order_item_id | order_id | product_name | quantity | unit_price | discount_rate |
+---------------+----------+----------------------+----------+------------+---------------+
| 1 | 101 | 白瓷马克杯 | 1 | 39.90 | 0.0000 |
| 2 | 101 | 原木托盘 | 1 | 89.00 | 0.1124 |
| 3 | 102 | 65W 氮化镓充电器 | 1 | 169.00 | 0.0947 |
| 4 | 102 | 编织数据线 | 1 | 39.00 | 0.1026 |
+---------------+----------+----------------------+----------+------------+---------------+共享结构把 discount_rate 定义为 NOT NULL DEFAULT 0,并用检查约束把范围限制在 0 到 1。也就是说,订单明细中的“无折扣”已经明确保存为 0.0000,不能与“折扣未知”混淆。本章仍会完整讲 NULL 传播,但会使用确实允许缺失的联系方式,以及独立表达式来观察未知值怎样参与计算。
SELECT 后面不只可以写列名,也可以写字面量、运算、函数调用以及它们的组合。它们统称为表达式。
SELECT
product_name,
unit_price,
unit_price * 0.9 AS vip_price,
UPPER(status) AS status_code,
CHAR_LENGTH(product_name) AS name_chars
FROM products
WHERE product_id = 5;+----------------------+------------+-----------+-------------+------------+
| product_name | unit_price | vip_price | status_code | name_chars |
+----------------------+------------+-----------+-------------+------------+
| 65W 氮化镓充电器 | 169.00 | 152.100 | 在售 | 10 |
+----------------------+------------+-----------+-------------+------------+这一行里同时发生了三种处理:数值参与乘法,字符串参与大小写转换,字符串又被计算字符数量。数据库会为每个表达式确定结果类型。UPPER(status) 仍然是字符串;CHAR_LENGTH(...) 返回整数;unit_price * 0.9 是数值,但结果的精度和小数位数会受两边操作数类型影响。
如果查询返回三件商品,CHAR_LENGTH(product_name) 就会分别处理三个商品名,不会先把三行合成一个字符串。
SELECT
product_id,
product_name,
CHAR_LENGTH(product_name) AS name_chars
FROM products
WHERE product_id IN (1, 5, 7)
ORDER BY product_id;+------------+----------------------+------------+
| product_id | product_name | name_chars |
+------------+----------------------+------------+
| 1 | 白瓷马克杯 | 5 |
| 5 | 65W 氮化镓充电器 | 10 |
| 7 | 亚麻抱枕 | 4 |
+------------+----------------------+------------+这类函数叫标量函数:一行输入得到一行输出。下一章的 SUM、AVG 和 COUNT 不一样,它们会接收一组行并把这一组压缩成一个结果。现在先把“每一行如何变干净、变准确”学会,下一章才能放心汇总。
别名只改变结果列的名称,不改变底层数据,也不会自动修改原列的类型。
SELECT
unit_price * stock AS stock_value
FROM products
WHERE product_id = 5;+-------------+
| stock_value |
+-------------+
| 5408.00 |
+-------------+stock_value 是这次查询临时产生的结果。表里没有多出一列,unit_price 和 stock 也没有被更新。如果业务经常使用同一表达式,可以把它封装进视图或生成列;在需求还没稳定时,先保留为查询表达式通常更容易维护。
商品名、手机号、邮箱、状态码都能显示成文字,但适合它们的类型未必相同。
不要因为某个编号只包含数字,就把它存成数值。手机号、邮编、快递单号通常不参加算术,还可能有前导零;它们的业务身份更接近字符串。反过来,价格如果存成 '128.00元',每次计算前都要剥掉单位并转换类型,会把一个本来简单的问题变得脆弱。

MySQL 的 CHAR_LENGTH() 返回字符数量,LENGTH() 返回字节数量。在 utf8mb4 下,一个常用汉字通常占 3 个字节,部分表情符号占 4 个字节。英文数字常常只占 1 个字节。于是,同一段文字会得到两个不同的“长度”。
SELECT
'咖啡豆A' AS sample_text,
CHAR_LENGTH('咖啡豆A') AS character_count,
LENGTH('咖啡豆A') AS byte_count;+-------------+-----------------+------------+
| sample_text | character_count | byte_count |
+-------------+-----------------+------------+
| 咖啡豆A | 4 | 10 |
+-------------+-----------------+------------+三个汉字是 9 个 UTF-8 字节,加上字母 A 的 1 个字节,总共 10 个字节。若产品经理说“商品名最多 40 个字”,应该检查字符数;若你在估算协议报文、索引空间或存储尺寸,字节数才是相关指标。
PostgreSQL 中 length(text) 计算字符数,计算字节要用 octet_length(text)。所以把 MySQL 的 LENGTH() 原样迁过去,语句可能仍能运行,含义却已经变了。
导入文件里常会出现两端带空格的姓名。先用一个固定字面量观察清理前后,不改动共享客户数据:
SELECT
CONCAT('[', ' 苏小满 ', ']') AS raw_name,
CONCAT('[', TRIM(' 苏小满 '), ']') AS clean_name,
CHAR_LENGTH(' 苏小满 ') AS raw_chars,
CHAR_LENGTH(TRIM(' 苏小满 ')) AS clean_chars;+-------------+------------+-----------+-------------+
| raw_name | clean_name | raw_chars | clean_chars |
+-------------+------------+-----------+-------------+
| [ 苏小满 ] | [苏小满] | 5 | 3 |
+-------------+------------+-----------+-------------+TRIM() 默认去掉两端空格,LTRIM() 只处理左侧,RTRIM() 只处理右侧。它们不会把“65W 氮化镓充电器”中间本来有意义的空格删除。清洗时最怕规则过猛:本来只想修掉录入边缘的空白,却顺手改变了内容本身。
如果数据来自网页表单,建议在写入前先清理,同时在查询层保留防守式处理。只在查询时 TRIM() 能让页面暂时好看,却没有阻止脏数据继续进入数据库。
下面把商品名做成列表页短标题,并检查是否包含“咖啡”:
SELECT
product_name,
LEFT(product_name, 6) AS short_name,
LOCATE('杯', product_name) AS cup_position,
REPLACE(product_name, ' ', '·') AS normalized_name
FROM products
WHERE product_id IN (1, 5, 7)
ORDER BY product_id;+----------------------+------------+--------------+----------------------+
| product_name | short_name | cup_position | normalized_name |
+----------------------+------------+--------------+----------------------+
| 白瓷马克杯 | 白瓷马克杯 | 5 | 白瓷马克杯 |
| 65W 氮化镓充电器 | 65W 氮化 | 0 | 65W·氮化镓充电器 |
| 亚麻抱枕 | 亚麻抱枕 | 0 | 亚麻抱枕 |
+----------------------+------------+--------------+----------------------+MySQL 的字符串位置从 1 开始,找不到时返回 0。LEFT(..., 6) 也是按字符截取,不会把一个汉字截成半个字节。若你要写更接近 SQL 标准的语法,可以使用 POSITION('杯' IN product_name) 和 SUBSTRING(product_name FROM 1 FOR 6);MySQL 与 PostgreSQL 都支持这两种写法。
这里还有一个产品层面的选择:LEFT(product_name, 6) 只是数据库按字符截断,不懂中文词义,也不知道页面究竟能容纳多宽。中文、拉丁字母和数字的视觉宽度并不相同。数据库可以帮你准备短文本,真正按屏幕宽度显示省略号通常应由前端完成。
我们想拼出“客户名 / 手机号”这类标签:
SELECT
customer_id,
CONCAT(TRIM(customer_name), ' / ', phone) AS contact_label
FROM customers
WHERE customer_id IN (1, 3, 5)
ORDER BY customer_id;+-------------+--------------------------+
| customer_id | contact_label |
+-------------+--------------------------+
| 1 | 苏小满 / 13800001001 |
| 3 | NULL |
| 5 | 乔木 / 13800001005 |
+-------------+--------------------------+白露的名字明明存在,整段标签为什么还是 NULL?因为 MySQL 的 CONCAT() 只要有一个参数是 NULL,结果就是 NULL。数据库没有资格替你决定:缺少手机号时该显示空白、短横线、还是“未填写”。
如果产品规则是缺失手机号时显示“未填写”,就把规则写出来:
SELECT
customer_id,
CONCAT(
TRIM(customer_name),
' / ',
COALESCE(phone, '未填写')
) AS contact_label
FROM customers
WHERE customer_id IN (1, 3, 5)
ORDER BY customer_id;+-------------+--------------------------+
| customer_id | contact_label |
+-------------+--------------------------+
| 1 | 苏小满 / 13800001001 |
| 3 | 白露 / 未填写 |
| 5 | 乔木 / 13800001005 |
+-------------+--------------------------+MySQL 的 CONCAT_WS(separator, ...) 会跳过分隔符之后的 NULL 参数,适合做“有哪个字段就拼哪个字段”的标签:
SELECT
customer_id,
CONCAT_WS(' / ', TRIM(customer_name), phone, city) AS contact_card
FROM customers
WHERE customer_id IN (1, 3, 5)
ORDER BY customer_id;+-------------+-----------------------------------+
| customer_id | contact_card |
+-------------+-----------------------------------+
| 1 | 苏小满 / 13800001001 / 杭州 |
| 3 | 白露 / 成都 |
| 5 | 乔木 / 13800001005 / 北京 |
+-------------+-----------------------------------+PostgreSQL 的 concat() 与 concat_ws() 会忽略 NULL 参数,而 || 运算符遇到 NULL 会得到 NULL。同名函数在两个数据库里也可能有不同的空值策略,迁移时必须用包含 NULL 的样例验证。
邮箱常被转换为小写展示:
SELECT
'XIAOMAN@EXAMPLE.COM' AS raw_email,
LOWER('XIAOMAN@EXAMPLE.COM') AS normalized_email;+---------------------+---------------------+
| raw_email | normalized_email |
+---------------------+---------------------+
| XIAOMAN@EXAMPLE.COM | xiaoman@example.com |
+---------------------+---------------------+这条表达式适合搜索和展示,却不等于自动建立了“邮箱唯一”的规则。如果系统要求忽略大小写后唯一,应使用合适的排序规则、规范化列或表达式索引,再配合唯一约束。只在页面查询里 LOWER(email),并不能阻止另一行写入大小写不同但逻辑相同的地址。
字符串比较会受字符集和排序规则影响。某些排序规则忽略大小写或重音符号,另一些按字节或大小写区分。遇到“明明不同却被判相等”或“明明一样却查不到”,除了看函数,还要检查列的字符集、排序规则和两边表达式的类型。
数据库里的“数字”不是一个笼统类型。整数、定点小数和浮点数使用不同的表示方式,也适合不同问题。
订单金额不是“差不多 243 元”,而是要能对账的 243.02 元。因此小满商店用 DECIMAL 保存单价、折扣和总额,不使用 FLOAT。

DECIMAL(p,s) 中,p 是总有效数字位数,s 是小数点右侧位数。DECIMAL(10,2) 最多保存 10 位十进制数字,其中 2 位在小数点后,所以整数部分最多 8 位。
SELECT
CAST(128.00 AS DECIMAL(10, 2)) AS price,
CAST(0.0500 AS DECIMAL(5, 4)) AS discount_rate,
CAST(243.02 AS DECIMAL(12, 2)) AS order_amount;+--------+---------------+--------------+
| price | discount_rate | order_amount |
+--------+---------------+--------------+
| 128.00 | 0.0500 | 243.02 |
+--------+---------------+--------------+DECIMAL(5,4) 不是“最多 5 位小数”,而是总共 5 位,其中 4 位小数,整数部分只剩 1 位。它可以保存 0.0500,却不适合保存 12.5000。设计折扣率时还要决定业务允许 100% 以上的值吗;类型只限制表示范围,不会自动理解折扣规则。
小满商店已经在表定义里把类型与业务约束放在一起:
discount_rate DECIMAL(5, 4) NOT NULL DEFAULT 0
CHECK (discount_rate >= 0 AND discount_rate <= 1)NOT NULL DEFAULT 0 表示这套业务已经决定:明细创建时没有单独折扣,就按零折扣保存;CHECK 再阻止小于 0 或大于 1 的值。类型负责“能表示什么”,约束负责“允许写入什么”,两者缺一不可。
订单明细 3 是一件 65W 氮化镓充电器,历史成交单价 169 元,折扣率 9.47%。逐行计算如下:
SELECT
order_item_id,
quantity,
unit_price,
discount_rate,
quantity * unit_price AS original_amount,
quantity * unit_price * (1 - discount_rate) AS discounted_amount
FROM order_items
WHERE order_item_id = 3;+---------------+----------+------------+---------------+-----------------+-------------------+
| order_item_id | quantity | unit_price | discount_rate | original_amount | discounted_amount |
+---------------+----------+------------+---------------+-----------------+-------------------+
| 3 | 1 | 169.00 | 0.0947 | 169.00 | 152.995700 |
+---------------+----------+------------+---------------+-----------------+-------------------+结果出现 6 位小数并不说明金额真的需要精确到百万分之一。表达式结果的标度会根据操作数推导。进入最终金额口径时,我们通常再按结算规则舍入到分:
SELECT
order_item_id,
ROUND(
quantity * unit_price * (1 - discount_rate),
2
) AS payable_amount
FROM order_items
WHERE order_item_id = 3;+---------------+----------------+
| order_item_id | payable_amount |
+---------------+----------------+
| 3 | 153.00 |
+---------------+----------------+舍入放在哪一步会影响总额。“每个明细行先舍入到分,再相加”和“所有高精度明细先相加,最后舍入一次”可能相差几分钱。数据库能准确执行两种规则,却不会替财务选择规则。建表和写报表前,应先确认税、折扣、优惠券分别在哪个阶段计算。
ROUND(x, 2) 把值四舍五入到两位小数;MySQL 的 TRUNCATE(x, 2) 直接截掉更低位,不进位;FORMAT(x, 2) 则返回带分组符的字符串,主要用于展示。
SELECT
ROUND(12.345, 2) AS rounded_value,
TRUNCATE(12.345, 2) AS truncated_value,
FORMAT(12345.6, 2) AS display_text;+---------------+-----------------+--------------+
| rounded_value | truncated_value | display_text |
+---------------+-----------------+--------------+
| 12.35 | 12.34 | 12,345.60 |
+---------------+-----------------+--------------+三个结果看起来都像数,第三个却已经不能安全地继续做金额运算。它包含逗号,并且类型是字符串。排序时,字符串 '900.00' 可能排在 '12,000.00' 后面,因为字符串排序比较的是字符顺序,不是数值大小。
很多十进制小数无法用有限个二进制位精确表示。浮点数会选择一个非常接近的值。这不是数据库算错,而是表示方式本来就是近似的。
SELECT
CAST(0.1 AS DOUBLE) + CAST(0.2 AS DOUBLE) AS float_sum,
CAST(0.1 AS DECIMAL(10, 2))
+ CAST(0.2 AS DECIMAL(10, 2)) AS decimal_sum;在不同客户端里,浮点结果可能被格式化后显示成 0.3,也可能暴露更多近似位;DECIMAL 结果则按十进制精确计算:
+---------------------+-------------+
| float_sum | decimal_sum |
+---------------------+-------------+
| 0.30000000000000004 | 0.30 |
+---------------------+-------------+不要用 float_value = 0.3 判断测量值是否相等。若业务允许误差,应比较差值是否落在容差内:
SELECT
ABS(
(CAST(0.1 AS DOUBLE) + CAST(0.2 AS DOUBLE)) - 0.3
) < 0.000001 AS close_enough;+--------------+
| close_enough |
+--------------+
| 1 |
+--------------+金额并不适合靠“足够接近”对账。保存和计算货币时直接使用 DECIMAL,能省掉一整类很难解释的尾差。
除法要先问两个问题:分母会不会为零,结果要整数还是小数。
SELECT
7 / 2 AS normal_division,
7 DIV 2 AS integer_division,
MOD(7, 2) AS remainder_value;+-----------------+------------------+-----------------+
| normal_division | integer_division | remainder_value |
+-----------------+------------------+-----------------+
| 3.5000 | 3 | 1 |
+-----------------+------------------+-----------------+DIV 和 MOD() 是 MySQL 的常用写法。PostgreSQL 中整数除以整数会得到整数结果,想保留小数要先把一边转为 numeric。这类差异很隐蔽,因为语句不报错,只是结果少了小数部分。
共享订单明细用 NOT NULL 阻止了空折扣率。为了单独观察传播规则,我们先把一个未知折扣放进表达式:
SELECT
79.90 * (
1 - CAST(NULL AS DECIMAL(5, 4))
) AS payable_amount;+----------------+
| payable_amount |
+----------------+
| NULL |
+----------------+79.90 * (1 - NULL) 不是 79.90。数据库知道单价,却不知道折扣率,因此不能断言结果是多少。只要表达式需要一个未知输入,输出通常也会变成未知。这就是 NULL 传播。订单明细的 NOT NULL DEFAULT 0 正是在写入入口消除这种歧义;客户手机号则允许缺失,因为“尚未提供联系方式”是合理状态。

在小满商店里,几个看似“没有内容”的值表达不同事实:
把 NULL 全部替换成零或空字符串会让页面看起来整齐,却可能抹掉“未知”和“已知为零”的区别。只有业务已经明确两者等价时,才应该替换。
SELECT customer_id, customer_name
FROM customers
WHERE phone = NULL;Empty set这条语句不是在问“phone 是否缺失”,而是在尝试比较“phone 是否等于一个未知值”。比较结果是 UNKNOWN,WHERE 只保留 TRUE,所以没有任何行留下。正确写法是:
SELECT customer_id, customer_name
FROM customers
WHERE phone IS NULL;+-------------+---------------+
| customer_id | customer_name |
+-------------+---------------+
| 3 | 白露 |
| 7 | 陆野 |
+-------------+---------------+相反条件使用 IS NOT NULL。MySQL 还提供 a <=> b 做 NULL 安全相等比较;PostgreSQL 和标准 SQL 常用 a IS NOT DISTINCT FROM b。如果查询需要跨数据库,后者的可移植性更好。
假设你想找“手机号不是苏小满这个号码的客户”:
SELECT customer_id, customer_name, phone
FROM customers
WHERE phone <> '13800001001';手机号为空的白露和陆野不会出现在结果里。NULL <> '13800001001' 的结果仍是 UNKNOWN,并不因为用了“不等于”就自动变成真。想把手机号未知的客户也纳入,需要把条件明确写全:
SELECT customer_id, customer_name, phone
FROM customers
WHERE phone <> '13800001001'
OR phone IS NULL;可以把三值逻辑简化成下面这张表:
这也是 NOT IN 遇到子查询中的 NULL 时容易返回意外空结果的根源。只要集合里有一个未知值,“外部值不等于集合中每一个值”就无法被证明。需要排除关联记录时,通常优先考虑语义明确的 NOT EXISTS。
COALESCE(a, b, c) 从左向右检查,返回第一个不是 NULL 的值;所有参数都是 NULL 时才返回 NULL。
SELECT
customer_id,
COALESCE(phone, email, '暂无联系方式') AS preferred_contact
FROM customers
WHERE customer_id IN (1, 3, 5)
ORDER BY customer_id;+-------------+--------------------------+
| customer_id | preferred_contact |
+-------------+--------------------------+
| 1 | 13800001001 |
| 3 | bailu@example.com |
| 5 | 13800001005 |
+-------------+--------------------------+它不是“把所有值拼起来”,而是按优先级选一个。参数还必须能转换为共同类型。COALESCE(phone, 0) 把手机号和数字混在一起,会把类型决策交给数据库;更稳妥的写法是 COALESCE(phone, '0'),但对于手机号,展示“未填写”通常比展示字符零更准确。
若另一套导入数据允许折扣率缺失,并且业务明确“缺失按零折扣”,可以把规则写进表达式:
SELECT
ROUND(
79.90
* (1 - COALESCE(
CAST(NULL AS DECIMAL(5, 4)),
0
)),
2
) AS payable_amount;+----------------+
| payable_amount |
+----------------+
| 79.90 |
+----------------+这里的 COALESCE 有明确业务依据。如果 NULL 代表“折扣审批还没完成”,直接按零结算就可能错收钱。函数写法相同,含义取决于数据约定。小满商店当前结构已经把无折扣直接保存为零,所以正常订单计算不需要在 discount_rate 外重复包一层 COALESCE。
NULLIF(a, b) 在两者相等时返回 NULL,否则返回 a。它常用来防止除以零。
例如计算每件商品的平均金额时,数量可能异常地为零:
SELECT
100.00 / NULLIF(0, 0) AS safe_result;+-------------+
| safe_result |
+-------------+
| NULL |
+-------------+这不会凭空产生一个平均值,只是把“无法计算”表示为 NULL。之后可以保留 NULL 提醒上游处理,也可以在展示层使用 COALESCE(..., '无法计算')。不要为了让报表没有空格,就把所有无法计算的结果都改成零;零可能是一个真实而完全不同的结果。
一条金额表达式突然返回 NULL 时,不要立刻把每个字段都包上 COALESCE。按下面顺序排查更稳:
先把复杂表达式拆开查询,逐列显示 quantity、unit_price、discount_rate,找出哪个输入是 NULL。
再确认这个 NULL 的业务含义:未知、不适用、尚未发生,还是导入失败。不同含义需要不同处理。
只有在默认值有业务依据时才使用 COALESCE,并让默认值与原参数保持兼容类型。
最后补一条包含 的测试数据,防止以后改公式时又把空值路径漏掉。
日期时间最麻烦的地方,不是记住 DATE_ADD() 或 EXTRACT() 的拼法,而是先回答:这个字段究竟表示一个日期、一段时长、某地墙上时钟显示的时间,还是全世界可以对齐的同一时刻?
小满商店常见的时间信息可以这样选择:
MySQL 中,DATETIME 保存给出的日期时间字段,本身不按会话时区自动转换;TIMESTAMP 写入时会从当前会话时区转为 UTC,读取时再从 UTC 转为当前会话时区。也就是说,TIMESTAMP 更像一个会随会话时区显示的时刻,DATETIME 更像一组原样保存的日历字段。
如果小满商店决定让 orders.order_date 使用 DATETIME 保存 UTC,就必须把这个约定写进字段说明和接口文档。另一种可行设计是使用 TIMESTAMP 并严格管理连接的会话时区。最危险的不是选了其中某一种,而是同一个字段有时写 UTC,有时写上海本地时间。
PostgreSQL 的 timestamp with time zone 会把时刻归一化保存,并按当前会话时区显示;它不会保留用户最初输入的时区名称。timestamp without time zone 不进行时区换算。看到两套数据库都写着 timestamp 时,不能只凭名字判断行为。

MySQL 常用 CURRENT_TIMESTAMP 或 NOW() 取得当前日期时间,CURRENT_DATE 只取日期:
SELECT
CURRENT_DATE AS today,
CURRENT_TIMESTAMP AS current_moment;结果会随执行时间变化,形状类似:
+------------+---------------------+
| today | current_moment |
+------------+---------------------+
| 2025-03-10 | 2025-03-10 10:30:00 |
+------------+---------------------+测试代码时,不要把不断变化的当前时间直接塞进预期结果。把基准时刻写成固定字面量,更容易复现:
SELECT
TIMESTAMP('2025-03-10 10:30:00') AS fixed_moment,
DATE('2025-03-10 10:30:00') AS fixed_date;+---------------------+------------+
| fixed_moment | fixed_date |
+---------------------+------------+
| 2025-03-10 10:30:00 | 2025-03-10 |
+---------------------+------------+SELECT
order_id,
order_date,
YEAR(order_date) AS order_year,
MONTH(order_date) AS order_month,
DAY(order_date) AS order_day,
HOUR(order_date) AS order_hour
FROM orders
WHERE order_id = 110;+----------+---------------------+------------+-------------+-----------+------------+
| order_id | order_date | order_year | order_month | order_day | order_hour |
+----------+---------------------+------------+-------------+-----------+------------+
| 110 | 2026-03-08 18:25:00 | 2026 | 3 | 8 | 18 |
+----------+---------------------+------------+-------------+-----------+------------+更接近标准 SQL 的写法是:
SELECT
EXTRACT(YEAR FROM order_date) AS order_year,
EXTRACT(MONTH FROM order_date) AS order_month
FROM orders
WHERE order_id = 110;+------------+-------------+
| order_year | order_month |
+------------+-------------+
| 2026 | 3 |
+------------+-------------+下一章按月份聚合订单时会用到这种表达式。不过用于筛选整月数据时,不要急着在列上套 YEAR() 和 MONTH(),后面“表达式与索引”会解释原因。
订单支付后保留 7 天,可以这样求截止时间:
SELECT
order_id,
order_date,
DATE_ADD(order_date, INTERVAL 7 DAY) AS service_deadline
FROM orders
WHERE order_id = 101;+----------+---------------------+---------------------+
| order_id | order_date | service_deadline |
+----------+---------------------+---------------------+
| 101 | 2025-06-18 10:05:00 | 2025-06-25 10:05:00 |
+----------+---------------------+---------------------+计算相隔天数可以使用 DATEDIFF():
SELECT
DATEDIFF('2025-03-15', '2025-03-08') AS days_between;+--------------+
| days_between |
+--------------+
| 7 |
+--------------+MySQL 的 DATEDIFF() 只比较日期部分,忽略具体时分秒。下面两个时刻只差 20 分钟,跨了午夜,结果仍是 1 天:
SELECT DATEDIFF(
'2025-03-09 00:10:00',
'2025-03-08 23:50:00'
) AS calendar_day_difference;+-------------------------+
| calendar_day_difference |
+-------------------------+
| 1 |
+-------------------------+如果业务问的是经过多少秒,应使用 TIMESTAMPDIFF(SECOND, start_time, end_time):
SELECT TIMESTAMPDIFF(
SECOND,
'2025-03-08 23:50:00',
'2025-03-09 00:10:00'
) AS elapsed_seconds;+-----------------+
| elapsed_seconds |
+-----------------+
| 1200 |
+-----------------+“跨了几个日历日”和“经过了多少完整的 24 小时”是不同问题。函数选错时,SQL 往往仍然给出一个很整齐的数字,所以比直接报错更危险。
SELECT
DATE_ADD('2025-01-31', INTERVAL 1 MONTH) AS next_month_date;+-----------------+
| next_month_date |
+-----------------+
| 2025-02-28 |
+-----------------+日历月份长度不同。一个月不是固定 30 天,也不是固定 31 天。订阅续费、账期和月末结算要先定义“31 日下单时,下个月在哪一天续费”。数据库函数会按自己的日历规则给出合法日期,但产品规则可能要求“每月最后一天”或“顺延到下一个工作日”,这些都需要额外表达。
假设接口收到的订单时刻按 UTC 保存,要展示为上海时间。这里用一个跨日边界的固定时刻把差异放大:
SELECT
CAST('2025-03-08 23:50:00' AS DATETIME) AS utc_time,
CONVERT_TZ(
'2025-03-08 23:50:00',
'+00:00',
'+08:00'
) AS shanghai_time;+---------------------+---------------------+
| utc_time | shanghai_time |
+---------------------+---------------------+
| 2025-03-08 23:50:00 | 2025-03-09 07:50:00 |
+---------------------+---------------------+同一时刻在 UTC 是 3 月 8 日,在上海已经是 3 月 9 日。按“订单当地日期”做日报时,必须先转换到业务时区再取日期,否则午夜附近的订单会落进错误的一天。
固定偏移 +08:00 对中国标准时间足够直观,但纽约、伦敦等地区有夏令时和历史规则。生产系统更适合使用 IANA 时区名,例如 America/New_York,前提是 MySQL 已加载时区表。若时区参数无效或相关时区数据不可用,CONVERT_TZ() 可能返回 NULL。因此上线前要检查时区数据,不要等日报突然出现空时间才排查。
PostgreSQL 通常使用 AT TIME ZONE 转换。对于带时区时刻,order_time AT TIME ZONE 'Asia/Shanghai' 得到上海当地的日历时间。读迁移代码时要特别留意输入是否带时区,因为 AT TIME ZONE 会根据输入类型执行不同方向的转换。
查询 2025 年 9 月 9 日全天订单,常见但脆弱的写法是:
SELECT order_id, order_date
FROM orders
WHERE order_date BETWEEN '2025-09-09 00:00:00'
AND '2025-09-09 23:59:59';这会遗漏 23:59:59.123456 这样的高精度时间。更稳的写法是从当天零点开始,到下一天零点之前结束:
SELECT order_id, order_date
FROM orders
WHERE order_date >= '2025-09-09 00:00:00'
AND order_date < '2025-09-10 00:00:00'
ORDER BY order_date;+----------+---------------------+
| order_id | order_date |
+----------+---------------------+
| 104 | 2025-09-09 09:20:00 |
+----------+---------------------+左闭右开区间还有一个好处:相邻两天可以无缝拼接,不会重复计算边界时刻。按月、按年筛选也使用同一规则。
CAST(value AS type) 是 SQL 中最清楚的显式转换方式。它让读者知道:这里的字符串要被当作日期,这里的数字要按固定精度计算,这里的编号只是为了展示才变成文字。
SELECT
CAST('2025-03-10' AS DATE) AS parsed_date,
CAST('128.90' AS DECIMAL(10, 2)) AS parsed_price,
CAST(101 AS CHAR) AS order_code;+-------------+--------------+------------+
| parsed_date | parsed_price | order_code |
+-------------+--------------+------------+
| 2025-03-10 | 128.90 | 101 |
+-------------+--------------+------------+MySQL 对部分错误转换比较宽松,并且行为受 SQL 模式影响。例如把 '79.90元' 放入数值上下文时,它可能读取开头的数字、忽略后面的文字并产生警告;严格环境里某些写入会直接报错。PostgreSQL 对这类字符串转数值通常直接报错。
因此,下面这种“只要 CAST 能给出结果就算合法”的导入方案不可靠:
SELECT CAST('79.90元' AS DECIMAL(10, 2)) AS suspicious_price;你可能在某个 MySQL 会话里看到:
+------------------+
| suspicious_price |
+------------------+
| 79.90 |
+------------------+
1 row in set, 1 warning这个结果恰恰说明风险:查询有值,不代表输入完全合法。导入时要先验证完整格式,再转换。价格文件最好把数值和货币单位拆成两列;接口直接发送数值字段,不要发送带“元”的展示文字。
不要依赖开发环境里一次宽松转换的结果。SQL 模式、目标类型和数据库产品都可能改变失败方式。关键数据应在输入边界验证完整格式,并在写入后检查告警或错误。
下面用 staging_products 模拟一次价格文件导入。它通过 CREATE TEMPORARY TABLE 建立,只在当前数据库会话中可见,不属于小满商店的核心九表;连接结束时数据库会自动清理,本例也会在演示后主动删除。
先创建会话级暂存表,并放入两条合法价格和一条带单位的异常价格:
CREATE TEMPORARY TABLE staging_products (
raw_product_name VARCHAR(80) NOT NULL,
raw_price VARCHAR(40) NOT NULL
);
INSERT INTO staging_products (raw_product_name, raw_price)
VALUES
('便携电子秤', '128.00'),
('咖啡滤纸', '79.9'),
('异常记录', '79.90元');暂存数据还没有进入 products。先把明显符合十进制格式的行筛出来:
SELECT raw_price
FROM staging_products
WHERE REGEXP_LIKE(raw_price, '^[0-9]+(\\.[0-9]{1,2})?$');+-----------+
| raw_price |
+-----------+
| 128.00 |
| 79.9 |
+-----------+然后再转换:
SELECT CAST(raw_price AS DECIMAL(10, 2)) AS unit_price
FROM staging_products
WHERE REGEXP_LIKE(raw_price, '^[0-9]+(\\.[0-9]{1,2})?$');+------------+
| unit_price |
+------------+
| 128.00 |
| 79.90 |
+------------+正则只是一层格式检查,完整导入还要检查范围、负数规则、空值语义和精度是否溢出。校验结束后,合格行可以再写入核心 products 表;本例不执行这一步,以免改变贯穿全课的固定数据。最后主动清理会话级暂存表:
DROP TEMPORARY TABLE staging_products;批量导入不需要跨多条语句保存中间行时,也可以用 WITH staging_products AS (...) 建立仅供一条查询使用的 CTE。无论选择临时表还是 CTE,都不要把导入原文长期混进核心商品表。更理想的方式是让上游按结构化类型传值,不要先把所有字段压成字符串,再让数据库猜。
'03/04/2025' 可能表示 3 月 4 日,也可能表示 4 月 3 日。数据库或客户端设置不同,理解也可能不同。交换日期时间时优先使用 ISO 形式:
SELECT
CAST('2025-03-04' AS DATE) AS clear_date,
CAST('2025-03-04 11:20:00' AS DATETIME) AS clear_datetime;+------------+---------------------+
| clear_date | clear_datetime |
+------------+---------------------+
| 2025-03-04 | 2025-03-04 11:20:00 |
+------------+---------------------+带时区的接口时间建议使用完整 ISO 8601 表达,例如 2025-03-04T11:20:00+08:00 或 UTC 的 2025-03-04T03:20:00Z。进入 MySQL 的 DATETIME 前要先按系统约定归一化,不能只把末尾时区字符裁掉。
上一章把多个结果集合并时,对应列需要兼容类型。假设运营想把一个订单编号和一个支付编号放进同一张“待核对编码”清单,编号都只用于展示,还要用第二列标明来源。与其让数据库决定共同类型,不如明确把两个编号转成字符:
SELECT CAST(order_id AS CHAR) AS business_code, '订单' AS source_type
FROM orders
WHERE order_id = 101
UNION ALL
SELECT CAST(payment_id AS CHAR) AS business_code, '支付' AS source_type
FROM payments
WHERE payment_id = 1;+---------------+-------------+
| business_code | source_type |
+---------------+-------------+
| 101 | 订单 |
| 1 | 支付 |
+---------------+-------------+business_code 现在具有统一字符类型,source_type 则防止读者把订单 101 与支付 1 混为一类编号。类型兼容只是集合操作的最低要求,列的业务含义也必须对齐。CAST 只能改变类型,不能自动统一业务含义。
当运算符两边类型不同,数据库必须想办法让它们可比较或可计算。MySQL 会在不少场景中自动把字符串转数字,或把数字转字符串:
SELECT
1 + '2' AS implicit_sum,
CONCAT('订单-', 101) AS implicit_text;+--------------+---------------+
| implicit_sum | implicit_text |
+--------------+---------------+
| 3 | 订单-101 |
+--------------+---------------+这看起来很省事,却会把错误数据藏起来。更糟的例子是:
SELECT
0 = 'abc' AS surprising_result,
7 > '6x' AS another_result;在 MySQL 的数值比较规则下,字符串可能按开头可解析的数字参与比较,并伴随警告:
+-------------------+----------------+
| surprising_result | another_result |
+-------------------+----------------+
| 1 | 1 |
+-------------------+----------------+如果你只看结果集、不看告警,很容易把非法输入当成正常值。PostgreSQL 的类型系统通常不会接受这种比较,要求你明确转换。严格报错虽然显得“不通融”,却能更早暴露问题。
products.product_id 是 BIGINT。查询应该传数值参数:
SELECT product_id, product_name
FROM products
WHERE product_id = 5;不要因为 HTTP 请求里所有值最初都是文本,就一直把 '5' 当字符串传进数据库。数据库驱动支持绑定参数类型;应用层知道目标列是整数,就应把输入验证、转换为整数后再绑定。
同样,unit_price 是 DECIMAL,参数也应绑定为准确十进制值。若语言本身的普通浮点数不能准确表示金额,可传十进制对象或符合接口约定的纯数值字符串,并由驱动按 DECIMAL 绑定。
为了和字符串参数比较,有人会这样写:
SELECT product_id, product_name
FROM products
WHERE CAST(product_id AS CHAR) = '5';它对每一行的 product_id 执行转换,还改变了普通索引里的比较形式。更合理的是把参数转成列的类型:
SELECT product_id, product_name
FROM products
WHERE product_id = CAST('5' AS UNSIGNED);如果参数来自应用,最好在应用层完成验证并以整数绑定,SQL 直接写 product_id = ?。原则很简单:让稳定的列保持原样,让外部输入去适配列的类型。
数据库中的值通常要经过两层处理。第一层产生正确的业务值,例如订单明细 3 的折后金额 153.00、上海当地日期 2025-03-09;第二层才决定怎么展示,例如“¥153.00”“2025年03月09日”。这两层混在一起,后续排序、筛选和汇总就会越来越别扭。
SELECT
total_amount,
CONCAT('¥', FORMAT(total_amount, 2)) AS amount_text
FROM orders
WHERE order_id = 101;+--------------+-------------+
| total_amount | amount_text |
+--------------+-------------+
| 118.90 | ¥118.90 |
+--------------+-------------+total_amount 可以相加、比较和排序,amount_text 是给人看的文字。数据量超过千元时差异更明显:
SELECT
FORMAT(12800.5, 2) AS formatted_amount,
FORMAT(12800.5, 2, 'de_DE') AS german_style;+------------------+--------------+
| formatted_amount | german_style |
+------------------+--------------+
| 12,800.50 | 12.800,50 |
+------------------+--------------+同一个值会因地区规则显示成不同文字。货币符号、千位分隔、负数样式都属于展示协议。面向单一内部报表时可以在 SQL 中格式化;面向多语言、多币种应用时,通常把原始数值和币种代码交给应用层,由用户语言环境决定显示形式。
错误思路是先把金额转成文字,再按文字排序:
SELECT
order_id,
FORMAT(total_amount, 2) AS amount_text
FROM orders
WHERE order_id IN (101, 107, 110)
ORDER BY amount_text DESC;当金额位数不同时,字符串顺序可能不符合数值大小。正确做法是按原数值排序:
SELECT
order_id,
FORMAT(total_amount, 2) AS amount_text
FROM orders
WHERE order_id IN (101, 107, 110)
ORDER BY total_amount DESC;+----------+-------------+
| order_id | amount_text |
+----------+-------------+
| 107 | 137.00 |
| 101 | 118.90 |
| 110 | 99.00 |
+----------+-------------+别名用于显示,原列负责业务顺序。这条习惯同样适用于百分比、日期标签和带单位的数量。
SELECT
order_id,
order_date,
DATE_FORMAT(order_date, '%Y年%m月%d日 %H:%i') AS order_time_text
FROM orders
WHERE order_id = 110;+----------+---------------------+--------------------------+
| order_id | order_date | order_time_text |
+----------+---------------------+--------------------------+
| 110 | 2026-03-08 18:25:00 | 2026年03月08日 18:25 |
+----------+---------------------+--------------------------+格式化后的文本不再知道月份、时区或时间先后,只剩字符。若接口只返回 order_time_text,调用方很难可靠地按用户时区重新显示。更好的接口结构是返回机器可读时间,例如带时区的 ISO 8601 字符串,再让界面生成中文样式。
PostgreSQL 使用 to_char() 格式化日期和数字,格式模板与 MySQL 的 %Y-%m-%d 体系不同。迁移时不能只改函数名,模板也要重写。
可以用下面四个问题判断:
例如,折后金额的计算口径属于业务逻辑,应该统一;金额前放人民币符号属于展示。订单 UTC 时间转换成报表所属时区,可能是报表语义的一部分;把月份显示成“3月”还是“Mar”,属于语言环境。
一个好用的查询结果通常同时保留“可继续计算的值”和“必要的展示标签”。如果只能二选一,优先保留有正确类型的原始业务值,因为文字可以稍后生成,丢掉的类型信息却很难可靠找回。
索引通常按列值本身建立有序结构。小满商店的 idx_orders_status_date(status, order_date) 先按状态、再按下单时间组织查找路径,却不一定直接知道每行 DATE(order_date) 的结果。把函数包在筛选列外面,可能让数据库无法按原来的时间范围定位,只能计算更多行。

第一种写法直观,但在列上执行了函数:
SELECT order_id, order_date
FROM orders
WHERE status = '已完成'
AND DATE(order_date) = '2025-09-09';第二种把日期转换成原列可以直接比较的范围:
SELECT order_id, order_date
FROM orders
WHERE status = '已完成'
AND order_date >= '2025-09-09 00:00:00'
AND order_date < '2025-09-10 00:00:00';两条查询在这组数据上返回相同行:
+----------+---------------------+
| order_id | order_date |
+----------+---------------------+
| 104 | 2025-09-09 09:20:00 |
+----------+---------------------+差异在读取路径。第二种条件直接描述了 order_date 索引中的连续区间,更容易形成范围扫描。
可以用 EXPLAIN 检查计划:
EXPLAIN
SELECT order_id, order_date
FROM orders
WHERE status = '已完成'
AND order_date >= '2025-09-09 00:00:00'
AND order_date < '2025-09-10 00:00:00';在数据量和统计信息合适时,重点关注的计划字段会呈现为:
+-------+------------------------+------------------------+------------------------+
| type | possible_keys | key | Extra |
+-------+------------------------+------------------------+------------------------+
| range | idx_orders_status_date | idx_orders_status_date | Using index condition |
+-------+------------------------+------------------------+------------------------+小表上优化器可能认为全表扫描更便宜,这不代表索引无效。EXPLAIN 不是死记某个字段值,而是用来确认:估算行数是否合理、候选索引是什么、最终是否选择了范围访问。
所谓“条件可利用索引”,常被称作可搜索性。你不必背术语,只要记住一个直觉:尽量让索引列站在比较符号一边,保持原样;把换算放到常量或参数一边。
如果业务确实总按 LOWER(email) 搜索,就有三个方向:
MySQL 可以使用生成列:
ALTER TABLE customers
ADD COLUMN email_normalized VARCHAR(120)
GENERATED ALWAYS AS (LOWER(TRIM(email))) STORED,
ADD INDEX idx_customers_email_normalized (email_normalized);查询时引用生成列:
SELECT customer_id, customer_name, email
FROM customers
WHERE email_normalized = 'xiaoman@example.com';+-------------+---------------+---------------------+
| customer_id | customer_name | email |
+-------------+---------------+---------------------+
| 1 | 苏小满 | xiaoman@example.com |
+-------------+---------------+---------------------+PostgreSQL 可以建立表达式索引:
CREATE INDEX idx_customers_lower_email
ON customers ((lower(email)));之后查询条件要与索引表达式匹配:
SELECT customer_id, customer_name, email
FROM customers
WHERE lower(email) = 'xiaoman@example.com';表达式索引会提高读取速度,但写入或更新时需要计算并维护索引,占用额外空间。不是见到函数就建索引,而是先看查询频率、选择性、数据量和写入成本。
如果列是字符串,参数却是数字,MySQL 可能把大量列值转为数值再比较。除了产生 '001'、'1x' 等意外相等问题,也可能妨碍字符串索引按原值查找。
以字符串类型的手机号为例,错误地传入数值参数会触发不必要的隐式转换:
-- 参数类型错误:把手机号作为数字
SELECT customer_id, customer_name
FROM customers
WHERE phone = 13800001001;应按列的真实类型传参:
SELECT customer_id, customer_name
FROM customers
WHERE phone = '13800001001';这也是为什么建表时应把业务类型设计对。若订单编号本质是带前缀的编码,就使用字符串;若它是纯内部主键,就使用整数。不要把长期的类型混乱留给每条查询临时修补。
现在把本章的知识放进一条完整查询。页面需要显示订单号、客户名、商品名、原价小计、折后金额、下单时间和联系方式。我们先保留可计算的数值与时间,再额外生成少量标签。
SELECT
oi.order_item_id,
o.order_id,
TRIM(c.customer_name) AS customer_name,
p.product_name,
oi.quantity,
oi.unit_price,
oi.discount_rate,
oi.quantity * oi.unit_price AS original_amount,
+---------------+----------+---------------+----------------------+----------+------------+---------------+-----------------+----------------+---------------------+------------------+-----------------------+
| order_item_id | order_id | customer_name | product_name | quantity | unit_price | discount_rate | original_amount | payable_amount | order_date | order_time_text | preferred_contact |
+---------------+----------+---------------+----------------------+----------+------------+---------------+-----------------+----------------+---------------------+------------------+-----------------------+
| 1 | 101 | 苏小满 | 白瓷马克杯 | 1 | 39.90 | 0.0000 | 39.90 | 39.90 | 2025-06-18 10:05:00 | 2025-06-18 10:05 | 13800001001 |
| 2 | 101 | 苏小满 | 原木托盘 | 1 | 89.00 | 0.1124 | 89.00 | 79.00 | 2025-06-18 10:05:00 | 2025-06-18 10:05 | 13800001001 |
| 3 | 102 | 顾言 | 65W 氮化镓充电器 | 1 | 169.00 | 0.0947 | 169.00 | 153.00 | 2025-07-02 21:10:00 | 2025-07-02 21:10 | 13800001002 |
+---------------+----------+---------------+----------------------+----------+------------+---------------+-----------------+----------------+---------------------+------------------+-----------------------+这条查询的每个表达式都能回答“为什么”:
TRIM 处理客户名两端录入空白,不改中间字符。discount_rate 已由 NOT NULL DEFAULT 0 保证每行都有明确数值,查询不再替结构重复兜底。DECIMAL 字段参与金额计算,最终在结算层 ROUND(..., 2)。order_date 保留日期时间类型,order_time_text 只负责本次页面显示。若存储约定为 UTC,还应先转换到目标时区再格式化。如果许多页面都要使用同一口径,可以建立视图:
CREATE VIEW v_order_item_details AS
SELECT
oi.order_item_id,
o.order_id,
o.order_date,
o.status AS order_status,
TRIM(c.customer_name) AS customer_name,
p.product_name,
oi.quantity,
oi.unit_price
查询视图:
SELECT
order_id,
customer_name,
product_name,
payable_amount
FROM v_order_item_details
WHERE order_id IN (101, 102)
ORDER BY order_id, payable_amount DESC;+----------+---------------+----------------------+----------------+
| order_id | customer_name | product_name | payable_amount |
+----------+---------------+----------------------+----------------+
| 101 | 苏小满 | 原木托盘 | 79.00 |
| 101 | 苏小满 | 白瓷马克杯 | 39.90 |
| 102 | 顾言 | 65W 氮化镓充电器 | 153.00 |
| 102 | 顾言 | 编织数据线 | 35.00 |
+----------+---------------+----------------------+----------------+视图能统一表达式,却不会自动缓存结果,也不会保证所有外部筛选都能获得最佳计划。复杂视图上线前仍要用 EXPLAIN 检查,并根据访问模式决定是否需要生成列、索引或单独的汇总表。
函数问题常表现成三类:值不对、行消失、查询变慢。下面是一套可以直接照着走的检查顺序。
不要只查询最终的折后金额:
SELECT
order_item_id,
quantity,
unit_price,
discount_rate,
1 - discount_rate AS remaining_rate,
quantity * unit_price AS original_amount,
quantity * unit_price * (1 - discount_rate) AS raw_result
FROM order_items
WHERE order_item_id <= 4
ORDER BY order_item_id;+---------------+----------+------------+---------------+----------------+-----------------+------------+
| order_item_id | quantity | unit_price | discount_rate | remaining_rate | original_amount | raw_result |
+---------------+----------+------------+---------------+----------------+-----------------+------------+
| 1 | 1 | 39.90 | 0.0000 | 1.0000 | 39.90 | 39.900000 |
| 2 | 1 | 89.00 | 0.1124 | 0.8876 | 89.00 | 78.996400 |
| 3 | 1 | 169.00 | 0.0947 | 0.9053 | 169.00 | 152.995700 |
| 4 | 1 | 39.00 | 0.1026 | 0.8974 | 39.00 | 34.998600 |
+---------------+----------+------------+---------------+----------------+-----------------+------------+中间列一展开,为什么 78.9964 最终应按结算规则显示为 79.00、为什么 152.9957 舍入为 153.00,就都清楚了。排查长表达式时,这比盯着括号看快得多。若某个输入出现 NULL,也能立刻看到它从哪一段开始向后传播。
先移除可疑条件,再把条件本身投影出来:
SELECT
customer_id,
phone,
phone <> '13800001001' AS is_other_phone,
phone IS NULL AS phone_missing
FROM customers
WHERE customer_id IN (1, 3, 5)
ORDER BY customer_id;+-------------+-------------+----------------+---------------+
| customer_id | phone | is_other_phone | phone_missing |
+-------------+-------------+----------------+---------------+
| 1 | 13800001001 | 0 | 0 |
| 3 | NULL | NULL | 1 |
| 5 | 13800001005 | 1 | 0 |
+-------------+-------------+----------------+---------------+结果里的 is_other_phone 如果是 NULL,放进 WHERE 就不会保留。用这种方法可以排查 NOT IN、范围比较和多个 AND 条件中藏着的空值路径。
当结果排序异常时,先看表达式是否已经变成字符串。最常见的线索是使用了 FORMAT()、DATE_FORMAT()、CONCAT() 或字符串字面量作为默认值。
SELECT
total_amount,
FORMAT(total_amount, 2) AS amount_text
FROM orders
ORDER BY total_amount;如果必须短暂保存计算结果,可以让数据库展示推导出的列定义。这里的 tmp_amounts 是 CREATE TEMPORARY TABLE 创建的会话级排错对象,不属于小满商店的核心九表,也不会成为后续章节依赖的业务表:
CREATE TEMPORARY TABLE tmp_amounts AS
SELECT
total_amount,
FORMAT(total_amount, 2) AS amount_text
FROM orders;
SHOW COLUMNS FROM tmp_amounts;你会看到 total_amount 仍是十进制数,而 amount_text 是字符类型。临时表在连接关闭时会自动消失;为了让清理时点明确,排查结束后立即删除:
DROP TEMPORARY TABLE tmp_amounts;日报常见错误是先取 UTC 日期,再换时区:
-- 错误方向:日期已经被截断,时区转换来不及了
SELECT DATE(order_date) AS utc_calendar_date
FROM (
SELECT CAST('2025-03-08 23:50:00' AS DATETIME) AS order_date
) AS sample_order;+-------------------+
| utc_calendar_date |
+-------------------+
| 2025-03-08 |
+-------------------+先转成上海当地时间,再取当地日期:
SELECT DATE(
CONVERT_TZ(order_date, '+00:00', '+08:00')
) AS shanghai_calendar_date
FROM (
SELECT CAST('2025-03-08 23:50:00' AS DATETIME) AS order_date
) AS sample_order;+------------------------+
| shanghai_calendar_date |
+------------------------+
| 2025-03-09 |
+------------------------+这两个日期都“正确”,只是回答的问题不同。排错时必须先写清报表采用哪个时区。
拿到慢查询,依次检查:
WHERE、JOIN ON、ORDER BY 中的索引列是否被函数或 CAST 包裹。EXPLAIN 核对估算行数和访问方式,不凭查询文本猜测。优化不能以改变结果为代价。把 DATE(order_date) = ? 改成范围条件时,要确认边界、精度和时区仍然回答同一个问题。
下面的练习继续使用小满商店固定数据。建议先自己写,再展开参考答案。只要结果正确,还要再问一遍:类型是否合适、NULL 路径是否明确、筛选列是否保持可搜索。
查询商品 5 的名称,并分别显示字符数与 UTF-8 字节数。
查询客户 1、3、5,标签格式为“姓名 / 联系方式”。优先用手机号,其次用邮箱,两者都没有时显示“暂无联系方式”。
要求保留两位小数,并同时显示计算前的高精度结果,观察舍入发生在哪一步。
不要在 order_date 上使用 YEAR(),写成左闭右开的范围。
某时刻以 UTC 保存为 2025-12-31 18:30:00。求它对应的上海日期。
原查询要找 2025 年 9 月创建的在售商品:
SELECT product_id, product_name, created_at
FROM products
WHERE status = '在售'
AND DATE_FORMAT(created_at, '%Y-%m') = '2025-09';请改写筛选条件,让 created_at 保持原样。
下面的查询想找邮箱不是 xiaoman@example.com 的客户,为什么客户 5 没出现?该怎样把邮箱未知的客户也保留?
SELECT customer_id, customer_name, email
FROM customers
WHERE email <> 'xiaoman@example.com';查询订单 113,同时返回数值金额和带人民币符号的展示文字。排序仍然使用数值列。
走到这里,我们已经能把一行原始记录加工成可靠的业务值:
DECIMAL,计算口径与舍入阶段都写清楚。NULL 不靠猜,判断用 IS NULL,替代值用有业务依据的 COALESCE。现在再看订单明细 1 到 4,数据库已经能为每一行算出折后金额:39.90、79.00、153.00、35.00。可运营真正想问的通常不是“这一行是多少”,而是“每个品类卖了多少”“每位客户消费多少”“全店平均客单价是多少”。这些问题要把许多行放进组里,再把每组压成一个结果。
下一章会从这条已经校准过的逐行表达式出发:
quantity * unit_price * (1 - discount_rate)它先在每条订单明细上产生准确金额,再交给 SUM、AVG、COUNT、MIN 和 MAX。先把一行算对,再谈一组;否则聚合只会把同一个小错误放大成一张看起来很正式的报表。
NULL先用 COALESCE 得到一个确定的字符串,再交给 MySQL 的 CONCAT,就不会因为其中一个联系方式为 NULL 而让整句变成 NULL。
原始计算仍保持更高标度,最终结算口径才舍入到分。不要先把 discount_rate 格式化成百分比文字再参与运算。