前言
「附近商家」是一个看起来简单、做起来处处是细节的需求。最常见的误解有两个。
第一个误解是把这件事当成一道纯 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.1+(PHP 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.1+
require __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 厘米的精度,足够;范围可到 ±999):
CREATE 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`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;idx_lat_lng的意义在于:latitude BETWEEN ? AND ?是范围条件,可以让 MySQL 用索引把扫描范围收缩到矩形的「纬度带」上,longitude条件再在回表后过滤。没有这个索引,无论公式写得多优雅都是全表扫描。
下面是用 Laravel 的查询构造器实现的版本。为了讲清原理,先给出最直白的写法:
<?php // 适用于 Laravel 9/10/11(PHP 8.1+)
namespace 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 Collection<int, 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 9+(PDO 位置绑定)
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_km
FROM shops
WHERE status = 1
AND latitude BETWEEN ? AND ?
AND longitude BETWEEN ? AND ?
HAVING distance_km <= ?
ORDER BY distance_km ASC
LIMIT '.(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_m
FROM shops
WHERE status = 1
HAVING distance_m <= 5000
ORDER BY distance_m ASC
LIMIT 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写入、用GEOSEARCH(Redis 6.2+,旧版是GEORADIUS)按半径查询并直接返回距离,查询复杂度与数据量基本无关;搜索引擎则用 geo_distance 一类的查询。代价是要维护数据同步——商家信息更新时两边都得写,写失败时会出现「搜到但点进去 404」的情况,需要补偿机制。
常见坑点
- ❌ 用余弦定理
R * acos(sin φ1 sin φ2 + cos φ1 cos φ2 cos Δλ)算几百米内的距离,短距离下误差被放得很大。
✅ 用半正矢公式的2 * asin(sqrt(a))形式,短距离精度更好;a记得钳制到 0~1 之间。
- ❌ 直接把用户传来的经纬度拼进 SQL:
whereRaw("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()参数顺序这三件事确认对。