背景
最近在開發商品瀏覽歷史,由於錯誤選擇了 Redis 的數據結構來進行存儲,導致性能低下。下面我們來分析一下瀏覽歷史需要考慮的點,以及實現上可選的數據結構。
實現思路
首先我們可以確認以下問題:
怎么添加
用戶最近瀏覽的商品,肯定是要在用戶打開商品詳情頁的時候才算瀏覽。
分頁
Redis 中 List 的 LRANGE 可以指定獲取指定長度的元素,可選。
Redis 中 SortedSet 有序集合,可以通過 ZREVRANGEBYSCORE 來分頁,可選。
map 不支持分頁,直接 pass。
數據重復
如果使用 List 來存儲商品,由於 list 是不支持去重的,pass。
使用map來存儲,可以通過map key的唯一性來保證元素唯一,但是map不支持分頁 pass。
使用 Set 和 SortedSet 來存儲,由於 set 天然支持去重,可選。
排序
如果使用 List 和 Map 來存儲,需要手動指定瀏覽日期(需要精確到秒)的方式,來在內存中進行排序,很不友好。
這里可以選擇上面提到的 SortedSet ,該數據結構有一個 score
值,可以進行排序。
失效時間
瀏覽歷史肯定是存在過期時間的,這一點上選擇 Redis 來設置最大過期時間(最大可以為一個月)是很方便的。
如果使用關系型數據庫,可能還需要定時清理數據庫中的數據。相比於 Redis 的 expire 就麻煩的多。
結論
結合上面的考慮,選擇 SortedSet 是最好的選擇。對去重和排序比較友好,並且使用SortedSet最大的好處是在查詢的時候就可以通過偏移量來進行分頁查詢,而不需要拿出所有的數據在內存中進行分頁。
所以我們在定義 key 的時候,就可以這么玩:
比如,一個人在pc端,store001店鋪下查看了spu001的商品,查看日期是 2020-06-09
key = user+pc+store001
value = spu001+2020-06-09
sorce = System.currentTimeMillis()
這里日期到天就好,使用 System.currentTimeMillis() 來進行精確到毫秒的排序。
SortedSet實現工具類
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.connection.RedisZSetCommands;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ZSetOperations;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.Set;
/**
* @author leizige
*/
@Component
public class RedisUtil{
@Resource
private ZSetOperations<String, String> zSetOperations;
private final Long EMPTY = 0L;
/**
* 添加一個元素, zset與set最大的區別就是每個元素都有一個score,因此有個排序的輔助功能; zadd
* key,value已存在,score覆蓋
*
* @param key
* @param value
*/
public boolean add(String key, String value, double score) {
return zSetOperations.add(key, value, score);
}
/**
* 查詢集合中指定順序的值 zrevrange
* <p>
* 返回有序的集合中,score大的在前面
*
* @param key
* @param offset
* @param count
* @return
*/
public Set<String> reverseRangeByScore(String key, int offset, int count) {
return zSetOperations.reverseRangeByScore(key, 1, Long.MAX_VALUE, (offset - 1) * count, count);
}
/**
* ZCARD key
* <p>
* 返回有序集 key 的基數。
* <p>
* 可用版本:
* >= 1.2.0
* 時間復雜度:
* O(1)
* 返回值:
* 當 key 存在且是有序集類型時,返回有序集的基數。
* 當 key 不存在時,返回 0
*
* @param key
* @return
*/
public Long zCard(String key) {
return zSetOperations.zCard(key);
}
/**
* 刪除元素 zrem
*
* @param key
* @param value
*/
public Long remove(String key, String value) {
return zSetOperations.remove(key, value);
}
/**
* 移除有序集 key 中,指定排名(rank)區間內的所有成員。
* <p>
* 區間分別以下標參數 start 和 stop 指出,包含 start 和 stop 在內。
* <p>
* 下標參數 start 和 stop 都以 0 為底,也就是說,以 0 表示有序集第一個成員,以 1 表示有序集第二個成員,以此類推。
* 你也可以使用負數下標,以 -1 表示最后一個成員, -2 表示倒數第二個成員,以此類推。
* 可用版本:
* >= 2.0.0
* 時間復雜度:
* O(log(N)+M), N 為有序集的基數,而 M 為被移除成員的數量。
*
* @param key
* @param start
* @param end
* @return 被移除成員的數量
*/
public Long removeRange(String key, long start, long end) {
return zSetOperations.removeRange(key, start, end);
}
}
實現原理
為了保證 Redis 中數據量的大小,限制每個用戶足跡最多保存 100 條記錄,最長保存 30 天。
新增足跡
使用System.currentTimeMillis()作為SortedSet的score來排序,並且 Set 天然支持去除重復數據,使用 ItemCode+LocalDate.now() 作為key,可以避免當天重復瀏覽一個商品,但Redis中只保存一條記錄。
void add(){
@Value("${browsingHistory.maxSize:100}")
private Long maxSize;
/**
* 默認過期時長,單位:秒
*/
private final static int DEFAULT_EXPIRE = 60 * 60 * 24 * 30;
public String set(String key,String value) {
redisUtil.add(key, value,System.currentTimeMillis());
//獲取數量
Long size = redisUtil.zCard(key);
//如果最大數量超過配置的,就把超出的那一個干掉
if(size > maxSize){
Long removeRange = redisUtil.removeRange(key, 0,0);
}
redisUtils.expire(key, DEFAULT_EXPIRE);
return key;
}
}
查詢足跡
先查看該用戶有沒有瀏覽歷史,數量為0直接返回
在從 Redis 中取出數據的時候就進行分頁,避免在內存中進行分頁操作。
void query(){
public Page<ItemResDto> queryAll(String key,int currentPage, int pageSize) {
Long size = redisUtil.zCard(key);
if (null == size || size.equals(0L)) {
return new Pager<>();
}
//這里將緩存中的商品編碼拿出來,組裝商品信息后返回
Set<String> values = redisUtil.reverseRangeByScore(key, currentPage, pageSize);
return new Page<>(newItemResDtoList, size, pageSize, currentPage);
}
}
清空足跡
public void removeAll(String key) {
redisUtils.delete(key);
}