前言「附近商家」是一个看起来简单、做起来处处是细节的需求。最常见的误解有两个。第一个误解是把这件事当成一道纯 SQL 题直接写个ORDER BY带公式的查询就完事。这样做在小数据量下能跑但公式里的三角函数没法走索引数据量一上来就是全表扫描加全表计算。真正的做法是两段式先用一个廉价的「外包矩形」bounding box把候选集缩小再在候选集里做精确计算。第二个误解是关于「距离」本身的。用经纬度算出来的是球面直线距离大圆距离great-circle distance不是沿道路走的实际里程。两者在城市里差异可能很大——隔一条河的两个点直线距离两百米走路要绕两公里。所以展示时可以写「直线距离约 800 米」别写成「步行 800 米」。本文讲三件事地理距离的数学原理半正矢公式、PHP 侧的纯计算实现、以及 Laravel 里结合数据库索引的完整查询方案最后给出高数据量下的可选路线。一、原理为什么是半正矢Haversine公式地球近似为球体两点间最短路径是大圆上的一段弧。已知两点的经纬度角度制大圆距离的标准算法是半正矢公式a sin²(Δφ/2) cos φ1 · cos φ2 · sin²(Δλ/2)c 2 · atan2(√a, √(1−a)) 等价形式c 2 · asin(√a)d R · c R 为地球平均半径其中φ是纬度、λ是经度Δ表示差值全部要转成弧度。地球平均半径常用 6371 公里有的库用 6371.0088有的用 6378.137 赤道半径差别在千分之几做「附近」排序完全够用但不要在常量上来回换否则同一条数据算出的距离会跳变。为什么不直接用它的一阶近似把经纬度差按固定比例换算成公里因为经度方向的实际长度随纬度收缩比例是cos φ。在中低纬度做几百米范围内的小范围比较近似值误差可能可以接受但一旦范围扩大到几公里以上、或跨纬度跨度较大误差就会明显到影响排序。半正矢公式代码量只多几行没必要省。还有一个「不要用」的算法值得说明直接用球面余弦定理d R · acos(sin φ1 sin φ2 cos φ1 cos φ2 cos Δλ)。数学上等价但在短距离场景下数值不稳定——acos的自变量接近 1 时浮点误差会被放大几百米的距离可能算出几公里的偏差。半正矢用asin形式在短距离下精度好得多。这也是下面代码里坚持用asin的原因。二、PHP 侧一个可以直接用的距离函数?php // 纯 PHP 实现适用于 PHP 8.1PHP 7.x 去掉返回类型声明即可namespace App\Support;final class Geo{/** 地球平均半径单位公里 */private const EARTH_RADIUS_KM 6371.0088;/*** 用半正矢公式计算两点间的球面直线距离。** param float $lat1 起点纬度-90 ~ 90角度制* param float $lng1 起点经度-180 ~ 180角度制* param float $lat2 终点纬度* param float $lng2 终点经度* return float 距离单位公里*/public static function distanceKm(float $lat1, float $lng1, float $lat2, float $lng2): float{$dLat deg2rad($lat2 - $lat1);$dLng deg2rad($lng2 - $lng1);$a sin($dLat / 2) ** 2 cos(deg2rad($lat1)) * cos(deg2rad($lat2)) * sin($dLng / 2) ** 2;// 浮点误差可能让 $a 略微越界先钳制再开方避免 sqrt 拿到负数$a max(0.0, min(1.0, $a));$centralAngle 2 * asin(sqrt($a));return self::EARTH_RADIUS_KM * $centralAngle;}/*** 计算外包矩形bounding box用于在 SQL 里做粗筛。** return array{0: float, 1: float, 2: float, 3: float} [纬度下限, 纬度上限, 经度下限, 经度上限]*/public static function boundingBox(float $lat, float $lng, float $radiusKm): array{// 纬度方向上1 度约等于 111 公里这个比例在地球上基本恒定$latDelta $radiusKm / 111.0;// 经度方向的实际长度随纬度收缩高纬度处必须做除零保护$cosLat max(cos(deg2rad($lat)), 0.01);$lngDelta min($radiusKm / (111.0 * $cosLat), 180.0);return [max($lat - $latDelta, -90.0),min($lat $latDelta, 90.0),max($lng - $lngDelta, -180.0),min($lng $lngDelta, 180.0),];}}自测代码不需要数据库直接php geo.php就能看到结果?php // 手动推演用的小例子适用于 PHP 8.1require __DIR__./Geo.php;use App\Support\Geo;// 上海人民广场 - 上海外滩直线距离约 1.4 公里量级参考$km Geo::distanceKm(31.2304, 121.4737, 31.2397, 121.4900);printf(distance %.3f km\n, $km);// 同一个点算出来必须是 0.0用来确认公式没有符号写错var_dump(Geo::distanceKm(31.2304, 121.4737, 31.2304, 121.4737));print_r(Geo::boundingBox(31.2304, 121.4737, 5.0));最后一行打印的是 5 公里半径对应的矩形四至可以直接拿去拼 SQL 的BETWEEN条件。注意boundingBox()只保证「矩形包含这个圆」矩形是圆的外接区域所以粗筛之后必须再做一次精确计算否则四个角上的点会被错误地算进「5 公里内」。三、数据库侧粗筛加精算的完整查询数据表建议这样建decimal(10,7)的 7 位小数对应约 1 厘米的精度足够范围可到 ±999CREATE TABLE shops (id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,name VARCHAR(120) NOT NULL,latitude DECIMAL(10,7) NOT NULL,longitude DECIMAL(10,7) NOT NULL,status TINYINT NOT NULL DEFAULT 1,PRIMARY KEY (id),KEY idx_lat_lng (latitude, longitude)) ENGINEInnoDB DEFAULT CHARSETutf8mb4;idx_lat_lng的意义在于latitude BETWEEN ? AND ?是范围条件可以让 MySQL 用索引把扫描范围收缩到矩形的「纬度带」上longitude条件再在回表后过滤。没有这个索引无论公式写得多优雅都是全表扫描。下面是用 Laravel 的查询构造器实现的版本。为了讲清原理先给出最直白的写法?php // 适用于 Laravel 9/10/11PHP 8.1namespace App\Services;use App\Models\Shop;use App\Support\Geo;use Illuminate\Database\Eloquent\Collection;class ShopLocator{private const EARTH_RADIUS_KM 6371.0088;/*** 查询坐标附近的营业中门店按距离升序。** return Collectionint, Shop*/public function nearby(float $lat, float $lng, float $radiusKm 5.0, int $limit 20): Collection{[$latMin, $latMax, $lngMin, $lngMax] Geo::boundingBox($lat, $lng, $radiusKm);// LIMIT 不能用绑定参数Laravel 的 MySQL 连接默认关闭模拟预处理// 把 LIMIT 当字符串绑定会直接语法报错。这里强制转成整数后再拼进 SQL。$limit max(1, min(100, $limit));$distanceExpr ? * 2 * ASIN(SQRT(.POWER(SIN(RADIANS(latitude - ?) / 2), 2). COS(RADIANS(?)) * COS(RADIANS(latitude)). * POWER(SIN(RADIANS(longitude - ?) / 2), 2).));return Shop::query()-select(shops.*)-selectRaw($distanceExpr. AS distance_km, [self::EARTH_RADIUS_KM, // 地球半径$lat, // latitude 的差值基准$lat, // 起点纬度$lng, // longitude 的差值基准])-where(status, 1)-whereBetween(latitude, [$latMin, $latMax])-whereBetween(longitude, [$lngMin, $lngMax])-having(distance_km, , $radiusKm)-orderBy(distance_km)-limit($limit)-get();}}几点必须解释清楚selectRaw()的第二个参数是绑定值顺序要和表达式里的?一一对应。写反了不会报错只会得到完全错误的距离这是最难排查的一类 bug。having(distance_km, , $radiusKm)里的别名只有在 MySQL 里才能这样用——MySQL 允许HAVING和ORDER BY引用SELECT的别名。标准 SQL 不保证这一点如果将来要兼容 PostgreSQL 等其他数据库应把计算包进子查询再从外层过滤。LIMIT通过(int)强制转换后内联而不是作为参数绑定。原因是 Laravel 的 MySQL 连接默认设置PDO::ATTR_EMULATE_PREPARES false此时把LIMIT用字符串参数绑定会触发语法错误。查询构造器的-limit($n)内部也是把数字直接内联进 SQL 的同一套道理。矩形粗筛只做「缩小范围」不做「判定」判定由HAVING distance_km ?完成。如果需要完全可控的 SQL比如要联合其他表可以直接写原生语句并用位置绑定?php // 原生 SQL 版本适用于 Laravel 9PDO 位置绑定use Illuminate\Support\Facades\DB;$limit 20;$sql SELECT id, name, latitude, longitude,(? * 2 * ASIN(SQRT(POWER(SIN(RADIANS(latitude - ?) / 2), 2) COS(RADIANS(?)) * COS(RADIANS(latitude))* POWER(SIN(RADIANS(longitude - ?) / 2), 2)))) AS distance_kmFROM shopsWHERE status 1AND latitude BETWEEN ? AND ?AND longitude BETWEEN ? AND ?HAVING distance_km ?ORDER BY distance_km ASCLIMIT .(int) $limit;$rows DB::select($sql, [6371.0088,$lat, $lat, $lng,$latMin, $latMax,$lngMin, $lngMax,$radiusKm,]);DB::select()返回的是stdClass对象的数组不是 Eloquent 模型要模型就改用Shop::query()-selectRaw(...)那套。四、更省事的路线MySQL 内置球面距离函数MySQL 5.7.6 起内置了ST_Distance_Sphere()参数是两个点返回米为单位的球面距离SELECT id, name,ST_Distance_Sphere(POINT(longitude, latitude), POINT(?, ?)) AS distance_mFROM shopsWHERE status 1HAVING distance_m 5000ORDER BY distance_m ASCLIMIT 20;用它的第一个坑是参数顺序POINT()的第一个参数是 X 也就是经度第二个是 Y 也就是纬度。写成POINT(latitude, longitude)在语法上完全合法只是结果全错——而且错得「看起来合理」距离依然是正数只是排序乱了。这是最容易上线后才发现的问题之一。第二个坑是索引。上面的写法里ST_Distance_Sphere出现在SELECT和HAVING里无法用于索引筛选还是全表计算。想真正用上空间索引需要把坐标存成带 SRID 的POINT列并加SPATIAL INDEX再配合ST_Distance_Sphere()和矩形条件一起用这涉及列定义、SRID 约束列必须 NOT NULL、SRID 一致等一整套要求改动量不小只在数据量确实到了瓶颈时才值得做。各方案对比方案需要索引适用数据量主要风险纯 PHP 计算不涉及候选集几百条内先把全量数据取出来I/O 是瓶颈矩形粗筛 半正矢纬度索引十万级绑定值顺序写错结果静默错误ST_Distance_Sphere()无全表算万级POINT()参数顺序写反空间索引 函数空间索引百万级建表与迁移改动大Redis GEO / 搜索引擎由外部服务维护千万级数据一致性与运维成本数据量再往上走百万级、且读写都很频繁更常见的做法是把坐标同步一份到 Redis 或搜索引擎里Redis 用GEOADD写入、用GEOSEARCHRedis 6.2旧版是GEORADIUS按半径查询并直接返回距离查询复杂度与数据量基本无关搜索引擎则用 geo_distance 一类的查询。代价是要维护数据同步——商家信息更新时两边都得写写失败时会出现「搜到但点进去 404」的情况需要补偿机制。常见坑点❌ 用余弦定理R * acos(sin φ1 sin φ2 cos φ1 cos φ2 cos Δλ)算几百米内的距离短距离下误差被放得很大。✅ 用半正矢公式的2 * asin(sqrt(a))形式短距离精度更好a记得钳制到 0~1 之间。❌ 直接把用户传来的经纬度拼进 SQLwhereRaw(ST_Distance_Sphere(...) $radius)。✅ 全部走绑定参数能拼接的只有已经强制转成整数、且做过范围限制的LIMIT值。❌ 用POINT(latitude, longitude)查询不报错但排序全乱。✅ MySQL 的点类型是 X 在前、Y 在后必须写成POINT(longitude, latitude)。❌ 把LIMIT ?也做成绑定参数。✅ 框架的 MySQL 连接默认关闭了 PDO 的模拟预处理LIMIT用字符串参数绑定会触发语法错误先(int)强制转换再内联这也正是查询构造器-limit()的做法。❌ 只做矩形粗筛就返回结果导致「5 公里内」实际返回的是外接矩形内、对角线上接近 7 公里的商家。✅ 矩形只用来缩小候选集必须再做一次精确的半正矢计算并过滤。❌ 经度方向直接用半径 / 111计算差值在高纬度地区矩形窄得离谱漏掉本该命中的商家。✅ 经度温差要除以cos(纬度)同时对cos结果设下限避免接近极点时除零导致矩形无限大。❌ 距离用浮点数四舍五入到整数公里后排序导致几百米内的商家顺序随机。✅ 排序用原始的浮点距离值只在展示时才做「保留一位小数」这类格式化。❌ 把球面直线距离当作步行或驾车里程展示给用户。✅ 文案写「直线距离」或接入路径规划服务拿真实里程两者不可混用。总结步骤做法关键点1. 粗筛用半径反推经纬度矩形经度要除以cos(纬度)并做极值保护2. 走索引纬度加索引或经纬度复合索引没有索引公式再优也是全表扫描3. 精算半正矢公式asin形式a钳制到 0~1常量固定不变4. 过滤排序HAVING distance 半径ORDER BY distance绑定值顺序必须与?一一对应5. 展示格式化到米或一位小数的公里说明是直线距离结论这个需求的正确姿势是一句话——用索引做粗筛用数学做精算用绑定值防注入。三者缺一都会出问题没索引是慢没精算是错没绑定是安全事故。至于精度球面距离在城市尺度上的误差远小于「直线还是步行」这个语义差别与其纠结地球半径取 6371 还是 6371.0088不如先把粗筛、索引和POINT()参数顺序这三件事确认对。 SEO 优化官网定制响应式建站教育培训建站