Redis GEO is not an independent data type, but encodes the latitude and longitude into a 52-bit integer and inserts it into the score of ZSet. This article starts from the coding principle: the grid size determined by the three-step coding (normalization → interleaving → writing score) is 0.597 m × 0.280 m. GEOHASH only has 50 bits of effective information due to the strong 0 at the end. The grid is enlarged to 1.2 m × 0.6 m, and it cannot be interpolated with the score; what we should really worry about is the offset of the coordinate system rather than the coding accuracy.
GEO is not a new data type.TYPE return zset,ZSCORE Get a 52-bit integer, and the coordinates are encoded into the ZSet’s score – remember this sentence, all the pitfalls will be explained later.
This article only answers three things: how do coordinates become 52 bits, and what is the accuracy?GEOSEARCH of COUNT、ANYWhat are the real boundaries of paging; how to design members in production, how to deal with expiration, and when to change to ES.
Three things with the same name and different synonyms need to be clearly distinguished first, so that the subsequent discussion of accuracy will be meaningful.

There is only one conversion channel between the three:GEOPOS. Strings cannot be used to infer scores, and scores cannot be used as strings.
1.1 Three-step encoding and the grid size determined by it
Write that moment to do three things:
- Normalize. Longitude by [-180, 180] Maps to 26-bit integer, latitude by [-85.05112878, 85.05112878] Maps to a 26-bit integer. The upper limit of latitude is not ±90, it is the mathematical boundary of Web Mercator (EPSG:900913) – encoding according to ±90 will cause the polar area to be pulled out on the map projection and become unusable. The official document states that “near the polar area cannot be indexed”.
- Intertwined. The latitude is placed in even digits starting from the lowest digit, and the longitude is placed in odd digits, resulting in a 52-bit integer. The source code is
interleave64. Don’t remember the order in reverse: the lower digit is latitude, not longitude. - Write this integer into the ZSet’s score.
The grid size is directly determined by the number of digits: longitude 360° / 2^26 is about 5.36e-6°, 0.597 m at the equator; latitude 170.102° / 2^26 is about 2.53e-6°, 0.280 m. So the actual size of a 52-bit grid is 0.597 m × 0.280 m——”0.6 meters” in the official caliber refers to the number in the longitude direction, and the grids in the two directions are not square.
The higher the latitude, the narrower the grid in the longitude direction is actually: 0.597 m at the equator, about 0.51 m at 31 degrees north latitude (Shanghai), and about 0.30 m at 60 degrees north latitude.
GEOPOS What is returned is the center of the grid, not the original coordinates. This is quantification, not error, and it is irreversible.
1.2 Side effects caused by interweaving: Z-order curve discontinuity
The interweaving in step 2 is not a random filling. It leaves a structural defect when pressing the two-dimensional plane into one dimension: two adjacent grids may not be adjacent in one-dimensional score.
The level at which the cause is present. The four grids at the same level (left, right, lower left, and lower right) are arranged in a Z-shape. The two horizontally adjacent grids have a score difference of 1; but on the next level, the two diagonal grids in the same parent grid, the lower left and the upper right, may have a score difference of several thousand. The performance is as follows: the two points are actually tens of meters apart, and the scores are far apart.
This is the reason why “cutting the geohash prefix yourself and doing range filtering” inevitably misses the mark – the same prefixes are indeed similar, but those with different prefixes may also be directly adjacent neighbors. Redis’s solution is nine-square grid compensation: after finding the central grid, scan the surrounding 8 grid intervals together, and then filter according to the actual distance. This is also GEOSEARCH Than single grid ZRANGEBYSCORE Slow and the cost varies with latitude.
1.3 GEOHASH Why is it 1.2 meters, not 0.6 meters?
There are two widely circulated misunderstandings in this paragraph: 11 bits of standard geohash are approximately equivalent to centimeters, so some people think that Redis is also centimeters; the other is that “11 bits just express 52 bits losslessly.” Both are wrong.
Source code geohashCommand Do three steps: use the internal range to decode the score back to latitude and longitude → re-encode it into 26 bits using the standard geohash range (latitude ±90) → output 11 Base32 characters, and write the 11th unconditionally '0'. Source code comments original words:We have just 52 bits, but the API used to output an 11 bytes geohash. For compatibility we assume zero.
Theoretically, 11 Base32 characters are 55 bits. After the strong 0 at the end, only 50 bits of valid information remain, 25 bits for each axis. The grid is linearly doubled: 1.194 m × 0.561 m in the internal range and 1.19 m × 0.59 m in the standard range. It is enough to say 1.2 m × 0.6 m to the outside world.
Three conclusions:
- It does not move out the 52 bits, but re-encodes them after decoding. It uses another set of latitude ranges, and the bits do not correspond to the bits.
- It and score cannot be mutually exclusive. Need coordinates
GEOPOSif you want a string, useGEOHASHthe two roads do not cross. - The official sentence “no precision is lost compared to the internal 52-bit representation” is only true at the level of “city-level retrieval is sufficient” and cannot be read as bit-by-bit lossless.
A checkable example: Palermo’s WITHHASH is 3479099956230698, the standard geohash is sqc8b49rnyt,GEOHASH return sqc8b49rny0——The first 10 digits are the same, and the last digit is forced to 0.
GEOHASH The two properties of are also officially stated: truncated from the right still points to the same area, but the accuracy decreases; similar prefixes indicate that the positions are similar, and vice versa is not true.
1.4 Accuracy Account: What you should really worry about is the coordinate system

