Redis GeoHash 全面指南

Redis GeoHash 提供了一套强大的命令集,如 GEOADDGEORADIUSGEODIST,用于高效地存储、查询和计算地理位置信息。通过将经纬度编码为 GeoHash 值并利用 Redis 的有序集合进行存储,可以实现诸如「附近的人」和「附近的商家」等功能。性能优化方面,Redis 7.0 对 Geo 命令进行了显著改进,同时客户端策略(如九宫格算法)和 Lua 脚本也能进一步提升查询效率。GeoHash 编码的特性使其适用于多种场景,包括社交应用、本地服务搜索和实时定位。


1. Redis GeoHash 核心命令与操作

Redis 自 3.2 版本起,引入了对地理位置(Geospatial)数据的支持,通过一系列 Geo 命令,开发者可以方便地在 Redis 中存储、查询和计算地理位置信息。这些功能的核心是 GeoHash 算法,它将二维的经纬度坐标编码成一维的字符串,使得地理位置相关的查询操作更为高效。Redis 的 Geo 模块主要包含六个核心命令:GEOADDGEOPOSGEODISTGEOHASHGEORADIUSGEORADIUSBYMEMBER 。这些命令使得 Redis 能够处理诸如「附近的人」、「附近的店铺」等基于地理位置的功能。在 Redis 内部,地理位置信息是使用有序集合(Sorted Set)来存储的,其中成员(member)是地理位置的标识(如用户ID或地点名称),而分数(score)则是该位置的 GeoHash 编码值 。这种设计巧妙地利用了有序集合的特性,使得范围查询等操作非常高效。

1.1 添加地理位置信息:GEOADD

GEOADD 命令是 Redis Geo 模块中最基础也是最重要的命令之一,它用于向指定的 key 中添加一个或多个地理位置信息。每个地理位置信息由经度(longitude)、纬度(latitude)和成员(member)三部分组成。成员是一个唯一的字符串标识,用于代表该地理位置,例如用户ID、店铺名称等。当执行 GEOADD 命令时,Redis 会首先将传入的经纬度转换为一个 52 位的 GeoHash 值,然后将这个 GeoHash 值作为分数(score),将成员作为元素(member),存储到一个有序集合(Sorted Set)中 。如果指定的 key 不存在,Redis 会先创建一个新的有序集合。如果成员已经存在于该有序集合中,那么它的分数(即 GeoHash 值)将会被更新。

命令的基本语法如下:

GEOADD key longitude latitude member [longitude latitude member ...]

其中 key 是存储地理位置的有序集合的键名。longitudelatitude 分别是地理位置的经度和纬度,它们的范围应符合地理规范(经度:-180° ~ 180°,纬度:-90° ~ 90°)。member 是与该经纬度关联的唯一标识符。GEOADD 命令允许一次添加多个地理位置信息,只需按顺序提供多组 longitude latitude member 参数即可 。

例如,要向名为 Sicily 的 key 中添加两个城市的地理位置信息,可以执行以下命令:

GEOADD Sicily 13.3614 40.6384 "Palermo" 15.0845 37.8287 "Catania"

这条命令会将 “Palermo” 的坐标 (13.3614, 40.6384) 和 “Catania” 的坐标 (15.0845, 37.8287) 添加到名为 Sicily 的有序集合中 。如果添加成功,GEOADD 命令会返回一个整数,表示成功添加到有序集合中的新成员数量(不包括已存在并更新分数的成员)。例如,如果 Sicily 中原本没有 “Palermo” 和 “Catania”,则上述命令会返回 (integer) 2

在实际应用中,例如社交应用中,当用户登录或更新位置时,可以调用 GEOADD 命令将用户的 ID 和当前经纬度添加到 Redis 中 。例如,用户 user123 的经纬度是 (40.7128, -74.0060),则可以执行 GEOADD users 40.7128 -74.0060 user123 。同样,在本地服务搜索场景中,可以将商家信息(如咖啡店)及其经纬度添加到 Redis,例如 GEOADD coffee_shops 121.4737 31.2304 "Starbucks_001"

需要注意的是,Redis 对经纬度的存储范围有要求,经度必须在 -180 到 180 度之间,纬度必须在 -85.05112878 到 85.05112878 度之间。超出这个范围的值会导致命令执行失败。此外,由于 GeoHash 编码的特性,GEOADD 添加的经纬度信息会存在一定的精度误差,这个误差通常在 0.5% 左右 。对于需要极高精度的场景,可能需要额外的处理。

1.2 查询指定半径内的地理位置:GEORADIUSGEORADIUSBYMEMBER

Redis 提供了两个核心命令用于查询指定半径范围内的地理位置:GEORADIUSGEORADIUSBYMEMBER。这两个命令的功能相似,主要区别在于指定中心点的方式不同。GEORADIUS 命令通过给定的经纬度作为中心点进行查询,而 GEORADIUSBYMEMBER 命令则是通过一个已经存在于有序集合中的成员作为中心点进行查询 。这两个命令在实现「附近的人」或「附近的商家」等功能时非常关键。

GEORADIUS 命令的基本语法如下:

GEORADIUS key longitude latitude radius unit [WITHDIST] [WITHCOORD] [WITHHASH] [ASC|DESC] [COUNT count] [STORE key] [STOREDIST key]

参数说明:

  • key:存储地理位置的有序集合的键名。
  • longitudelatitude:中心点的经度和纬度。
  • radius:查询半径。
  • unit:半径的单位,可以是 m(米)、km(千米)、mi(英里)或 ft(英尺)。
  • WITHDIST:可选参数,返回结果同时包含成员与中心点的距离。
  • WITHCOORD:可选参数,返回结果同时包含成员的经纬度。
  • WITHHASH:可选参数,返回结果同时包含成员的原始 GeoHash 编码值(52位有符号整数)。这个选项主要用于底层调试,实际应用场景较少 。
  • ASC|DESC:可选参数,指定结果按距离排序,ASC 为从近到远,DESC 为从远到近。
  • COUNT count:可选参数,限制返回结果的数量。
  • STORE key:可选参数,将查询到的地理位置信息存储到另一个指定的 key 中(作为有序集合)。
  • STOREDIST key:可选参数,将查询到的地理位置信息及其与中心点的距离存储到另一个指定的 key 中(作为有序集合,距离作为分数)。

例如,要查询以经纬度 (15, 37) 为中心,半径为 100 公里内的最多 5 个地点,并返回这些地点的距离和坐标,可以使用以下命令:

GEORADIUS Sicily 15 37 100 km WITHDIST WITHCOORD COUNT 5

这条命令会返回 Sicily 这个 key 中,距离中心点 (15, 37) 100 公里范围内的最多 5 个成员,并且每个成员的信息会包含其名称、距离以及经纬度坐标 。

GEORADIUSBYMEMBER 命令的语法与 GEORADIUS 类似,只是将中心点的经纬度参数替换为一个已有的成员名称:

GEORADIUSBYMEMBER key member radius unit [WITHDIST] [WITHCOORD] [WITHHASH] [ASC|DESC] [COUNT count] [STORE key] [STOREDIST key]

例如,要查询以 “Palermo” 这个成员为中心,半径为 100 公里内的地点,可以使用:

GEORADIUSBYMEMBER Sicily "Palermo" 100 km WITHDIST WITHCOORD COUNT 5

这条命令会返回 Sicily 这个 key 中,距离 “Palermo” 100 公里范围内的最多 5 个成员,并包含距离和坐标信息 。

在社交应用中,当用户 A 想要查找附近的人时,可以先获取用户 A 的经纬度,然后使用 GEORADIUS 命令查询。例如,用户 A 的经纬度是 (116.054579, 39.030452),要查找 5 公里内的用户,可以执行 GEORADIUS users:locations 116.054579 39.030452 5 km ASC COUNT 10 。如果用户 A 本身的位置信息已经存储在 Redis 中(例如 key 为 user_A_location),那么也可以使用 GEORADIUSBYMEMBER user_A_location user_A_id 5 km ASC COUNT 10 来实现相同的功能 。

需要注意的是,GEORADIUSGEORADIUSBYMEMBER 命令在 Redis 3.2.10 和 Redis 4.0.0 版本之后,如果使用了 STORESTOREDIST 选项,它们会被标记为写入命令,这意味着在集群环境下,这些命令会被路由到主节点执行,可能会对主节点的负载造成影响。为了解决这个问题,Redis 引入了 GEORADIUS_ROGEORADIUSBYMEMBER_RO 这两个只读命令 。在实际应用中,如果只是查询而不需要存储结果,建议使用这两个只读命令以避免潜在的性能问题。

1.3 获取成员经纬度:GEOPOS

GEOPOS 命令用于从指定的 key 中获取一个或多个成员的经纬度坐标。这个命令非常直接,当你需要知道某个特定地点(由成员名标识)的确切位置时,可以使用它。

命令的基本语法如下:

GEOPOS key member [member ...]

参数说明:

  • key:存储地理位置的有序集合的键名。
  • member:要查询经纬度的成员名称。可以指定一个或多个成员。

例如,要获取名为 Sicily 的 key 中 “Palermo” 和 “Catania” 这两个成员的经纬度,可以执行:

GEOPOS Sicily "Palermo" "Catania"

