好记性不如烂笔头,不如趁热记录下,给未来的自己。
前言
前段时间,遇到了这样一个产品需求:在地球上任意选一点,实时返回这个位置的天气信息(如温度,湿度等) 。如下图(最终效果):
该需求的前置条件已满足:
- 在地图上任意选点的时候,可以获取到该点经纬度信息;
- 给定一个json文件,以一个列表为整体数据结构,列表里每个对象会包含:经纬度,天气信息等。
- 定义天气数据的分辨率为 0.25 度,即以
经度 + 0.25 度
和纬度 + 0.25 度
覆盖的区域为列表里的一个对象,根据经纬度范围:经度 -180 度到 +180 度,纬度大约 -85 度到 +85 度,那么给定的 json 列表一共有将近 100 万(979200)个对象。
那么,产品需求就可以转换成技术需求:任意给定一个经纬度点位置,从已有的 100 万个经纬度位置里,匹配最接近给定经纬度位置对应的天气信息。
方案选择
朴素方案
从 100 万个位置(p_list)中,匹配最接近给定经纬度位置(p_0),其实就是计算给定位置(p_0)与这 100 万个位置(p_list)每个位置的距离(d_list),并获得其最小值(d_min)。
伪代码如下:
python
d_list = []
for p in p_list:
dist = calc_dist(p, p_0)
d_list.append(dist)
d_min = sort(d_list, order=asc)[0]
calc_dist()
计算两点间的距离:这个距离可以是两点间的真实物理距离(近似),也可以是数学概念上的距离,如欧式距离,曼哈顿距离,甚至可以是余弦相似度。
朴素方案在原理上可以实现需求,但是有一个致命的问题:时间复杂度为 O(n)
,会随着待匹配对象越来越多,导致耗时线性增加:
在本场景下,假设处理一次 calc_dist()
的时间为 1 毫秒,那么 100 万次的计算需要 1000 秒 = 16.67 分钟。转换到当前业务场景,就是在地图上选一个点,需要等待至少 16 分钟,才能获得该点的天气信息,这是不能被接受的。
改进方案
改进方案:使用 redis 自带的地理编码功能来处理地理空间数据,这里会涉及到 redis (版本>=3.2) 的两个命令:geoadd 和 georadius 。
geoadd:
GEOADD
命令用于将指定的地理空间位置(经度、纬度、名称)添加到指定的 key 中。
shell
GEOADD key longitude latitude member [longitude latitude member ...]
key
:你想要添加地理空间数据的 Redis 键。longitude
:地理位置的经度。latitude
:地理位置的纬度。member
:与指定地理位置相关联的名称或标识符。
举例:
arduino
GEOADD locations 116.405285 39.904989 "Beijing" 121.473701 31.230416 "Shanghai"
在redis的数据结构是:
可以看到,geoadd 的数据使用有序集合(sorted set)来存储地理空间信息。每个加入到有序集合的地理位置元素都会被分配一个分数(score),在地理空间命令的上下文中,这个分数代表地理位置的 Geohash 值。
Geohash 是一种地理编码系统,它能够将地球表面的任何位置转换成一串短的字母和数字组合。这种编码方法通过将地球划分成一个网格,每个格子都有一个唯一的标识符。
georadius
GEORADIUS
命令在 Redis 中用于根据给定的经度和纬度坐标,查询指定范围内的地理位置元素。这个命令可以用来找出某个中心点周围一定距离内的所有地理位置点,非常适合于实现"查找附近的..."这类功能。
shell
GEORADIUS key longitude latitude radius m|km|ft|mi [WITHCOORD] [WITHDIST] [WITHHASH] [COUNT count] [ASC|DESC] [STORE key] [STOREDIST key]
key
:包含地理位置数据的 key。longitude
、latitude
:查询中心点的经度和纬度。radius
:查询的半径。m|km|ft|mi
:半径的单位,分别代表米、千米、英尺、英里。[WITHCOORD]
:可选,返回地理位置的经纬度坐标。[WITHDIST]
:可选,返回地理位置与中心点的距离。[WITHHASH]
:可选,返回地理位置的 geohash 值。[COUNT count]
:可选,限制返回的地理位置数量。[ASC|DESC]
:可选,根据距离对结果进行升序或降序排序。[STORE key]
:可选,将结果保存到指定的 key 中。[STOREDIST key]
:可选,将结果的距离保存到指定的 key 中。
举例:
shell
GEORADIUS locations 116.405285 39.904989 1 km WITHCOORD WITHDIST
查询 redis key 为 locations,距离经纬度为(1116.405285,39.9049891)为 1 公里内所有符合条件的位置信息,并返回其距离和位置坐标。
具体实现
根据 redis 的 geoadd 和 georadius,来实现我们的业务需求:
- 使用 geoadd 将经纬度+天气信息(高度,时间戳,风速,风向以及温度)更新到 redis 里:
python
# depends on redis==5.0.1
r = redis.Redis(host='0.0.0.0', port=6379, db=0)
with open('example.json', 'r') as f:
forecasts = json.load(f)
for forcast in forecasts:
lat = float(forcast["lat"])
lon = float(forcast["lon"])
lon = lon if lon<=180 else 180-lon
height = "1000"
ts = forcast['timestamp']
winds, directions = do_winds(forcast.get('data').get('u'), forcast.get('data').get('v'))
temps = forcast.get('data').get('temp')
member = {
"height": height,
"ts": ts,
"winds": winds,
"directions": directions,
"temps": temps,
}
member_json_str = json.dumps(member)
print(f"lat={lat},lon={lon}, member={member_json_str}")
r.geoadd(name="weather_info", values= [lon, lat, member_json_str], nx=True)
- 前端地图选点+获取天气信息接口:
这里用的是 NodeJS 作为后端服务框架
javascript
// depends on ioredis = ^5.3.2
app.get('/api/v1/predict/7d', (req, res) => {
let lat = req.query.lat;
let lon = req.query.lon;
let ts = req.query.ts;
function getGeohashFromRedis(lat, lon) {
// 根据传入的经纬度 lon 和 lat,从 redis 的 weather_info 里获取距离此经纬度100 公里以内所有符合条件的天气信息,按照距离从短到长升序排列,并选第一个(最短的距离)返回。
let georadius = redis.georadius("weather_info", lon, lat, "100", "km", "count", "1", "asc");
georadius.then( results => {
if (results.length === 0) {
res.send([{}])
return;
}
let forecast = JSON.parse(results[0]);
let ret = [];
let length = forecast.winds.length;
let ts = forecast.ts;
for (let i = 0; i < length; i++) {
let retItem = {};
let dateFormatted = new Date(ts * 1000);
let date = dateFormatted.getFullYear() + '-' + (dateFormatted.getMonth() + 1).toString() + '-' + dateFormatted.getDate();
let hour = dateFormatted.getHours();
retItem['date'] = date;
retItem['time'] = hour;
retItem['wind'] = forecast.winds[i];
retItem['direction'] = forecast.directions[i];
retItem['temp'] = forecast.temps[i];
retItem['lon'] = lon;
retItem['lat'] = lat;
ret.push(retItem);
}
// 将从 redis 里获得原始数据通过上面的业务代码重新适配后,返回给前端。
res.send(ret)
})
}
try {
getGeohashFromRedis(lat, lon);
} catch (e) {
console.error("get data from redis failed, e=" + e);
}
});
以上。