The number 0.5% comes from the spherical approximation:GEODIST and GEOSEARCH The distances are all Haversine, and the radius is taken as the average Earth radius 6372797.560856 m, rather than the WGS84 ellipsoid. The great circle distance between the sphere and the ellipsoid itself has a deviation, so this error is a proportion of the radius rather than a fixed value – the larger the radius, the greater the absolute error, and the relative error remains unchanged.
0.5% at a radius of 3 km is 15 m, which is 25 times larger than the quantization error of 0.6 m. Rather than optimizing the encoding accuracy, it is better to align the coordinate systems first: for the same coordinate, the offset between WGS84 and GCJ-02 is tens to hundreds of meters, which is three orders of magnitude greater than the encoding accuracy.
1.5 Verify it yourself
# 两个相距约 14.6 m 的点,用来验证一个格子能装下几个点
DEL geo:verify
GEOADD geo:verify 121.4737 31.2304 p1
GEOADD geo:verify 121.4738 31.2305 p2
# 类型永远是 zset,不是 geo
TYPE geo:verify
# score 是 52 位整数,与官方示例逐位一致
ZSCORE geo:verify p1
# 两个点共同前缀 7 位,末位都被强成 '0'
GEOHASH geo:verify p1 p2
# 反查坐标:给的是格子中心,不是原值
GEOPOS geo:verify p1
# 两点实距约 14.63 m;单位可换 km / ft / mi,缺省是 m
GEODIST geo:verify p1 p2 m
# 只有 2 个成员时仍是 listpack
OBJECT ENCODING geo:verify
expected:zset / 4054803464923741 / wtw3sjq6q20 and wtw3sjqdbe0 / The difference from the written value is only five or six decimal places / 14.6345 nearby / listpack。
To calculate the score yourself at the application layer (estimated sorting during batch import), it must be implemented according to the internal range. interleave64cannot use any standard geohash library:
# 内部范围:纬度上限是 Web Mercator 边界,不是 +/-90,用错就与 Redis 对不上
LON_MIN, LON_MAX = -180.0, 180.0
LAT_MIN, LAT_MAX = -85.05112878, 85.05112878
# 每轴 26 位,两个 26 位交织成 52 位
STEP = 26
# 位交织:把有效位「摊开」,位与位之间留一个空位给另一轴
def spread(v):
v &= 0xFFFFFFFF
v = (v | (v << 16)) & 0x0000FFFF0000FFFF
v = (v | (v << 8)) & 0x00FF00FF00FF00FF
v = (v | (v << 4)) & 0x0F0F0F0F0F0F0F0F
v = (v | (v << 2)) & 0x3333333333333333
v = (v | (v << 1)) & 0x5555555555555555
return v
def geo_score(lon, lat):
lo = int((lon - LON_MIN) / (LON_MAX - LON_MIN) * (1 << STEP))
la = int((lat - LAT_MIN) / (LAT_MAX - LAT_MIN) * (1 << STEP))
# 纬度占低位起的偶数位,经度占奇数位
return spread(la) | (spread(lo) << 1)
# 期望输出:4054803464923741
print(geo_score(121.4737, 31.2304))
# 语法骨架:[] 可选,<> 必选,| 二选一
# 中心点:FROMMEMBER 用集合内已有成员,FROMLONLAT 直接给经纬度
# 范围:BYRADIUS 圆 / BYBOX 矩形,单位必须写(m|km|ft|mi)
# 排序:不写 ASC/DESC 时顺序不保证;ANY 会牺牲顺序换速度
GEOSEARCH key
[ASC | DESC] [COUNT count [ANY]]
[WITHCOORD] [WITHDIST] [WITHHASH]
2.1 GEOADD: Three calibers of parameter order and return value
The parameter order is 经度 纬度 memberwhich is exactly the opposite of the Chinese colloquial saying “39 North Latitude, 116 East Longitude”. The consequence of writing the reverse is not an error – if both values are still within the legal range, it will still succeed, and the point will fall to the other side of the earth. The latitude range is -85.05112878 to 85.05112878, exceeding the ERR invalid longitude,latitude pair;The same goes for longitude.
There are three calibers of return values. Mixing them will cause all monitoring indicators to be wrong:

A practical impact: The driver reports every 3 to 5 seconds, and the default return value is almost always 0because there are no new members. Use the default return value as the “number of successful reports” for monitoring. This indicator is always zero. When a failure occurs, it looks like all reports have been lost. add CH It makes sense – but it can only tell you that your location has changed, but it cannot tell you that your report has been rejected.
2.2 COUNT and ANY: The most easily “optimized” place

The key is “COUNT The limit is the number of returns, not the amount of scans.” bring COUNT Without ANY When , the server first collects, sorts, and then truncates all hits in the range, and may also apply for additional memory for this purpose. So “large range + small COUNT” is a pseudo-optimization: the radius is expanded from 3 km to 10 km,COUNT 5 The cost increased more than ten times, but I still got 5 items.
If you want to hurry up, you can only add more ANYthe price is to give up the “nearest N”.ANY It is only suitable for two types of scenarios: Boolean judgment (whether there is a person, whether there is a merchant), and subsequent rough screening that requires fine sorting by other fields.
FROMMEMBER Report an error directly when the member does not exist could not decode requested zset memberinstead of returning an empty array. This difference should be written into the client’s error handling: throwing an exception means that the center point is lost (check the reporting link), returning null means that the entire key is gone or there is no one in the range (check sharding and expiration).
2.3 Pagination: None OFFSET
GEOSEARCH No OFFSET Parameters, this is a design choice and not a flaw – adding offset means skipping the first N parameters on the server side, and the sorting cost has been calculated based on the full amount. Three approaches:

STOREDIST The two consequences must be distinguished: without addition, the score of the target key is still a geohash integer.ZRANGE The ranking is in Z order, and the nearest merchant may appear on the second page; after adding it, the score becomes distance.ZRANGE This is page turning based on distance, but this key is no longer a spatial index and cannot be GEOSEARCH。
# 第 1 步:3 km 内前 200 个商家落到临时 key,STOREDIST 让 score 变成距离(km)
GEOSEARCHSTORE geo:tmp:req8f3a1 geo:shop:food:shanghai FROMLONLAT 121.4737 31.2304 BYRADIUS 3 km ASC COUNT 200 STOREDIST
# 第 2 步:设 30 秒 TTL,客户端崩了也不留垃圾 key
EXPIRE geo:tmp:req8f3a1 30
# 第 3 步:翻第 2 页(第 20 到 39 名),按 score 升序就是按距离升序
ZRANGE geo:tmp:req8f3a1 20 39 WITHSCORES
Not added STOREDISTthe result of the first step of the command is “the nearest merchant is ranked on the second page”——ZRANGE Sort by geohash size, and geohash size has nothing to do with distance. This counterexample has a decidable verification in Appendix A.
2.4 Remaining boundaries
GEODISTThe default unit is meters. If any member does not exist, nil is returned, not 0.nilIt is easy to confuse with “distance is 0” on the client side: two points with the same coordinates are returned0.0000the member does not exist and returns empty. To determine “whether this person is still there”, you cannot look at whether the distance is 0.- three
WITHOptions have costs.WITHDISTHaversine is counted once per hit,WITHCOORDDecode each hit once,WITHHASHThe cheapest (directly brings out the score). When hundreds of items are returned, only the one you really want to use is taken. - No
GEODELuse to deleteZREM——Because it is originally a ZSet. GEORADIUSThe family has been marked deprecated since 6.2 and is@writeCommand: Master-slave switching and read-only replicas cannot be used. Migrate toGEOSEARCHPlease pay attention to the unit case: 6.xextractUnitOrReplyusestrcmponly recognizes lowercase letters; it will be replaced from 7.0 onwardsstrcasecmp,KMYes, the error text has also changed from “please use m, km, ft, mi” to “please use M, KM, FT, MI”.- Version upgrade does not require data migration, and the RDB/AOF format has not changed; conversely, it is downgraded from 7.x to 6.x, and the code is written in uppercase
KMwill become a syntax error.
2.5 Three pitfalls of client (.NET)
// count 必须配 order:Ascending 才是「最近的 count 个」,只给 count 是「任意 count 个」
var nearest = await db.GeoSearchAsync(
"geo:shop:food:shanghai", 121.4737, 31.2304,
new GeoSearchCircle(3, GeoUnit.Kilometers),
count: 20, demandClosest: true, order: Order.Ascending,
options: GeoRadiusOptions.WithDistance);
// FROMMEMBER 版本:成员不存在会抛异常,客户端把它当空列表处理就会误判成「附近没人」
var around = await db.GeoSearchAsync(
"geo:shop:food:shanghai", (RedisValue)"shop:101",
new GeoSearchCircle(3, GeoUnit.Kilometers),
count: 20, demandClosest: true, order: Order.Ascending);
// 布尔判断:demandClosest:false 才会拼上 ANY;此时 count 必须是正数,否则驱动直接抛 ArgumentException
var hit = await db.GeoSearchAsync(
"geo:shop:area:101", 121.4737, 31.2304,
new GeoSearchBox(4000, 3000, GeoUnit.Meters),
count: 1, demandClosest: false);
StackExchange.Redis of GetGeoSearchMessage It is written very straightforwardly:if (!demandClosest) { if (count < 0) throw new ArgumentException(...); ...Add(ANY); }. So “not sorted” and “count must be given” are two sides of the same thing. If you want to save a parameter, you will run into an exception.
Let’s sort out the behavioral differences of the three parameters:demandClosest default truewhich is equivalent to the server-side spelling ASC;order for null When not sorted, only count It takes effect, and the result is “any count”. So the “nearest 20” must demandClosest: true add order: Order.Ascending Giving both at the same time or just one is not stable enough.
3.1 member only puts immutable identifiers
member is a member of ZSet. Changing member is equivalent to replacing a member: the old member remains inside and becomes a ghost – there is an extra point on the map that never moves or disappears. Therefore, fields such as nickname, avatar, and status that change and cannot be retrieved must be externalized to Hash or String.
# 位置索引:一个城市一个 key,member 只放不可变 ID
geo:user:online:{city}:{bucket} ZSet,member = u:{id},score = Geohash
# 状态外置:会变的字段不进 member,多一次 GET 但省掉幽灵
user:profile:{id} Hash,nickname / avatar / last_active
The cost is two queries, but one MGET That’s enough, just one more round trip. Compared with “spelling the nickname into member”, it saves more than just memory: member needs to be changed ZREM Old again GEOADD Newly, there is a window period between two writings, and within that window the same person will appear in two places at the same time.
The length of member has a second impact: the SDS of each point is 8 + 长度 Round up,u:1001 about 16 B, while user:1001:nickname:张三 It can reach more than 40 B, and 100 million points is a price difference of 2 GB. There is also an encoding problem for Chinese members – different clients use UTF-8 and GBK to write them separately. They look the same but have different bytes, so the same person becomes two members.
3.2 Aging: TTL idempotence does not mean member expiration
EXPIRE Acts on keys, not members. This matter determines that there are only three ways for GEO to be effective:

There is an easy mistake in Option B: the “current bucket” should be reported every timeEXPIRE It is safe because the bucket name changes every minute, the old bucket will no longer be renewed, and the TTL is always 15 minutes; while in plan A, the same key is used repeatedly EXPIREwhich is equivalent to making the key never expire——EXPIRE The semantics are “reset” not “accumulation”.
It can be seen that the window must be calculated in advance: the query is “current bucket + previous bucket”. A user who reported at 20:15 can be fished out in the query at 20:20 and 20:25, and disappears at 20:30. So the definition of “online” is actually “reported in the last 10 to 15 minutes”, not “still connected now”. If you want a longer window, add one more bucket, at the cost of one more retrieval and one round of deduplication each time.
# 写当前桶;桶名即分桶时刻,同一 5 分钟内的上报落进同一个 key
GEOADD geo:user:online:sh:202611081620 121.4737 31.2304 u:1001
# TTL 给 3 个桶的长度(5 x 3 = 15 分钟),旧桶自然过期,不需要定时任务
EXPIRE geo:user:online:sh:202611081620 900
The deduplication rules between two buckets must be written down: when the same person has a position in both buckets, the one in the current bucket will be used. You can’t “take the closer one” – his position in the old bucket may be closer, which is equivalent to treating the expired coordinates as the latest position.
3.3 Sharding: by query dimension, not by ID
GEOSEARCH It is a single key command, and one GEO key all falls on one slot. The consequences of “installing the whole country with one key” are high memory on a single node, slow single-thread query, and the need to move a large key when expanding the capacity. Fragmentation is necessary, but the dimension must be consistent with the query dimension: sharding by city, not hashing by user ID – the latter requires all shards to be searched for “people nearby”, and the overhead of merge and sort far exceeds the memory saved.
The only commands that require multiple keys are GEOSEARCHSTOREit requires dest and source to be in the same slot. The solution is to use the same hash tag for dest as source:
# source 与 dest 用同一个 hash tag,既保证同 slot,又不会把全国数据挤进一个 slot
GEOSEARCHSTORE geo:shop:tmp:{shanghai}:req8f3a1 geo:shop:food:{shanghai} FROMLONLAT 121.4737 31.2304 BYRADIUS 3 km ASC COUNT 200 STOREDIST
The definition of hash tag is often reversed: only the first one {...} inThe content participates in the slot calculation,{} Empty will not take effect, there are multiple in one key {} Only recognize the first one. so geo:{sh}:food and geo:{sh}:tmp Same as slot, but geo:sh:{food} Different from the former slot – the position of the curly braces is one character wrong, and there are two slots in the cluster.
The two boundaries must be thought out in advance: the longitude wrap across the 180-degree longitude (in areas such as Fiji) will be split into two intervals that are far apart. The method is to save and check twice according to the east and west longitude keys; the polar region latitude exceeds 85.05 and cannot be indexed. The longitude converges between 66 and 85 degrees, and the candidate set becomes significantly larger under the same radius. If you really want to do polar region business, don’t use GEO.
3.4 Reporting out of order: coordinates are overwritten
GEOADD Without comparing time, whoever arrives last will take effect. GPS reports via HTTP or MQ. Network jitter will cause the old coordinates to arrive late and overwrite the new coordinates. The symptoms are that the vehicles on the map bounce back, the dispatch distance is miscalculated, and the destination distance seen by passengers suddenly becomes farther.
The solution is to store a timestamp next to it, compare it before writing, and use Lua to make “compare + write” into an atomic operation:
-- KEYS[1] = geo key, KEYS[2] = lastseen key(同 slot,用 hash tag 保证)
-- ARGV[1] = member, ARGV[2] = lon, ARGV[3] = lat, ARGV[4] = 上报时间戳(毫秒)
-- 返回:1 = 已写入,0 = 旧消息丢弃,-1 = 坐标非法
local lon = tonumber(ARGV[2])
local lat = tonumber(ARGV[3])
-- 范围校验必须在写之前:Redis 的 Lua 出错不回滚,先写后校验会留下脏数据
if lon == nil or lat == nil
or lon < -180 or lon > 180
or lat < -85.05112878 or lat > 85.05112878 then
-- 坐标非法:一个字段都不写
return -1
end
-- 取已存的时间戳,比新上报的新则丢弃(相等也丢,以先到为准)
local last = redis.call('ZSCORE', KEYS[2], ARGV[1])
if last and tonumber(last) >= tonumber(ARGV[4]) then
-- 旧消息,丢弃
return 0
end
-- 先写事实(坐标),再写元数据(时间戳):顺序反了,一次 GEOADD 失败就永久丢掉该用户后续上报
redis.call('GEOADD', KEYS[1], ARGV[2], ARGV[3], ARGV[1])
redis.call('ZADD', KEYS[2], ARGV[4], ARGV[1])
return 1
The writing order is the only thing you need to remember about this script. Redis’s Lua does not roll back when an error occurs: if you write it in reverse, write it first lastseen,once GEOADD Due to illegal coordinate throwing, the timestamp has been advanced, and all legitimate reports from this user will be permanently discarded as “old messages”. Putting range verification at the front is equivalent to killing this risk in advance.
return 0 The count itself is a monitoring indicator of “client out-of-order rate”. The client can also go one step further: giving each report a monotonic auto-increment seqthe server presses seq Losing expired messages is more stable than timestamps – timestamps rely on the client clock, and clock rollback or time zone mismatches will invalidate the entire set of judgments.seq Just rely on the client to increment itself.
4.1 People nearby
Requirements: The latest 20 online users within 5 km, in ascending order by distance, support sliding loading, and do not display themselves.
# 查当前桶 + 上一桶,两次检索用同一个 FROMLONLAT,距离可直接比较
GEOSEARCH geo:user:online:sh:202611081620 FROMLONLAT 121.4737 31.2304 BYRADIUS 5 km ASC COUNT 60 WITHDIST
# COUNT 取终值 3 倍:两桶各取 60,合并去重后仍够 20
GEOSEARCH geo:user:online:sh:202611081615 FROMLONLAT 121.4737 31.2304 BYRADIUS 5 km ASC COUNT 60 WITHDIST
COUNT Choose 3 times instead of 2 times: there are overlapping people in the two buckets and there are selves to be excluded. The 20 results may actually come from 30 to 40 different members. 2 times will not be enough in dense urban areas. 3 times is the experience value, at the cost of scanning 40 more lines per barrel, which is acceptable under a radius of 5 km.
Complete implementation:
public sealed class NearbyService
{
// 连接复用:同进程共用一个 ConnectionMultiplexer
private readonly IDatabase _db;
// 城市维度:写入与查询必须落在同一个城市的 key 上
private readonly string _city;
public NearbyService(IConnectionMultiplexer mux, string city)
{
_db = mux.GetDatabase();
_city = city;
}
// 桶名对齐到 5 分钟的整数刻度,同一刻度内的上报落进同一个桶
// 用 UTC 而不是本地时间:夏令时切换当天不会出现重复桶名或跳桶
private static string BucketKey(string city, DateTime utc, int minutes = 5)
{
var t = new DateTime(utc.Year, utc.Month, utc.Day, utc.Hour,
utc.Minute / minutes * minutes, 0, DateTimeKind.Utc);
return $"geo:user:online:{city}:{t:yyyyMMddHHmm}";
}
// 上报:只写当前桶。重复设 TTL 是幂等的——同一个桶的 TTL 永远是 15 分钟
// (3 个桶的长度,见 3.2),旧桶不再被续期,所以不会无限延长
public async Task ReportAsync(long userId, double lon, double lat)
{
var key = BucketKey(_city, DateTime.UtcNow);
// Batch:GEOADD 与 EXPIRE 一次往返发出
var batch = _db.CreateBatch();
var add = batch.GeoAddAsync(key, lon, lat, $"u:{userId}");
var exp = batch.KeyExpireAsync(key, TimeSpan.FromMinutes(15));
batch.Execute();
// 必须 await 结果,否则异常被吞
await Task.WhenAll(add, exp);
}
// 查询:当前桶 + 上一桶,keys 顺序是「新 -> 旧」,合并时先到先得
// afterKm 是续页游标(上一页最后一条的距离),首页传 null
public async Task> NearbyAsync(long selfId, double lon, double lat,
double radiusKm = 5, int take = 20,
double? afterKm = null)
{
var now = DateTime.UtcNow;
var keys = new[]
{
// [0] 当前桶
BucketKey(_city, now),
// [1] 上一桶
BucketKey(_city, now.AddMinutes(-5))
};
var self = $"u:{selfId}";
// 两桶并发查,仍是一个 Batch 一次往返
// count 取 take * 3,为合并去重留出余量
var batch = _db.CreateBatch();
var tasks = keys.Select(k => batch.GeoSearchAsync(
k, lon, lat, new GeoSearchCircle(radiusKm, GeoUnit.Kilometers),
count: take * 3, demandClosest: true, order: Order.Ascending,
options: GeoRadiusOptions.WithDistance)).ToArray();
batch.Execute();
var pages = await Task.WhenAll(tasks);
// 当前桶优先:两桶都有位置时取当前桶那条,不能「取距离更近的」
// ——旧桶的位置完全可能更近,那样会把过期坐标当成最新
var merged = new Dictionary();
foreach (var page in pages)
{
foreach (var r in page)
{
var member = r.Member.ToString();
// 不显示自己
if (member == self) continue;
// 续页游标:跳过上一页末条的距离,避免重复(见 2.3)
if (afterKm.HasValue && (r.Distance ?? double.MaxValue) <= afterKm.Value) continue;
// 当前桶已命中,旧桶同名记录直接丢
if (merged.ContainsKey(member)) continue;
merged[member] = r.Distance ?? double.MaxValue;
}
}
// 合并后统一排序、截断,不能在各自 key 里先截断再合并
return merged
.OrderBy(kv => kv.Value)
.Take(take)
.Select(kv => new NearbyUser(kv.Key, kv.Value))
.ToList();
}
}
// 注意单位:GeoUnit.Kilometers 下 Distance 就是公里,字段名别写成 Meters
public record NearbyUser(string Member, double Kilometers);
The cursor has details:<= The last item on the previous page will be deleted, so the next page starts from the second item; if there are multiple people at the same distance, they will be skipped together, and a few fewer items may be returned. To avoid this, add a secondary key (if the distance is the same, press member lexicographic order), and the cursor will be divided into two segments: “distance + member”.
4.2 Takeaway and taxi-hailing: The difference lies in timeliness and status