执行结果会返回一个数组,数组中的每个元素对应一个成员的经纬度。如果成员存在,则返回一个包含两个元素的数组,分别是经度和纬度。如果成员不存在于该 key 中,则对应位置返回 nil 。

例如,执行 GEOPOS diner:location zhangsan lisi 可能会返回如下结果:

1) 1) "121.44661813974380493"
   2) "31.20559220971455971"
2) 1) "121.44657522439956665"
   2) "31.20485207113603821"

这表示用户 “zhangsan” 的经纬度是 (121.44661813974380493, 31.20559220971455971),用户 “lisi” 的经纬度是 (121.44657522439956665, 31.20485207113603821) 。

需要注意的是,GEOPOS 命令返回的经纬度坐标是当初通过 GEOADD 命令添加时的值,或者通过其他方式更新后的值。由于 GeoHash 编码和解码过程存在一定的精度损失,GEOPOS 返回的坐标可能与最初添加的坐标有微小的差异,但这个差异通常非常小,对于大多数应用场景来说是可以接受的 。例如,如果最初添加的坐标是 (116.48105, 39.996794),通过 GEOPOS 获取到的可能是 (116.48104995489120483, 39.99679348858259686) 。

这个命令在需要获取特定用户或地点位置信息的场景中非常有用。例如,在社交应用中,当用户查看附近的人列表时,可能需要获取列表中每个用户的详细位置信息以在地图上显示。或者在物流配送场景中,需要获取配送员或商家的精确位置。

1.4 计算成员间距离:GEODIST

GEODIST 命令用于计算指定 key 中两个成员之间的直线距离。这个命令在需要知道两个地理位置之间实际距离的场景下非常有用,例如计算用户与附近商家的距离,或者计算两个配送点之间的距离。

命令的基本语法如下:

GEODIST key member1 member2 [unit]

参数说明:

  • key:存储地理位置的有序集合的键名。
  • member1member2:需要计算距离的两个成员的名称。
  • unit:可选参数,指定返回距离的单位。可以是 m(米)、km(千米)、mi(英里)或 ft(英尺)。如果未指定单位,默认为米 。

例如,要计算名为 Sicily 的 key 中 “Palermo” 和 “Catania” 两个城市之间的距离,并以千米为单位返回,可以执行:

GEODIST Sicily "Palermo" "Catania" km

如果两个成员都存在,命令会返回它们之间的距离(浮点数)。如果其中一个或两个成员不存在,或者 key 不存在,则命令返回 nil 。

例如,执行 GEODIST diner:location zhangsan lisi m 可能会返回 "82.4241",表示 “zhangsan” 和 “lisi” 之间的距离是 82.4241 米 。执行 GEODIST diner:location zhangsan lisi km 则会返回 "0.0824",表示距离是 0.0824 千米 。

在社交应用中,当用户查看「附近的人」列表时,通常会显示每个人与当前用户的距离,这个距离就可以通过 GEODIST 命令计算得出。例如,用户 A 想查看与用户 B 的距离,可以执行 GEODIST users:locations userA_id userB_id km。在本地服务搜索中,用户搜索附近的咖啡店,搜索结果中显示的店铺与用户的距离也可以通过此命令获取,例如 GEODIST coffee_shops user_location starbucks_001 km

需要注意的是,GEODIST 命令计算的是两个点之间的直线距离(大圆距离),并没有考虑实际的道路网络或地形因素。因此,在需要精确路径距离的场景下,GEODIST 的结果可能仅作为参考。此外,距离的计算精度也受到 GeoHash 编码精度的影响。

1.5 获取成员GeoHash编码:GEOHASH

GEOHASH 命令用于获取指定 key 中一个或多个成员的 GeoHash 编码。GeoHash 是一种将二维经纬度坐标编码成一维字符串的方法,这个字符串可以用于表示一个矩形区域。GeoHash 编码的一个特性是,编码值越相似,表示对应的地理位置越接近(在大多数情况下,但需要注意边界条件)。

命令的基本语法如下:

GEOHASH key member [member ...]

参数说明:

  • key:存储地理位置的有序集合的键名。
  • member:要获取 GeoHash 编码的成员名称。可以指定一个或多个成员。

例如,要获取名为 Sicily 的 key 中 “Palermo” 和 “Catania” 这两个成员的 GeoHash 编码,可以执行:

GEOHASH Sicily "Palermo" "Catania"

命令会返回一个数组,数组中的每个元素是对应成员的 GeoHash 编码字符串。如果成员不存在,则对应位置返回 nil 。

例如,执行 GEOHASH locations Beijing Shanghai 可能会返回类似于 ["wx4g0b7f", "wtw3sjtv"] 这样的结果 。Redis 返回的 GeoHash 编码是经过 Base32 编码后的字符串,其长度默认为 11 个字符。这个长度对应于 Redis 内部使用的 52 位 GeoHash 整数编码的精度 。

GeoHash 编码在实际应用中有多种用途。首先,它可以作为一种紧凑的表示地理位置的方式,方便存储和传输。其次,由于 GeoHash 编码的前缀匹配特性,可以用于快速筛选大致在同一区域内的地点。例如,如果两个地点的 GeoHash 编码的前几位相同,那么它们很可能在同一个较大的区域内。这个特性可以用于数据分区、缓存优化等场景 。例如,可以将具有相同 GeoHash 前缀(如前 5 位或 6 位)的地点数据存储在一起,或者将针对某个 GeoHash 区域的查询结果缓存起来,因为该区域内的用户查询请求的 GeoHash 编码可能具有相同的前缀。

需要注意的是,Redis 返回的 GeoHash 编码是基于其内部 52 位 GeoHash 值的,这个值的精度是有限的。因此,通过 GeoHash 编码还原出的经纬度坐标与原始坐标之间可能存在一定的误差。此外,GeoHash 编码虽然具有「邻近性」特点,但并非绝对,在边界附近的地点,即使 GeoHash 编码差异较大,实际距离也可能很近,反之亦然。因此,在需要精确判断地理位置关系的场景,不能仅仅依赖 GeoHash 编码的相似性,还需要结合其他计算(如实际距离计算)。

2. Redis GeoHash 性能优化与高级特性

Redis 的 GeoHash 功能虽然强大且易于使用,但在大规模数据和高并发查询的场景下,性能优化仍然是一个重要的考虑因素。了解其性能特点、瓶颈以及可用的优化策略,对于构建高效的地理位置服务至关重要。Redis 的 Geo 命令底层依赖于有序集合(Sorted Set)和 GeoHash 算法,其性能通常很高,但不当的使用或大规模数据集仍可能导致性能问题。

2.1 Redis GeoHash 性能概述与瓶颈分析

Redis 的地理空间索引功能(GeoHash)虽然强大,但在处理大规模数据和高并发查询时,性能瓶颈依然存在。主要的性能挑战源于其底层实现和计算复杂性。Redis 使用有序集合(Sorted Set)存储地理空间数据,其中成员的分数(score)是经过 GeoHash 编码后的 52 位整数。这种编码方式虽然能够将二维的经纬度转换为一维的字符串,便于范围查询,但在进行距离计算和范围搜索时,仍需要进行大量的解码和数学运算。特别是在执行 GEORADIUSGEOSEARCH 命令时,Redis 需要遍历指定范围内的所有潜在 GeoHash 块,对每个候选位置进行解码,计算其与中心点的实际距离(通常使用 Haversine 公式),然后根据用户指定的参数(如距离、数量、排序等)进行过滤和返回。这个过程涉及到大量的 CPU 计算,尤其是在搜索范围较大或数据集内成员密度较高的情况下,性能开销会显著增加。

根据 Redis 官方的性能分析,在 Redis 7.0 之前的版本中,Geo 命令的性能瓶颈主要体现在以下几个方面:首先,冗余的距离计算。在 geohashGetDistanceIfInRectangle 函数中,geohashGetDistance 被调用了三次,其中前两次是为了产生中间结果,例如检查一个点是否超出经度或纬度范围,以避免更耗 CPU 的完整距离计算。然而,这种检查本身也存在计算开销,尤其是在数据量大的情况下,重复的中间计算累积起来会消耗大量 CPU 资源 。其次,不必要的内存分配与释放。在处理大型数据集时,尤其是在许多元素位于搜索范围之外的情况下,Redis 会频繁调用 sdsdupsdsfree 来分配和释放字符串内存,这会导致 CPU 周期的浪费 。再次,复杂的三角函数计算。Haversine 距离公式依赖于大量的三角函数运算(如 sin, cos, asin, sqrt 等),这些运算本身在 CPU 层面就是比较昂贵的操作。在 Redis 的单线程模型中,这些密集的 CPU 计算会阻塞其他命令的执行,从而影响整体的吞吐量和响应延迟 。此外,数据类型转换也是一个潜在的瓶颈,例如在 GEODIST 命令中,将双精度浮点数转换为字符串表示(如使用 snprintf)也会消耗一定的 CPU 时间 。

