如何结合thinkphp中的GeoHash和Haversine公式来实现地理位置的存储与计算,这是一套兼顾查询效率(GeoHash)和距离精度(Haversine)的经典方案,下面详细拆解实现步骤、核心原理和实战代码:
一、核心概念说明
1. GeoHash 核心作用
GeoHash是一种将二维经纬度坐标编码为一维字符串的地理空间索引算法,其核心价值是:
-
邻近性特征:地理位置越接近的点,GeoHash字符串前缀相同的长度越长(例如:编码
wx4g0s和wx4g0t对应的坐标距离极近); - 高效范围查询:通过匹配GeoHash前缀,可以快速筛选出某一区域内的所有点,避免全表扫描,大幅提升“附近的人/商家”这类场景的查询效率;
- 编码长度决定精度(长度越长,精度越高),常用长度为6-8位(对应精度约100米-10米)。
2. Haversine 公式 核心作用
Haversine公式专门用于计算地球球面上两个经纬度点之间的实际距离,弥补了GeoHash无法精准计算距离的不足:
- 输入:两个点的纬度(lat)、经度(lng);
- 输出:两点间的球面距离(可转换为米/公里);
- 精度高于平面距离计算,适用于全球范围的地理位置距离测算。
3. 方案优势
GeoHash(快速筛选候选集) + Haversine(精准计算距离) = 兼顾查询效率和距离精度,是地理位置业务的最优组合之一。
二、第一步:创建支持GeoHash的数据表
需要同时存储原始经纬度(用于Haversine公式计算)和GeoHash编码(用于快速范围查询),SQL示例如下:
CREATE TABLE `location_info` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`name` VARCHAR(100) NOT NULL COMMENT '名称(用户/商家)',
`latitude` DECIMAL(10, 6) NOT NULL COMMENT '纬度(WGS84坐标系)',
`longitude` DECIMAL(10, 6) NOT NULL COMMENT '经度(WGS84坐标系)',
`geohash` CHAR(12) NOT NULL COMMENT 'GeoHash编码(最长12位,推荐6-8位)',
`address` VARCHAR(255) DEFAULT '' COMMENT '详细地址',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
-- 为GeoHash创建普通索引,提升前缀匹配查询效率
INDEX `idx_geohash` (`geohash`),
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='地理位置信息表(GeoHash+经纬度)';
三、第二步:PHP实现GeoHash编码+插入数据
1. 先获取GeoHash编码工具(PHP实现)
GeoHash编码逻辑较固定,直接使用成熟的PHP GeoHash编码函数(无需额外扩展,直接复用):
/**
* 生成指定区域内的随机经纬度(以北京区域为例,方便验证效果)
* 北京大致范围:纬度39.4°~41.6°,经度115.4°~117.8°
* @return array [lat, lng] 随机纬度、经度
*/
function generate_random_lnglat() {
// 随机纬度(39.4 ~ 41.6)
$lat = 39.4 + mt_rand(0, 220000) / 100000;
// 随机经度(115.4 ~ 117.8)
$lng = 115.4 + mt_rand(0, 240000) / 100000;
// 保留6位小数(与数据表字段一致)
return [
round($lat, 6),
round($lng, 6)
];
}
function geohash_encode($lat, $lng, $precision = 8) {
$base32 = '0123456789bcdefghjkmnpqrstuvwxyz';
$latRange = [-90.0, 90.0];
$lngRange = [-180.0, 180.0];
$binary = '';
while (strlen($binary) < $precision * 5) {
$lngMid = ($lngRange[0] + $lngRange[1]) / 2;
if ($lng > $lngMid) {
$binary .= '1';
$lngRange[0] = $lngMid;
} else {
$binary .= '0';
$lngRange[1] = $lngMid;
}
$latMid = ($latRange[0] + $latRange[1]) / 2;
if ($lat > $latMid) {
$binary .= '1';
$latRange[0] = $latMid;
} else {
$binary .= '0';
$latRange[1] = $latMid;
}
}
$geohash = '';
for ($i = 0; $i < strlen($binary); $i += 5) {
$chunk = substr($binary, $i, 5);
$index = bindec($chunk);
$geohash .= $base32[$index];
}
return substr($geohash, 0, $precision);
}
function geohash_get_8bit_neighbors($geohash6) {
$neighbors = [
'north' => ['p0r21436x8zb9dcf5h7kjnmqesgutwvy', 'bc01fg45238967deuvhjyznpkmstqrwx'],
'northeast' => ['14365h7k9dcfesgujnmqp0r2twvyx8zb', '238967debc01fg45kmstqrwxuvhjyznp'],
'east' => ['bc01fg45238967deuvhjyznpkmstqrwx', 'p0r21436x8zb9dcf5h7kjnmqesgutwvy'],
'southeast' => ['238967debc01fg45kmstqrwxuvhjyznp', '14365h7k9dcfesgujnmqp0r2twvyx8zb'],
'south' => ['14365h7k9dcfesgujnmqp0r2twvyx8zb', '238967debc01fg45kmstqrwxuvhjyznp'],
'southwest' => ['p0r21436x8zb9dcf5h7kjnmqesgutwvy', 'bc01fg45238967deuvhjyznpkmstqrwx'],
'west' => ['bc01fg45238967deuvhjyznpkmstqrwx', 'p0r21436x8zb9dcf5h7kjnmqesgutwvy'],
'northwest' => ['238967debc01fg45kmstqrwxuvhjyznp', '14365h7k9dcfesgujnmqp0r2twvyx8zb']
];
$borders = [
'north' => ['prxz', 'bcfguvyz'],
'northeast' => ['prxz', 'bcfguvyz'],
'east' => ['bcfguvyz', 'prxz'],
'southeast' => ['bcfguvyz', 'prxz'],
'south' => ['028b', '0145hjnp'],
'southwest' => ['028b', '0145hjnp'],
'west' => ['0145hjnp', '028b'],
'northwest' => ['0145hjnp', '028b']
];
$geohash = strtolower($geohash6);
$len = strlen($geohash);
if ($len !== 6) {
return [$geohash6];
}
$base32 = '0123456789bcdefghjkmnpqrstuvwxyz';
$base32Idx = array_flip(str_split($base32));
$neighborsList = [$geohash6];
// 遍历8个方向
$directions = ['north', 'northeast', 'east', 'southeast', 'south', 'southwest', 'west', 'northwest'];
foreach ($directions as $dir) {
$neighbor = geohash_calculate_neighbor($geohash, $dir, $neighbors, $borders, $base32, $base32Idx);
if ($neighbor && !in_array($neighbor, $neighborsList)) {
$neighborsList[] = $neighbor;
}
}
return $neighborsList;
}
function geohash_calculate_neighbor($hash, $dir, $neighbors, $borders, $base32, $base32Idx) {
$len = strlen($hash);
$last = substr($hash, -1);
$prefix = substr($hash, 0, $len - 1);
// 修复问题3:简化并严谨化isLat判断(偶数长度=经度优先,奇数长度=纬度优先)
$isLat = ($len % 2) == 1;
// 修复问题1:颠倒borders索引映射(原逻辑:isLat?0:1 改为 isLat?1:0)
$borderChars = $borders[$dir][$isLat ? 1 : 0];
$borderArray = str_split($borderChars);
if (in_array($last, $borderArray) && $prefix !== '') {
$prefix = geohash_calculate_neighbor($prefix, $dir, $neighbors, $borders, $base32, $base32Idx);
}
// 修复问题1:颠倒neighbors索引映射(原逻辑:isLat?0:1 改为 isLat?1:0)
$neighborChars = $neighbors[$dir][$isLat ? 1 : 0];
$lastIdx = strpos($neighborChars, $last);
if ($lastIdx === false) {
return $prefix . $last;
}
// 修复问题2:获取相邻字符(原逻辑是取原字符,现在取对应邻居字符集中的映射字符)
// 补充:通过base32索引找到原字符的位置,再映射到邻居字符
$originalIdx = $base32Idx[$last];
$newLast = $neighborChars[$originalIdx]; // 关键修复:不再使用lastIdx,而是原字符的base32索引
return $prefix . $newLast;
}
2. PHP插入数据(经纬度+GeoHash编码)
<?php
namespace app\command;
use think\console\Command;
use think\console\Input;
use think\console\Output;
class Geo extends Command{
protected function configure()
{
$this->setName('geo')->setDescription('生成地理位置信息');
}
protected function execute(Input $input, Output $output)
{
// 设置页面执行时间
set_time_limit(0);
// 设置内存无限制
ini_set('memory_limit', '-1');
$this->index2();
echo 'oooookkkkkkkkk';
}
public function index2()
{
// 模拟数据配置
$totalCount = 900000; // 总数据量:90万条
$batchSize = 1000; // 每批次插入1000条
$geohashPrecision = 6; // GeoHash编码长度,10公里范围用6位前缀,1公里范围用7位前缀,100米范围用8位前缀。
$baseName = "模拟商家_"; // 基础名称前缀
$insertedCount = 0; // 已插入计数
$startTime = microtime(true); // 记录开始时间
while ($insertedCount < $totalCount) {
// 计算当前批次的实际插入数量(最后一批可能不足1000条)
$currentBatch = min($batchSize, $totalCount - $insertedCount);
// 重置占位符绑定参数
$params = [];
for ($i = 0; $i < $currentBatch; $i++) {
$id = $insertedCount + $i + 1;
// 构造数据
$name = $baseName . $id;
[$lat, $lng] = generate_random_lnglat();
$geohash = geohash_encode($lat, $lng, $geohashPrecision);
$address = "北京市随机地址_" . $id; // 模拟详细地址
$stmt = [];
// 绑定参数
$stmt['name'] = $name;
$stmt['latitude'] = $lat;
$stmt['longitude'] = $lng;
$stmt['geohash'] = $geohash;
$stmt['address'] = $address;
$stmt['create_time'] = date("Y-m-d H:i:s");
db("location_info")->insert($stmt);
$insertedCount++;
}
}
}
}
四、第三步:PHP实现GeoHash筛选+Haversine公式计算距离
1. Haversine公式PHP实现(计算两点球面距离)
<?php
/**
* Haversine公式:计算两个经纬度点之间的球面距离
* @param float $lat1 点1纬度
* @param float $lng1 点1经度
* @param float $lat2 点2纬度
* @param float $lng2 点2经度
* @return float 两点间距离(单位:米)
*/
function haversine_distance($lat1, $lng1, $lat2, $lng2) {
$earthRadius = 6371000; // 地球半径(单位:米)
// 角度转换为弧度(三角函数计算需要弧度值)
$dLat = deg2rad($lat2 - $lat1);
$dLng = deg2rad($lng2 - $lng1);
// Haversine公式核心计算
$a = sin($dLat / 2) * sin($dLat / 2) +
cos(deg2rad($lat1)) * cos(deg2rad($lat2)) *
sin($dLng / 2) * sin($dLng / 2);
$c = 2 * atan2(sqrt($a), sqrt(1 - $a));
$distance = $earthRadius * $c; // 距离(米)
return $distance;
}
2. 实战需求:查询指定坐标附近的地点(高效+精准)
需求:以“北京故宫(39.90882, 116.39748)”为中心,查询周边10公里内的地点,步骤如下:
- 对中心坐标生成GeoHash编码,截取前缀(如6位),快速筛选候选集(GeoHash前缀匹配);
- 对候选集使用Haversine公式精准计算距离,筛选出10公里内的结果;
- 按距离从近到远排序,返回最终结果。
<?php
namespace app\index\controller;
class Index
{
public function index()
{
$centerLat = 39.81782;
$centerLng = 116.88648;
$maxDistance = 10000; // 10公里(10000米)
$geohashPrecision = 8;
$prefixLength = 6; // 6位前缀(适配10公里范围)
// ========== 方案1:GeoHash前缀筛选(推荐,高效) ==========
$startTime1 = microtime(true);
// 1. 生成中心坐标GeoHash并截取前缀
$centerGeohash = geohash_encode($centerLat, $centerLng, $geohashPrecision);
$geohashPrefix = substr($centerGeohash, 0, $prefixLength);
$nineplaces = geohash_get_8bit_neighbors($geohashPrefix);
$where['geohash'] = ['in',$nineplaces];
// 2. 前缀匹配查询候选集(利用索引,快速筛选)
$candidates = db("location_info")->where($where)->select();
// echo db("location_info")->getLastSql();die;
// 3. Haversine公式精准筛选+排序
$result1 = [];
foreach ($candidates as $item) {
$distance = haversine_distance($centerLat, $centerLng, $item['latitude'], $item['longitude']);
if ($distance <= $maxDistance) {
$item['distance'] = round($distance, 2);
$item['distance_km'] = round($distance / 1000, 2);
$result1[] = $item;
}
}
// 按距离排序
usort($result1, function($a, $b) {
return $a['distance'] - $b['distance'];
});
$endTime1 = microtime(true);
$costTime1 = round($endTime1 - $startTime1, 4);
echo "========== 查询结果(90万条数据) ==========<br/>";
echo "中心坐标:模拟地方({$centerLat}, {$centerLng})<br/>";
echo "查询范围:10公里内<br/>";
echo "<br/>【方案:GeoHash+Haversine】<br/>";
echo "查询耗时:{$costTime1} 秒<br/>";
echo "符合条件的地点数:" . count($result1) . " 个<br/>";
echo "前3个地点:<br/>";
for ($i = 0; $i < min(100, count($result1)); $i++) {
$item = $result1[$i];
$json = json_encode($item);
echo " - {$item['name']}:距离 {$item['distance']} 米({$item['distance_km']} 公里),json数据{$json}<br/>";
}
}
}
四、关键优化与注意事项
-
GeoHash前缀长度选择:
- 前缀越长,候选集范围越小(精度越高,可能遗漏邻近点);
- 前缀越短,候选集范围越大(不易遗漏,后续筛选工作量略增);
- 推荐:10公里范围用6位前缀,1公里范围用7位前缀,100米范围用8位前缀。
-
坐标顺序:
- GeoHash编码时是「纬度在前,经度在后」(与MySQL POINT类型相反,需注意区分);
- Haversine公式对参数顺序无强制要求,只需保证两个点的「纬度、经度」对应一致即可。
-
索引优化:
- 必须为
geohash字段创建普通索引(INDEX idx_geohash (geohash)),否则LIKE '前缀%'查询会变成全表扫描,失去GeoHash的效率优势。
- 必须为
-
精度补充:
- GeoHash存在「边缘问题」(两个距离极近的点,可能因跨GeoHash网格导致前缀不同),可通过查询中心GeoHash的8个相邻网格编码解决(进阶优化,适用于高精度场景)。
总结
- 核心组合:GeoHash(快速筛选候选集,提升查询效率) + Haversine公式(精准计算球面距离,保证结果精度);
- 存储要求:需同时存储「原始经纬度(DECIMAL类型)」和「GeoHash编码(CHAR类型)」,并为GeoHash创建普通索引;
- 关键步骤:经纬度→GeoHash编码→插入数据→GeoHash前缀匹配查候选集→Haversine公式算距离→筛选+排序;
- 避坑点:GeoHash编码(纬前经后)与MySQL POINT类型(经前纬后)的坐标顺序差异,前缀长度合理选择。