MYSQL空间索引使用指南
背景
近期在开发一个需求的使用用到了 MYSQL 的空间索引,之前一般也用不上这个,这次第一次用倒是踩了不少的坑。
目前需求开发完成且稳定运行一段时间没有异常了,才来写这篇文章,主要就是介绍一下如何使用 MYSQL 的空间索引以及在使用中需要注意的一些坑。
问题引入
在 MYSQL 数据库中保存了上千万条记录,每条记录保存了一个地理坐标点。需求是传入一个坐标点的位置以及一个半径r,检索出该坐标点方圆r距离内的所有记录。
看起来有点小小的难度,上网检索了一下,MYSQL 中有个函数 ST_Distance_Sphere 能直接计算两个坐标点的距离。
那 easy 了,省略表中无关字段,表的 DDL 为:
1 | create table `locations` |
查询 sql 则为:
1 | SELECT * FROM `locations` |
简单运行一下试试,随便检索一个坐标点 1km 范围内的记录并验证一下结果。
结果就是完全没问题,耗时600ms(测试表内存在约10w条数据)。ok,需求解决,提交!
但话又说回来,10w的数据量级跑出了 600ms 的耗时,这是很不合格的。所以,接下来的问题就是如何优化了。
MYSQL 的空间索引
innodb 以及 myisam 引擎均实现了地理空间索引,可以通过 SPATIAL INDEX 添加空间索引。
这两个引擎对于空间索引的实现方式是不同的,innodb 是通过 B-Tree 实现的,不过 B-Tree 主要是用于单维度的数据比较以及排序,对于空间这种多维数据的支持是比较有限的。而 myisam 是通过 R-Tree 实现的,这是专为处理多维空间数据而设计的索引结构,所以能够很好的支持多维数据的范围查询和空间交集查询。
简单来说就是在空间查询上,使用 R-Tree 实现的 myisam 引擎是比 innodb 更合适的。不过该需求也是集成在原项目上的,还需要考虑其他功能的需求,在其他需求中事务支持又是必须的,所以最终还是选择采用 innodb 的空间索引来优化上文中的查询。
添加空间索引
为上文中的表添加 SPATIAL INDEX:
1 | ALTER TABLE `locations` |
利用空间索引
此时,我们使用上文中的查询 SQL 进行查询分析,发现查询计划仍然是走的全表扫描。这是什么原因呢?这是因为在 MYSQL 中并不是使用到空间索引字段的查询条件就会自动利用空间索引,而是需要使用特定的函数,才会使用到空间索引。
空间索引的工作机制是基于 最小边界矩形(MBR)
,即每个几何对象的最小外包矩形。
经过测试(此处指的是趟过所有坑后测试的结果),以下几个函数都能利用空间索引进行查询优化:
MBRContains(g1, g2)
:判断几何对象 g1 的 MBR 是否完全包含 g2 的 MBR;MBRWithin(g1, g2)
:判断几何对象 g1 的 MBR 是否在 g2 的 MBR 之内;MBRIntersects(g1, g2)
:判断 g1 和 g2 的 MBR 是否相交;MBRTouches(g1, g2)
:判断 g1 的 MBR 是否接触 g2 的 MBR 边界;MBROverlaps(g1, g2)
:判断 g1 和 g2 的 MBR 是否部分重叠;
为了能用利用上上述的索引,我们可以先过滤一下表中的数据,将不可能的结果先排除掉,只留下可能得结果进行距离的计算,得出最终的结果。
为了排查不可能的结果,我们可以先计算出圆的 bounding box(最小的包含矩形)
,再使用上述的 MBRContains 函数,过滤出包含在该矩形中的记录。
此时,就涉及到了 bouding box 的计算方法了。
众所周知的,地球是一个类似椭圆的形状,不同经纬度时固定距离间经度的差值和纬度的差值是不一样的。
可以参考以下公式来计算一个经纬度移动指定距离后的经纬度(此处的 radius 单位为 km):
纬度:lat ± (radius / 111)
经度:lng ± (radius / (111 * cos(lat)))
参考以下示意图,可以得出 bouding box 上下左右四个方向的坐标点与圆心偏移的偏移量为 √2r。
计算出圆心向四个方向偏移 √2r 后的四个坐标点,再使用 POLYGON 函数生成多边形,将其作为参数1代入到 MBRContains 函数中,即可完成数据的初筛。
这里需要注意第一个坑:经纬度的最大值和最小值。
- 对于计算后经度大于 180 的,需要减去 360;
- 对于计算后经度小于 -180 的,需要加上 360;
- 对于计算后纬度大于 90 的,需要减去 180;
- 对于计算后纬度小于 -90 的,需要加上 180;
优化后的 sql 语句:
1 | SELECT * |
指定 SRID
介绍到这里,你以为结束了吗?nonono,这里面的坑还是不少的。对上述的语句查询分析,可以发现该查询还是没能利用索引,走的是全表扫描。再又翻阅了一些文档和别人写的文章后发现,要想空间索引生效,空间索引所在的字段必须满足两个条件:
- NOT NULL
- 指定了 SRID
这里简单介绍一下 SRID:
SRID 指定的是 spatial 数据的坐标系,这个坐标系定义了一些诸如大地基准、海平面、地球形状、球心、球心偏移等等数据,在不同地区这些值都是不同的。
SRID 的存在就是为了尽量消除误差,以接近真实的地理信息。
我们可以看到,上文中的建表语句以及 ST_GeomFromText 函数中都没有指定 SRID。其实这时候将会采用默认的 SRID,即 0。
而通常情况下,我们在服务端存储数据时,采用的都是 4326 的 SRID。
平常我们见到的 84坐标系、高德坐标系、百度坐标系、国测、火星等乱糟糟的都是基于 4326 坐标系的定义的。
所以,对于上述表以及查询,我们需要改一改:
1 | -- 修改表的 SRID |
1 | SELECT * |
运行上述查询,不出意外的,报错了~这时候就遇到第二个坑了:不同 SRID 的坐标点使用的坐标轴顺序是不同的。
SRID 除了定义上述提到的大地基准,球体,水准面等,他还会定义坐标轴的单位和顺序,例如是度,还是公里,是 lng/lat 还是 lat/lng。
而 4326 SRID 采用的坐标轴顺序是 lat/lng,而上文中我们使用默认的 0 SRID,其坐标轴顺序是 lng/lat。
所以,我们还需要调转一下坐标点的经纬度。也可以在 ST_GeomFromText 函数中添加一个参数指定一下坐标轴顺序,如:
1 | st_geomfromtext('POINT(116.391276 39.906217)',4326,'axis-order=long-lat') |
对于我来说还是比较建议直接在代码层面解决这个问题,直接传入 lat/lng 格式的坐标点,不把复杂度传递到数据库。这点大家见仁见智。
调转完经纬度之后,再使用上述语句进行查询分析,就可以看到查询类型为 range,能成功利用索引了。
更多小细节优化
1.冗余字段
以上的查询语句,使用的是 SELECT *
,但是 point 类型在数据库中存储的是一个二进制数据,查询出来的结果是其二进制表示,是没办法直接看出其经纬度信息的。
可以通过 ST_X
、ST_Y
、ST_AsText
等函数来将其转换为可观察的数据。
我的做法是在存储数据的时候直接冗余两个字段 lng 和 lat,而在查询的时候直接查询 lng 和 lat 的值得到其经纬度,避免额外的数据库函数开销。
2.计算更精确的 bounding box
上文中介绍的计算 bounding box 的方式是比较粗糙的,有需要的可以自行采用更精确的方案去计算 bounding box,达到过滤掉更多数据的目的。
3.使用变量保存函数结果
可以使用变量保存上述查询语句中的函数结果,避免重复的函数计算。
一步到位 DDL
贴一个在建表时直接指定 SRID 并创建空间索引的 DDL 供大家参考:
1 | CREATE TABLE `locations` |
总结
这边文章主要介绍的是在运用 MYSQL 进行空间索引优化时踩的一些坑和解决方案,文章的描述顺序与我开发的顺序基本是一致的,希望大家看完这篇文章都能有所收获,有任何问题也欢迎在评论区评论交流。