Used to determine the service area for takeaway BYBOXthe parameter order is width height:width Along the longitude direction,height Along the latitude direction. If the square service area is written upside down, it will not be visible. If the service area is three kilometers long and one kilometer wide, it will be judged incorrectly – the user is 400 meters outside the service area but it will be judged as covered.
There is another iron rule for taxi dispatch: read the main database. If the copy is delayed for a few seconds and sent to a driver who has just gone offline, the order will be dropped and a customer complaint will be added. To judge “the driver is still online” we can’t just look at whether there is this person in GEO, but also lastseen Whether it is within the threshold,status whether availableall three are excellent.
5.1 Single point cost breakdown
# 64 位系统、skiplist 编码下的粗估;member 越长 SDS 越大
单点字节 = 24 (dictEntry) + 8 (桶位) + (8 + member长度) (SDS,向上取整) + 64 (跳表节点)
# 乘数 1.2 到 1.5 是余量:dict 扩容 + 内存碎片
总内存 = 点位数 x 单点字节 x (1.2 到 1.5)
Sources of four items:dictEntry 24 B is “key pointer + value pointer + next pointer”; bucket bit 8 B is the pointer bit in the hash table; SDS is 8 + 长度the short string sdshdr8 header only has 3 B, and it is still counted as 8 after being aligned by 8; the skip table node 64 B is zskiplistNode The average cost – value pointer, score, backward pointer, plus an average of 1.33 layers of “forward pointer + span”.
member is u:1001 This kind of short string costs about 100 to 110 B per point. three scales of magnitude (The reference value must be replaced according to your own measured member length and number of copies.):

