Redis 到底是怎麼實現“附近的人”這個功能的?
前言:針對“附近的人”這一位置服務領域的應用場景,常見的可使用PG、MySQL和MongoDB等多種DB的空間索引進行實現。而Redis另闢蹊徑,結合其有序佇列zset以及geohash編碼,實現了空間搜尋功能,且擁有極高的執行效率。
本文將從原始碼角度對其演算法原理進行解析,並推算查詢時間複雜度。
要提供完整的“附近的人”服務,最基本的是要實現“增”、“刪”、“查”的功能。以下將分別進行介紹,其中會重點對查詢功能進行解析。
操作命令
自Redis 3.2開始,Redis基於geohash和有序集合提供了地理位置相關功能。Redis Geo模組包含了以下6個命令:
- GEOADD: 將給定的位置物件(緯度、經度、名字)新增到指定的key;
- GEOPOS: 從key裡面返回所有給定位置物件的位置(經度和緯度);
- GEODIST: 返回兩個給定位置之間的距離;
- GEOHASH: 返回一個或多個位置物件的Geohash表示;
- GEORADIUS: 以給定的經緯度為中心,返回目標集合中與中心的距離不超過給定最大距離的所有位置物件;
- GEORADIUSBYMEMBER: 以給定的位置物件為中心,返回與其距離不超過給定最大距離的所有位置物件。
其中,組合使用GEOADD和GEORADIUS可實現“附近的人”中“增”和“查”的基本功能。
要實現微信中“附近的人”功能,可直接使用GEORADIUSBYMEMBER命令。其中“給定的位置物件”即為使用者本人,搜尋的物件為其他使用者。
不過本質上,GEORADIUSBYMEMBER = GEOPOS + GEORADIUS,即先查詢使用者位置再通過該位置搜尋附近滿足位置相互距離條件的其他使用者物件。
以下會從原始碼角度入手對GEOADD和GEORADIUS命令進行分析,剖析其演算法原理。
Redis geo操作中只包含了“增”和“查”的操作,並沒有專門的“刪除”命令。主要是因為Redis內部使用有序集合(zset)儲存位置物件,可用zrem進行刪除。 在Redis原始碼geo.c的檔案註釋中,只說明瞭該檔案為GEOADD、GEORADIUS和GEORADIUSBYMEMBER的實現檔案(其實在也實現了另三個命令)。從側面看出其他三個命令為輔助命令。
GEOADD
使用方式
GEOADD key longitude latitude member [longitude latitude member ...]
將給定的位置物件(緯度、經度、名字)新增到指定的key。
其中,key為集合名稱,member為該經緯度所對應的物件。在實際運用中,當所需儲存的物件數量過多時,可通過設定多key(如一個省一個key)的方式對物件集合變相做sharding,避免單集合數量過多。
成功插入後的返回值:
(integer)N
其中N為成功插入的個數。
原始碼分析
/* GEOADD key long lat name [long2 lat2 name2 ... longN latN nameN] */ void geoaddCommand(client *c) { //引數校驗 /* Check arguments number for sanity. */ if ((c->argc - 2) % 3 != 0) { /* Need an odd number of arguments if we got this far... */ addReplyError(c, "syntax error. Try GEOADD key [x1] [y1] [name1] " "[x2] [y2] [name2] ... "); return; } //引數提取Redis int elements = (c->argc - 2) / 3; int argc = 2+elements*2; /* ZADD key score ele ... */ robj **argv = zcalloc(argc*sizeof(robj*)); argv[0] = createRawStringObject("zadd",4); argv[1] = c->argv[1]; /* key */ incrRefCount(argv[1]); //引數遍歷+轉換 /* Create the argument vector to call ZADD in order to add all * the score,value pairs to the requested zset, where score is actually * an encoded version of lat,long. */ int i; for (i = 0; i < elements; i++) { double xy[2]; //提取經緯度 if (extractLongLatOrReply(c, (c->argv+2)+(i*3),xy) == C_ERR) { for (i = 0; i < argc; i++) if (argv[i]) decrRefCount(argv[i]); zfree(argv); return; } //將經緯度轉換為52位的geohash作為分值 & 提取物件名稱 /* Turn the coordinates into the score of the element. */ GeoHashBits hash; geohashEncodeWGS84(xy[0], xy[1], GEO_STEP_MAX, &hash); GeoHashFix52Bits bits = geohashAlign52Bits(hash); robj *score = createObject(OBJ_STRING, sdsfromlonglong(bits)); robj *val = c->argv[2 + i * 3 + 2]; //設定有序集合的物件元素名稱和分值 argv[2+i*2] = score; argv[3+i*2] = val; incrRefCount(val); } //呼叫zadd命令,儲存轉化好的物件 /* Finally call ZADD that will do the work for us. */ replaceClientCommandVector(c,argc,argv); zaddCommand(c); }
通過原始碼分析可以看出Redis內部使用有序集合(zset)儲存位置物件,有序集合中每個元素都是一個帶位置的物件,元素的score值為其經緯度對應的52位的geohash值。
double型別精度為52位; geohash是以base32的方式編碼,52bits最高可儲存10位geohash值,對應地理區域大小為0.6*0.6米的格子。換句話說經Redis geo轉換過的位置理論上會有約0.3*1.414=0.424米的誤差。
演算法小結
簡單總結下GEOADD命令都幹了啥:
1、引數提取和校驗;
2、將入參經緯度轉換為52位的geohash值(score);
3、呼叫ZADD命令將member及其對應的score存入集合key中。
GEORADIUS
使用方式
GEORADIUS key longitude latitude radius m|km|ft|mi [WITHCOORD] [WITHDIST] [WITHHASH] [ASC|DESC] [COUNT count] [STORE key] [STORedisT key]
以給定的經緯度為中心,返回目標集合中與中心的距離不超過給定最大距離的所有位置物件。
範圍單位:m | km | ft | mi --> 米 | 千米 | 英尺 | 英里
額外引數:
- WITHDIST:在返回位置物件的同時,將位置物件與中心之間的距離也一併返回。距離的單位和使用者給定的範圍單位保持一致。
- WITHCOORD:將位置物件的經度和維度也一併返回。
- WITHHASH:以 52 位有符號整數的形式,返回位置物件經過原始 geohash 編碼的有序集合分值。這個選項主要用於底層應用或者除錯,實際中的作用並不大。
- ASC|DESC:從近到遠返回位置物件元素 | 從遠到近返回位置物件元素。- COUNT count:選取前N個匹配位置物件元素。(不設定則返回所有元素) - STORE key:將返回結果的地理位置資訊儲存到指定key。- STORedisT key:將返回結果離中心點的距離儲存到指定key。
由於 STORE 和 STORedisT 兩個選項的存在,GEORADIUS 和 GEORADIUSBYMEMBER 命令在技術上會被標記為寫入命令,從而只會查詢(寫入)主例項,QPS過高時容易造成主例項讀寫壓力過大。 為解決這個問題,在 Redis 3.2.10 和 Redis 4.0.0 中,分別新增了 GEORADIUS_RO 和 GEORADIUSBYMEMBER_RO兩個只讀命令。 不過,在實際開發中筆者發現 在java package Redis.clients.jedis.params.geo 的 GeoRadiusParam 引數類中並不包含 STORE 和 STORedisT 兩個引數選項,在呼叫georadius時是否真的只查詢了主例項,還是進行了只讀封裝。感興趣的朋友可以自己研究下。
成功查詢後的返回值:
不帶WITH限定,返回一個member list,如:
["member1","member2","member3"]
帶WITH限定,member list中每個member也是一個巢狀list,如:
[ ["member1", distance1, [longitude1, latitude1]] ["member2", distance2, [longitude2, latitude2]] ]
原始碼分析
此段原始碼較長,看不下去的可直接看中文註釋,或直接跳到小結部分
/* GEORADIUS key x y radius unit [WITHDIST] [WITHHASH] [WITHCOORD] [ASC|DESC] * [COUNT count] [STORE key] [STORedisT key] * GEORADIUSBYMEMBER key member radius unit ... options ... */ void georadiusGeneric(client *c, int flags) { robj *key = c->argv[1]; robj *storekey = NULL; int stoRedist = 0; /* 0 for STORE, 1 for STORedisT. */ //根據key獲取有序集合 robj *zobj = NULL; if ((zobj = lookupKeyReadOrReply(c, key, shared.null[c->resp])) == NULL || checkType(c, zobj, OBJ_ZSET)) { return; } //根據使用者輸入(經緯度/member)確認中心點經緯度 int base_args; double xy[2] = { 0 }; if (flags & RADIUS_COORDS) { …… } //獲取查詢範圍距離 double radius_meters = 0, conversion = 1; if ((radius_meters = extractDistanceOrReply(c, c->argv + base_args - 2, &conversion)) < 0) { return; } //獲取可選引數 (withdist、withhash、withcoords、sort、count) int withdist = 0, withhash = 0, withcoords = 0; int sort = SORT_NONE; long long count = 0; if (c->argc > base_args) { ... ... } //獲取 STORE 和 STORedisT 引數 if (storekey && (withdist || withhash || withcoords)) { addReplyError(c, "STORE option in GEORADIUS is not compatible with " "WITHDIST, WITHHASH and WITHCOORDS options"); return; } //設定排序 if (count != 0 && sort == SORT_NONE) sort = SORT_ASC; //利用中心點和半徑計算目標區域範圍 GeoHashRadius georadius = geohashGetAreasByRadiusWGS84(xy[0], xy[1], radius_meters); //對中心點及其周圍8個geohash網格區域進行查詢,找出範圍內元素物件 geoArray *ga = geoArrayCreate(); membersOfAllNeighbors(zobj, georadius, xy[0], xy[1], radius_meters, ga); //未匹配返空 /* If no matching results, the user gets an empty reply. */ if (ga->used == 0 && storekey == NULL) { addReplyNull(c); geoArrayFree(ga); return; } //一些返回值的設定和返回 …… geoArrayFree(ga); }
上文程式碼中最核心的步驟有兩個,一是“計算中心點範圍”,二是“對中心點及其周圍8個geohash網格區域進行查詢”。
對應的是geohashGetAreasByRadiusWGS84
和membersOfAllNeighbors
兩個函式。
我們依次來看:
- 計算中心點範圍:
// geohash_helper.c
GeoHashRadius geohashGetAreasByRadiusWGS84(double longitude, double latitude, double radius_meters) { return geohashGetAreasByRadius(longitude, latitude, radius_meters); } //返回能夠覆蓋目標區域範圍的9個geohashBox GeoHashRadius geohashGetAreasByRadius(double longitude, double latitude, double radius_meters) { //一些引數設定 GeoHashRange long_range, lat_range; GeoHashRadius radius; GeoHashBits hash; GeoHashNeighbors neighbors; GeoHashArea area; double min_lon, max_lon, min_lat, max_lat; double bounds[4]; int steps; //計算目標區域外接矩形的經緯度範圍(目標區域為:以目標經緯度為中心,半徑為指定距離的圓) geohashBoundingBox(longitude, latitude, radius_meters, bounds); min_lon = bounds[0]; min_lat = bounds[1]; max_lon = bounds[2]; max_lat = bounds[3]; //根據目標區域中心點緯度和半徑,計算帶查詢的9個搜尋框的geohash精度(位) //這裡用到latitude主要是針對極地的情況對精度進行了一些調整(緯度越高,位數越小) steps = geohashEstimateStepsByRadius(radius_meters,latitude); //設定經緯度最大最小值:-180<=longitude<=180, -85<=latitude<=85 geohashGetCoordRange(&long_range,&lat_range); //將待查經緯度按指定精度(steps)編碼成geohash值 geohashEncode(&long_range,&lat_range,longitude,latitude,steps,&hash); //將geohash值在8個方向上進行擴充,確定周圍8個Box(neighbors) geohashNeighbors(&hash,&neighbors); //根據hash值確定area經緯度範圍 geohashDecode(long_range,lat_range,hash,&area); //一些特殊情況處理 …… //構建並返回結果 radius.hash = hash; radius.neighbors = neighbors; radius.area = area; return radius; }
- 對中心點及其周圍8個geohash網格區域進行查詢:
-
// geo.c
//在9個hashBox中獲取想要的元素 int membersOfAllNeighbors(robj *zobj, GeoHashRadius n, double lon, double lat, double radius, geoArray *ga) { GeoHashBits neighbors[9]; unsigned int i, count = 0, last_processed = 0; int debugmsg = 0; //獲取9個搜尋hashBox neighbors[0] = n.hash; …… neighbors[8] = n.neighbors.south_west; //在每個hashBox中搜索目標點 for (i = 0; i < sizeof(neighbors) / sizeof(*neighbors); i++) { if (HASHISZERO(neighbors[i])) { if (debugmsg) D("neighbors[%d] is zero",i); continue; } //剔除可能的重複hashBox (搜尋半徑>5000KM時可能出現) if (last_processed && neighbors[i].bits == neighbors[last_processed].bits && neighbors[i].step == neighbors[last_processed].step) { continue; } //搜尋hashBox中滿足條件的物件 count += membersOfGeoHashBox(zobj, neighbors[i], ga, lon, lat, radius); last_processed = i; } return count; } int membersOfGeoHashBox(robj *zobj, GeoHashBits hash, geoArray *ga, double lon, double lat, double radius) { //獲取hashBox內的最大、最小geohash值(52位) GeoHashFix52Bits min, max; scoresOfGeoHashBox(hash,&min,&max); //根據最大、最小geohash值篩選zobj集合中滿足條件的點 return geoGetPointsInRange(zobj, min, max, lon, lat, radius, ga); } int geoGetPointsInRange(robj *zobj, double min, double max, double lon, double lat, double radius, geoArray *ga) { //搜尋Range的引數邊界設定(即9個hashBox其中一個的邊界範圍) zrangespec range = { .min = min, .max = max, .minex = 0, .maxex = 1 }; size_t origincount = ga->used; sds member; //搜尋集合zobj可能有ZIPLIST和SKIPLIST兩種編碼方式,這裡以SKIPLIST為例,邏輯是一樣的 if (zobj->encoding == OBJ_ENCODING_ZIPLIST) { …… } else if (zobj->encoding == OBJ_ENCODING_SKIPLIST) { zset *zs = zobj->ptr; zskiplist *zsl = zs->zsl; zskiplistNode *ln; //獲取在hashBox範圍內的首個元素(跳錶資料結構,效率可比擬於二叉查詢樹),沒有則返0 if ((ln = zslFirstInRange(zsl, &range)) == NULL) { /* Nothing exists starting at our min. No results. */ return 0; } //從首個元素開始遍歷集合 while (ln) { sds ele = ln->ele; //遍歷元素超出range範圍則break /* Abort when the node is no longer in range. */ if (!zslValueLteMax(ln->score, &range)) break; //元素校驗(計算元素與中心點的距離) ele = sdsdup(ele); if (geoAppendIfWithinRadius(ga,lon,lat,radius,ln->score,ele) == C_ERR) sdsfree(ele); ln = ln->level[0].forward; } } return ga->used - origincount; } int geoAppendIfWithinRadius(geoArray *ga, double lon, double lat, double radius, double score, sds member) { double distance, xy[2]; //解碼錯誤, 返回error if (!decodeGeohash(score,xy)) return C_ERR; /* Can't decode. */ //最終距離校驗(計算球面距離distance看是否小於radius) if (!geohashGetDistanceIfInRadiusWGS84(lon,lat, xy[0], xy[1], radius, &distance)) { return C_ERR; } //構建並返回滿足條件的元素 geoPoint *gp = geoArrayAppend(ga); gp->longitude = xy[0]; gp->latitude = xy[1]; gp->dist = distance; gp->member = member; gp->score = score; return C_OK; }
-
演算法小結
拋開眾多可選引數不談,簡單總結下GEORADIUS命令是怎麼利用geohash獲取目標位置物件的:
1、引數提取和校驗;
2、利用中心點和輸入半徑計算待查區域範圍。這個範圍引數包括滿足條件的最高的geohash網格等級(精度) 以及 對應的能夠覆蓋目標區域的九宮格位置;(後續會有詳細說明)
3、對九宮格進行遍歷,根據每個geohash網格的範圍框選出位置物件。進一步找出與中心點距離小於輸入半徑的物件,進行返回。
直接描述不太好理解,我們通過如下兩張圖在對演算法進行簡單的演示:
-
令左圖的中心為搜尋中心,綠色圓形區域為目標區域,所有點為待搜尋的位置物件,紅色點則為滿足條件的位置物件。
在實際搜尋時,首先會根據搜尋半徑計算geohash網格等級(即右圖中網格大小等級),並確定九宮格位置(即紅色九宮格位置資訊);再依次查詢計算九宮格中的點(藍點和紅點)與中心點的距離,最終篩選出距離範圍內的點(紅點)。
演算法分析
為什麼要用這種演算法策略進行查詢,或者說這種策略的優勢在哪,讓我們以問答的方式進行分析說明。
為什麼要找到滿足條件的最高的geohash網格等級?為什麼用九宮格?
這其實是一個問題,本質上是對所有的元素物件進行了一次初步篩選。在多層geohash網格中,每個低等級的geohash網格都是由4個高一級的網格拼接而成(如圖)。
-
令左圖的中心為搜尋中心,綠色圓形區域為目標區域,所有點為待搜尋的位置物件,紅色點則為滿足條件的位置物件。
在實際搜尋時,首先會根據搜尋半徑計算geohash網格等級(即右圖中網格大小等級),並確定九宮格位置(即紅色九宮格位置資訊);再依次查詢計算九宮格中的點(藍點和紅點)與中心點的距離,最終篩選出距離範圍內的點(紅點)。
演算法分析
為什麼要用這種演算法策略進行查詢,或者說這種策略的優勢在哪,讓我們以問答的方式進行分析說明。
為什麼要找到滿足條件的最高的geohash網格等級?為什麼用九宮格?
這其實是一個問題,本質上是對所有的元素物件進行了一次初步篩選。在多層geohash網格中,每個低等級的geohash網格都是由4個高一級的網格拼接而成(如圖)。
-
其擁有類似二叉查詢樹的查詢效率,操作平均時間複雜性為O(log(N))。且最底層的所有元素都以連結串列的形式按序排列。
所以在查詢時,只要找到集合中處在目標geohash網格中的第一個值,後續依次對比即可,不用多次查詢。
九宮格不能一起查,要一個個遍歷的原因也在於九宮格各網格對應的geohash值不具有連續性。只有連續了,查詢效率才會高,不然要多做許多距離運算。
綜上,我們從原始碼角度解析了Redis Geo模組中 “增(GEOADD)” 和 “查(GEORADIUS)” 的詳細過程。並可推算出Redis中GEORADIUS查詢附近的人功能,時間複雜度為:O(N+log(M))
其中N為指定半徑範圍內的位置元素數量,而M則是被九宮格圈住計算距離的元素的數量。結合Redis本身基於記憶體的儲存特性,在實際使用過程中有非常高的執行效率。