一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第1天,点击查看活动详情。
背景
售卖端会定期将全量门店数据同步过去做缓存,数据同步方式是通过门店域提供的分页查询接口,分批次拉取。
前两天他们反馈了一个问题:最终同步结果中总会缺几条门店的数据。
对方开发还提供了一个线索:不同的分页查询批次中存在重复数据。
问题复现
首先确认“不同分页批次中存在重复数据”这个问题是否确实存在。 通过手动调接口发现难以复现问题,该问题带有一定随机性。
本地写了一个测试脚本,模拟分批查询门店,通过不断调整分页大小,在分页大小等于 5000 时,稳定复现该问题。
问题定位
现在的核心问题是:为什么分页查询会出现重复数据,网上搜索了一下,发现大量的案例都指向一个重要因素:mysql 基于 file-sort 排序的不稳定性问题。
Mysql 的官网也提到了这个问题:
If multiple rows have identical values in the ORDER BY columns, the server is free to return those rows in any order, and may do so differently depending on the overall execution plan.
如果参与排序的列中的多行具有相同的值,服务器可以以任意顺序返回这些重复行。
相关链接:dev.mysql.com/doc/refman/…
通过 explain 分析了对应的查询语句,发现确实用到了 file-sort 排序。
到这里基本就可以推断就是因为该问题导致最终的同步结果中总会缺几条数据。
在我们的分页查询场景中,默认是根据记录的更新时间倒序排列,如图所示:
可以看到,前后两次查询一共得到 4 条数据,但去重之后只存在 3 条,原因是前后两次排序结果不一致,排序项值相同的记录(门店id为2,3)顺序被打乱了。
继续往下探索问题:
- mysql 什么时候会触发 file-sort 排序
- file-sort 排序不稳定的原因
file-sort
file-sort 是 mysql 在待排序元素本身无序的情况下(没有用到索引本身的有序性),使用的排序手段,即当排序没有用到索引时,需要额外触发 file-sort。
file-sort 基于下列三种排序算法:
- 堆排序,基于优先队列,通常使用 order by limit 语法时会优先触发堆排序,堆排序对 sort-buffer 的大小要求不是很高,但假如 sort-buffer 仍然不足,则还是会触发归并排序
- 快速排序,当 mysql 的 sort-buffer 足够放下所有待排序数据时,则使用快速排序,效率更高
- 归并排序,当 sort-buffer 不足以存放下所有待排序数据时,会生成临时文件,作为排序过程中的辅助存储媒介
以上三种排序算法均为不稳定的算法。
排序算法稳定性的定义:排序项值相同的记录,在排序前后的相对位置不发生变化,即为稳定的算法,反之则为不稳定算法。
而 order by limit 语法默认会使用堆排序算法,现在的问题变成了堆排序为什么是不稳定的算法。
堆排序
堆的数据结构本质是一棵完全二叉树,底层可通过数组实现,根据父节点与子节点的大小关系,可以把堆分为大顶堆和小顶堆。
堆排序的核心包括以下部分:
- 建堆,一般是通过节点下沉的方式建堆,即从最后一个非叶子节点开始,依次执行下沉操作,调整达到堆的平衡性
- 排序,堆顶节点与堆尾节点交换,完成堆顶节点的排序
- 重复上述两个步骤,最终完成所有节点的排序
假设有一个无序的数组,其中存在两个重复的元素 8,这边使用不同颜色标识出来,主要为了证明堆排序的不稳定性,即排序前后,两个 8 的相对位置是否发生了变化。
期望最终达到升序排列的效果,故这边通过构建大顶堆完成排序。
排序过程如下:
省略剩余几个元素的排序步骤,最终的排序结果如下:
对比排序前后,可以发现两个8的相对位置已经发生了变化,故可以证明堆排序是不稳定的算法。
问题结论
虽说堆排序是不稳定的算法,但这边的不稳定是指单次排序结果的不稳定,多次排序的结果应该是保持一致的,就算不稳定也要不稳定得一致。
为什么会出现多次排序结果不一致的场景?多次排序结果一致的前提是每次堆中参与排序的元素集合是相同的。
对于 order by limit 的分页查询请求,可以把它看做是一个 topk 问题(最终会对 k 个元素进行排序),取最大/最小的 k 条记录:
- order by limit k 等效于 topk
- order by limit n, k 等效于 top(n *k + k),有偏移量的分页场景,mysql 的做法是排序结果中前 n *k 条记录舍弃,只取最后 k 条记录
mysql 通过堆排序优化 order by limit 查询,而分页参数变化时,堆中参与排序的元素基数也会随着发生变化,举个例子:
- 当 order by limit 0, 2 时,堆的大小 k = 2
- 当 order by limit 1, 2 时,堆的大小 k = 4,前 4 条记录都会参与排序,最后从排序结果中舍弃前 2 条
综上所述,结论如下:
- 当分页参数不断增加时,进入堆中排序的元素增多了,出现排序项值相同的概率也随之增加
- 排序过程中值相同的元素位置被交换(下图中门店id = 2, 3 的记录在第二次排序的过程中被交换位置),导致前面分页已经出现过的记录,被交换到了后面分页
- 最终导致不同分页中存在相同记录
以上结论,更多的是我个人的推测,如果要加以验证,可能需要翻看 mysql 关于这块排序实现的源码,后续有时间的话可以安排上。
问题解决
在排序项中再加入能够唯一确定元素的字段进行联合排序,保证最终输出的结果的序列是确定的即可。
即原 SQL:order by datachange_lasttime desc
改进后 SQL:order by datachange_lasttime desc, store_id asc
问题的解决过程相对容易,后期花了更多的时间思考问题产生的根本原因,本文也算是对这个问题的简单收尾总结。