The numbers above do not include copies. Three copies are three copies of memory.MEMORY USAGE What is measured is a single instance and a single key, so don’t forget to multiply.
5.2 From listpack to skiplist: an irreversible upgrade
The GEO key is zset, and the encoding rules are the same: the number of members does not exceed zset-max-listpack-entries(default 128), a single value does not exceed zset-max-listpack-value(Default 64), it is listpack, if it exceeds, it will switch to skiplist. The conversion is one-way – deleting up to 3 members will not go back.
This brings about two things. A small set is more economical than the formula. A demo instance of 3 points may only occupy a few hundred bytes; once the threshold is crossed, each point will immediately have about 64 B more jump table nodes, and this step is often not reproduced in stress tests at low business peaks. The stress test data must be created according to the target scale. Don’t generalize the conclusion from 3 points to 3 million points.
5.3 The fourfold price of big key
After a single key exceeds 500,000 points:
- Single thread time consuming:
GEOSEARCHLarge-scale scanning is more obvious. One request occupies the main thread, and all commands of the same instance are queued. - Migration stuck: Cluster relocation is based on key. When moving this big key, the entire slot of data is waiting.
- memory fragmentation: The fragmentation rate increases after long-term additions and deletions,
MEMORY USAGEThe worse it is from the actual RSS, the better. - remove blocking:
DELWhen released synchronously, keys with millions of points will be stuck in seconds; changeUNLINKTake the background thread.
500,000 is not a hard target. The criterion is whether your P99 and operation and maintenance actions can withstand:GEOSEARCH The P99 is already above 10 ms, and the fragmentation should be cut down.
Test the performance numbers yourself:redis-benchmark press GEOSEARCH,look INFO commandstats inside usec_per_callthen use SLOWLOG GET 10 Catch requests exceeding the threshold,LATENCY DOCTOR Look at the causes of latency spikes. The number obtained by pressing only one command and one key cannot be used as cluster capacity – the real slowness often comes from other commands on the same instance.
6.1 Six-dimensional comparison