性能瓶颈还可能出现在以下几个方面:

  1. 数据量过大:当单个有序集合中存储的地理位置数据量非常庞大时(例如数千万或上亿级别),即使是 O(log N. 的操作也可能变得相对较慢。此外,如果数据量过大,可能会导致 Redis 实例的内存不足,或者在集群环境下,单个 key 的数据迁移会成为瓶颈 。单个 key 对应的数据量不宜超过 1MB,否则在集群迁移时可能导致卡顿 。
  2. 查询范围过大或返回结果过多:如果 GEORADIUSGEORADIUSBYMEMBER 命令指定的查询半径过大,或者查询区域内的点非常密集,会导致需要处理的候选点数量 M 增加,从而增加计算距离和排序的开销 。如果返回的结果集非常大,网络传输和客户端处理这些结果也会消耗更多时间。
  3. 并发查询量过高:在高并发场景下,如果大量客户端同时执行 Geo 查询,会对 Redis 服务器造成较大的 CPU 和 I/O 压力,可能导致响应延迟。
  4. GeoHash 精度选择:虽然 Redis 内部使用固定精度(52位)的 GeoHash,但在应用层面,如果对 GeoHash 的精度理解不当,可能会导致不必要的查询或计算。例如,过高的精度要求可能会使得 GeoHash 网格划分过细,增加查询的复杂度。
  5. 命令选项的使用GEORADIUS 命令的一些选项,如 WITHCOORDWITHDISTWITHHASH 以及排序 (ASC|DESC),会增加命令的计算量和返回数据的大小,从而影响性能 。特别是 STORESTOREDIST 选项,它们会将查询结果写入新的 key,这在集群模式下会被视为写操作,可能导致主节点负载过高 。

为了应对这些潜在的瓶颈,可以采取一系列优化措施,例如合理设计数据模型、优化查询参数、使用更高效的查询方式、进行数据分片以及利用客户端缓存等。

2.2 Redis 7.0 对Geo命令的性能优化

Redis 7.0 版本针对地理空间(Geo)相关的命令进行了一系列显著的性能优化,这些优化措施主要集中在减少不必要的计算、优化内存分配以及简化算法逻辑等方面,从而使得 GEORADIUS(在 Redis 7.0 及以后版本中,推荐使用 GEOSEARCH 命令)等核心地理查询命令的性能得到了大幅提升。根据官方博客和相关技术文章的介绍,这些优化措施的综合效果使得 Redis 地理空间命令的性能提升了高达四倍。具体来说,这些优化体现在以下几个关键方面:

首先,在距离计算方面,Redis 7.0 优化了 geohashGetDistanceIfInRectangle 函数。该函数在判断一个点是否在指定矩形区域内,并计算其与中心点距离时,原先会多次调用 geohashGetDistance 函数。通过分析发现,前两次调用 geohashGetDistance 主要是为了产生中间结果,例如检查一个点是否超出了经度或纬度的范围。如果这些条件不满足,则可以避免后续更耗 CPU 的距离计算。Redis 7.0 通过减少这些中间结果的不必要计算,显著降低了 CPU 消耗。具体而言,优化包括减少中间 geohashGetDistance 计算中的昂贵计算,以及如果纬度距离条件不满足,则避免昂贵的经度距离计算。这一轮优化使得特定 GEOSEARCH 查询的平均延迟(包括往返时间)从 93.598 毫秒降低到 73.046 毫秒,延迟降低了约 22%。

其次,在内存分配方面,Redis 7.0 针对 geoAppendIfWithinShape 函数进行了优化。该函数在处理大量数据集,特别是许多元素超出搜索范围的情况下,会对 sdsdupsdsfree 执行冗余调用,这两个命令分别用于分配和释放字符串内存。性能分析表明,在某些情况下,超过 28% 的 CPU 时间花费在 sdsdup 命令上。Redis 7.0 的优化方案是改变 geoAppendIfWithinShape 的作用,让调用者只在需要时创建字符串,而不是预先分配字符串内存。这一简单的改进使得延迟降低了约 14%,可实现的每秒操作数(ops/sec)提高了 25%。

再次,在算法简化方面,Redis 7.0 关注了数据模式信息,旨在简化计算半正矢距离(Haversine distance)所需的计算次数。一个重要的发现是,当经度差为 0 时,半正矢公式中的 asin(sqrt(a)) 可以简化为 asin(sin(abs(u)))。进一步地,当 x 属于 [-π/2, π/2] 区间时,arcsin(sin(x)) 等于 x。考虑到纬度值总是在 [-π/2, π/2] 之间,因此可以将 arcsin(sin(x)) 简化为 abs(x)。这项针对特定情况的算法简化,使得 Redis GEOSEARCH 命令的可实现每秒操作数增加了 55%。

根据 Redis 官方博客提供的数据,在基于 Intel Xeon Platinum 8360Y 处理器的服务器上,Redis 7.0.7 相较于 Redis 7.0.5 在 GEO 命令的吞吐量和延迟方面均有显著提升。例如,GEODIST 命令的吞吐量提升了 1.3 倍,p50 延迟降低了 1.3 倍;GEOSEARCH ... FROMLONLAT ... BYRADIUS 命令的吞吐量提升了 1.2 倍,p50 延迟降低了 1.1 倍;而 GEOSEARCH ... FROMLONLAT ... BYBOX 命令的吞吐量更是提升了 3.8 倍,p50 延迟降低了 3.7 倍。这些数据充分证明了 Redis 7.0 在 Geo 命令性能优化方面取得的成果。开发者在使用 Redis 7.0 及以上版本时,可以天然享受到这些性能提升带来的好处,尤其是在处理大规模地理空间数据和需要低延迟响应的场景下,如实时定位、附近的人等应用。

下表总结了 Redis 7.0.7 相对于 Redis 7.0.5 在 Geo 命令性能方面的具体提升数据,测试环境为 Intel Xeon Platinum 8360Y 处理器服务器,数据集包含 6000 万个元素 :

命令测试用例Redis 7.0.5 (ops/sec)Redis 7.0.7 (ops/sec)吞吐量提升倍数
GEODIST key ...geo-60M-elements-geodist-pipeline-10775,524993,6321.3 X
GEOSEARCH ... FROMLONLAT ... BYRADIUSgeo-60M-elements-geosearch-fromlonlat11.813.81.2 X
GEOSEARCH ... FROMLONLAT ... BYBOXgeo-60M-elements-geosearch-fromlonlat-bybox13.249.63.8 X
命令测试用例Redis 7.0.5 P50延迟 (ms)Redis 7.0.7 P50延迟 (ms)延迟降低倍数
GEODIST key ...geo-60M-elements-geodist-pipeline-102.5752.0071.3 X
GEOSEARCH ... FROMLONLAT ... BYRADIUSgeo-60M-elements-geosearch-fromlonlat679.935598.0971.1 X
GEOSEARCH ... FROMLONLAT ... BYBOXgeo-60M-elements-geosearch-fromlonlat-bybox605.739161.7913.7 X

Table 1: Redis 7.0 Geo 命令性能提升对比

这些数据显示,Redis 7.0 对 Geo 命令的优化效果显著,尤其是在 GEOSEARCH ... BYBOX 场景下,吞吐量提升了近 4 倍,延迟降低了近 4 倍。这些改进使得 Redis 能够更高效地处理大规模地理空间数据和实时查询需求。

2.3 客户端优化策略:九宫格算法与距离计算

尽管 Redis 7.0 对 Geo 命令进行了显著的性能优化,但在某些特定场景下,尤其是当查询范围较大或者目标区域内的点非常密集时,GEORADIUS(或其替代命令 GEOSEARCH)的性能仍然可能成为瓶颈。这是因为 Redis 在执行这类查询时,需要在服务器端进行大量的距离计算和结果过滤。一个值得考虑的优化策略是将部分计算任务从 Redis 服务器端转移到客户端应用程序端,特别是针对九宫格算法的处理和初步的距离筛选。

九宫格算法(客户端辅助查询)的核心思想是,在客户端预先计算出目标搜索范围所覆盖的 GeoHash 区块(通常是以中心点所在的 GeoHash 块及其周围的八个相邻区块,共九个,故称「九宫格」)。然后,客户端将这些 GeoHash 区块转换为对应的 ZSet(有序集合)的 score 范围。接着,客户端向 Redis 服务器发送 ZRANGEBYSCORE 命令,一次性获取所有落在这些 score 范围内的成员。由于 GeoHash 编码的特性,这些成员包含了所有可能落在搜索范围内的点,以及一些误报的点(即地理位置上靠近但实际距离超出指定半径的点)。最后,客户端在本地对这些获取到的候选点进行遍历,使用 Haversine 公式或其他距离计算方法,精确计算每个点到中心点的实际距离,并根据查询条件(如半径、数量、排序)进行过滤和排序,最终得到精确的搜索结果。

这种策略的优势在于:

  1. 减少 Redis 服务器的计算压力:将最耗 CPU 的距离计算和过滤操作从 Redis 单线程转移到客户端,可以利用客户端多核处理能力,避免阻塞 Redis 服务器。
  2. 减少网络往返次数:通过一次性获取所有候选点,可以减少与 Redis 服务器的交互次数,尤其是在需要分页或者进行复杂后处理的场景下。
  3. 灵活性:客户端可以根据具体需求实现更复杂的过滤和排序逻辑,而无需修改 Redis 服务器端的配置或使用 Lua 脚本。

然而,这种策略也存在一些需要注意的地方:

  1. 数据传输量:如果九宫格覆盖的范围过大,或者区域内成员密度很高,一次性从 Redis 获取的数据量可能会很大,增加网络传输开销和客户端内存消耗。因此,需要合理选择 GeoHash 的精度级别,以平衡查询的准确性和数据传输量。
  2. 客户端计算能力:将计算任务转移到客户端,意味着客户端的 CPU 和内存资源消耗会增加。需要确保客户端有足够的处理能力来应对。
  3. 适用场景:根据博客园的一篇文章分析,当附近点特别多时,这种优化方案效果较好;但如果附近点较少,额外的命令解析和响应内容消耗可能会抵消甚至超过距离计算带来的性能提升,导致性能反而下降 。因此,是否采用此方案需要根据实际数据分布和查询特点进行评估。

例如,陌陌(一款社交应用)在其「附近的人」功能实现中,据称也采用了类似的客户端计算方案 。开发者可以将 Redis 中的 GEORADIUS 部分代码移植到客户端语言(如 Golang、Python 等)中实现,从而在客户端完成 GeoHash 编码、九宫格计算以及精确距离过滤。一个名为 go-georadius 的 Golang 库就提供了这样的功能 。

2.4 使用Lua脚本优化查询性能

在某些复杂的查询场景中,可能需要多次与 Redis 交互才能获取最终结果,或者需要在服务器端执行一些原子性的复合操作。此时,使用 Lua 脚本可以成为一种有效的性能优化手段。Lua 脚本在 Redis 服务器端原子性地执行,这意味着脚本执行期间不会插入其他客户端的命令,保证了数据的一致性。同时,由于脚本在服务器端执行,可以减少客户端与服务器之间的网络往返次数(RTT),从而降低延迟,特别是在需要多次读写操作时效果更为明显。

对于 GeoHash 相关的查询,Lua 脚本可以用于封装一些复杂的逻辑。例如,如果需要先根据地理位置查询到一批用户,然后再根据这些用户的 ID 去查询其他关联信息(如用户资料、状态等),可以将这些步骤合并到一个 Lua 脚本中执行。脚本内部可以先调用 GEORADIUSGEOSEARCH 获取附近用户的 ID,然后使用 HGETHMGET 等命令批量获取这些用户的详细信息,最后将整合后的结果返回给客户端。这样可以避免客户端先获取 ID 列表,再逐个或批量请求详细信息的多次网络交互。

在 RedisConf17 的一个分享中,提到了使用 Lua 脚本作为优化 GEORADIUS 性能的一种手段,特别是在需要排序但不需要游标(cursor)功能的场景下 。由于原生的 GEORADIUS 命令在排序时可能会比较耗时,尤其是在返回结果集较大时。通过 Lua 脚本,可以实现自定义的排序逻辑,例如,可以构建一个最大堆(max heap)来维护最近的 N 个地理围栏(geofence),而不是对所有结果进行全排序。当一个新的候选点距离更近时,将其加入堆中并移除最远的点。这种方式可以在只获取最接近的若干个结果时,避免不必要的排序开销。分享中提到,Lua 脚本在缺乏游标支持的情况下,也能带来大部分模块级别的性能改进 。

然而,使用 Lua 脚本也需要注意以下几点:

  1. 脚本的复杂度:Lua 脚本在执行时会阻塞 Redis 的单线程。因此,必须确保 Lua 脚本的高效性,避免长时间运行的脚本。复杂的循环、大量的数据操作都可能导致 Redis 无法及时响应其他请求。
  2. 调试与维护:Lua 脚本的调试相对复杂,需要仔细测试以确保其正确性和性能。脚本逻辑的修改也需要重新加载到 Redis 中。
  3. 资源消耗:虽然 Lua 脚本可以减少网络交互,但其本身在服务器端的执行也会消耗 CPU 和内存资源。需要监控脚本的执行情况,避免对 Redis 服务器造成过大压力。
  4. 可读性:复杂的 Lua 脚本可能降低代码的可读性和可维护性。

在 Redis 官方文档中,也提到了如果查询是长时间运行的,可以启用线程(query performance factor)来减少对主 Redis 线程的争用 。虽然这主要针对 Redis Query Engine (RQE),但也反映了 Redis 在处理复杂查询时对并发性的考虑。对于 Geo 查询,如果 Lua 脚本中的计算量较大,可以考虑是否可以通过客户端计算或异步任务来分担。

2.5 数据冗余与分片策略

随着数据量的不断增长和查询负载的增加,单一的 Redis 实例可能无法满足性能和容量的需求。此时,需要考虑采用数据冗余和分片策略来提升系统的可扩展性和可用性。

数据冗余通常通过 Redis 的主从复制(Replication)来实现。通过配置一个或多个从节点(slave/replica)来复制主节点(master)的数据,可以实现数据的备份和读写分离。读请求可以被分发到从节点,从而减轻主节点的压力,提高系统的整体读取吞吐量。对于 GeoHash 这类读多写少的场景(例如「附近的人」查询),读写分离可以带来显著的性能提升。然而,需要注意的是,Redis 的复制是异步的,从节点的数据可能会有一定的延迟。对于一致性要求极高的场景,需要仔细评估这种延迟是否可接受。此外,过多的从节点也会增加主节点在同步数据时的网络和 CPU 开销。

数据分片(Sharding)是将数据分散存储在多个 Redis 实例(或集群)中的策略,每个实例只负责一部分数据。这样可以水平扩展存储容量和处理能力。对于 GeoHash 数据,一种常见的分片思路是基于地理位置进行分片,例如,将不同地理区域的用户数据存储在不同的 Redis 实例上。客户端在进行查询时,首先根据查询的中心点确定目标分片,然后向该分片发送查询请求。这种方式的挑战在于如何设计合理的分片规则,以保证数据分布的均匀性,并尽量减少跨分片查询的需求。如果一次查询可能涉及到多个分片,那么查询的复杂度会显著增加,需要在客户端或中间层进行结果的聚合。

在 Redis 官方文档和一些社区讨论中,提到了通过将数据分区(partitioning)或分片(sharding)到多个节点来提高搜索的并行性 。例如,可以将数据根据地理位置分布到不同的服务器或数据库中 。然而,需要注意的是,原生的 Redis Cluster 对于跨 slot 的操作支持有限,像 GEORADIUS 这样的命令,如果其查询范围覆盖了多个 slot,可能无法直接在集群模式下执行,或者需要特殊处理。一种可能的方案是,如果 Geo 数据是与其他业务数据绑定的,并且主要基于该业务键进行查询,那么可以将整个业务对象及其关联的 Geo 数据哈希到同一个 slot 中。另一种更通用的 Geo 分片方案可能需要借助外部的代理层或客户端库来实现,它们负责将 GeoHash 键路由到正确的分片。

在实施分片策略时,还需要考虑以下几点:

  1. 分片键的选择:选择合适的分片键至关重要。对于 Geo 数据,可以直接使用 GeoHash 字符串的前几位作为分片键,或者根据业务逻辑(如城市、区域 ID)进行分片。
  2. 跨分片查询:尽量避免或优化跨分片查询。如果无法避免,需要在客户端或代理层进行查询的拆分和结果的合并,这会增加系统的复杂性。
  3. 数据倾斜:如果分片规则设计不当,可能导致某些分片的数据量或查询负载远高于其他分片,从而形成热点,影响整体性能。
  4. 运维复杂度:管理多个 Redis 实例比管理单个实例更复杂,需要更完善的监控、备份和故障转移机制。

一些文章提到,当数据量变得非常大,影响搜索性能时,可以考虑对数据进行分区,将用户分布到不同的数据库或服务器上,或者使用分布式数据库解决方案如 Cassandra 或 MongoDB 。这表明在极端情况下,仅依赖 Redis 本身的分片能力可能不足,需要结合其他技术栈。

2.6 GeoHash编码特性与应用

GeoHash 编码是一种将二维的经纬度坐标转换为一维字符串的算法。这种编码具有一些重要的特性,使其非常适合用于地理位置索引和邻近搜索。首先,GeoHash 编码具有前缀匹配特性。如果两个位置的 GeoHash 字符串拥有共同的前缀,那么这两个位置在地理上通常是相近的。前缀越长,表示的地理范围越精确,位置也越接近。这个特性使得可以通过比较 GeoHash 字符串的前缀来快速筛选出大致在某个区域内的点。Redis 的 Geo 模块正是利用了 GeoHash 的这种特性,将 GeoHash 值作为有序集合(Sorted Set)的 score 进行存储,从而能够高效地进行范围查询。

其次,GeoHash 编码可以将地理空间划分为网格状的区域,每个区域对应一个唯一的 GeoHash 编码。通过改变编码的位数(精度),可以控制网格的大小。位数越多,网格越小,定位越精确。这种网格化的表示方法使得空间搜索可以转化为对一系列 GeoHash 块的查找。例如,在搜索附近的人时,可以先计算出中心点所在 GeoHash 块及其周围的八个相邻块(即「九宫格」),然后查询这些块内的所有成员,再进行精确的距离过滤。这种方式比遍历所有点进行距离计算要高效得多。

GeoHash 编码本身也可以被直接获取和利用。Redis 提供了 GEOHASH 命令,用于获取一个或多个地理空间成员的 GeoHash 编码。这个编码是一个标准的 Base32 编码字符串。客户端可以获取这个编码,并在客户端进行一些处理,例如:

  1. 快速判断大致距离:通过比较两个 GeoHash 编码的共同前缀长度,可以粗略估计它们之间的距离。共同前缀越长,距离越近。
  2. 空间填充曲线的性质:GeoHash 编码实际上是空间填充曲线(如 Z-order 曲线或 Peano 曲线)的一种应用。这种曲线能够将多维空间映射到一维空间,并保持一定程度的空间局部性。虽然 GeoHash 编码在边界附近可能存在突变(即两个地理位置很近的点,其 GeoHash 编码可能差异较大),但在大部分情况下,它仍然能有效地将邻近的点聚集在一起。
  3. 作为其他系统的索引:获取到的 GeoHash 编码可以作为其他数据库或索引系统的输入,实现更复杂的空间查询和分析。例如,可以将 GeoHash 编码存储在关系型数据库中,并结合 B-tree 索引进行范围查询。
  4. 数据可视化:GeoHash 编码可以用于将数据聚合到不同精度的网格中,从而方便地进行热力图等可视化展示。

在 Redis 的 GEORADIUSGEOSEARCH 命令中,可以使用 WITHHASH 选项来返回位置对象经过原始 GeoHash 编码的有序集合分值(一个 52 位有符号整数)。这个选项主要用于底层应用或调试,实际应用中直接使用 GEOHASH 命令获取标准的 Base32 编码字符串更为常见 。

需要注意的是,GeoHash 编码的精度和其表示的地理范围是相关的。较短的 GeoHash 编码表示较大的地理区域,而较长的 GeoHash 编码表示较小的、更精确的地理区域。在选择 GeoHash 精度时,需要在查询的准确性和索引的效率之间进行权衡。过高的精度会导致 GeoHash 块数量过多,增加索引和维护的开销;而过低的精度则可能导致查询结果不准确,包含过多无关的点或遗漏一些边缘点。Redis 内部会根据查询半径自动估算所需的 GeoHash 精度级别 。

3. Redis GeoHash 在特定场景下的实现方案

Redis GeoHash 凭借其高效的地理位置存储和查询能力,在多种应用场景中都有广泛的应用。本章节将探讨 Redis GeoHash 在社交应用、本地服务搜索、实时定位等特定场景下的实现方案,并分析如何结合其他数据结构进行进阶应用。

3.1 社交应用中「附近的人」功能实现

在社交应用中,「附近的人」是一个核心且常见的功能,它允许用户发现并连接到其地理位置附近的其他用户。Redis 的 GeoHash 功能为实现这一需求提供了高效且相对简单的解决方案。其核心思想是利用 Redis 的 GEOADD 命令存储用户的地理位置信息,并通过 GEORADIUSGEORADIUSBYMEMBER 命令进行范围查询。

基本实现步骤通常包括:

  1. 存储用户位置信息
    当用户登录、更新位置或应用主动获取位置时,使用 GEOADD 命令将用户的唯一标识(如用户ID)及其经纬度坐标添加到 Redis 的一个有序集合(Sorted Set)中。例如,可以使用一个名为 users:locations 的 key 来存储所有用户的位置信息。 GEOADD users:locations <longitude> <latitude> <user_id> 例如,添加用户 user1 的位置(经度 -74.0059,纬度 40.7128): GEOADD users:locations -74.0059 40.7128 user1 如果用户位置发生变化,再次调用 GEOADD 命令更新其位置即可,因为 Sorted Set 中成员的 Score(即 GeoHash 编码)是唯一的,新的添加操作会覆盖旧的位置。
  2. 查找附近的人
    当用户 A 想要查找附近的人时,应用首先获取用户 A 的当前位置(经纬度)。然后,使用 GEORADIUS 命令,以用户 A 的经纬度为中心,指定一个搜索半径(例如 10 公里),来查询 users:locations 这个 key 中符合条件的其他用户。 GEORADIUS users:locations <current_longitude> <current_latitude> <radius> <unit> [WITHCOORD] [WITHDIST] [ASC|DESC] [COUNT count] 例如,查找距离用户当前位置(经度 -74.0059,纬度 40.7128)10 公里范围内的用户,并返回他们的距离和坐标: GEORADIUS users:locations -74.0059 40.7128 10 km WITHDIST WITHCOORD 如果需要以某个已知用户为中心进行查找,可以使用 GEORADIUSBYMEMBER 命令,它接受一个成员名作为中心点,而不是经纬度坐标。 GEORADIUSBYMEMBER users:locations <user_id> <radius> <unit> [WITHCOORD] [WITHDIST] [ASC|DESC] [COUNT count] 可选参数 WITHCOORD 会返回匹配项的经纬度,WITHDIST 会返回匹配项与中心点的距离,ASCDESC 用于指定按距离排序,COUNT 用于限制返回结果的数量。
  3. 处理查询结果
    GEORADIUS 命令会返回一个包含符合条件的成员列表。根据是否使用了 WITHCOORDWITHDIST 选项,返回结果的格式会有所不同。如果没有任何 WITH 选项,则返回一个简单的成员列表(例如用户ID列表)。如果使用了 WITH 选项,则每个成员会是一个嵌套列表,包含成员名、距离和/或坐标。
    应用程序在收到查询结果后,可以进一步处理这些数据,例如进行分页展示、过滤掉不符合特定条件的用户(如已屏蔽用户),或者将结果与其他用户信息(如头像、昵称)进行关联。

Java 实现示例:

许多资料提供了使用 Java 客户端(如 Jedis 或 Spring Data Redis)实现「附近的人」功能的示例代码。以下是一个基于 Jedis 的简化示例:

import redis.clients.jedis.Jedis;
import redis.clients.jedis.GeoRadiusResponse;
import redis.clients.jedis.GeoUnit;
import java.util.List;

public class NearbyPeopleService {

    private Jedis jedis; // Assume Jedis instance is initialized and connected

    public void addUserLocation(String userId, double longitude, double latitude) {
        String key = "user_locations";
        jedis.geoadd(key, longitude, latitude, userId);
        System.out.println("Added/Updated user location: " + userId);
    }

    public List<GeoRadiusResponse> findNearbyPeople(double userLongitude, double userLatitude, double radiusInKm) {
        String key = "user_locations";
        // Using GEORADIUS to find nearby people within the given radius
        // WITHDIST will include distance, WITHCOORD will include coordinates
        // COUNT can be used to limit the number of results
        // ASC/DESC for sorting by distance
        List<GeoRadiusResponse> nearbyPeople = jedis.georadius(key, userLongitude, userLatitude, radiusInKm, GeoUnit.KM,
                                                               GeoRadiusParam.geoRadiusParam().withDist().withCoord().sortAscending());
        return nearbyPeople;
    }

    public static void main(String[] args) {
        NearbyPeopleService service = new NearbyPeopleService(); // Assume Jedis is initialized in constructor
        // Example: Add some user locations
        service.addUserLocation("user1", 116.4074, 39.9042); // Beijing
        service.addUserLocation("user2", 121.4737, 31.2304); // Shanghai
        // Example: Find people near Beijing within 1500km
        List<GeoRadiusResponse> nearby = service.findNearbyPeople(116.4074, 39.9042, 1500);
        System.out.println("People nearby:");
        for (GeoRadiusResponse response : nearby) {
            System.out.println(response.getMemberByString() + " - Distance: " + response.getDistance() + " km");
        }
    }
}

性能考虑与优化:

  • 查询范围与数量:查询范围越大,或者返回的数量越多,计算量就越大,性能越低。应尽量缩小查询范围,并根据实际需求限制返回数量。
  • 排序:对结果进行排序会增加计算量,如果不需要严格按距离排序,可以考虑避免排序。
  • Redis 版本:确保使用 Redis 3.2.0 或更高版本,因为 Geo 命令是从这个版本开始引入的。Redis 7.0 对 Geo 命令有显著的性能优化。
  • 数据分片:如果用户数量极大(例如上亿),将所有用户位置存储在单个 Sorted Set 中可能会导致单个 key 过大,影响 Redis 集群的迁移和性能。此时可以考虑对 Geo 数据进行拆分,例如按国家、省份、城市甚至区域进行分片,使用不同的 key 存储。另一种策略是为 Geo 数据使用单独的 Redis 实例,而不是集群环境。
  • 缓存查询结果:对于频繁查询的「附近的人」结果,可以考虑将其缓存起来,设置一个合理的 TTL(生存时间),以减少对 Redis 的直接访问压力。
  • 客户端优化:如前所述,可以考虑将九宫格计算和初步距离筛选放到客户端执行,以减轻 Redis 服务器的压力,但这需要权衡网络开销和实现复杂度。
  • 读写分离与只读命令GEORADIUSGEORADIUSBYMEMBER 命令如果使用了 STORESTOREDIST 选项,则会被标记为写入命令,在集群环境下只会查询主实例,可能导致主实例压力过大。Redis 3.2.10 和 4.0.0 引入了 GEORADIUS_ROGEORADIUSBYMEMBER_RO 两个只读命令来解决这个问题。在使用 Java 客户端时,需要注意其参数类是否包含 STORESTOREDIST 选项,以判断其行为。

通过合理设计数据模型、选择适当的查询参数以及结合上述优化策略,Redis GeoHash 能够高效地支持社交应用中「附近的人」这类核心功能。

3.2 本地服务搜索(如附近商家)的实现

本地服务搜索,例如查找附近的餐馆、咖啡店、加油站等,是 Redis GeoHash 的另一个典型应用场景。其实现原理与「附近的人」功能类似,核心在于利用 GEOADD 存储商家位置,并通过 GEORADIUSGEOSEARCH 进行范围查询。

实现步骤:

  1. 商家数据准备与导入
    • 数据采集:首先需要收集商家的地理位置信息,包括商家ID(或名称)、经度和纬度。这些数据可以来自商家自行注册、第三方数据提供商或公开数据源。
    • 数据导入:将收集到的商家信息批量导入到 Redis 中。可以使用 GEOADD 命令,将商家ID作为成员(member),经纬度作为坐标,存储到一个专门的有序集合(Sorted Set)中,例如 shops:locations 或按商家类型细分的 key,如 coffee_shops:locations
      shell GEOADD coffee_shops:locations 121.4737 31.2304 "Starbucks_001" GEOADD coffee_shops:locations 116.405285 39.904989 "Cafe_Beijing_002"
    • 商家属性存储:除了地理位置,商家通常还有其他属性,如名称、地址、联系方式、评分、营业时间等。这些属性可以存储在 Redis Hash 中,以商家ID作为 key。例如,可以为每个商家创建一个 Hash,如 shop_info:Starbucks_001,其中包含字段如 name, address, phone, rating
  2. 用户搜索附近商家
    • 获取用户位置:当用户发起「附近商家」搜索请求时,应用首先获取用户当前的经纬度坐标(例如,通过GPS定位或用户手动输入)。
    • 执行范围查询:使用 GEORADIUSGEOSEARCH 命令,以用户坐标为圆心,用户指定的搜索半径(例如1公里、5公里)进行查询。
      shell GEORADIUS coffee_shops:locations <user_longitude> <user_latitude> <radius_in_km> km WITHDIST ASC COUNT 20
      此命令会返回指定半径内,距离用户最近的20家咖啡店,并包含它们与用户的距离。
    • 结果排序与筛选:可以根据返回结果中的距离(WITHDIST)进行排序(ASC 表示由近及远)。如果需要更复杂的排序(例如,按距离和评分综合排序),则可能需要在客户端获取初步结果后进行二次处理。
  3. 展示搜索结果
    • 获取商家详情GEORADIUS 命令返回的是商家ID列表及其距离。为了在界面上展示更丰富的信息(如商家名称、地址、评分),需要根据商家ID去查询之前存储的商家属性 Hash。
    • 分页:如果搜索结果较多,需要进行分页处理。GEORADIUSCOUNT 参数可以限制单次返回的数量,结合游标(如果客户端库支持)或客户端逻辑可以实现分页。

优化与进阶:

  • 数据分片:如果商家数量巨大,可以考虑按城市、区域或商家类别对地理位置数据进行分片,使用不同的 Redis key 存储。例如,shops:nyc:locations, shops:sf:locations
  • 缓存热门查询:对于热门区域或常见搜索条件的查询结果,可以将其缓存起来,设置合理的过期时间,以减少对 Redis 的直接查询压力。
  • 结合其他筛选条件:除了地理位置和半径,用户可能还需要根据商家类别、评分、价格范围等进行筛选。这通常需要在客户端或应用服务器层面,在获取到初步的地理位置结果后,再进行进一步的属性过滤。
  • 使用 GEOSEARCH 命令:在 Redis 7.0 及以上版本,推荐使用 GEOSEARCH 命令替代 GEORADIUS,它提供了更丰富的查询选项和更好的性能。例如,GEOSEARCH 支持按矩形区域(BYBOX)查询,以及更灵活的结果排序和存储选项。
  • 客户端九宫格优化:对于大规模商家数据,可以考虑使用客户端九宫格算法,先获取用户所在九宫格及相邻格内的商家,然后在客户端进行精确距离计算和筛选,以减轻 Redis 服务器的计算压力。

通过合理的架构设计和优化策略,Redis GeoHash 能够高效地支持本地服务搜索功能,为用户提供快速准确的附近商家信息。

3.3 实时定位与轨迹追踪场景的应用

Redis GeoHash 不仅可以用于静态位置的存储和查询,还可以应用于需要处理动态位置变化的场景,例如实时定位和简单的轨迹追踪。虽然 Redis 本身不是专门的时空数据库,但其 Geo 命令和有序集合的特性使其能够支持一些基本的实时定位需求。

实时定位应用:

  1. 存储实时位置
    • 当设备(如车辆、配送员、移动终端)上报其实时位置(经纬度)时,可以使用 GEOADD 命令将其最新位置更新到 Redis 中。通常,会为每个可定位对象(或对象类型)创建一个有序集合。
    • 例如,存储配送员 courier_123 的实时位置:
      shell GEOADD couriers_live_locations 116.404 39.915 "courier_123"
    • 由于 GEOADD 会更新已存在成员的分数(即 GeoHash 编码),因此重复执行此命令即可实现位置的刷新。
  2. 查询附近的可定位对象
    • 类似于「附近的人」功能,可以使用 GEORADIUSGEOSEARCH 命令查询某个中心点附近一定范围内的所有可定位对象。
    • 例如,调度中心可以查询附近 5 公里内所有空闲的配送员:
      shell GEORADIUS couriers_live_locations <center_longitude> <center_latitude> 5 km WITHDIST ASC
    • 结合其他数据结构(如 Hash),可以存储和查询可定位对象的其他状态信息(如是否空闲、负载情况等)。
  3. 显示实时位置
    • 客户端(如地图应用)可以定期查询特定可定位对象的位置(使用 GEOPOS)或查询某个区域内的所有对象,并在地图上实时更新其位置。

简单的轨迹追踪应用:

  1. 存储轨迹点
    • 如果需要记录对象的移动轨迹,可以为每个对象创建一个有序集合,其中成员是时间戳(或包含时间戳的ID),分数是该时间点位置的 GeoHash 编码。
    • 例如,记录车辆 car_001 的轨迹:
      shell ZADD car_001_track <geohash_of_timestamp1_location> "timestamp1" ZADD car_001_track <geohash_of_timestamp2_location> "timestamp2"
    • 这里,<geohash_of_timestampX_location> 需要客户端先将经纬度和时间戳转换为一个可排序的 GeoHash 分数。一种简单的方式是将时间戳作为分数的高位,GeoHash 作为低位,或者将时间戳和 GeoHash 编码拼接成一个字符串作为成员,分数则统一设为0(如果不需要按分数排序轨迹点本身)。
    • 更直接的方式是使用 GEOADD 添加每个轨迹点,成员可以是 object_id:timestamp,例如 car_001:1633027200。这样可以直接利用 Geo 命令。
  2. 查询轨迹
    • 要查询某个对象在特定时间范围内的轨迹,可以使用 ZRANGEBYSCOREZRANGE(如果成员本身包含了可排序的时间信息)。
    • 如果使用 GEOADD 存储轨迹点,并且成员格式为 object_id:timestamp,可以先获取该对象的所有轨迹点成员,然后过滤出符合时间范围的点,再使用 GEOPOS 获取这些点的经纬度。
    • 例如,获取 car_001timestamp_starttimestamp_end 之间的轨迹:
      shell # 假设轨迹点成员格式为 car_001:<timestamp> # 1. 获取所有轨迹点成员 (这是一个简化的示例,实际中可能需要更精细的范围查询) ZRANGE car_001_track_members 0 -1 # 2. 客户端过滤出 timestamp_start <= timestamp <= timestamp_end 的成员 # 3. 对过滤后的成员使用 GEOPOS 获取经纬度 GEOPOS car_001_track_locations <filtered_member1> <filtered_member2> ...

注意事项与局限性:

  • 数据量:实时定位会产生大量的位置更新,如果追踪大量对象的详细轨迹,数据量会迅速增长,需要考虑 Redis 的内存容量和持久化策略。
  • 精度与频率:轨迹的精度取决于位置上报的频率。高频上报会产生更平滑的轨迹,但也会增加数据存储和处理的开销。
  • 查询效率:对于大规模的轨迹数据,按时间范围查询的效率需要仔细设计数据模型和查询方式。Redis 的有序集合虽然支持范围查询,但如果单个对象的轨迹点过多,查询性能可能会下降。
  • 分析能力:Redis 主要提供数据存储和基本查询,复杂的轨迹分析(如速度计算、停留点检测、路径相似度比较等)通常需要在应用层面或借助专门的时空数据库进行处理。
  • 数据过期:对于实时位置数据,可以设置合理的 TTL 让其自动过期,以避免存储无限增长。对于历史轨迹数据,需要根据业务需求制定数据归档或清理策略。

Redis GeoHash 在实时定位和简单轨迹追踪场景中,凭借其高效的写入和范围查询能力,可以作为一个轻量级的解决方案。但对于需要复杂时空分析和海量轨迹数据管理的场景,可能需要结合其他更专业的时空数据存储和分析工具。

3.4 结合其他数据结构的进阶应用

Redis GeoHash 本身提供了强大的地理位置存储和查询能力,但通过将其与 Redis 的其他数据结构(如 Hash, Set, Sorted Set, List, Bitmaps, HyperLogLog, Streams 等)相结合,可以实现更复杂和更丰富的进阶应用。这种组合能够充分利用各种数据结构的优势,满足多样化的业务需求。

1. 丰富地理位置对象的属性信息 (GeoHash + Hash):
这是最常见的组合。Geo 命令存储位置信息(成员ID和GeoHash分数),而 Hash 则用于存储与这些成员ID关联的详细属性。

  • 场景示例:在「附近商家」应用中,GEOADD shops:locations <lon> <lat> shop_id 存储商家位置。同时,为每个 shop_id 创建一个 Hash,如 shop:details:shop_id,存储商家的名称、地址、电话、评分、营业时间、类别标签等。
    shell HMSET shop:details:Starbucks_001 name "Starbucks Central Park" address "..." phone "..." rating 4.5 category "Coffee"
  • 查询流程:首先使用 GEORADIUS 获取附近的 shop_id 列表,然后对每个 shop_id 执行 HGETALL shop:details:<shop_id>HMGET 来获取其详细信息。这种组合使得查询结果不仅包含位置和距离,还能展示丰富的上下文信息。

2. 基于标签或类别的筛选 (GeoHash + Set/Sorted Set):
如果地理位置对象(如商家、用户)具有标签或类别属性,可以使用 Set 或 Sorted Set 来存储这些标签,并与 GeoHash 数据结合进行筛选。

  • 场景示例:用户想搜索附近提供「外卖服务」且类别为「川菜」的餐馆。
    • 使用 GEOADD restaurants:locations ... 存储所有餐馆位置。
    • 使用 Set 存储具有特定标签的餐馆ID,例如 tag:delivery:restaurants 包含所有提供外卖的餐馆ID,category:sichuan:restaurants 包含所有川菜馆的ID。
    • 查询流程
      1. 使用 GEORADIUS 获取用户附近的所有餐馆ID。
      2. 获取 tag:delivery:restaurantscategory:sichuan:restaurants 这两个 Set。
      3. 在客户端或使用 Lua 脚本计算这三个结果集的交集,得到同时满足地理位置、外卖标签和川菜类别的餐馆。

3. 动态评分或热度排序 (GeoHash + Sorted Set):
除了按距离排序,有时还需要根据商家的实时评分、销量、人气等动态属性进行排序。

  • 场景示例:在「附近商家」结果中,优先展示评分高且距离近的商家。
    • 使用 GEOADD shops:locations ... 存储商家位置。
    • 使用一个额外的 Sorted Set,例如 shops:ratings,其成员是商家ID,分数是商家的平均评分。
    • 查询流程
      1. 使用 GEORADIUS 获取附近的商家ID及其距离。
      2. 对每个获取到的商家ID,查询其在 shops:ratings 中的评分(例如使用 ZSCORE shops:ratings shop_id)。
      3. 在客户端对商家列表进行综合排序(例如,优先按评分降序,评分相同则按距离升序)。

4. 用户签到与活跃区域分析 (GeoHash + Set/Bitmaps/HyperLogLog):
可以记录用户在哪些地理位置区域(GeoHash 区块)进行过签到或活动,用于分析用户活跃区域或进行区域化运营。

  • 场景示例:记录用户 user_123 在哪些 GeoHash 精度为6的区块内进行过签到。
    • 当用户签到时,获取其位置的 GeoHash 编码(精度6),例如 wx4g0b
    • 使用一个 Set 来存储用户活跃的 GeoHash 区块:SADD user:activity:geo:user_123 wx4g0b
    • 可以分析哪些 GeoHash 区块的用户活跃度最高,或者判断某个用户是否经常在特定区域活动。
    • 对于海量用户的签到统计,可以考虑使用 Bitmaps(如果用户ID是数字且连续)或 HyperLogLog(用于估算不重复元素数量)来优化存储和计算。

5. 实时事件的地理围栏 (GeoHash + Pub/Sub 或 Streams):
当某个地理位置发生事件(如订单产生、设备报警)时,需要通知附近的相关用户或设备。

  • 场景示例:外卖订单产生后,通知附近3公里内的骑手。
    • 骑手端:使用 GEOADD riders:locations ... 上报实时位置。
    • 订单产生:获取订单地点的经纬度。
    • 通知流程
      1. 使用 GEORADIUS riders:locations <order_lon> <order_lat> 3 km 查询附近的骑手ID。
      2. 对每个查询到的骑手ID,通过 Redis Pub/Sub 向其订阅的频道发送订单信息,或者将订单信息写入一个 Stream,骑手作为消费者从 Stream 中读取。
    • 结合 Streams 可以实现更可靠的消息传递和消费状态跟踪。

6. 地理围栏的状态监控 (GeoHash + Lua 脚本):
持续监控某个对象(如车辆、设备)是否进入或离开预设的地理围栏区域。

  • 场景示例:监控车辆是否驶入或驶出特定区域。
    • 预设地理围栏:例如,一个仓库的区域可以用一个 GeoHash 编码(或一组编码)表示。
    • 车辆实时位置:使用 GEOADD vehicles:locations ... 更新车辆位置。
    • 监控流程:可以编写一个 Lua 脚本,定期(或由位置更新触发)执行以下操作:
      1. 获取车辆当前位置(GEOPOS vehicles:locations vehicle_id)。
      2. 计算车辆位置与预设地理围栏的关系(例如,判断车辆位置的 GeoHash 编码是否与围栏区域的 GeoHash 编码匹配,或计算距离)。
      3. 如果状态发生变化(如从围栏外变为围栏内),则触发相应动作(如记录日志、发送通知)。
    • Lua 脚本可以保证判断和状态更新的原子性。

通过灵活组合 Redis 的各种数据结构,可以极大地扩展 GeoHash 的应用场景,实现更复杂、更智能的地理位置相关功能。关键在于根据具体的业务需求,选择合适的数据结构组合,并设计高效的数据模型和查询逻辑。

4. GeoHash 算法原理与自行实现空间索引

GeoHash 是一种将二维的经纬度坐标编码成一维字符串的算法,这种编码具有前缀匹配的特性,即编码越相似,地理位置通常越接近。理解 GeoHash 的原理对于更好地使用 Redis Geo 命令以及自行实现一些定制化的空间索引功能至关重要。

4.1 GeoHash 编码原理简介

GeoHash 算法的核心思想是将地球表面视为一个二维平面,并通过递归地将该平面划分为更小的网格来进行编码。其编码过程可以概括为以下几个步骤:

  1. 纬度区间划分:将整个纬度范围(-90° 到 +90°)视为一个初始区间。根据给定的经度值,判断其位于当前纬度区间的上半部分还是下半部分。如果在上半部分,则在该纬度编码的末尾追加一个二进制 1,并更新当前纬度区间为其上半部分;如果在下半部分,则追加二进制 0,并更新当前纬度区间为其下半部分。
  2. 经度区间划分:紧接着,将整个经度范围(-180° 到 +180°)视为一个初始区间。根据给定的纬度值,判断其位于当前经度区间的右半部分还是左半部分。如果在右半部分,则在该经度编码的末尾追加一个二进制 1,并更新当前经度区间为其右半部分;如果在左半部分,则追加二进制 0,并更新当前经度区间为其左半部分。
  3. 交替进行:重复步骤1和步骤2,交替地对纬度和经度区间进行二分,并将对应的二进制位(0或1)追加到各自的编码中。每次迭代都会增加编码的精度。
  4. 合并编码:将得到的纬度二进制编码和经度二进制编码按照一定的规则(通常是奇偶位交错,例如偶数位放经度,奇数位放纬度)合并成一个单一的二进制串。
  5. Base32 编码:将合并后的二进制串按照每5位一组进行划分,并将每组5位二进制数转换为一个Base32字符(使用0-9, b-z 去掉 a, i, l, o 这32个字符)。这个Base32字符串就是最终的GeoHash编码。

特性:

  • 前缀匹配:GeoHash 编码的一个关键特性是,如果两个编码拥有共同的前缀,那么它们所代表的地理位置通常是相近的。前缀越长,表示的区域越小,位置越精确。这个特性使得可以通过比较GeoHash编码的前缀来快速筛选出大致在同一区域内的点。
  • 精度可变:GeoHash 编码的长度决定了其表示的区域的精度。编码越长,网格划分越细,定位越精确。例如,一个长度为6的GeoHash编码可能代表一个约1.2km x 0.6km的区域,而长度为8的编码可能代表一个约38m x 19m的区域。
  • 非均匀性:由于地球是球体,而GeoHash是将经纬度投影到平面进行划分,因此在不同的纬度,GeoHash网格的实际大小和形状会有所不同。在高纬度地区,经度方向的网格会显得更窄。
  • 边界问题:虽然GeoHash编码具有邻近性,但在GeoHash网格的边界附近,可能会出现两个地理位置非常接近的点,其GeoHash编码却相差较大的情况。反之,两个编码相似的点,实际距离也可能较远(如果它们位于相邻网格的边界两侧)。因此,在进行精确的邻近搜索时,通常需要查询中心点所在的网格及其周围的八个相邻网格(即「九宫格」),并对结果进行精确距离过滤。

Redis 内部使用 52位整数来表示GeoHash编码,这提供了相当高的精度。当使用 GEOHASH 命令时,Redis 会将这个52位整数转换为一个长度为 11个字符的Base32字符串返回给客户端 。

理解GeoHash的编码原理有助于更好地利用其特性进行空间索引和查询优化,例如设计合理的分片策略、缓存策略以及处理边界情况。

4.2 基于Sorted Set自行实现GeoHash索引

虽然 Redis 提供了内置的 Geo 命令来处理地理位置数据,但在某些特定场景下,开发者可能需要更灵活的控制或更底层的优化,这时可以考虑基于 Sorted Set 自行实现 GeoHash 索引。其核心思想是将地理位置信息(经纬度)通过 GeoHash 算法编码成一个可以排序的字符串或整数,然后将这个编码作为 Sorted Set 中成员的 score,而成员本身则可以存储与该地理位置相关的唯一标识符(如用户ID、店铺ID等)。通过这种方式,可以利用 Sorted Set 的有序特性来高效地进行范围查询,从而找到特定区域内的点。

具体实现步骤如下:

  1. GeoHash 编码转换:首先,需要将经纬度坐标转换为 GeoHash 编码。GeoHash 编码通常是一个 base32 编码的字符串,或者在某些实现中是一个整数。如果使用整数形式的 GeoHash,通常是一个 52 位的整数,可以直接用作 Sorted Set 的 score 。如果使用字符串形式的 GeoHash,虽然也可以作为 score(Redis 的 Sorted Set 支持字符串类型的 score,按字典序排序),但在进行范围查询时,整数 score 通常更为直观和高效。有许多现成的 GeoHash 库可以完成这个转换。
  2. 数据存储:将计算得到的 GeoHash 编码(整数或字符串)作为 score,将地理位置对应的唯一标识符(例如 “user:1001″)作为 member,添加到 Sorted Set 中。例如,可以使用 ZADD geo_index <geohash_score> <member_id> 命令。
  3. 范围查询:当需要查询某个中心点附近一定半径内的成员时,首先计算中心点的 GeoHash 编码。然后,根据查询半径,确定一个或多个 GeoHash 编码的范围。这个范围可以通过计算中心点 GeoHash 编码的相邻区域(九宫格)来得到,以确保覆盖所有可能的点。例如,如果查询半径为 1km,可能需要考虑中心点 GeoHash 编码的前 6 位或 7 位相同的区域,以及其相邻的 8 个同等精度的 GeoHash 区域。确定了这些 GeoHash 编码的范围(通常是整数范围)后,就可以使用 ZRANGEBYSCORE 命令来查找 score 落在这个范围内的成员。例如,ZRANGEBYSCORE geo_index <min_geohash_score> <max_geohash_score>

这种自行实现的方式相比于直接使用 Redis 的 GEORADIUS 命令,提供了更大的灵活性。例如,可以更精细地控制 GeoHash 的精度,或者结合其他数据结构(如 Hash)来存储和检索更丰富的位置相关信息。然而,这也意味着开发者需要自行处理 GeoHash 编码的细节、相邻区域的计算以及精确距离的筛选(因为基于 GeoHash 的范围查询返回的是矩形区域内的点,可能包含一些超出实际圆形半径的点,需要进行二次筛选)。此外,Redis 7.0 对内置 Geo 命令进行了显著的性能优化 ,因此在大多数情况下,直接使用内置命令可能是更简单高效的选择,除非有非常特定的自定义需求。

4.3 计算九宫格及相邻GeoHash区域

在利用GeoHash进行地理位置查询时,一个关键的步骤是确定目标点所在的GeoHash网格以及其周围的八个相邻网格,即所谓的「九宫格」。这是因为GeoHash编码虽然能将二维空间映射到一维字符串,但也存在边界问题:两个地理位置非常接近的点,它们的GeoHash编码可能完全不同;反之,两个GeoHash编码非常相似的点,它们之间的实际距离也可能很远。为了确保查询结果的准确性,避免遗漏潜在的目标,需要将查询范围扩大到中心网格及其所有相邻网格。计算九宫格的核心在于理解GeoHash编码的生成规则和空间填充曲线的特性。GeoHash编码的每一位字符都代表了经度或纬度区间的一次二分。通过改变编码的最后几位,可以精确地定位到相邻的网格。

许多GeoHash库都提供了直接获取相邻GeoHash编码的函数。例如,在Python的 geohash 库中,可以使用 geohash.neighbors(geohash_str) 函数来获取给定GeoHash字符串的八个邻居以及自身,共九个GeoHash编码 。另一个Python库 libgeohash 也提供了类似的 gh.neighbors(ghash='wx4g0') 功能,返回一个包含八个方向邻居的字典,如 {'n': 'wx4g2', 's': 'wx4fb', 'e': 'wx4g1', 'w': 'wx4ep', 'ne': 'wx4g3', 'nw': 'wx4er', 'se': 'wx4fc', 'sw': 'wx4dz'} 。在Java中,ch.hsr.geohash 库的 GeoHash 对象也提供了 getAdjacent() 方法来获取相邻的GeoHash对象数组 。这些库函数内部实现了GeoHash编码的解析和相邻网格的计算逻辑,通常涉及到对编码的最后一位或几位进行增减操作,并处理可能的进位和借位,以及边界情况(例如在赤道或本初子午线附近的网格)。

如果自行实现九宫格计算,需要理解GeoHash编码的二进制表示。GeoHash编码的偶数位代表经度,奇数位代表纬度(或者相反,取决于实现)。要找到北边的邻居,需要对纬度部分的二进制编码进行加一操作;找到南边的邻居则进行减一操作。类似地,东边和西边的邻居则通过修改经度部分的二进制编码得到。对角线的邻居则需要同时修改经度和纬度部分的编码。在修改二进制编码后,还需要将其转换回Base32字符串。一个关键点是,当编码位于边界时(例如,一个网格的西边没有网格),需要向上查找其父网格的邻居,然后再向下定位到相应的子网格。例如,一篇博客中提供了一个Python实现的 geohash_neighbors 类,其中 adjacent(geohash, direction) 函数根据方向计算相邻的GeoHash,并处理了边界情况 。该实现中定义了 neighborborder 字典来辅助计算不同方向上相邻GeoHash编码的变化规律。通过这种方式,可以准确地获取到目标GeoHash区域周围的八个邻居,从而构建出完整的九宫格查询范围。

4.4 Python中GeoHash库的应用与示例

Python社区提供了多个用于GeoHash编码、解码以及相关计算的库,这些库极大地简化了在Python项目中集成地理位置功能的工作。常见的库包括 geohashlibgeohashmzgeohash (或 mz2geohash)、geohash-toolsgeolib 等 。这些库通常都支持将经纬度坐标编码为GeoHash字符串,将GeoHash字符串解码为经纬度坐标,以及获取指定GeoHash的邻居(即相邻的八个GeoHash区域)等核心功能。

libgeohash 为例,其提供了 encodedecodeneighborsbbox 等基础函数 。encode(lat, lon, precision) 函数可以将给定的纬度和经度编码为指定精度的GeoHash字符串。decode(geohash, errors=False) 函数则可以将GeoHash字符串解码为对应的经纬度坐标,如果 errors 参数为 True,还会返回纬度和经度的误差范围。neighbors(geohash) 函数能够返回一个包含给定GeoHash周围八个邻居GeoHash的字典,键为方向(如’n’, ‘s’, ‘e’, ‘w’, ‘ne’, ‘nw’, ‘se’, ‘sw’),值为对应方向的GeoHash编码。bbox(geohash, coordinates=False) 函数可以返回给定GeoHash所代表的地理区域的边界框(即最小纬度、最大纬度、最小经度、最大经度),如果 coordinates 参数为 True,则以坐标点列表的形式返回边界框的四个角点 。

另一个例子是 geohash-tools,它同样提供了 encodedecodeadjacentneighbours 等功能 。adjacent(geohash, direction) 函数用于获取指定方向(’top’, ‘right’, ‘bottom’, ‘left’)的相邻GeoHash。neighbours(geohash, include_self=True, show=False) 函数返回所有八个邻居的GeoHash列表,并可以选择是否包含自身,以及是否以ASCII字符画的形式展示九宫格。此外,该库还提供了 distance(geohash_1, geohash_2) 函数,用于计算两个GeoHash之间的精确距离(基于Haversine公式)。

geolib 是另一个Python GeoHash库,它是Chris Veness的JavaScript实现的Python移植版 。它提供了 encode(latitude, longitude, precision)decode(geohash)neighbours(geohash) (返回一个包含八个方向邻居的命名元组) 和 adjacent(geohash, direction) 等函数。此外,geolib 还提供了 bounds(geohash) 函数,用于返回GeoHash区域的西南角和东北角经纬度边界 。

在实际应用中,例如实现「附近的人」功能,可以先使用 encode 函数将用户的经纬度转换为GeoHash编码,并存储到Redis的有序集合(Sorted Set)中,其中GeoHash编码作为score,用户ID作为member。当需要查询附近的人时,首先获取目标用户的GeoHash编码,然后使用 neighbors 函数获取其九宫格区域的GeoHash编码列表。接着,遍历这个列表,对每个GeoHash编码(或其代表的score范围)在Redis的有序集合中进行 ZRANGEBYSCORE 查询,获取潜在的附近用户。最后,在应用层面,可以对获取到的用户进行精确距离计算(例如使用Haversine公式)和筛选,以排除GeoHash边界问题带来的误差,并按照距离排序返回结果 。这种结合GeoHash库和Redis Sorted Set的方式,能够有效地实现高效的地理位置查询。

发表评论

人生梦想 - 关注前沿的计算机技术 acejoy.com 🐾 步子哥の博客 🐾 背多分论坛 🐾 知差(chai)网 🐾 DeepracticeX 社区 🐾 老薛主机 🐾 智柴论坛 🐾