线上事故复盘:ORDER BY DESC LIMIT 1 为什么选错了数据?

37 阅读6分钟

搁置很久,终于又开始写博客了,今天记一次线上故障的原因及原理

PS:本文为手写大纲+AI扩展并润色,DDL是第一生产力,AI是第二生产力,哈哈哈~

一、数据库查询执行原理

1. ORDER BY 的排序机制

SELECT * FROM table_a 
ORDER BY period DESC 
LIMIT 1

MySQL排序过程:

阶段1: 数据扫描
├─ 全表扫描 或 索引扫描
└─ 读取所有符合条件的记录到内存
​
阶段2: 排序比较
├─ 使用 period 字段进行比较
├─ 当 period 值相同时 → 比较结果为 "相等"
└─ "相等" 的记录之间的相对顺序是 **未定义的**
​
阶段3: LIMIT 截取
└─ 取排序后的第1条记录(但相等记录的顺序不稳定)

2. 为什么相同 period 的记录顺序不确定?

原因 A:物理存储顺序影响

磁盘上的数据页(Page):
┌─────────────────────────────────┐
│ Page 1: 记录1-100               │
│ - 记录53: period=202401, id=53  │ ← 先写入
│ - 记录87: period=202401, id=87  │ ← 后写入
└─────────────────────────────────┘
​
如果没有索引,扫描顺序 = 物理存储顺序
但物理存储顺序受以下因素影响:
1. INSERT 顺序
2. UPDATE 导致的行迁移
3. DELETE 后的空间复用
4. 页分裂和合并

原因 B:索引扫描的不确定性

假设有索引 idx_period

B+树索引结构:
                [202401]
                /      \
         [202401,id=53] [202401,id=87]
              ↓              ↓
         数据页A          数据页B
​
当 period 相同时:
- 索引中的排序只保证 period 字段有序
- 同一个 period 值下的多条记录,在B+树的叶子节点中:
  * 可能按主键ID排序(InnoDB特性)
  * 可能按插入顺序
  * 但SQL标准不保证这个顺序是稳定的

二、具体场景分析

场景1:数据插入顺序导致的不一致

// 时刻 T1: 数据库中的数据
id  | period  | updateTime          
----|---------|--------------------
100 | 202401  | 2024-01-10 10:00:00  ← 先插入
101 | 202401  | 2024-01-15 14:30:00  ← 后插入
99  | 202312  | 2024-01-05 09:00:00// 执行查询 Query1
ORDER BY period DESC LIMIT 1
→ 可能返回 id=100 或 id=101(不确定)

为什么?

排序算法的稳定性:
期间值    记录ID    排序后的可能顺序
202401[100]  →  情况A: [100, 101]  
202401[101]  →  情况B: [101, 100]
202312[99]   →        [99]MySQLfilesort(文件排序):
- 使用 quicksort(快速排序)
- 快速排序是 **不稳定排序算法**
- 相等元素的相对位置可能改变

场景2:并发修改导致的不一致

// 线程A执行查询
Time: 10:00:00.000
SELECT * FROM table ORDER BY period DESC LIMIT 1// 线程B同时插入新数据
Time: 10:00:00.001
INSERT INTO table (period, updateTime) 
VALUES (202402, NOW())
​
// 结果:
查询A在10:00:00.000 → 返回 period=202401
查询A在10:00:00.002 → 返回 period=202402 (新数据)

隔离级别的影响:

READ COMMITTED(读已提交):
事务A ──────────[查询]──────────→
事务B ──[开始][插入][提交]───→
              ↑
         可能读到新数据
​
REPEATABLE READ(可重复读,MySQL默认):
事务A ──[开始]──[查询1]──[查询2]──→
事务B ──────[插入+提交]──────────→
                ↑
           查询1和查询2看到的数据可能不同
           (取决于事务开始时间)

场景3:索引选择导致的不一致

-- 有两个索引
CREATE INDEX idx_period ON table(period);
CREATE INDEX idx_update_time ON table(update_time);
​
-- 查询时,MySQL可能选择不同的索引:
执行计划A: 使用 idx_period
  ├─ 按 B+树顺序扫描
  └─ 叶子节点内部顺序 = 主键顺序
​
执行计划B: 全表扫描
  ├─ 按聚簇索引(主键)顺序扫描
  └─ 然后 filesort 排序