The most easily overlooked line is the “attribute filtering” line. GEO returns only member and optional distance/coordinates/hash without any filtering capabilities. Therefore, for a query such as “within 3 km, rating above 4.5, open now”, GEO can only provide 200 candidates first, and the remaining conditions must be filled in by the application layer or ES.
6.2 Decision-making sequence
First ask, “Do I need to filter in addition to distance?” As long as distance and ordering are concerned, GEO is sufficient and the cheapest: one round trip, no additional systems, millisecond latency. To sort the ratings, business status, and delivery fees together, a two-stage approach is used – GEO takes the first 200 IDs, ES adds filters to these 200 and then refines the sorting, and finally the application layer hybrid sorting.
The cost of the two-stage approach must be calculated: one more cross-system call (usually 5 to 20 ms), and one more layer of failure handling (if ES fails, should it be downgraded to a pure GEO result or return empty). GEO is placed first because it is cheap and can naturally perform “truncation by distance”. It is much more economical to pass 200 IDs to ES than to let ES sort them out from the entire list.
6.3 Migration signals
It’s time to migrate when three signals appear: the query begins to require “filtering by attributes and then sorting”; the single key is approaching 500,000 points and the sharding has been cut below the city; polygonal fences or road network distances are required.
Migration is not rewriting – first double-write (GEO and ES write at the same time, GEO shall prevail), then read in grayscale (cut according to the traffic ratio, compare the difference between the two results), and finally cut. Retaining GEO as a coarse screening layer is the most troublesome transitional form, and it is also the form that many teams eventually stop at: ES is responsible for fine screening, and GEO blocks 99% of the candidates in front of it.
7.1 Twelve pits

