Redis數(shù)據(jù)結構使用場景及優(yōu)化示例

Redis 學習路徑

image

基礎數(shù)據(jù)類型和底層數(shù)據(jù)結構對應關系

image

一、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的新增用戶


image

20200804的留存用戶


image

當你需要對多個集合進行聚合計算時,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ù)如下

image

image

執(zhí)行分頁查詢方法后,結果如下

image

image

image

可以看到以上三種查詢方式,效率上差別不大。所以,在面對需要展示最新列表、排行榜等場景時,可以采用以上方法。如果數(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)的


image

查詢附近車輛的結果如下圖,可以看出只有兩個距離小于10公里的車car1和car2被查了出來


image

七、List實現(xiàn)消息隊列

http://www.lxweimin.com/p/bd70702cda00

總結

image
最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發(fā)布,文章內容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容