​
不同执行计划 → 不同扫描顺序 → 相同period的记录顺序不同

查看执行计划:

EXPLAIN SELECT * FROM table_a 
ORDER BY period DESC LIMIT 1;
​
+----+-------+-------+------+----------+
| id | type  | key   | rows | Extra    |
+----+-------+-------+------+----------+
| 1  | index | idx_p | 1000 | Using... |  ← 索引扫描
+----+-------+-------+------+----------+-- 统计信息更新后可能变化:
ANALYZE TABLE table_a;
-- 再次执行可能选择不同的执行计划

三、period 是月份的特殊性

// period 字段特点
period = 202401  // 20241period = 202401  // 同一个月可能有多条记录
​
业务含义:
- 同一个月份(period)内,可能有:
  ├─ 不同日期的数据更新(updateTime不同)
  ├─ 同一天的多次更新
  └─ 补录的历史数据
​
示例数据:
id  | period  | updateTime           | 说明
----|---------|---------------------|------------------
201 | 202401  | 2024-01-31 23:59:00 | 月末汇总
202 | 202401  | 2024-02-05 10:00:00 | 次月初补录修正
203 | 202401  | 2024-02-10 14:00:00 | 再次修正

问题本质:

查询意图:获取最新期间的数据
代码实现:ORDER BY period DESC LIMIT 1
​
当最新期间有多条记录时:
- period 相同 → 排序无法区分
- LIMIT 1 随机取一条
- 可能不是业务上真正需要的"最新"记录

四、底层机制验证实验

-- 实验:创建测试数据
CREATE TABLE test_order (
    id INT PRIMARY KEY AUTO_INCREMENT,
    period VARCHAR(6),
    update_time DATETIME
);
​
-- 插入相同period的数据
INSERT INTO test_order (period, update_time) VALUES
('202401', '2024-01-10 10:00:00'),
('202401', '2024-01-15 14:00:00'),
('202401', '2024-01-20 16:00:00');
​
-- 多次执行相同查询
SELECT * FROM test_order ORDER BY period DESC LIMIT 1;
​
-- 查看内部排序详情
SELECT * FROM test_order ORDER BY period DESC;
-- 结果可能是:
-- id=1, period=202401  ← 这三条记录的顺序
-- id=2, period=202401  ← 在不同时刻
-- id=3, period=202401  ← 可能不同

MySQL内部排序日志:

# 开启排序日志
SET optimizer_trace="enabled=on";
​
# 执行查询
SELECT * FROM test_order ORDER BY period DESC LIMIT 1;
​
# 查看排序过程
SELECT * FROM information_schema.OPTIMIZER_TRACE\G
​
排序算法选择:
├─ 数据量小 → 内存排序(quicksort)
├─ 数据量大 → 外部排序(归并排序 + 临时文件)
└─ 相同值的排序顺序 → 依赖算法实现细节(不保证)

五、解决方案的底层原理

方案:添加第二排序字段

orderByDesc(TableA::getPeriod)
    .orderByDesc(TableA::getUpdateTime)
    .orderByDesc(TableA::getId)

原理:

排序比较逻辑:
比较两条记录 AB:
​
1. 比较 period
   if (A.period != B.period) 
       return A.period > B.period ? -1 : 1;
   
2. period相同,比较 updateTime
   if (A.updateTime != B.updateTime)
       return A.updateTime > B.updateTime ? -1 : 1;
   
3. updateTime也相同,比较 id(主键唯一)
   return A.id > B.id ? -1 : 1;
​
结果:每条记录都有唯一的排序位置

复合索引优化:

CREATE INDEX idx_period_time_id 
ON table_a(period DESC, update_time DESC, id DESC);
​
查询性能:
- 索引已排序 → 无需 filesort
- 直接读取第一条 → O(1) 复杂度
- 结果绝对稳定

总结

不一致的根本原因:

层面原因影响
SQL语义ORDER BY 单一字段,相同值排序未定义标准不保证顺序
排序算法快速排序是不稳定排序相等元素顺序可能变化
存储层物理存储顺序、页分裂、碎片扫描顺序不确定
索引层B+树同值节点顺序未定义索引扫描顺序不稳定
并发层事务隔离级别、MVCC机制不同时刻看到不同数据
业务层period月份可能有多条记录无法区分真正的"最新"

数据库只保证你明确指定的排序规则,没有指定的部分都是不确定的。