These twelve items have something in common: they all come from the same misjudgment – treating GEO as a spatial database. It’s not. It is a ZSet with 52-bit encoding. It only does “finding by range and sorting by distance”, and you have to do the rest by yourself.
7.2 Review: Repeat 60% of pages
Phenomenon: Swipe up to load nearby people, and 60% of the users on the second page are duplicates of the first page. Customer complaints are that “all the users on the second page are duplicates.”
Troubleshooting: Two requests in the log COUNT They are 20 and 40 respectively. The server GEOSEARCH Treated as offset paging – each time it is expanded COUNT Then slice on the client side. The interval between the two requests is 2 seconds. During this period, there are new reports, new buckets are switched, the collection changes, the slice boundaries drift, and the tail of the first page is re-acquired by the second page.
Two root causes overlap: one is that there is no OFFSET When using “expanded COUNT slice” to pretend to be a full page, the second is that there is no explicit ASCthe return order itself is not guaranteed——
Modification: Change to distance cursor, the distance of the last item on the previous page is used as the lower bound of the next page, and the client filters it out <= 游标 records; at the same time explicit ASC. The cost of repeated zeroing is that the tie-break field must be added when there are multiple users at the same distance.
Post-mortem monitoring: each request is recorded COUNT、STARTreturn the number of items, and draw the “COUNT growth curve of page turning requests”.
7.3 Up the production list

Conduct at least two pressure tests before going online, with different scales. Complete the target scale (for example, 3 million points) at one time, and the amount GEOSEARCH of P99 and MEMORY USAGEverify the formula and threshold; create a “hot zone” at once – stuff 30% of the points into a 1 km square to simulate the peak of the business district. Doing only the former will overturn the real business, because the time taken by GEO is positively related to the number of hits within the range, not the total points.
7.4 If you only remember five items
- GEO is a ZSet, score is a 52-bit integer, and the grid is 0.597 m × 0.280 m.
GEOHASHThe 11 bits are only 50 effective, the grid is 1.2 m × 0.6 m, and the score cannot be reversed.COUNTThe limit is the number of returns, not the amount of scans; the “last N” must be specified explicitlyASConly used if “is there any”ANY。- No
OFFSET;For deep pagesGEOSEARCHSTORE ... STOREDIST+ZRANGEuse distance cursor for online page continuation. - TTL only cares about the entire collection; member-level expiration can only be cleaned up by time bucket or last_seen.
Things outside the scope – road network distance, polygon fence, filtering by attributes – are not within the capabilities of GEO, so change to ES or PostGIS, or downgrade GEO to a coarse filter layer.
Each segment can be run independently; within a block
#It starts with a comment line,redis-cliNot recognized and skipped when executing by bar.
# 数据:官方示例两个点,外加同纬线上相隔 4.48 度的两个点(用来演示半径截断)
DEL geo:sicily
GEOADD geo:sicily 13.361389 38.115556 Palermo 15.087269 37.502669 Catania
GEOADD geo:sicily 12.758489 38.788135 edge1 17.241510 38.788135 edge2
# 不带 COUNT:返回全部命中并按距离升序;200 km 内只有 Palermo 和 Catania(edge 点在 279.7 km 外)
GEOSEARCH geo:sicily FROMLONLAT 15 37 BYRADIUS 200 km ASC
# 带 COUNT 不带 ANY:仍排序,取最近 1 个,必定是 Catania(56.4413 km)
GEOSEARCH geo:sicily FROMLONLAT 15 37 BYRADIUS 200 km ASC COUNT 1
# 带 COUNT ANY:凑够 1 个就返回,顺序不保证,可能是 Palermo
GEOSEARCH geo:sicily FROMLONLAT 15 37 BYRADIUS 200 km COUNT 1 ANY
# 对照组:同一次检索,一条不加 STOREDIST,一条加
DEL geo:k1 geo:k2 geo:sicily
GEOADD geo:sicily 13.361389 38.115556 Palermo 15.087269 37.502669 Catania
# 不加 STOREDIST:score 仍是 Geohash 整数,ZRANGE 排的是 Z 阶序,最近的 Catania 在第二
GEOSEARCHSTORE geo:k1 geo:sicily FROMLONLAT 15 37 BYRADIUS 250 km ASC COUNT 2
ZRANGE geo:k1 0 -1 WITHSCORES
# 加 STOREDIST:score 变成距离,ZRANGE 才是按距离翻页
GEOSEARCHSTORE geo:k2 geo:sicily FROMLONLAT 15 37 BYRADIUS 250 km ASC COUNT 2 STOREDIST
ZRANGE geo:k2 0 -1 WITHSCORES
expected:geo:k1 The two scores are 3479099956230698 and 3479447370796909 (the nearest Catania comes after);geo:k2 yes 56.4413 and 190.4424the order is reversed. Just try it again GEOSEARCH geo:k2 FROMLONLAT 15 37 BYRADIUS 250 km ASC:add STOREDIST After that, this key is no longer a spatial index, and this query cannot get meaningful results, which just confirms the statement in 2.3.