1. 从一个翻车现场说起:between and 到底怎么算边界
“统计11月1日到11月30日之间注册的用户数”,我当初直接在线上库写了这么一条:
SELECT COUNT(*) FROM users WHERE create_time BETWEEN '2023-11-01' AND '2023-11-30';结果比预期少了一整天的数据。排查之后发现,问题就出在那个'2023-11-30'上。MySQL 拿到这个字符串后,会把它当成'2023-11-30 00:00:00'来处理,所以11月30日当天零点以后注册的用户,全部被挡在了范围之外。那天晚上我盯着屏幕想了半天,最后才意识到这不是SQL写错了,而是对 between and 的边界语义理解不到位。
这个例子说明,between and 虽然看起来只是“在某某和某某之间”,实际上的边界包含关系、类型转换、索引利用、NULL值处理,每一个环节都有坑。它常用于数值过滤、日期区间统计、字符串前缀筛选这类范围查询场景,适合刚写完基础SELECT、开始接触真实业务查询的开发者,也适合写过不少SQL但偶尔被边界Bug打懵的老手。这篇文章不搞虚的,直接把语法、类型、索引和排错经验一次说透。
2. 基本语法与执行逻辑:闭区间、等价写法和类型转换
2.1 BETWEEN ... AND ... 的固定语法与等价条件
between and 的基本语法是这样的:
SELECT ... FROM 表 WHERE 列 BETWEEN 下限 AND 上限;这里有一个特别容易混淆的点:BETWEEN关键字后面的AND并不是逻辑与,而是 between 语法的一部分。如果你后面还要继续加其他条件,比如“列在区间内且状态为正常”,可以写成:
SELECT ... FROM 表 WHERE (列 BETWEEN a AND b) AND 状态 = 1;我建议给 between 条件加上括号,这样别人读SQL时不会产生歧义,也方便后续维护。
语义上,BETWEEN a AND b是一个闭区间,等价于列 >= a AND 列 <= b。比如“订单金额在100到200之间”,就包含100元和200元这两个端点:
SELECT * FROM orders WHERE amount BETWEEN 100 AND 200;很多初学者会把边界理解成开区间,以为100和200不算在内,这是最常见的认知偏差。在统计场景里,端点多算一笔或少算一笔,结果就完全对不上。写SQL之前先在草稿上问自己一句:这个范围,首尾到底算不算?想清楚再动手。
2.2 边界包含、NULL规则和隐式类型转换
between 对 NULL 的处理经常被人忽略。任何列值与边界比较时,如果列值是 NULL,那么比较结果既不是真也不是假,而是 UNKNOWN,最终这行数据不会出现在结果集里。换句话说,WHERE col BETWEEN 1 AND 10永远查不出 col IS NULL 的行。如果业务上需要统计“没有值”的记录,得单独加OR col IS NULL,不能指望 between 顺手帮你带出来。
另一个重点是类型转换。between 的两个边界本质上也是表达式,MySQL 会比较列值和边界的类型。当列是字符串类型、边界是数值时,MySQL 会尝试把列值转成数值再比较。最典型的翻车场景是手机号字段:
SELECT * FROM users WHERE mobile BETWEEN 13800000000 AND 13999999999;mobile 如果是 VARCHAR,这个查询会触发整列的类型转换,索引基本失效,还可能因为部分手机号里有特殊字符,转出来的值完全不符合预期。正确写法是让边界类型与列类型一致:
SELECT * FROM users WHERE mobile BETWEEN '13800000000' AND '13999999999';不要赌隐式转换。写范围查询时,先看一眼字段类型,再决定边界用什么形式,这是减少线上事故最有效的习惯之一。
3. 三种数据类型的范围查询实战与避坑
3.1 数值范围:直观简单,但浮点边界要谨慎
数值型的范围查询最好写,比如用户积分、库存数量、订单金额,直接写区间即可:
SELECT * FROM products WHERE stock BETWEEN 10 AND 50;数值类型里,整数和 DECIMAL 的边界比较是精确的,但 FLOAT 和 DOUBLE 存在精度问题。举个例子,商品价格是 FLOAT 类型的9.99,你写BETWEEN 9.9 AND 10.0,因为9.9在二进制浮点里无法精确表示,实际存储的值可能是9.9000000000000004,最终出来的结果就和你脑子里想的区间不完全一致。
金融、结算类业务,金额字段一定用 DECIMAL,别用 FLOAT。如果查询字段本身历史遗留是 FLOAT,那做范围过滤时最好用ROUND(col, 2)配合显式比较,虽然这样可能影响索引使用,但至少结果可控。优先保证数据准确,再谈性能。
数值边界还有一种常见低级错误:下限大于上限。BETWEEN 10 AND 5这种写法 MySQL 不会报错,但查出来的结果集永远是空的。排查“范围查询结果为空”时,除了看数据本身,第一反应就应该是检查上下限有没有写反。
3.2 日期时间范围:最常见也最容易出错的场景
日期时间范围是 between and 使用率最高、也最容易出Bug的场景。核心问题在于:日期字符串被当成什么精度来比较。
如果 create_time 是 DATETIME,你写:
WHERE create_time BETWEEN '2023-11-01' AND '2023-11-30'MySQL 会把它翻译成:
WHERE create_time >= '2023-11-01 00:00:00' AND create_time <= '2023-11-30 00:00:00'于是11月30日当天任何晚于零点的时间点都会被排除。这是典型的“少一天”Bug。
有同学会想,那我写'2023-11-30 23:59:59'总行了吧?在秒级精度的 DATETIME 下确实能覆盖当天,但如果表结构升级到了 DATETIME(6),也就是支持微秒精度,那么最后0.999999秒内的数据还是会漏掉。
我推荐的方案很简单:日期范围一律用前闭后开区间:
WHERE create_time >= '2023-11-01 00:00:00' AND create_time < '2023-12-01 00:00:00'含义是:包含11月1日零点开始,不包含12月1日零点,正好覆盖完整的11月。这样的好处是边界与编程语言里的[start, end)习惯完全一致,也不依赖23:59:59这种“取巧”的写法。
如果查询列本身是 DATE 类型,那直接写BETWEEN '2023-11-01' AND '2023-11-30'没问题,因为日期天然没有时分秒。TIMESTAMP 类型则要注意会话时区设置,存进去是UTC时间,查出来按当前时区转换,跨时区统计时边界的一头一尾很容易差8个小时,最好把会话时区统一后再跑范围查询。
3.3 字符串范围:隐藏的字典序与排序规则
字符串也能用 between,但它走的是字典序比较,不是数字大小,也不是拼音顺序。比如产品编码字段:
SELECT * FROM products WHERE product_code BETWEEN 'A001' AND 'C999';这个查询会返回所有以A、B、C开头的编码,前提是这些编码的字典序落在区间内。听起来简单,实际上排序规则影响很大。
MySQL 常用的 utf8mb4_general_ci 是大小写不敏感排序规则,你写BETWEEN 'a' AND 'z',结果可能把'Z'也算进去,因为在大小写不敏感的比较里,'Z'等于'z',落在区间内。如果换成 utf8mb4_bin 这种二进制排序规则,大小写又会严格区分。同一个SQL,在不同字符集、不同排序规则下结果完全不一样,这是字符串范围查询最难排查的地方。
中文场景更别乱用。比如按姓名拼音首字母筛选,直接写WHERE name BETWEEN '张' AND '李',MySQL按的是Unicode码点比较,不是拼音顺序,结果大概率和你想要的对不上。字符串范围查询比较适合编码、编号这类按固定字典序生成的字段,不适合做“字母表筛选”或“拼音筛选”。真正要做前缀匹配,用LIKE '张%'更符合直觉;要做首字母筛选,就在表里单独存一个首字母字段,查询时等值匹配。
4. 让范围查询走索引:执行计划与联合索引实战
4.1 用 EXPLAIN 判断是否走了范围索引
between and 能不能用上索引,关键看条件能否被优化器转化成索引范围扫描。判断方法很简单,执行:
EXPLAIN SELECT * FROM orders WHERE create_time BETWEEN '2023-11-01 00:00:00' AND '2023-12-01 00:00:00';如果 type 列显示range,说明走了索引范围扫描;如果显示ALL,说明是全表扫描。type 为 range 时,key 列通常能看到实际用到的索引名。
要注意的是,range并不代表一定快。如果区间跨度太大,比如BETWEEN '2020-01-01' AND '2023-12-31',优化器算一下发现要访问的行数占全表比例很高,回表成本大于直接扫全表,它就会放弃索引。这是成本评估的结果,不是 SQL 写法的问题。想提高效率,要么缩小范围,要么把查询改成覆盖索引,让索引本身包含要返回的所有列。
4.2 联合索引设计时,范围列为什么要放在后面
联合索引的使用遵循最左前缀原则,而范围条件会影响到它后面的列。比如在 (user_id, create_time) 上建了联合索引,下面这条查询能高效利用索引:
SELECT * FROM orders WHERE user_id = 100 AND create_time BETWEEN '2023-11-01' AND '2023-12-01';user_id 的等值条件在最前面,可以精确定位,create_time 的范围条件在第二位,依然能利用索引排序和扫描。反过来,如果范围列在前、等值列在后,比如:
SELECT * FROM orders WHERE create_time BETWEEN '2023-11-01' AND '2023-12-01' AND user_id = 100;若 create_time 作为联合索引第一列,那么范围条件之后的 user_id 等值条件就很难再有效利用索引了,因为索引先按时间定位了大量行,然后才在这批行里过滤 user_id。MySQL 8.0 的跳跃扫描在某些场景下能缓解问题,但限制不少,不能当常规手段依赖。
所以设计联合索引时,老实遵循“等值列放前、范围列放后”的规则。SQL 的 WHERE 顺序不影响优化器调整,但索引定义的列顺序会影响。另外,如果查询结果需要按时间 ORDER BY,把排序列也放到索引里,还能省掉 filesort 排序。
4.3 隐式转换如何让 between and 索引失效
这是慢 SQL 排查看得最多的一个坑:字段类型和边界类型不一致,MySQL 做了隐式转换,导致索引失效。
典型例子是 varchar 列和数值边界:
SELECT * FROM users WHERE mobile BETWEEN 13800000000 AND 13999999999;mobile 是 VARCHAR,MySQL 会把 mobile 列里的值转成数字再和边界比较,这一转换发生在每一行上,索引自然用不上,EXPLAIN 的结果就是 type=ALL。遇到这种慢查询,先看表结构,再看 SQL 边界类型,把条件改成字符串边界,问题立刻解决。
同样的问题也会出现在数值列和字符串边界上:WHERE id BETWEEN '1' AND '10'通常没问题,因为'1'和'10'能安全转成数字,但这依赖MySQL的转换规则,不值得赌。与其依赖隐式转换,不如从一开始就保持类型一致。
5. between and 常见问题与排查经验实录
5.1 高频问题速查表
我把这几年遇到的范围查询问题整理成了一张表,碰到类似现象可以直接对照排查。
| 现象 | 根因 | 处理方式 |
|---|---|---|
| 按天统计,结果少了最后一天的数据 | 上边界写成'2023-11-30',被当成当天零点 | 改为前闭后开区间,上边界用下一天零点 |
| 按秒统计,最后一秒的数据丢失 | 上边界写'23:59:59',字段是DATETIME(6) | 改用col < 下一天零点 |
| 查询结果为空 | 上下限写反,比如BETWEEN 10 AND 5 | 检查边界顺序 |
| 范围查询查不到 NULL 值 | between 不包含 NULL | 需要统计空值时,单独加OR col IS NULL |
| 字符串范围结果和预期不符 | 排序规则是大小写不敏感或 Unicode 排序 | 确认 collation,或改用 LIKE 前缀匹配 |
| 慢SQL、全表扫描 | varchar列和数值边界比较,隐式转换 | 统一边界类型,EXPLAIN 验证 |
| NOT BETWEEN 行为反直觉 | 等价于< 下限 OR > 上限,不包含边界 | 明确业务需要开区间还是闭区间 |
NOT BETWEEN 也值得单独说。col NOT BETWEEN 6 AND 10等价于col < 6 OR col > 10,6和10本身不在结果里。如果你想要“排除特定区间,但保留端点”,那 NOT 写法就不合适了。
5.2 一次慢SQL的完整排查过程
有一次线上反馈,订单统计接口很慢,单次查询要好几秒。我拉出SQL一看,结构是这样的:
SELECT SUM(amount) FROM orders WHERE pay_time BETWEEN '2023-11-01 00:00:00' AND '2023-12-01 00:00:00';pay_time 字段上有索引,理论上应该很快。EXPLAIN 执行完,type 列是 ALL,key 列是 NULL,明显全表扫描。
一开始以为是边界类型的问题,但看字段是 DATETIME,边界也是日期时间字符串,类型匹配没问题。继续往下查才发现,SQL 里对 pay_time 做了函数包裹,实际线上版本是这样的:
SELECT SUM(amount) FROM orders WHERE STR_TO_DATE(pay_time, '%Y-%m-%d %H:%i:%s') BETWEEN '2023-11-01 00:00:00' AND '2023-12-01 00:00:00';虽然 pay_time 字段实际定义是 VARCHAR,但因为历史原因存的是格式化好的时间字符串,代码里习惯性用了 STR_TO_DATE 把它转成日期再比较。问题就出在这里:一旦对索引列使用函数,优化器没法直接利用索引定位,只能全表逐行算一遍。
解决方案有两种:一是如果格式固定,直接去掉函数,用字符串比较字符串,让范围查询落在索引上;二是新增一个真正的 DATETIME 冗余字段,写入时同步维护,查询走新字段索引。第一种方案立竿见影,第二种方案更规范,适合长期维护。
这个案例给我的教训是:范围查询慢,优先看 EXPLAIN;看到 ALL 之后,依次检查边界类型、索引列有没有套函数、区间跨度是否过大。这三步走完,大部分问题都能定位。
6. 个人经验:用“前闭后开”替代日期型 between
6.1 为什么业务代码里我更推荐 col >= start AND col < end
现在写业务SQL,日期时间范围我基本不再用 between 了,统一改写为:
WHERE col >= '2023-11-01 00:00:00' AND col < '2023-12-01 00:00:00'原因很简单:前闭后开天然避免“少一天”“少最后1秒”这类边界问题。程序的循环、分页、时间处理也普遍采用[start, end)习惯,SQL 和代码保持同一种心智模型,联调时不用来回换算。
对于数值范围,我仍然会用 between,因为它表达区间更简洁。比如积分 100到200之间,BETWEEN 100 AND 200读起来非常清爽。但日期时间、时间戳、带微秒的字段,一律前闭后开。这个约定建议团队在代码规范里写死,新代码按这个来,老代码遇到就顺手改掉,时间久了,半夜被拉起来处理边界Bug的概率会小很多。
6.2 与Flink、ClickHouse同步等工具配合时的边界提醒
现在很多项目用 Flink CDC 把 MySQL 数据同步到 ClickHouse 或 Elasticsearch,同步插件读的是 binlog,一般不受范围 SQL 影响。但如果做的是按时间增量的补偿同步,仍然要写类似这样的SQL:
SELECT * FROM orders WHERE update_time >= ? AND update_time < ?;这种增量边界查询如果用 between 来写,区间端点很容易多算一次或少算一次,导致重复数据或丢数据。比如上边界包含关系没搞清楚,同一批订单会被重复拉取,下游就出现重复记录。用前闭后开区间,配合程序里已处理位点做推进,边界才能严格对齐。
分库分表中间件对 between 的改写能力也不完全一致,有的能按边界路由到不同分片,有的会把区间拆得比较粗糙。跨分片范围查询前,建议先看中间件的路由日志,确认没有把所有分片都扫一遍。
我个人在这几年维护系统的过程中最深的一个感触是:最隐蔽的Bug往往不是来自复杂JOIN,而是这类范围边界问题。要么是 between 的端点没算对,要么是时区没对齐,要么是精度差了一秒。写范围查询前,把这篇文章提到的每个坑在脑子里过一遍,比上线后再排查要划算得多。