统计到月底那天,数据少了一天
9 月的订单有 5 笔,合计 150。
WHERE created_at BETWEEN '2026-09-01' AND '2026-09-30'查出来是 3 笔、60;把上界改成'2026-09-30 23:59:59',4 笔;改成'2026-10-01',6 笔。应用服务器换了一个时区之后,同一条 SQL 的结果又变了。
时间范围、环比、连续天数、排名,这几类统计的 SQL 写起来都很短,语义却各有一个容易想当然的地方:区间的端点算不算、「上一行」是不是「前一天」、并列怎么排、窗口到底包含哪些行。结果不会报错,只是和业务想要的不一样。
本文用几张可以手算的小表,在 MySQL 8.4.11 上执行这些查询并与手算答案对比。除了时区一节涉及 MySQL 的类型,其余都是 SQL 标准的语义。实验见文末。
一、时间范围用半开区间
created_at 是 DATETIME(3)。9 月 30 日有三笔订单(00:00、10:00、23:59:59.500),10 月 1 日 00:00:00 整还有一笔。
BETWEEN 两端都包含:上界写 30 日,只到 30 日零点;写 23:59:59,漏掉这一秒之后的小数部分;写下个月 1 日,又把零点整那笔算了进来。只有「大于等于月初、小于下月初」的半开区间不多不少| 条件 | 结果 | 问题 |
|---|---|---|
BETWEEN '2026-09-01' AND '2026-09-30' | 3 笔 / 60 | '2026-09-30' 被当作 30 日 00:00:00,这一天其余的订单都不在范围内 |
BETWEEN '2026-09-01' AND '2026-09-30 23:59:59' | 4 笔 / 100 | 漏掉 23:59:59 之后不足一秒的订单 |
BETWEEN '2026-09-01' AND '2026-10-01' | 6 笔 / 210 | BETWEEN 包含上界,10 月 1 日零点整的订单被算进来 |
>= '2026-09-01' AND < '2026-10-01' | 5 笔 / 150 | 正确 |
DATE(created_at) BETWEEN '2026-09-01' AND '2026-09-30' | 5 笔 / 150 | 结果正确,但用不上索引范围 |
第二种写法在秒级精度的列上「看起来没问题」,换成毫秒精度的列,或者数据来自另一个保留了小数秒的系统,就开始漏数据。第三种在相邻两个月的报表里会把同一笔订单各算一次。
半开区间(包含起点、不包含终点)没有这些问题:不需要知道这个月有几天、列的精度是多少,相邻两个区间不重叠也不留缝。
最后一种写法结果是对的,代价在执行计划上。实测半开区间是 type=range,在 created_at 的索引上只扫描范围内的条目;DATE(created_at) 是 type=index、possible_keys=NULL,优化器没有可用的范围,只能扫描整个索引。条件里对列做了运算,就不能再用这一列的索引定位,原因见读懂 Explain 执行计划。
二、时区:同一条 SQL,两个答案
半开区间写对了,结果还取决于「9 月 1 日零点」是哪个时区的零点。同一张表、同一条 SQL,只改会话时区:
| 列类型 | 会话时区 +00:00 | 会话时区 +08:00 |
|---|---|---|
TIMESTAMP | 5 笔 / 150 | 4 笔 / 100 |
DATETIME | 5 笔 / 150 | 5 笔 / 150 |
TIMESTAMP 存的是一个时刻:写入时从会话时区转成 UTC,读出时再转回会话时区。条件里的字面量也按会话时区解释,'2026-09-01' 在 +08:00 会话里对应 UTC 的 8 月 31 日 16:00。那笔 UTC 23:59:59.500 的订单,在 +08:00 会话里显示为 2026-10-01 07:59:59.500,属于 10 月。
DATETIME 存的是「年月日时分秒」这几个数字本身,不做任何转换。它不随会话时区变,但也不知道自己是哪个时区的时间:两个时区不同的应用写同一张表,数字就混在一起了。
两种类型都能用,要点是三件事保持一致并写下来:数据库里存的是哪个时区的时间、连接的会话时区是什么(JDBC 连接参数或 time_zone 设置)、报表说的「一天」按哪个时区切。应用服务器迁移、容器镜像的默认时区不同,都会悄悄改变第二项。
三、LAG 取的是上一行,不是前一天
每日销售额表里,9 月 3 日没有记录(当天没有销售,或者数据没同步过来)。算「较前一日的变化」:
SELECT day, amount - LAG(amount) OVER (ORDER BY day) AS diff FROM daily;| 日期 | 直接 LAG | 上一行是前一天才比较 | 先补齐日历 |
|---|---|---|---|
| 09-02 | +20 | +20 | +20 |
| 09-03 | (没有这一行) | (没有这一行) | -120 |
| 09-04 | -30 | NULL | +90 |
LAG 只认排序后的上一行。9 月 4 日的上一行是 9 月 2 日,所以 -30 是「比两天前少 30」,却出现在「较前一日」这一列里。
选哪种取决于缺失的含义。缺一天表示「当天是 0」,就用日历表(或递归 CTE 生成日期序列)左连接补齐,再算差值;表示「数据缺失、不知道」,就在上一行不是前一天时返回 NULL。两种含义对应的数字完全不同(-30、NULL、+90),需要和数据的来源方确认,不能由写 SQL 的人猜。
同比、移动平均、留存都有同样的前提:窗口函数按行工作,行与行之间的时间间隔它不关心。
四、「累计 3 天」与「连续 3 天」
三个用户的登录日期:u1 是 1、2、3 日;u2 是 1、3、5 日;u3 是 1、2 日和 4、5、6 日。
| 条件 | 满足的用户 |
|---|---|
GROUP BY user_id HAVING COUNT(*) >= 3 | u1、u2、u3 |
| 连续登录不少于 3 天 | u1、u3 |
COUNT 只管次数。判断连续的常用办法是:按日期排序编号,用「日期减去行号」作为分组键。连续的日期减去连续的行号,得到同一个值;一旦中断,差值就变了。
SELECT user_id, COUNT(*) AS days
FROM (SELECT user_id, day,
day - INTERVAL ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY day) DAY AS grp
FROM logins) g
GROUP BY user_id, grp
HAVING COUNT(*) >= 3;前提是每个用户每天最多一行。一天有多条记录时先去重,否则行号比日期走得快,分组就错了,这又回到了结果的一行代表什么。
五、并列时的「前 3 名」
五个人的分数是 90、90、80、80、70:
| 函数 | 名次 | 取「名次 ≤ 3」 |
|---|---|---|
RANK() | 1、1、3、3、5 | 4 行 |
DENSE_RANK() | 1、1、2、2、3 | 5 行 |
ROW_NUMBER() | 1、2、3、4、5 | 3 行 |
三种都叫「前 3 名」。要固定 3 个名额用 ROW_NUMBER,并列的都算用 RANK,取分数最高的 3 档用 DENSE_RANK。
ROW_NUMBER 和 ORDER BY … LIMIT 有同一个隐患:排序键有并列时,并列的行谁先谁后是不确定的,两次执行可能不同,翻页时会出现重复或漏掉的行。在排序键最后加上一个唯一列(通常是主键),顺序才确定。
六、默认窗口帧不是「到当前行」
累计求和:
SELECT day, shop, SUM(amount) OVER (ORDER BY day) AS running FROM sales;SUM(amount) OVER (ORDER BY day) 没有写帧,默认是 RANGE:帧的终点不是当前行,而是所有排序值与当前行相同的行。第 2 天有两行,它们的累计值都是 220。要逐行累计,排序键要能区分每一行,并显式写 ROWS窗口函数只写 ORDER BY、不写帧时,默认帧是 RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW。RANGE 按排序值划范围,「当前行」指的是所有排序值与当前行相同的行。第 2 天的两行因此得到同一个累计值 220,而不是 150 和 220。
同一个默认帧还让 LAST_VALUE 看起来「不工作」:帧到当前行为止,最后一个值就是当前行自己。
| 写法 | 四行的结果 |
|---|---|
SUM(amount) OVER (ORDER BY day) | 100、220、220、230 |
SUM(amount) OVER (ORDER BY day, shop ROWS UNBOUNDED PRECEDING) | 100、150、220、230 |
LAST_VALUE(amount) OVER (ORDER BY day, shop) | 100、50、70、10 |
同上,帧为 ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING | 10、10、10、10 |
用到累计、移动窗口、FIRST_VALUE/LAST_VALUE 时,显式写出帧,并让排序键唯一。
七、ROLLUP 的总计行也是 NULL
按城市汇总并带总计,数据里有一行的城市本身是 NULL(未知):
SELECT city, SUM(amount), GROUPING(city) FROM city_sales GROUP BY city WITH ROLLUP;| city | 合计 | GROUPING(city) |
|---|---|---|
| NULL | 30 | 0 |
| X | 100 | 0 |
| Y | 50 | 0 |
| NULL | 180 | 1 |
两行的 city 都是 NULL:一行是「城市未知」的小计,一行是总计。应用层用 city IS NULL 取总计,会拿到两行。GROUPING() 返回 1 的才是汇总行,用它来区分,或者用它把总计行的名称换成固定的文字。
八、每组最新的一行
取每个设备最新的一条读数,常见写法是先求每个设备的最大时间,再连回原表:
SELECT r.* FROM readings r
JOIN (SELECT device, MAX(ts) AS ts FROM readings GROUP BY device) m
ON m.device = r.device AND m.ts = r.ts;设备 d1 在最新时刻有两条读数,这个查询返回 3 行(d1 两行、d2 一行),下游如果按「每个设备一行」来用,就产生了重复。
SELECT * FROM (
SELECT r.*, ROW_NUMBER() OVER (PARTITION BY device ORDER BY ts DESC, id DESC) AS rn
FROM readings r) t
WHERE rn = 1; -- 2 行ROW_NUMBER 保证每组恰好一行,并列时由第二排序键决定取哪一条。同一时刻为什么会有两条,本身也值得查:是重复上报,还是时间精度不够。
九、检查清单
- 时间范围用
>= 起点 AND < 终点,不用BETWEEN;不对时间列套函数。 - 写明存储的时区、会话时区、报表的「一天」按哪个时区。
LAG/LEAD之前确认行与行之间的间隔;缺失的日期是「0」还是「未知」。- 「连续」不能用
COUNT。 - 排名:并列怎么处理;排序键最后加唯一列。
- 窗口函数显式写帧。
ROLLUP用GROUPING()识别汇总行。- 「每组一行」用
ROW_NUMBER,不用最大值回连。
配套实验
- codesphere-labs/storage/sql-time-and-window:MySQL 8.4.11 上 26 条查询的结果与手算期望值逐行对比,以及两种时间范围写法的执行计划(验证记录)
参考资料
- MySQL 8.4 Reference Manual:The DATE, DATETIME, and TIMESTAMP Types(
TIMESTAMP的时区转换) - MySQL 8.4 Reference Manual:Window Function Frame Specification(默认帧)
- MySQL 8.4 Reference Manual:Window Function Descriptions(
LAG、RANK、DENSE_RANK、ROW_NUMBER、LAST_VALUE) - MySQL 8.4 Reference Manual:GROUP BY Modifiers(
WITH ROLLUP与GROUPING()) - MySQL 8.4 Reference Manual:Comparison Functions and Operators(
BETWEEN包含两端)