GeoHash和Haversine 公式来实现地理位置的存储与计算

如何结合thinkphp中的GeoHashHaversine公式来实现地理位置的存储与计算,这是一套兼顾查询效率(GeoHash)和距离精度(Haversine)的经典方案,下面详细拆解实现步骤、核心原理和实战代码:

一、核心概念说明

1. GeoHash 核心作用

GeoHash是一种将二维经纬度坐标编码为一维字符串的地理空间索引算法,其核心价值是:

  • 邻近性特征:地理位置越接近的点,GeoHash字符串前缀相同的长度越长(例如:编码wx4g0swx4g0t对应的坐标距离极近);
  • 高效范围查询:通过匹配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公里内的地点,步骤如下:

  1. 对中心坐标生成GeoHash编码,截取前缀(如6位),快速筛选候选集(GeoHash前缀匹配);
  2. 对候选集使用Haversine公式精准计算距离,筛选出10公里内的结果;
  3. 按距离从近到远排序,返回最终结果。
<?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/>";
        }
    }
}

四、关键优化与注意事项

  1. GeoHash前缀长度选择
    • 前缀越长,候选集范围越小(精度越高,可能遗漏邻近点);
    • 前缀越短,候选集范围越大(不易遗漏,后续筛选工作量略增);
    • 推荐:10公里范围用6位前缀,1公里范围用7位前缀,100米范围用8位前缀。
  2. 坐标顺序
    • GeoHash编码时是「纬度在前,经度在后」(与MySQL POINT类型相反,需注意区分);
    • Haversine公式对参数顺序无强制要求,只需保证两个点的「纬度、经度」对应一致即可。
  3. 索引优化
    • 必须为geohash字段创建普通索引(INDEX idx_geohash (geohash)),否则LIKE '前缀%'查询会变成全表扫描,失去GeoHash的效率优势。
  4. 精度补充
    • GeoHash存在「边缘问题」(两个距离极近的点,可能因跨GeoHash网格导致前缀不同),可通过查询中心GeoHash的8个相邻网格编码解决(进阶优化,适用于高精度场景)。

总结

  1. 核心组合:GeoHash(快速筛选候选集,提升查询效率) + Haversine公式(精准计算球面距离,保证结果精度);
  2. 存储要求:需同时存储「原始经纬度(DECIMAL类型)」和「GeoHash编码(CHAR类型)」,并为GeoHash创建普通索引;
  3. 关键步骤:经纬度→GeoHash编码→插入数据→GeoHash前缀匹配查候选集→Haversine公式算距离→筛选+排序;
  4. 避坑点:GeoHash编码(纬前经后)与MySQL POINT类型(经前纬后)的坐标顺序差异,前缀长度合理选择。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容