Redis 學習路徑
基礎數(shù)據(jù)類型和底層數(shù)據(jù)結構對應關系
一、Hash在什么時候使用壓縮列表
Hash 類型底層結構什么時候使用壓縮列表,什么時候使用哈希表呢?
其實,Hash 類型設置了用壓縮列表保存數(shù)據(jù)時的兩個閾值,一旦超過了閾值,Hash 類型就會用哈希表來保存數(shù)據(jù)了。
這兩個閾值分別對應以下兩個配置項:
hash-max-ziplist-entries:表示用壓縮列表保存時哈希集合中的最大元素個數(shù)。
hash-max-ziplist-value:表示用壓縮列表保存時哈希集合中單個元素的最大長度。
// 設置 hash-max-ziplist-entries
config set hash-max-ziplist-entries 512
// 獲取 hash-max-ziplist-entries
config get hash-max-ziplist-entries
// 設置 hash-max-ziplist-value
config set hash-max-ziplist-value 64
// 獲取 hash-max-ziplist-value
config get hash-max-ziplist-value
如果我們往 Hash 集合中寫入的元素個數(shù)超過了 hash-max-ziplist-entries,或者寫入的單個元素大小超過了 hash-max-ziplist-value,Redis 就會自動把 Hash 類型的實現(xiàn)結構由壓縮列表轉為哈希表。
一旦從壓縮列表轉為了哈希表,Hash 類型就會一直用哈希表進行保存,而不會再轉回壓縮列表了。在節(jié)省內存空間方面,哈希表就沒有壓縮列表那么高效了。
優(yōu)化示例,存儲格式為 key: 1000000000 value: 2000000000
使用String存儲1萬個鍵值對,平均每個鍵值對占用內存情況如下
數(shù)據(jù)結構 | key類型 | value類型 | 平均占用/B |
---|---|---|---|
String | String | Long | 69.1072 |
String | String | String | 85.1072 |
使用Hash且底層為壓縮列表
在保存單值的鍵值對時,可以采用基于 Hash 類型的二級編碼方法。
即將key: 1000000000 拆分為兩部分,前7位做為key,后3位做為field
因為field為三位數(shù),范圍為0~999共1000個,所以將hash-max-ziplist-entries設置為1000后,按照以上規(guī)格進行存儲,就可以一直使用Hash且底層為壓縮列表來存儲數(shù)據(jù),存儲占用內存情況如下
數(shù)據(jù)結構 | key類型 | field類型 | value類型 | 平均占用/B |
---|---|---|---|---|
Hash | String | String | Long | 10.2952 |
Hash | String | String | String | 20.5336 |
綜上所述,我們以前總認為 String 是“萬金油”,什么場合都適用,但是,在保存的鍵值對本身占用的內存空間不大時(例如這里的例子),String 類型的元數(shù)據(jù)開銷就占據(jù)主導了,這里面包括了 RedisObject 結構、SDS 結構、dictEntry 結構的內存開銷。針對這種情況,我們可以使用壓縮列表保存數(shù)據(jù)。當然,使用 Hash 這種集合類型保存單值鍵值對的數(shù)據(jù)時,我們需要將單值數(shù)據(jù)拆分成兩部分,分別作為 Hash 集合的鍵和值,希望你能把這個方法用到自己的場景中。
如果你想知道鍵值對采用不同類型保存時的內存開銷,可以在這個http://www.redis.cn/redis_memory/網址里輸入你的鍵值對長度和使用的數(shù)據(jù)類型,這樣就能知道實際消耗的內存大小了。建議你把這個小工具用起來,它可以幫助你充分地節(jié)省內存。
二、Set的聚合統(tǒng)計
現(xiàn)在有一個需求,統(tǒng)計每天的新增用戶數(shù)和第二天的留存用戶數(shù)。
我們可以使用Set的聚合統(tǒng)計功能,所謂的聚合統(tǒng)計,就是指統(tǒng)計多個集合元素的聚合結果,包括:
1.統(tǒng)計多個集合的共有元素(交集統(tǒng)計);
2.把兩個集合相比,統(tǒng)計其中一個集合獨有的元素(差集統(tǒng)計);
3.統(tǒng)計多個集合的所有元素(并集統(tǒng)計)。
Java實現(xiàn)代碼
@SpringBootTest
public class RedisTest {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Test
void test() {
// 1. 記錄 "2020-08-03登錄用戶" = [1,2,3,4,5,6,7,8,9,10]
for (int i = 1; i <= 10; i++) {
redisTemplate.opsForSet().add("user:id:20200803", i);
}
// 2. 2020-08-04 00:00:00的時候,設置 "累計用戶" = "2020-08-03登錄用戶" 和 "累計用戶" 的并集
redisTemplate.opsForSet().unionAndStore("user:id", "user:id:20200803", "user:id");
// 3. 記錄 "2020-08-04登錄用戶" = [6,7,8,9,10,11,12,13,14,15]
for (int i = 6; i <= 15; i++) {
redisTemplate.opsForSet().add("user:id:20200804", i);
}
// 4. 2020-08-05 00:00:00的時候,計算并存儲 "20200804的新增用戶" = [11,12,13,14,15]
redisTemplate.opsForSet().differenceAndStore("user:id:20200804", "user:id", "user:id:20200804:add");
// 5. 2020-08-05 00:00:00的時候,計算并存儲 "20200804的留存用戶" = [6,7,8,9,10]
redisTemplate.opsForSet().intersectAndStore("user:id:20200804", "user:id:20200803", "user:id:20200804:keep");
}
}
運行以上代碼后,可在可視化工具中看到以下結果
20200804的新增用戶
20200804的留存用戶
當你需要對多個集合進行聚合計算時,Set 類型會是一個非常不錯的選擇。不過,我要提醒你一下,這里有一個潛在的風險。
Set 的差集、并集和交集的計算復雜度較高,在數(shù)據(jù)量較大的情況下,如果直接執(zhí)行這些計算,會導致 Redis 實例阻塞。
所以,我給你分享一個小建議:你可以從主從集群中選擇一個從庫,讓它專門負責聚合計算,或者是把數(shù)據(jù)讀取到客戶端,在客戶端來完成聚合統(tǒng)計,這樣就可以規(guī)避阻塞主庫實例和其他從庫實例的風險了。
三、List和ZSet的排序統(tǒng)計
接下來,我們再來聊一聊應對集合元素排序需求的方法。我以在電商網站上提供最新評論列表的場景為例,進行講解。
最新評論列表包含了所有評論中的最新留言,這就要求集合類型能對元素保序,也就是說,集合中的元素可以按序排列,這種對元素保序的集合類型叫作有序集合。
在 Redis 常用的 4 個集合類型中(List、Hash、Set、Sorted Set),List 和 Sorted Set 就屬于有序集合。
List 是按照元素進入 List 的順序進行排序的,而 Sorted Set 可以根據(jù)元素的權重來排序,我們可以自己來決定每個元素的權重值。比如說,我們可以根據(jù)元素插入 Sorted Set 的時間確定權重值,先插入的元素權重小,后插入的元素權重大。
Java實現(xiàn)代碼
@SpringBootTest
public class RedisTest {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
// list和zset數(shù)據(jù)初始化
private static final String key = "commentList";
private static final String key2 = "commentZset";
@Test
void set() {
for (int i = 1; i <= 1000; i++) {
// 根據(jù)時間戳生成唯一的score,13位時間戳+3位序列,zset的score超過16位會丟失精度
Double score = ScoreGenerator.nextScore();
Comment comment = Comment.builder()
.score(new BigDecimal(score).toString())
.comment(String.valueOf(i))
.build();
redisTemplate.opsForList().leftPush(key, comment);
redisTemplate.opsForZSet().add(key2, comment, new BigDecimal(score).doubleValue());
}
}
// list分頁方法
private List<Comment> listPage(Long current, Long size) {
Long start = (current - 1) * size;
Long end = current * size - 1;
List<Object> objs = redisTemplate.opsForList().range(key, start, end);
List<Comment> comments = objs.stream().map(p -> (Comment) p).collect(Collectors.toList());
return comments;
}
// zset分頁方法
private List<Comment> sortedSetPage(Long current, Long size) {
Long start = (current - 1) * size;
Set<Object> objects = redisTemplate.opsForZSet().reverseRangeByScore(key2, 0, 9999999999999999.0, start, size);
List<Comment> comments = objects.stream().map(p -> (Comment) p).collect(Collectors.toList());
return comments;
}
// zset分頁方法2
private List<Comment> sortedSetPage2(Double maxScore, Long size) {
Set<Object> objects = redisTemplate.opsForZSet().reverseRangeByScore(key2, 0, maxScore, 0, size);
List<Comment> comments = objects.stream().map(p -> (Comment) p).collect(Collectors.toList());
return comments;
}
// list查詢打印
@Test
void getListPage(Long current, Long size) {
long l = System.currentTimeMillis();
List<Comment> comments = listPage(current, size);
System.out.println("list查詢耗時: " + (System.currentTimeMillis() - l) + "ms");
System.out.println(JSON.toJSONString(comments));
}
// zset查詢打印
@Test
void getSortedSetPage(Long current, Long size) {
long l = System.currentTimeMillis();
List<Comment> comments = sortedSetPage(current, size);
System.out.println("zset查詢耗時: " + (System.currentTimeMillis() - l) + "ms");
System.out.println(JSON.toJSONString(comments));
}
// zset查詢打印
@Test
double getSortedSetPage2(Double maxScore, Long size) {
long l = System.currentTimeMillis();
List<Comment> comments = sortedSetPage2(maxScore, size);
System.out.println("zset查詢耗時: " + (System.currentTimeMillis() - l) + "ms");
System.out.println(JSON.toJSONString(comments));
return new BigDecimal(comments.get(comments.size() - 1).getScore()).doubleValue();
}
@Test
void test() {
// 遍歷查詢出所有數(shù)據(jù),每頁10條
Long size = 10L;
Double maxScore = 9999999999999999.0;
for (int i = 1; i <= 100; i++) {
System.out.println("查詢第" + i + "頁");
// 這兩種方法在翻頁的時候,如果新插入一條數(shù)據(jù)都會造成,上一頁最后一條和下一頁第一條數(shù)據(jù)重復
getListPage(0L + i, size);
getSortedSetPage(0L + i, size);
// 這種根據(jù)maxScore 來查詢的方法能避免以上數(shù)據(jù)重復問題
// 查詢第一頁,給一個足夠大的maxScore,第二頁的maxScore等于第一頁最后一項的score-1
maxScore = getSortedSetPage2(maxScore, size) - 1;
System.out.println();
}
}
}
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
class Comment {
private String score;
private String comment;
}
class ScoreGenerator {
public static void main(String[] args) {
HashSet<Double> doubles = new HashSet<>();
long l = System.currentTimeMillis();
for (int i = 0; i < 1000000; i++) {
Double aDouble = ScoreGenerator.nextScore();
// 校驗是否會重復
if (!doubles.contains(aDouble)) {
doubles.add(aDouble);
} else {
throw new RuntimeException("重復");
}
}
System.out.println((System.currentTimeMillis() - l) + "ms");
}
private static long lastTimestamp = -1L;
private static long sequence = 1L;
public synchronized static Double nextScore() {
long timestamp = System.currentTimeMillis();
//獲取當前時間戳如果小于上次時間戳,則表示時間戳獲取出現(xiàn)異常
if (timestamp < lastTimestamp) {
System.err.printf("clock is moving backwards. Rejecting requests until %d.", lastTimestamp);
throw new RuntimeException(String.format("Clock moved backwards. Refusing to generate id for %d milliseconds",
lastTimestamp - timestamp));
}
//獲取當前時間戳如果等于上次時間戳(同一毫秒內),則在序列號加一;否則序列號賦值為0,從0開始。
if (lastTimestamp == timestamp) {
sequence = sequence + 1;
if (sequence > 999) {
timestamp = tilNextMillis(lastTimestamp);
sequence = 1;
}
} else {
sequence = 1;
}
//將上次時間戳值刷新
lastTimestamp = timestamp;
Long score = timestamp * 1000 + sequence;
return new BigDecimal(score).doubleValue();
}
//獲取時間戳,并與上次時間戳比較
private static long tilNextMillis(long lastTimestamp) {
long timestamp = System.currentTimeMillis();
while (timestamp <= lastTimestamp) {
timestamp = System.currentTimeMillis();
}
return timestamp;
}
}
執(zhí)行List和ZSet初始化方法后,數(shù)據(jù)如下
執(zhí)行分頁查詢方法后,結果如下
可以看到以上三種查詢方式,效率上差別不大。所以,在面對需要展示最新列表、排行榜等場景時,可以采用以上方法。如果數(shù)據(jù)更新頻繁,不能接受翻頁時可能出現(xiàn)重復數(shù)據(jù)的問題,建議你優(yōu)先考慮使用 Sorted Set。
四、Bitmap的二值狀態(tài)統(tǒng)計
現(xiàn)在,我們再來分析下第三個場景:二值狀態(tài)統(tǒng)計。這里的二值狀態(tài)就是指集合元素的取值就只有 0 和 1 兩種。在簽到打卡的場景中,我們只用記錄簽到(1)或未簽到(0),所以它就是非常典型的二值狀態(tài)。
在簽到統(tǒng)計時,每個用戶一天的簽到用 1 個 bit 位就能表示,一個月(假設是 31 天)的簽到情況用 31 個 bit 位就可以,而一年的簽到也只需要用 365 個 bit 位,根本不用太復雜的集合類型。這個時候,我們就可以選擇 Bitmap。這是 Redis 提供的擴展數(shù)據(jù)類型。我來給你解釋一下它的實現(xiàn)原理。
Bitmap 本身是用 String 類型作為底層數(shù)據(jù)結構實現(xiàn)的一種統(tǒng)計二值狀態(tài)的數(shù)據(jù)類型。String 類型是會保存為二進制的字節(jié)數(shù)組,所以,Redis 就把字節(jié)數(shù)組的每個 bit 位利用起來,用來表示一個元素的二值狀態(tài)。你可以把 Bitmap 看作是一個 bit 數(shù)組。
Java實現(xiàn)代碼
@SpringBootTest
public class RedisTest {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
// 計算用戶(1000)在2021年1月簽到總天數(shù)
@Test
void bitCount() {
String key = "uid:sign:1000:202101";
// 代表用戶1000在2021年1月1日簽到了
redisTemplate.opsForValue().setBit(key, 0, true);
// 代表用戶1000在2021年1月6日簽到了
redisTemplate.opsForValue().setBit(key, 5, true);
// 查詢用戶1000在2021年1月6日是否簽到了
Boolean bit = redisTemplate.opsForValue().getBit(key, 5);
System.out.println(bit); // true
// 查詢用戶1000在2021年1月的簽到次數(shù)
Long count = redisTemplate.execute((RedisCallback<Long>) con -> con.bitCount(key.getBytes()));
System.out.println(count); // 2次
}
// 計算用戶連續(xù)簽到天數(shù),假設某個7天的活動連續(xù)簽到7天有獎品
@Test
void bitOp() {
// 假設5個用戶參加活動,每一天的每一位是一個用戶
// day1每個用戶都簽到了
for (int i = 0; i < 5; i++) {
redisTemplate.opsForValue().setBit("day1", i, true);
}
// day1前4個用戶都簽到了
for (int i = 0; i < 4; i++) {
redisTemplate.opsForValue().setBit("day2", i, true);
}
// day1前3個用戶都簽到了
for (int i = 0; i < 3; i++) {
redisTemplate.opsForValue().setBit("day3", i, true);
}
// day1前2個用戶都簽到了
for (int i = 0; i < 2; i++) {
redisTemplate.opsForValue().setBit("day4", i, true);
}
// 之后只有用戶0堅持了下來
redisTemplate.opsForValue().setBit("day5", 0, true);
redisTemplate.opsForValue().setBit("day6", 0, true);
redisTemplate.opsForValue().setBit("day7", 0, true);
// 對day1~day7 按位與,存儲到destination中。RedisStringCommands.BitOperation還包含其他邏輯操作
Long bitAnd = redisTemplate.execute((RedisCallback<Long>) con ->
con.bitOp(RedisStringCommands.BitOperation.AND,
"destination".getBytes(), "day1".getBytes(),
"day2".getBytes(), "day3".getBytes(), "day4".getBytes(),
"day5".getBytes(), "day6".getBytes(), "day7".getBytes()));
System.out.println(bitAnd);
// 查詢結果,對應位數(shù)上的用戶為ture則代表連續(xù)簽到了七天
ArrayList<Boolean> destination = new ArrayList<>();
for (int i = 0; i < 5; i++) {
destination.add(redisTemplate.opsForValue().getBit("destination", i));
}
System.out.println(destination); // [true, false, false, false, false]
}
// bitpos 是用來查詢一個二進制串里第一個0或者1的位置。可設置查詢的位范圍
@Test
void bitPos() {
redisTemplate.opsForValue().setBit("bitPos", 5, true);
Long execute = redisTemplate.execute((RedisCallback<Long>) con -> con.bitPos("bitPos".getBytes(), true));
System.out.println(execute); // 5
}
}
如果只需要統(tǒng)計數(shù)據(jù)的二值狀態(tài),例如商品有沒有、用戶在不在等,就可以使用 Bitmap,因為它只用一個 bit 位就能表示 0 或 1。在記錄海量數(shù)據(jù)時,Bitmap 能夠有效地節(jié)省內存空間。
五、HyperLogLog的基數(shù)統(tǒng)計
我們再來看一個統(tǒng)計場景:基數(shù)統(tǒng)計?;鶖?shù)統(tǒng)計就是指統(tǒng)計一個集合中不重復的元素個數(shù)。對應到我們剛才介紹的場景中,就是統(tǒng)計網頁的 UV。網頁 UV 的統(tǒng)計有個獨特的地方,就是需要去重,一個用戶一天內的多次訪問只能算作一次。
在 Redis 的集合類型中,Set 類型默認支持去重,所以看到有去重需求時,我們可能第一時間就會想到用 Set 類型。但是,如果 UV 達到了千萬,這個時候,一個頁面的 Set 就要記錄千萬個用戶 ID, 如果每個頁面都用這樣的一個 Set,就會消耗很大的內存空間。
HyperLogLog 是一種用于統(tǒng)計基數(shù)的數(shù)據(jù)集合類型,它的最大優(yōu)勢就在于,當集合元素數(shù)量非常多時,它計算基數(shù)所需的空間總是固定的,而且還很小。在 Redis 中,每個 HyperLogLog 只需要花費 12 KB 內存,就可以計算接近 2^64 個元素的基數(shù)。
Java實現(xiàn)代碼
@SpringBootTest
public class RedisTest {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Test
void test() {
// 假設一萬個用戶訪問了page1
long l = System.currentTimeMillis();
for (int i = 0; i < 10000; i++) {
redisTemplate.opsForHyperLogLog().add("page1:uv", i);
}
System.out.println("添加完成,耗時" + (System.currentTimeMillis() - l) + "ms");
Long size = redisTemplate.opsForHyperLogLog().size("page1:uv");
System.out.println(size); // 9987個,存在標準誤算 0.81%
}
}
不過,有一點需要你注意一下,HyperLogLog 的統(tǒng)計規(guī)則是基于概率完成的,所以它給出的統(tǒng)計結果是有一定誤差的,標準誤算率是 0.81%。這也就意味著,你使用 HyperLogLog 統(tǒng)計的 UV 是 100 萬,但實際的 UV 可能是 101 萬。雖然誤差率不算大,但是,如果你需要精確統(tǒng)計結果的話,最好還是繼續(xù)用 Set 或 Hash 類型。
六、GEO 支持 LBS 應用
在日常生活中,我們越來越依賴搜索“附近的餐館”、在打車軟件上叫車,這些都離不開基于位置信息服務(Location-Based Service,LBS)的應用。LBS 應用訪問的數(shù)據(jù)是和人或物關聯(lián)的一組經緯度信息,而且要能查詢相鄰的經緯度范圍,GEO 就非常適合應用在 LBS 服務的場景中。
Java實現(xiàn)代碼
這里模擬叫車業(yè)務,查詢用戶附近十公里內的車輛信息
@SpringBootTest
public class RedisTest {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Test
void test() {
String key = "cars:locations";
// 添加車
redisTemplate.opsForGeo().add(key, new Point(114.417265, 30.453276), "car1");
redisTemplate.opsForGeo().add(key, new Point(114.403287, 30.455175), "car2");
redisTemplate.opsForGeo().add(key, new Point(100, 20), "car3");
// 人的位置
Point people = new Point(114.408031, 30.447827);
// 查10公里內最近的10輛車
List<Result> nearCars = this.getNearCars(key, people,
new Distance(10000, RedisGeoCommands.DistanceUnit.METERS), // 10公里內
10L); // 十條記錄
// 打印查詢結果
nearCars.forEach(p -> System.out.println(p));
}
// 獲取附近車、車坐標以及人與車的距離
public List<Result> getNearCars(String key, Point point, Distance distance, Long limit) {
// 獲取附近車的集合
GeoResults<RedisGeoCommands.GeoLocation<Object>> radiusGeo = redisTemplate.opsForGeo().radius(key,
new Circle(point, distance),
RedisGeoCommands.GeoRadiusCommandArgs.newGeoRadiusArgs()
.includeDistance() // 包含距離
.includeCoordinates() // 包含坐標
.sortAscending() // 由近到遠排序
.limit(limit) // 獲取記錄數(shù)
);
List<Result> cars = new ArrayList<>();
// 遍歷附近車的集合
Iterator<GeoResult<RedisGeoCommands.GeoLocation<Object>>> result = radiusGeo.iterator();
while (result.hasNext()) {
GeoResult<RedisGeoCommands.GeoLocation<Object>> geoLocation = result.next();
// 車
String c = (String) geoLocation.getContent().getName();
// 車坐標
Point p = geoLocation.getContent().getPoint();
// 人與車的距離
Distance d = geoLocation.getDistance();
cars.add(Result.builder()
.carId(c)
.longitude(new BigDecimal(p.getX()).setScale(6, BigDecimal.ROUND_HALF_UP))
.latitude(new BigDecimal(p.getY()).setScale(6, BigDecimal.ROUND_HALF_UP))
.distance((int) d.getValue())
.build()
);
}
return cars;
}
}
@Data
@Builder
class Result {
private String carId;
private BigDecimal longitude;
private BigDecimal latitude;
private Integer distance;
}
添加車輛后,可以在下圖redis中看到,車輛坐標信息是用ZSET存儲的,說明GEO是用ZSET做為底層實現(xiàn)的
查詢附近車輛的結果如下圖,可以看出只有兩個距離小于10公里的車car1和car2被查了出來
七、List實現(xiàn)消息隊列
http://www.lxweimin.com/p/bd70702cda00