MySQL 高级函数之 GeoHash:地理位置编码与索引应用
大家好,今天我们来深入探讨 MySQL 中用于地理位置编码和索引的关键函数——GeoHash。在当今移动互联网时代,基于地理位置的服务(LBS)无处不在,例如附近商家搜索、地图导航、外卖配送等。如何高效地存储、索引和查询地理位置数据,成为构建这些应用的关键挑战。GeoHash 作为一种高效的地理空间编码方法,为解决这些问题提供了强大的工具。在 MySQL 中,虽然没有内置的 GeoHash 函数,但我们可以通过自定义函数来实现 GeoHash 的编码和解码,并利用其特性进行地理位置索引和查询优化。
1. GeoHash 编码原理
GeoHash 是一种分层空间索引方法,它将地球表面递归地划分为网格,并为每个网格分配一个唯一的字符串编码。其基本思想是将二维的经纬度坐标转换为一维的字符串,使得距离相近的位置具有相似的 GeoHash 编码前缀。
1.1 编码过程
GeoHash 的编码过程主要包含以下步骤:
-
经纬度范围划分: 将经度范围(-180° ~ +180°)和纬度范围(-90° ~ +90°)分别进行二分。
-
二分编码: 根据给定的经纬度值,判断其位于二分范围的左半部分还是右半部分。如果位于左半部分,则编码为 0;如果位于右半部分,则编码为 1。
-
交替编码: 经度和纬度分别进行二分编码,然后将经度和纬度的编码交替组合,形成一个二进制序列。
-
Base32 编码: 将二进制序列按照 5 位一组进行分组,并将每组二进制数转换为对应的 Base32 字符。Base32 字符集通常为
0123456789bcdefghjkmnpqrstuvwxyz
(注意:Base32 字符集不包含a
,i
,l
,o
,以避免混淆)。
1.2 编码示例
假设我们要对经纬度 (116.3895, 39.9281) 进行 GeoHash 编码,以 5 位 GeoHash 精度为例:
-
经度编码 (116.3895):
- (-180, 180) 二分 -> (-180, 0), (0, 180)。116.3895 在 (0, 180) 中,编码为 1。
- (0, 180) 二分 -> (0, 90), (90, 180)。116.3895 在 (90, 180) 中,编码为 1。
- (90, 180) 二分 -> (90, 135), (135, 180)。116.3895 在 (90, 135) 中,编码为 0。
- (90, 135) 二分 -> (90, 112.5), (112.5, 135)。116.3895 在 (112.5, 135) 中,编码为 1。
- (112.5, 135) 二分 -> (112.5, 123.75), (123.75, 135)。116.3895 在 (112.5, 123.75) 中,编码为 0。
经度二进制编码为:11010
-
纬度编码 (39.9281):
- (-90, 90) 二分 -> (-90, 0), (0, 90)。39.9281 在 (0, 90) 中,编码为 1。
- (0, 90) 二分 -> (0, 45), (45, 90)。39.9281 在 (0, 45) 中,编码为 0。
- (0, 45) 二分 -> (0, 22.5), (22.5, 45)。39.9281 在 (22.5, 45) 中,编码为 1。
- (22.5, 45) 二分 -> (22.5, 33.75), (33.75, 45)。39.9281 在 (33.75, 45) 中,编码为 1。
- (33.75, 45) 二分 -> (33.75, 39.375), (39.375, 45)。39.9281 在 (39.375, 45) 中,编码为 1。
纬度二进制编码为:10111
-
交替组合: 将经度和纬度的二进制编码交替组合:11100 11101
-
Base32 编码: 将二进制序列按照 5 位一组进行分组:11100 和 11101。
- 11100 转换为十进制是 28,对应 Base32 字符
w
。 - 11101 转换为十进制是 29,对应 Base32 字符
x
。
因此,(116.3895, 39.9281) 的 5 位 GeoHash 编码为
wx
。 - 11100 转换为十进制是 28,对应 Base32 字符
1.3 精度与长度的关系
GeoHash 编码的长度决定了编码的精度。编码越长,精度越高,代表的区域范围越小。以下表格展示了 GeoHash 长度与精度的大致关系:
GeoHash 长度 | 宽度 (km) | 高度 (km) |
---|---|---|
1 | ~5000 | ~5000 |
2 | ~1250 | ~625 |
3 | ~156 | ~156 |
4 | ~39 | ~20 |
5 | ~4.9 | ~4.9 |
6 | ~1.2 | ~0.61 |
7 | ~0.15 | ~0.15 |
8 | ~0.037 | ~0.019 |
9 | ~0.0048 | ~0.0048 |
10 | ~0.0012 | ~0.00059 |
11 | ~0.00015 | ~0.00015 |
12 | ~0.000019 | ~0.0000074 |
2. MySQL 自定义 GeoHash 函数
MySQL 没有内置的 GeoHash 函数,我们需要自定义函数来实现 GeoHash 的编码和解码。
2.1 GeoHash 编码函数
DELIMITER //
CREATE FUNCTION geohash_encode(latitude DECIMAL(10, 6), longitude DECIMAL(11, 6), precision INT)
RETURNS VARCHAR(20)
DETERMINISTIC
BEGIN
DECLARE base32_chars VARCHAR(32) DEFAULT '0123456789bcdefghjkmnpqrstuvwxyz';
DECLARE lat_interval_min DECIMAL(10, 6) DEFAULT -90;
DECLARE lat_interval_max DECIMAL(10, 6) DEFAULT 90;
DECLARE lon_interval_min DECIMAL(11, 6) DEFAULT -180;
DECLARE lon_interval_max DECIMAL(11, 6) DEFAULT 180;
DECLARE bit_even BOOLEAN DEFAULT TRUE;
DECLARE geohash VARCHAR(20) DEFAULT '';
DECLARE bits INT DEFAULT 0;
DECLARE ch INT DEFAULT 0;
WHILE LENGTH(geohash) < precision DO
IF bit_even THEN
DECLARE mid DECIMAL(11, 6) DEFAULT (lon_interval_min + lon_interval_max) / 2;
IF longitude >= mid THEN
ch := (ch << 1) | 1;
lon_interval_min := mid;
ELSE
ch := (ch << 1);
lon_interval_max := mid;
END IF;
ELSE
DECLARE mid DECIMAL(10, 6) DEFAULT (lat_interval_min + lat_interval_max) / 2;
IF latitude >= mid THEN
ch := (ch << 1) | 1;
lat_interval_min := mid;
ELSE
ch := (ch << 1);
lat_interval_max := mid;
END IF;
END IF;
bit_even := !bit_even;
bits := bits + 1;
IF bits = 5 THEN
SET geohash = CONCAT(geohash, SUBSTRING(base32_chars, ch + 1, 1));
SET bits = 0;
SET ch = 0;
END IF;
END WHILE;
RETURN geohash;
END //
DELIMITER ;
代码解释:
geohash_encode(latitude, longitude, precision)
函数接受纬度、经度和精度作为参数,返回 GeoHash 编码。base32_chars
变量定义了 Base32 字符集。lat_interval_min
,lat_interval_max
,lon_interval_min
,lon_interval_max
变量分别表示纬度和经度的范围。bit_even
变量用于交替处理经度和纬度的编码。geohash
变量用于存储 GeoHash 编码结果。bits
变量用于记录当前的二进制位数。ch
变量用于存储当前的 Base32 字符索引。WHILE
循环执行 GeoHash 编码过程,直到达到指定的精度。- 在循环中,根据经纬度值,判断其位于二分范围的左半部分还是右半部分,并更新
ch
的值。 - 当
bits
达到 5 时,将ch
转换为 Base32 字符,并添加到geohash
变量中。
2.2 GeoHash 解码函数
DELIMITER //
CREATE FUNCTION geohash_decode(geohash VARCHAR(20))
RETURNS POINT
DETERMINISTIC
BEGIN
DECLARE base32_chars VARCHAR(32) DEFAULT '0123456789bcdefghjkmnpqrstuvwxyz';
DECLARE lat_interval_min DECIMAL(10, 6) DEFAULT -90;
DECLARE lat_interval_max DECIMAL(10, 6) DEFAULT 90;
DECLARE lon_interval_min DECIMAL(11, 6) DEFAULT -180;
DECLARE lon_interval_max DECIMAL(11, 6) DEFAULT 180;
DECLARE bit_even BOOLEAN DEFAULT TRUE;
DECLARE i INT DEFAULT 1;
DECLARE ch INT DEFAULT 0;
DECLARE bits INT DEFAULT 0;
WHILE i <= LENGTH(geohash) DO
SET ch = LOCATE(SUBSTRING(geohash, i, 1), base32_chars) - 1;
SET bits = 0;
WHILE bits < 5 DO
DECLARE mask INT DEFAULT POW(2, 4 - bits);
IF bit_even THEN
IF (ch & mask) > 0 THEN
SET lon_interval_min = (lon_interval_min + lon_interval_max) / 2;
ELSE
SET lon_interval_max = (lon_interval_min + lon_interval_max) / 2;
END IF;
ELSE
IF (ch & mask) > 0 THEN
SET lat_interval_min = (lat_interval_min + lat_interval_max) / 2;
ELSE
SET lat_interval_max = (lat_interval_min + lat_interval_max) / 2;
END IF;
END IF;
bit_even := !bit_even;
SET bits = bits + 1;
END WHILE;
SET i = i + 1;
END WHILE;
RETURN ST_GeomFromText(CONCAT('POINT(', (lon_interval_min + lon_interval_max) / 2, ' ', (lat_interval_min + lat_interval_max) / 2, ')'));
END //
DELIMITER ;
代码解释:
geohash_decode(geohash)
函数接受 GeoHash 编码作为参数,返回一个POINT
对象,表示解码后的经纬度。- 函数首先初始化变量,包括 Base32 字符集、经纬度范围、以及一些辅助变量。
- 外层
WHILE
循环遍历 GeoHash 编码的每个字符。 - 内层
WHILE
循环遍历每个字符的 5 个二进制位。 - 根据每个二进制位的值,更新经纬度的范围。
- 最后,函数使用
ST_GeomFromText
函数创建一个POINT
对象,表示解码后的经纬度。
2.3 使用示例
-- 编码
SELECT geohash_encode(39.9281, 116.3895, 5); -- 输出:wx4g0
-- 解码
SELECT geohash_decode('wx4g0'); -- 输出:POINT(116.39013671875 39.927734375)
3. GeoHash 在地理位置索引中的应用
GeoHash 可以用于创建地理位置索引,从而加速地理位置相关的查询。
3.1 创建表结构
CREATE TABLE locations (
id INT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(255),
latitude DECIMAL(10, 6),
longitude DECIMAL(11, 6),
geohash VARCHAR(12),
SPATIAL INDEX(geohash)
);
代码解释:
locations
表用于存储地理位置信息。latitude
和longitude
列存储经纬度坐标。geohash
列存储 GeoHash 编码。SPATIAL INDEX(geohash)
创建了一个空间索引,用于加速 GeoHash 编码的查询。 注意,MySQL 5.7 及以下版本,创建空间索引需要指定存储引擎为 MyISAM,MySQL 8.0 及以上版本,则可以使用 InnoDB,InnoDB 在 MySQL 5.7 就已经支持空间索引,但是需要开启相关参数。
3.2 插入数据
INSERT INTO locations (name, latitude, longitude, geohash) VALUES
('北京天安门', 39.9087, 116.3975, geohash_encode(39.9087, 116.3975, 9)),
('北京大学', 39.9973, 116.3157, geohash_encode(39.9973, 116.3157, 9)),
('清华大学', 40.0093, 116.3276, geohash_encode(40.0093, 116.3276, 9)),
('北京首都机场', 40.0797, 116.6030, geohash_encode(40.0797, 116.6030, 9)),
('北京西站', 39.8937, 116.3217, geohash_encode(39.8937, 116.3217, 9));
3.3 查询附近地点
要查询某个地点附近的地点,可以使用以下步骤:
- 计算目标地点的 GeoHash 编码。
- 根据所需的搜索范围,确定 GeoHash 编码的前缀长度。 例如,如果需要搜索 1 公里范围内的地点,可以选择 6 位 GeoHash 编码前缀。
- 查询具有相同 GeoHash 前缀的地点。
- 对查询结果进行过滤,排除距离超过搜索范围的地点。
-- 查询北京天安门附近 1 公里范围内的地点
-- 1. 计算北京天安门的 GeoHash 编码
SET @target_latitude = 39.9087;
SET @target_longitude = 116.3975;
SET @target_geohash = geohash_encode(@target_latitude, @target_longitude, 9);
-- 2. 确定 GeoHash 编码的前缀长度 (例如 6 位)
SET @prefix_length = 6;
SET @geohash_prefix = SUBSTRING(@target_geohash, 1, @prefix_length);
-- 3. 查询具有相同 GeoHash 前缀的地点
SELECT *
FROM locations
WHERE SUBSTRING(geohash, 1, @prefix_length) = @geohash_prefix;
-- 4. 对查询结果进行过滤,排除距离超过搜索范围的地点 (可选,这里省略)
3.4 邻域 GeoHash 查询优化
为了提高查询效率,可以查询目标 GeoHash 周围的 8 个邻域 GeoHash 编码。这样可以覆盖更广的搜索范围,并减少边界效应。
-- 定义函数计算邻域 GeoHash
DELIMITER //
CREATE FUNCTION geohash_neighbors(geohash VARCHAR(20))
RETURNS TEXT
DETERMINISTIC
BEGIN
DECLARE neighbors TEXT DEFAULT '';
DECLARE north VARCHAR(20);
DECLARE south VARCHAR(20);
DECLARE east VARCHAR(20);
DECLARE west VARCHAR(20);
DECLARE northwest VARCHAR(20);
DECLARE northeast VARCHAR(20);
DECLARE southwest VARCHAR(20);
DECLARE southeast VARCHAR(20);
-- 这里需要实现计算邻域 GeoHash 的逻辑,这部分代码比较复杂,超出了本文的范围
-- 可以参考开源的 GeoHash 库来实现
-- 假设已经计算出了邻域 GeoHash
SET north = 'wx4g2'; -- 示例
SET south = 'wx4g8'; -- 示例
SET east = 'wx4g1'; -- 示例
SET west = 'wx4gb'; -- 示例
SET northwest = 'wx4ga';-- 示例
SET northeast = 'wx4g3';-- 示例
SET southwest = 'wx4gc';-- 示例
SET southeast = 'wx4g9';-- 示例
SET neighbors = CONCAT(geohash, ',', north, ',', south, ',', east, ',', west, ',', northwest, ',', northeast, ',', southwest, ',', southeast);
RETURN neighbors;
END //
DELIMITER ;
-- 使用示例:
SELECT geohash_neighbors('wx4g0');
查询时,可以使用 IN
运算符查询目标 GeoHash 及其邻域内的地点。
-- 查询北京天安门附近 1 公里范围内的地点 (使用邻域 GeoHash)
-- 1. 计算北京天安门的 GeoHash 编码
SET @target_latitude = 39.9087;
SET @target_longitude = 116.3975;
SET @target_geohash = geohash_encode(@target_latitude, @target_longitude, 6);
-- 2. 计算邻域 GeoHash
SET @neighbors = geohash_neighbors(@target_geohash);
-- 3. 查询具有相同 GeoHash 前缀的地点
SELECT *
FROM locations
WHERE geohash IN (@neighbors);
-- 4. 对查询结果进行过滤,排除距离超过搜索范围的地点 (可选,这里省略)
3.5 距离计算
上面的查询虽然使用了 GeoHash 进行索引,但是仍然需要对结果进行过滤,排除距离超过搜索范围的地点。可以使用 MySQL 内置的 ST_Distance
函数计算两个 POINT
对象之间的距离。
SELECT
*,
ST_Distance(
POINT(@target_longitude, @target_latitude),
POINT(longitude, latitude)
) * 111 AS distance_km -- 乘以 111 将结果转换为公里
FROM locations
WHERE SUBSTRING(geohash, 1, @prefix_length) = @geohash_prefix
HAVING distance_km <= 1; -- 1 公里范围内
4. GeoHash 的优缺点
4.1 优点
- 高效的地理位置编码: 将二维的经纬度坐标转换为一维的字符串,方便存储和索引。
- 支持前缀匹配: 距离相近的位置具有相似的 GeoHash 编码前缀,方便进行范围查询。
- 多精度支持: 可以根据需要调整 GeoHash 编码的长度,从而控制精度。
- 易于实现: GeoHash 编码和解码算法简单易懂,容易实现。
4.2 缺点
- 边界效应: 位于 GeoHash 网格边界附近的地点,可能需要查询多个邻域才能覆盖完整的搜索范围。
- 精度不均匀: 由于地球是球体,GeoHash 网格的形状和大小在不同纬度上会有所差异,导致精度不均匀。
5. GeoHash 的其他应用
除了地理位置索引,GeoHash 还可以应用于以下场景:
- 数据聚合: 可以根据 GeoHash 编码将地理位置数据聚合到不同的区域,例如统计每个区域的人口密度、车辆数量等。
- 数据可视化: 可以使用 GeoHash 编码将地理位置数据可视化到地图上,例如绘制热力图、散点图等。
- 数据压缩: 可以使用 GeoHash 编码对地理位置数据进行压缩,减少存储空间。
6. 其他优化策略
除了 GeoHash 索引,还可以结合其他优化策略来提高地理位置查询的性能:
- 使用缓存: 将常用的查询结果缓存起来,减少数据库的访问次数。
- 使用分布式数据库: 将地理位置数据分散存储到多个数据库节点上,提高查询的并发能力。
- 使用专业的地理空间数据库: 例如 PostGIS,它提供了更丰富的地理空间函数和索引类型。
7. 关于GeoHash与实际距离的误差的讨论
GeoHash的一个主要优点是其能够将二维地理坐标转换为一维字符串,从而方便索引和查询。然而,由于GeoHash本质上是一种网格划分方法,它在实际应用中会引入一定的误差。这种误差主要体现在以下几个方面:
-
网格边界效应 (Grid Boundary Effect):这是GeoHash最显著的误差来源。当两个地理位置非常接近,但恰好位于不同的GeoHash网格中时,它们会被认为是不相邻的,即使它们的实际距离可能非常小。反之,两个位于同一GeoHash网格内的位置,即使距离较远,也会被认为相邻。
-
精度损失:GeoHash通过递归地将经纬度范围二分来创建网格。每次二分都会损失一定的精度,因为一个GeoHash编码代表的是一个区域,而不是一个精确的点。编码长度越短,精度越低,误差越大。
-
网格形状和大小的变化:由于地球是球体,GeoHash网格的形状和大小在不同纬度上会有所变化。在赤道附近,网格的形状接近正方形,而在高纬度地区,网格则变得更加狭长。这意味着在不同的地理位置,相同长度的GeoHash编码所代表的区域大小和形状可能差异很大,从而导致误差不一致。
-
距离计算误差:在使用GeoHash进行距离估算时,通常是基于网格中心的距离或者网格的平均距离。这两种方法都会引入误差,因为实际的地理位置可能位于网格内的任何位置。
如何减少误差?
虽然GeoHash的误差是不可避免的,但可以通过一些方法来减少其影响:
- 选择合适的GeoHash精度:根据实际应用的需求选择合适的GeoHash编码长度。精度越高,网格越小,误差越小。
- 使用邻域查询:在进行范围查询时,不仅要查询目标GeoHash网格,还要查询其周围的8个邻域网格,以减少网格边界效应。
- 结合实际距离计算:在使用GeoHash进行初步筛选后,使用更精确的距离计算方法(如Haversine公式)来计算实际距离,并进行二次筛选。
- 使用自适应GeoHash:根据地理位置的密度和分布情况,动态调整GeoHash网格的大小和形状,以提高精度和减少误差。
8. 技术选型建议
以下是一些关于在实际项目中使用 GeoHash 的技术选型建议:
- 语言选择: 可以使用任何支持字符串操作和数学计算的编程语言来实现 GeoHash 编码和解码,例如 Java, Python, Go, C++ 等。
- 数据库选择: 如果只是简单的地理位置索引和查询,可以使用 MySQL 或 PostgreSQL。如果需要更强大的地理空间功能,建议使用 PostGIS 或 MongoDB。
- GeoHash 库: 可以使用现有的 GeoHash 库来简化开发,例如 Java 的
Geohash
库,Python 的python-geohash
库等。 - 精度选择: 根据实际应用的需求选择合适的 GeoHash 编码长度。一般来说,6-8 位的 GeoHash 编码可以满足大多数应用的需求。
9. 编码是为了更好地索引位置信息
总而言之,GeoHash 是一种高效的地理位置编码和索引方法,可以用于加速地理位置相关的查询。通过自定义 MySQL 函数,我们可以方便地在 MySQL 中使用 GeoHash。在实际应用中,需要根据具体的需求选择合适的 GeoHash 编码长度和优化策略,以达到最佳的性能和精度。