前言:
今天完結了Redis的所有實戰篇。
學習收獲:
GEO數據結構:
GEO就是Geolocation的簡寫形式,代表地理坐標。Redis在3.2版本中加入對Geo的支持,存儲、管理和操作地理空間數據的特殊數據結構,它能高效處理與位置、空間關系相關的信息。常見的命令有:
地理坐標數據結構其實底層就是基于sortset實現的,地理坐標被轉為數字作為score存儲。
- 添加了兩個地理空間信息:
GEOADD cities 116.404 39.915 "Beijing" 121.473 31.230 "Shanghai"
- 計算兩個地理位置的距離,默認單位是米:?
GEODIST cities Beijing Shanghai km # 返回約 1068.11 千米
- 獲取地理坐標位置:?
GEOPOS cities Beijing # 返回 [116.40400149041748, 39.91500072454179]
- ?附近查詢:
GEORADIUS key longitude latitude radius unit [WITHCOORD] [WITHDIST] [WITHHASH] [COUNT count] [ASC|DESC]
GEORADIUSBYMEMBER key member radius unit [WITHCOORD] [WITHDIST] [WITHHASH] [COUNT count] [ASC|DESC]
WITHCOORD
:返回經緯度。WITHDIST
:返回距離。COUNT count
:限制返回數量。ASC|DESC
:按距離升序 / 降序排列
實現附近商戶搜索功能:
?要把店鋪坐標的經緯度信息導入Redis中的GEO數據類型結構中,member存店鋪id。將來我們要做店鋪的篩選時,根據經緯度的到店鋪id,再根據id在數據庫中查詢店鋪;
?分離不同類型的商戶,再把不同類型的商戶分到不同的key中:
- 查詢點鋪信息
- 把店鋪分組,按照typeId分組,id一致的放到一個集合
通過收集的groupingBy來實現分組,通過TypeId來分組
- 分批完成寫入Redis
獲取類型id;獲取同類型的點鋪集合;寫入redis GEOADD key 經度 維度 member
- 最后導入店鋪數據到GEO
/*** 預熱店鋪數據,按照typeId進行分組,用于實現附近商戶搜索功能*/@Testpublic void loadShopListToCache() {// 1、獲取店鋪數據List<Shop> shopList = shopService.list();// 2、根據 typeId 進行分類
// Map<Long, List<Shop>> shopMap = new HashMap<>();
// for (Shop shop : shopList) {
// Long shopId = shop.getId();
// if (shopMap.containsKey(shopId)){
// // 已存在,添加到已有的集合中
// shopMap.get(shopId).add(shop);
// }else{
// // 不存在,直接添加
// shopMap.put(shopId, Arrays.asList(shop));
// }
// }// 使用 Lambda 表達式,更加優雅(優雅永不過時)Map<Long, List<Shop>> shopMap = shopList.stream().collect(Collectors.groupingBy(Shop::getTypeId));// 3、將分好類的店鋪數據寫入redisfor (Map.Entry<Long, List<Shop>> shopMapEntry : shopMap.entrySet()) {// 3.1 獲取 typeIdLong typeId = shopMapEntry.getKey();List<Shop> values = shopMapEntry.getValue();// 3.2 將同類型的店鋪的寫入同一個GEO ( GEOADD key 經度 維度 member )String key = SHOP_GEO_KEY + typeId;// 方式一:單個寫入(這種方式,一個請求一個請求的發送,十分耗費資源,我們可以進行批量操作)
// for (Shop shop : values) {
// stringRedisTemplate.opsForGeo().add(key, new Point(shop.getX(), shop.getY()),
// shop.getId().toString());
// }// 方式二:批量寫入List<RedisGeoCommands.GeoLocation<String>> locations = new ArrayList<>();for (Shop shop : values) {locations.add(new RedisGeoCommands.GeoLocation<>(shop.getId().toString(),new Point(shop.getX(), shop.getY())));}stringRedisTemplate.opsForGeo().add(key, locations);}}
?實現附近商鋪功能:
- 判斷是否要根據坐標查詢
- 計算分頁參數
- 查詢redis,按照距離排序并分頁。
- 解析出shopid
- 根據id查詢redis
- 返回
因為前端不一定是按照距離來查詢商鋪信息的,所以x和y的坐標不是必須的,所以通過required=false來設置可以有也可以沒有。沒傳就按數據庫查,傳了就按geo查。
List也要做非空判斷,因為stream流做了跳過,可能把有的數據跳過去了。
?
?ShopServiceImpl中的代碼:
@Overridepublic Result queryShopByType(Integer typeId, Integer current, Double x, Double y) {// 1.判斷是否需要根據坐標查詢if (x == null || y == null) {// 不需要坐標查詢,按數據庫查詢Page<Shop> page = query().eq("type_id", typeId).page(new Page<>(current, SystemConstants.DEFAULT_PAGE_SIZE));// 返回數據return Result.ok(page.getRecords());}// 2.計算分頁參數int from = (current - 1) * SystemConstants.DEFAULT_PAGE_SIZE;int end = current * SystemConstants.DEFAULT_PAGE_SIZE;// 3.查詢redis、按照距離排序、分頁。結果:shopId、distanceString key = SHOP_GEO_KEY + typeId;GeoResults<RedisGeoCommands.GeoLocation<String>> results = stringRedisTemplate.opsForGeo() // GEOSEARCH key BYLONLAT x y BYRADIUS 10 WITHDISTANCE.search(key,GeoReference.fromCoordinate(x, y),new Distance(5000),RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs().includeDistance().limit(end));// 4.解析出idif (results == null) {return Result.ok(Collections.emptyList());}List<GeoResult<RedisGeoCommands.GeoLocation<String>>> list = results.getContent();if (list.size() <= from) {// 沒有下一頁了,結束return Result.ok(Collections.emptyList());}// 4.1.截取 from ~ end的部分List<Long> ids = new ArrayList<>(list.size());Map<String, Distance> distanceMap = new HashMap<>(list.size());list.stream().skip(from).forEach(result -> {// 4.2.獲取店鋪idString shopIdStr = result.getContent().getName();ids.add(Long.valueOf(shopIdStr));// 4.3.獲取距離Distance distance = result.getDistance();distanceMap.put(shopIdStr, distance);});// 5.根據id查詢ShopString idStr = StrUtil.join(",", ids);List<Shop> shops = query().in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list();for (Shop shop : shops) {shop.setDistance(distanceMap.get(shop.getId().toString()).getValue());}// 6.返回return Result.ok(shops);}
用戶簽到:
但用戶的簽到狀態無非就兩種,簽了或者沒簽,我們可以用二進制位表示簽到卡,而這種二進制位可以通過BitMap來實現。
把每一個bit位對應當月的每一天,形成了映射關系。用0和1表示業務狀態,這種思路就稱為位圖(BitMap)。Redis中是利用String類型數據結構實現BitMap,因此最大上限是512M,轉換為bit則是2^32個bit位。
?核心思想就是把bit位與業務的某種核心狀態進行映射。
BitMap用法:
# 讀取所有的bit位
BITFIELD key
# 查找第一個數出現的位置
BITPOS key value
BITPOS bm1 1 # 返回的就是0,11100111 offset位0的位置就是1
BITPOS bm1 0 # 返回的就是0,11100111 offset位0的位置就是1
# 讀取指定位數的bit位
BITFIELD key GET type offset
# 獲取的數據是3
BITFIELD bm1 get u2 0
實現簽到功能:?
- 獲取當前登錄用戶
- 獲取日期
- 拼接key
- 獲取今天是本月的第幾天
- 寫入redis: setbit key offset 1
/*** 用戶簽到** @return*/@Overridepublic Result sign() {// 獲取當前登錄用戶Long userId = ThreadLocalUtls.getUser().getId();// 獲取日期LocalDateTime now = LocalDateTime.now();// 拼接keyString keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));String key = USER_SIGN_KEY + userId + keySuffix;// 獲取今天是本月的第幾天int dayOfMonth = now.getDayOfMonth();// 寫入Redis SETBIT key offset 1stringRedisTemplate.opsForValue().setBit(key, dayOfMonth - 1, true);return Result.ok();}
實現簽到統計功能:?
從當前時間開始向前逐個遍歷,依次判斷每個bit位,知道遇到第一次為0為止。并且定義一個計數器來計算總的簽到次數。
這里有三個問題來實現這個簽到統計功能:
/*** 記錄連續簽到的天數** @return*/@Overridepublic Result signCount() {// 1、獲取簽到記錄// 獲取當前登錄用戶Long userId = ThreadLocalUtls.getUser().getId();// 獲取日期LocalDateTime now = LocalDateTime.now();// 拼接keyString keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));String key = USER_SIGN_KEY + userId + keySuffix;// 獲取今天是本月的第幾天int dayOfMonth = now.getDayOfMonth();// 獲取本月截止今天為止的所有的簽到記錄,返回的是一個十進制的數字 BITFIELD sign:5:202203 GET u14 0List<Long> result = stringRedisTemplate.opsForValue().bitField(key,BitFieldSubCommands.create().get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth)).valueAt(0));// 2、判斷簽到記錄是否存在if (result == null || result.isEmpty()) {// 沒有任何簽到結果return Result.ok(0);}// 3、獲取本月的簽到數(List<Long>是因為BitFieldSubCommands是一個子命令,可能存在多個返回結果,這里我們知識使用了Get,// 可以明確只有一個返回結果,即為本月的簽到數,所以這里就可以直接通過get(0)來獲取)Long num = result.get(0);if (num == null || num == 0) {// 二次判斷簽到結果是否存在,讓代碼更加健壯return Result.ok(0);}// 4、循環遍歷,獲取連續簽到的天數(從當前天起始)int count = 0;while (true) {// 讓這個數字與1做與運算,得到數字的最后一個bit位,并且判斷這個bit位是否為0if ((num & 1) == 0) {// 如果為0,說明未簽到,結束break;} else {// 如果不為0,說明已簽到,計數器+1count++;}// 把數字右移一位,拋棄最后一個bit位,繼續下一個bit位num >>>= 1;}return Result.ok(count);}
?UV統計:
首先我們搞懂兩個概念:
- UV:全稱Unique Visitor,也叫獨立訪客量,是指通過互聯網訪問、瀏覽這個網頁的自然人。1天內同一個用戶多次訪問該網站,只記錄1次。
- PV:全稱Page View,也叫頁面訪問量或點擊量,用戶每訪問網站的一個頁面,記錄1次PV,用戶多次打開頁面,則記錄多次PV。往往用來衡量網站的流量。
UV統計在服務端做會比較麻煩,因為要判斷該用戶是否已經統計過了,需要將統計過的用戶信息保存。但是如果每個訪問的用戶都保存到Redis中,數據量會非常恐怖。
所以我們提出了HyperLogLog:
HyperLogLog用法:
Hyperloglog(HLL)是從LogLog算法派生的概率算法,用于確定非常大的集合的基數,而不是要存儲其所有值。相關算法原理可以參考:https://juejin.cn/post/6844903785744056333#heading-0
Redis中的HLL是基于string結構實現的,單個HLL的內存永遠小于16kb,內存占用低的令人發指。作為代價,其測量結果是概率性的,有小于0.81%的誤差。不過對于UV統計來說,這完全可以忽略。
?HLL對于重復的元素只記錄一次,可以方便的解決了獨立訪客量的問題。
由于當前系統并沒有足夠的用戶數據量,所以這里我們只是模擬實現UV統計。
/*** 測試 HyperLogLog 實現 UV 統計的誤差*/@Testpublic void testHyperLogLog() {String[] values = new String[1000];// 批量保存100w條用戶記錄,每一批1個記錄int j = 0;for (int i = 0; i < 1000000; i++) {j = i % 1000;values[j] = "user_" + i;if (j == 999) {// 發送到RedisstringRedisTemplate.opsForHyperLogLog().add("hl2", values);}}// 統計數量Long count = stringRedisTemplate.opsForHyperLogLog().size("hl2");System.out.println("count = " + count);}
?可以看到,存儲100w條用戶記錄,但是內存至多占用了0.02MB
?