附近的人、附近的门店、附近的充电桩,这类基于地理位置的搜索功能如今几乎是各类App的标配。提到地理位置搜索,很多人第一反应是上PostgreSQL的PostGIS或者MySQL的空间索引,功能确实强大,但对一个移动端离线应用或者小型项目来说,引入一整套空间数据库扩展未免有点杀鸡用牛刀。SQLite作为一个零配置、单文件的嵌入式数据库,配合一点数学知识,完全可以撑起一个性能不差的附近搜索功能。本文就通过一个门店查询的实战项目,完整演示从建表、数据录入到范围过滤、距离计算、排序分页的全过程。

一、数据表设计与基础准备
先说存储方案。地理位置最核心的数据就是经度和纬度,SQLite没有专门的空间数据类型,我们直接用REAL类型存储即可。假设要做的是一个连锁咖啡店的门店查询,表结构可以这样设计:
CREATE TABLE shops (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL, -- 门店名称
lng REAL NOT NULL, -- 经度
lat REAL NOT NULL, -- 纬度
address TEXT, -- 详细地址
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
-- 为了后面的矩形范围过滤,给经纬度建索引
CREATE INDEX idx_lng ON shops(lng);
CREATE INDEX idx_lat ON shops(lat);这里有个容易被忽略的细节:经纬度的存储顺序和字段命名要统一。有些接口返回的数据是纬度在前,经度在后,混用的话排查起来非常痛苦。建议团队内约定lng在前lat在后,或者反过来,总之保持一致。另外经度范围是-180到180,纬度是-90到90,写入数据前最好做一次校验,脏数据会让后续的距离计算结果完全不可信。
还需要准备一份门店的测试数据。可以手工插入几条,也可以从公开的POI数据集导入。测试时建议故意安排几个距离很近的点和几个很远的点,方便验证排序结果是否符合预期。
二、Haversine公式:计算两点间的球面距离
附近搜索的核心问题是:已知我的坐标和所有门店的坐标,怎么算出距离?地球是个球体(准确说是不规则椭球体),不能简单用平面上的勾股定理。业界最常用的是Haversine公式,它通过球面三角函数计算两个经纬度点之间的大圆距离,误差在绝大多数业务场景下可以接受。
公式本身长这样:
-- a = sin(dlat/2)^2 + cos(lat1) * cos(lat2) * sin(dlng/2)^2 -- c = 2 * asin(sqrt(a)) -- distance = R * c 其中R为地球半径,平均约6371千米
翻译成SQLite的用户自定义函数后,可以直接在SQL里调用。以Python为例:
import sqlite3
import math
def haversine(lng1, lat1, lng2, lat2):
R = 6371.0 # 地球平均半径,单位千米
rad_lat1 = math.radians(lat1)
rad_lat2 = math.radians(lat2)
dlat = math.radians(lat2 - lat1)
dlng = math.radians(lng2 - lng1)
a = math.sin(dlat / 2) ** 2 + \\
math.cos(rad_lat1) * math.cos(rad_lat2) * math.sin(dlng / 2) ** 2
c = 2 * math.asin(math.sqrt(a))
return R * c
conn = sqlite3.connect('shops.db')
conn.create_function('HAVERSINE', 4, haversine)注册了用户自定义函数之后,一条SQL就能查出距离我最近的门店并按距离升序排列。假设我当前位于经度116.40、纬度39.90(北京附近):
SELECT id, name, address, lng, lat,
HAVERSINE(116.40, 39.90, lng, lat) AS distance
FROM shops
ORDER BY distance ASC
LIMIT 20;这个方案在小数据量下完全够用。但问题也很明显:即使只取前20条,ORDER BY distance也必须对全表所有记录逐一计算Haversine值再排序。当门店数量达到几万甚至几十万时,三角函数计算的开销会显著拖慢查询速度,这就引出了下面的优化方案。
三、矩形范围预过滤:性能优化的关键
优化的思路是分两步走:先用一个粗略但廉价的条件把绝大多数无关记录过滤掉,再对剩下的少量候选记录做精确的球面距离计算。最常用的粗过滤方法是经纬度矩形范围。
以查询点为中心,划定一个矩形边界:只要目标点的经纬度落在矩形内,才有可能在搜索半径之内。矩形边界和搜索半径的换算需要一点地理常识——纬度方向上,1度大约对应111千米,这是固定的;经度方向上,1度对应的距离随纬度变化,等于111千米乘以纬度的余弦值。假设搜索半径为5千米:
def bounding_box(lng, lat, radius_km):
lat_delta = radius_km / 111.0
lng_delta = radius_km / (111.0 * math.cos(math.radians(lat)))
return (lng - lng_delta, lng + lng_delta,
lat - lat_delta, lat + lat_delta)拿到边界后,先用BETWEEN条件配合前面建的索引筛出候选记录,再对候选集做精确计算:
SELECT id, name, address,
HAVERSINE(116.40, 39.90, lng, lat) AS distance
FROM shops
WHERE lng BETWEEN 116.34 AND 116.46
AND lat BETWEEN 39.85 AND 39.95
ORDER BY distance ASC
LIMIT 20;经过实测,在10万条门店数据上,不做范围过滤时全表扫描加三角函数计算需要几百毫秒,而加了矩形过滤后,候选记录通常只剩下几十到几百条,查询时间可以压缩到几毫秒,性能提升非常可观。如果查询结果为空,说明当前区域内没有门店,业务上可以选择扩大半径重试一次,或者直接提示用户附近暂无门店。
还有一个边界情况值得注意:当查询点靠近经度180度线(太平洋上的日界线附近)时,矩形范围会横跨正负180度,BETWEEN条件需要拆成两段用OR连接。国内业务一般碰不到这个问题,但如果做全球化应用就必须处理。
四、进阶技巧与方案取舍
除了矩形过滤,还有几个实用的进阶做法。一是把Haversine的三角函数计算换成平方距离近似:在很小的范围内(几千米),球面可以近似看作平面,直接比较经纬度差的加权和即可,省掉所有三角函数,速度更快。二是引入Geohash编码,把二维的经纬度编码成一维字符串,相邻区域的Geohash前缀相同,可以用LIKE 'wx4g%'这样的前缀匹配快速圈定附近区域,这也是Redis GEO功能的底层原理。SQLite里可以建一张冗余字段存Geohash并加索引。
CREATE TABLE shops_v2 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
lng REAL NOT NULL,
lat REAL NOT NULL,
geohash TEXT NOT NULL
);
CREATE INDEX idx_geohash ON shops_v2(geohash);方案怎么选?如果数据量在一万以内,直接全表Haversine计算就够了,简单直接不易出错;数据量在几万到几十万之间,矩形范围过滤是性价比最高的方案,实现成本低效果好;如果数据量更大或者需要支持复杂空间查询,那就该认真考虑切换到带空间索引的方案,比如服务端用PostGIS,客户端缓存层继续用SQLite加范围过滤的组合。
最后提醒一点关于单位的问题:Haversine算出来的距离单位取决于地球半径的取值,用6371结果是千米,用6371000则是米,前端展示前务必统一。另外这种球面距离是直线距离,不是步行或驾车距离,如果产品需求是导航意义上的距离,还需要接入路线规划服务来计算,这两者在体验上差别很大,别在验收时才发现理解偏差。掌握这套思路之后,你会发现SQLite虽然轻量,但在地理位置这类常见需求上依然大有可为。