美團一面:專案中使用過Redis嗎?我說用Redis做快取。他對我哦了一聲

码农Academy發表於2024-03-29

引言

Redis,作為一種開源的、基於記憶體且支援持久化的鍵值儲存系統,以其卓越的效能、豐富靈活的資料結構和高度可擴充套件性在全球範圍內廣受歡迎。Redis不僅提供了一種簡單直觀的方式來儲存和檢索資料,更因其支援資料結構如字串、雜湊、列表、集合、有序集合等多種型別,使得其在眾多場景下表現出強大的適用性和靈活性。

Redis的核心特點包括:

  1. 高效能:基於記憶體操作,讀寫速度極快,特別適用於對效能要求高的實時應用。

關於Redis高效能的原因,請參考:京東二面:Redis為什麼快?我說Redis是純記憶體操作的,然後他對我笑了笑。。。。。。

  1. 資料持久化:支援RDB和AOF兩種持久化方式,確保即使在伺服器重啟後也能恢復資料。
  2. 分散式的特性:透過主從複製、哨兵模式或叢集模式,Redis可以輕鬆地構建高可用和可擴充套件的服務。
  3. 豐富的資料結構:提供了多種資料結構支援,便於開發人員根據實際需求進行資料建模和處理。

Redis的廣泛應用跨越了多個行業和技術領域,諸如網站加速、快取服務、會話管理、實時統計、排行榜、訊息佇列、分散式鎖、社交網路功能、限流控制等。本文將深入探討Redis在這些場景下的具體應用方法及其背後的工作原理,旨在幫助開發者更好地理解和掌握Redis,以應對各種複雜的業務需求,並充分發揮其潛能。同時,我們也將關注如何在實踐中平衡Redis的效能、安全性、一致性等方面的挑戰,為實際專案帶來更高的價值。

資料快取

在高併發訪問的場景下,資料庫經常成為系統的瓶頸。Redis因其記憶體儲存、讀取速度快的特點,常被用作資料庫查詢結果的快取層,有效降低資料庫負載,提高整體系統的響應速度。這也是我們使用場景頻率最高的一個。

通常我們選擇使用String型別來儲存資料庫查詢結果,如單個實體物件的JSON序列化形式。

@Service
public class ProductService {

    @Autowired
    private RedisTemplate<String, Product> redisTemplate;

    // 使用@Cacheable註解進行快取
    @Cacheable(value = "productCache", key = "#id")
    public Product getProductById(String id) {
        // 此處是從資料庫或其他資料來源獲取商品的方法
        // 在實際場景中,如果快取命中,則不會執行下面的資料庫查詢邏輯
        return getProductFromDatabase(id);
    }
}

而使用Redis作為快取使用時,有一些特別需要注意的事項:

  1. 快取穿透:當查詢的資料在資料庫和快取中均不存在時,可能會導致大量的無效請求直接打到資料庫。可透過布隆過濾器預防快取穿透。
  2. 快取雪崩:若大量快取在同一時刻失效,所有請求都會湧向資料庫,造成瞬時壓力過大。可透過設定合理的過期時間分散、預載入或採用Redis叢集等方式避免。
  3. 快取一致性:當資料庫資料發生變化時,需要及時更新快取,避免資料不一致。可以採用主動更新策略(如監聽資料庫binlog)或被動更新策略(如在讀取時判斷資料新鮮度)。

而對於資料快取,我們常使用的業務場景如熱點資料儲存、全頁快取等。

會話管理

在說會話管理之前,我們來簡單介紹一下Spring Session
Spring Session 是 Spring Framework 的一個專案,旨在簡化分散式應用程式中的會話管理。在傳統的基於 Servlet 的應用程式中,會話管理是透過 HttpSession 介面實現的,但在分散式環境中,每個節點上的 HttpSession 不能簡單地共享,因此需要一種機制來管理會話並確保會話在叢集中的一致性。

Spring Session 提供了一種簡單的方法來解決這個問題,它將會話資料從容器(如 Tomcat 或 Jetty)中分離出來,並儲存在外部資料儲存(如 Redis、MongoDB、JDBC 等)中。這樣,不同節點上的應用程式例項可以共享相同的會話資料,實現分散式環境下的會話管理。

所以在Web應用中,Redis用於會話管理時,可以取代傳統基於伺服器記憶體或Cookie的會話儲存方案。透過將會話資料序列化後儲存為Redis中的鍵值對,實現跨多個伺服器例項的會話共享。

<dependency>
	<groupId>org.springframework.session</groupId>
	<artifactId>spring-session-data-redis</artifactId>
	<version>3.2.0</version>
</dependency>

然後我們在啟動類中,使用@EnableRedisHttpSession啟用Redis作為會話儲存。

@Configuration
@EnableRedisHttpSession
public class RedisSessionConfig {

    @Bean
    public RedisConnectionFactory connectionFactory() {
        // 這裡假設你已經在application.properties或application.yml中配置了Redis的資訊
        // 根據實際情況填寫Redis伺服器地址、埠等資訊
        return new LettuceConnectionFactory();
    }

}

以上是一個簡單的Spring Session使用Redis進行會話管理的示例程式碼。透過這種方式,我們可以輕鬆地在分散式環境中管理會話,並確保會話資料的一致性和可靠性。如果需要了解一些具體的用法,請自行參考Spring Session

排行榜與計分板

有序集合(Sorted Sets)是Redis的一種強大資料結構,可以用來實現動態排行榜,每個成員都有一個分數,按分數排序。有序集合中的每一個成員都有一個分數(score),成員依據其分數進行排序,且成員本身是唯一的。

當需要給某個使用者增加積分或改變其排名時,可以使用ZADD命令向有序集合中新增或更新成員及其分數。例如,ZADD leaderboard score member,這裡的ranking是有序集合的名稱,score是使用者的積分值,member是使用者ID。

查詢排行榜時,可以使用ZRANGE命令獲取指定範圍內的成員及其分數,例如,ZRANGE ranking 0 -1 WITHSCORES,這條命令會返回集合中所有的成員及其對應的分數,按照分數從低到高排序。

若要按照分數從高到低顯示排行榜,使用ZREVRANGE命令,如ZREVRANGE ranking 0 -1 WITHSCORES

@Service
public class RankingService {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    public void addToRanking(String playerName, int score) {
        redisTemplate.opsForZSet().add("ranking", playerName, score);
    }

    public List<RankingInfo> getRanking() {
        List<RankingInfo> rankingInfos = new ArrayList<>();
        Set<ZSetOperations.TypedTuple<String>> rankingSet = redisTemplate.opsForZSet().rangeWithScores("ranking", 0, -1);
        for (ZSetOperations.TypedTuple<String> tuple : rankingSet) {
            RankingInfo rankingInfo = new RankingInfo();
            rankingInfo.setPlayerName(tuple.getValue());
            rankingInfo.setScore(tuple.getScore().intValue());
            rankingInfos.add(rankingInfo);
            System.out.println("playerName: " + tuple.getValue() + ", score: " + tuple.getScore().intValue());
        }
        return rankingInfos;
    }
}

我們模擬請求,往redis中填入一些資料,在獲取排行榜:

image.png
在實際場景中,有序集合非常適合處理實時動態變化的排行榜資料,比如京東的月度銷量榜單、商品按時間的上新排行榜等,因為它的更新和查詢操作都是原子性的,並且能高效地支援按分數排序的操作。

計數器與統計

Redis的原子性操作如INCRDECR可以用於計數,確保在高併發環境下的計數準確性。比如在流量統計、電商網站商品的瀏覽量、影片網站影片的播放數贊等場景的應用。

@Service
public class CounterService {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    public void incrementLikeCount(String postId) {
        redisTemplate.opsForValue().increment(postId + ":likes");
    }

    public void decrementLikeCount(String postId) {
        redisTemplate.opsForValue().decrement(postId + ":likes");
    }

    public long getLikeCount(String postId) {
        String value = redisTemplate.opsForValue().get(postId + ":likes");
        return StringUtils.isBlank(value) ? 0 : Long.parseLong(value);
    }
}

在使用Redis實現點贊,統計等功能時一定要考慮設定計數值的最大值或最小值限制,以及過期策略。

分散式鎖

分散式鎖

Redis的SETNX(設定並檢查是否存在)和EXPIRE命令組合可以實現分散式鎖,因其操作時原子性的,所以可以確保在分散式環境下同一資源只能被一個客戶端修改。

使用 Redis 實現分散式鎖通常會使用 Redis 的 SETNX 命令。這個命令用於設定一個鍵的值,如果這個鍵不存在的話,它會設定成功並返回 1,如果這個鍵已經存在,則設定失敗並返回 0。結合 Redis 的 EXPIRE 命令,可以為這個鍵設定一個過期時間,確保即使獲取鎖的客戶端異常退出,鎖也會在一段時間後自動釋放。

@Component
public class DistributedLock {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    public boolean acquireLock(String lockKey, String requestId, long expireTime) {
        Boolean result = redisTemplate.opsForValue().setIfAbsent(lockKey, requestId, expireTime);
        return result != null && result;
    }

    public void releaseLock(String lockKey, String requestId) {
        String value = redisTemplate.opsForValue().get(lockKey);
        if (value != null && value.equals(requestId)) {
            redisTemplate.delete(lockKey);
        }
    }
}

使用分散式鎖時,務必確保在加鎖和解鎖操作之間處理完臨界區程式碼,否則可能出現死鎖。並且要注意鎖定超時時間應當合理設定,以避免鎖定資源長時間無法釋放。

關於分散式鎖,推薦使用一些第三方的分散式鎖框架,例如Redisson

全域性ID

在全域性ID生成的場景中,我們可以使用 Redis 的原子遞增操作來實現。透過對 Redis 中的一個特定的 key 進行原子遞增操作,可以確保生成的ID是唯一的。

@Component
public class UniqueIdGenerator {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    public long generateUniqueId(String key) {
        return redisTemplate.opsForValue().increment(key, 1);
    }
}

庫存扣減

在扣減庫存的場景中,我們可以使用 Redis 的原子遞減操作來實現。將庫存數量儲存在 Redis 的一個特定key中(例如倉庫編碼:SKU),然後透過遞減操作來實現庫存的扣減。這樣可以保證在高併發情況下,庫存扣減的原子性。

@Component
public class StockService {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    /**商品庫存的key*/
    private static final String STOCK_PREFIX = "stock:%s:%s";

    /**
     * 扣減庫存
     * @param warehouseCode
     * @param productId
     * @param quantity
     * @return
     */
    public boolean decreaseStock(String warehouseCode, String productId, long quantity) {
        String key = String.format(STOCK_PREFIX, warehouseCode, productId);
        Long stock = redisTemplate.opsForValue().decrement(key, quantity);
        return stock >= 0;
    }
}

秒殺

在秒殺場景中,使用Lua指令碼。Lua 指令碼可以在 Redis 伺服器端原子性地執行多個命令,這樣可以避免在多個命令之間出現競態條件。

我們使用Lua指令碼來檢查庫存是否足夠並進行扣減操作。如果庫存足夠,則減少庫存並返回 true;如果庫存不足,則直接返回 false。透過 Lua 指令碼的原子性執行,可以確保在高併發情況下,庫存扣減操作的正確性和一致性。

我們先定義一個扣減庫存的lua指令碼,使用Lua指令碼一次性執行獲取庫存、判斷庫存是否充足以及扣減庫存這三個操作,確保了操作的原子性

-- 獲取Lua指令碼引數:商品ID和要購買的數量
local productId = KEYS[1]
local amount = tonumber(ARGV[1])

-- 獲取當前庫存
local currentStock = tonumber(redis.call('GET', 'seckill:product:'..productId))

-- 判斷庫存是否充足
if currentStock <= 0 or currentStock < amount then
    return 0
end

-- 扣減庫存
redis.call('DECRBY', 'seckill:product:'..productId, amount)

-- 返回成功標誌
return 1

然後在秒殺服務中使用Redis的DefaultRedisScript執行lua指令碼,完成秒殺

@Component
public class SeckillService {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    /**
     * 初始化RedisScript物件
     */
    private final DefaultRedisScript<Long> seckillScript = new DefaultRedisScript<>();
    {
        seckillScript.setLocation(new ClassPathResource("rate_limiter.lua"));
        seckillScript.setResultType(Long.class);
    }

    public boolean seckillyLua(String productId, int amount){
        // 設定Lua指令碼引數
        List<String> keys = Collections.singletonList(productId);
        List<String> args = Collections.singletonList(Integer.toString(amount));

        // 執行Lua指令碼
        Long result = redisTemplate.execute(seckillScript, keys, args);

        // 如果執行結果為1,表示秒殺成功
        return Objects.equals(result, 1L);
    }
}

關於秒殺場景,我們也可以使用WATCH命令監視庫存鍵,然後嘗試獲取並扣減庫存。如果在WATCH之後、EXEC之前庫存發生了變化,exec方法會返回null,此時我們取消WATCH並重新嘗試整個流程,直到成功扣減庫存為止。這樣就實現了基於Redis樂觀鎖的秒殺場景,有效防止了超賣現象。

/**
     * 秒殺方法
     * @param productId 商品ID
     * @param amount 要購買的數量
     * @return 秒殺成功與否
     */
    @Transactional(rollbackFor = Exception.class)
    public boolean seckilByWatch(String productId, int amount) {
        // 樂觀鎖事務操作
        while (true) {
            // WATCH指令監控庫存鍵
            redisTemplate.watch("stock:" + productId);

            // 獲取當前庫存
            String currentStockStr = redisTemplate.opsForValue().get("stock:" + productId);
            if (currentStockStr == null) {
                // 庫存不存在,可能是商品已售罄或異常情況
                return false;
            }
            int currentStock = Integer.parseInt(currentStockStr);

            // 判斷庫存是否充足
            if (currentStock < amount) {
                // 庫存不足,取消WATCH並退出迴圈
                redisTemplate.unwatch();
                return false;
            }

            // 開啟Redis事務
            redisTemplate.multi();

            // 執行扣減庫存操作
            redisTemplate.opsForValue().decrement("stock:" + productId, amount);

            // 執行其他與秒殺相關的操作,如增加訂單、更新使用者餘額等...

            // 提交事務,如果在此期間庫存被其他客戶端修改,則exec返回null
            List<Object> results = redisTemplate.exec();

            // 如果事務執行成功,跳出迴圈
            if (!results.isEmpty()) {
                return true;
            }
        }
    }

訊息佇列與釋出/訂閱

Redis的釋出/訂閱(Pub/Sub)模式,可以實現一個簡單的訊息佇列。釋出/訂閱模式允許訊息的釋出者(釋出訊息)和訂閱者(接收訊息)之間解耦,訊息的釋出者不需要知道訊息的接收者是誰,從而實現了一對多的訊息傳遞。

首先我們需要定義一個訊息監聽器,我們可以實現這個藉口並實現其中的方法來處理接收到的訊息。這樣可以根據具體的業務需求來定義訊息的處理邏輯。

public interface MessageListener {
    void onMessage(String channel, String message);
}

然後我們就可以定義訊息的生產者以及消費者。publish 方法用於向指定頻道釋出訊息,我們使用 RedisTemplate 的 convertAndSend 方法來傳送訊息到指定的頻道。
subscribe方法用於訂閱指定的頻道,並設定訊息監聽器。當有訊息釋出到指定的頻道時,訊息監聽器會收到訊息並進行處理。

@Component
public class MessageQueue {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    public void publish(String channel, String message) {
        redisTemplate.convertAndSend(channel, message);
    }

    public void subscribe(String channel, MessageListener listener) {
        redisTemplate.getConnectionFactory().getConnection().subscribe((message, pattern) -> {
            listener.onMessage(channel, message);
        }, channel.getBytes());
    }
}

使用Redis的釋出訂閱模式實現一個輕量級的佇列時要注意:Pub/Sub是非持久化的,一旦訊息釋出,沒有訂閱者接收的話,訊息就會丟失。還有就是Pub/Sub不適合大規模的訊息堆積場景,因為它不保證訊息順序和重複消費,更適合實時廣播型訊息推送。

社交網路

在社交網路中,Redis可以利用集合(Set)、雜湊(Hash)和有序集合(Sorted Set)等資料結構構建使用者關係圖譜。

使用雜湊(Hash)資料結構儲存使用者的個人資料資訊,每個使用者對應一個雜湊表,其中包含使用者的各種屬性,比如使用者名稱、年齡、性別等。

@Component
public class RelationshipGraphService {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    /**使用者資料*/
    private static final String USER_PROFILE_PREFIX = "user_profile:";

	/**
     * 儲存使用者個人資料
     * @param userId
     * @param profile
     */
    public void setUserProfile(String userId, Map<String, String> profile) {
        String key = USER_PROFILE_PREFIX + userId;
        redisTemplate.opsForHash().putAll(key, profile);
    }

    /**
     * 獲取使用者個人資料
     * @param userId
     * @return
     */
    public Map<Object, Object> getUserProfile(String userId) {
        String key = USER_PROFILE_PREFIX + userId;
        return redisTemplate.opsForHash().entries(key);
    }
}

使用集合(Set)資料結構來儲存使用者的好友關係。每個使用者都有一個集合,其中包含了他的所有好友的使用者ID。

@Component
public class RelationshipGraphService {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

	 /**使用者好友*/
    private static final String FRIENDS_PREFIX = "friends:";

	/**
     * 新增好友關係
     * @param userId
     * @param friendId
     */
    public void addFriend(String userId, String friendId) {
        String key = FRIENDS_PREFIX + userId;
        redisTemplate.opsForSet().add(key, friendId);
    }

    /**
     * 獲取使用者的所有好友
     * @param userId
     * @return
     */
    public Set<String> getFriends(String userId) {
        String key = FRIENDS_PREFIX + userId;
        return redisTemplate.opsForSet().members(key);
    }
}

同理,我們還可以實現點讚的業務場景

@Service
public class LikeService {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    /**
     * 點贊
     * @param objectId
     * @param userId
     */
    public void like(String objectId, String userId) {
        // 將點贊人放入zset中
        redisTemplate.opsForSet().add(getLikeKey(objectId), userId);
    }

    /**
     * 取消點贊
     * @param objectId
     * @param userId
     */
    public void unlike(String objectId, String userId) {
        // 減少點贊人數
        redisTemplate.opsForSet().remove(getLikeKey(objectId), userId);
    }

    /**
     * 是否點贊
     * @param objectId
     * @param userId
     * @return
     */
    public Boolean isLiked(String objectId, String userId) {
        return redisTemplate.opsForSet().isMember(getLikeKey(objectId), userId);
    }

    /**
     * 獲取點贊數
     * @param objectId
     * @return
     */
    public Long getLikeCount(String objectId) {
       return redisTemplate.opsForSet().size(getLikeKey(objectId));
    }

    /**
     * 獲取所有點讚的使用者
     * @param objectId
     * @return
     */
    public Set<String> getLikedUsers(String objectId) {
        return redisTemplate.opsForSet().members(getLikeKey(objectId));
    }

    private String getLikeKey(String objectId) {
        return "likes:" + objectId;
    }

}

使用有序集合(Sorted Set)資料結構來儲存使用者的關注者列表。有序集合中的成員是關注者的使用者ID,而分數可以是關注時間或者其他指標,比如活躍度。

@Component
public class RelationshipGraphService {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

	/**使用者關注者*/
    private static final String FOLLOWERS_PREFIX = "followers:";

	/**
     * 新增關注者
     * @param userId
     * @param followerId
     * @param score
     */
    public void addFollower(String userId, String followerId, double score) {
        String key = FOLLOWERS_PREFIX + userId;
        redisTemplate.opsForZSet().add(key, followerId, score);
    }

    /**
     * 獲取使用者的關注者列表(按照關注時間排序)
     * @param userId
     * @return
     */
    public Set<String> getFollowers(String userId) {
        String key = FOLLOWERS_PREFIX + userId;
        return redisTemplate.opsForZSet().range(key, 0, -1);
    }

}

除此之外,我們還可以實現可能認識的人,共同好友等業務場景。

限流與速率控制

Redis可以精確地實施限流策略,如使用INCR命令結合Lua指令碼實現滑動視窗限流。

建立一個Lua指令碼,該指令碼負責檢查在一定時間段內請求次數是否超過限制。

-- rate_limiter.lua
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local timeWindow = tonumber(ARGV[2]) -- 時間視窗,例如單位為秒

-- 獲取當前時間戳
local currentTime = redis.call('TIME')[1]

-- 獲取最近timeWindow秒內的請求次數
local count = redis.call('ZCOUNT', key .. ':requests', currentTime - timeWindow, currentTime)

-- 如果未超過限制,則累加請求次數,並返回true
if count < limit then
  redis.call('ZADD', key .. ':requests', currentTime, currentTime)
  return 1
else
  return 0
end

限流服務中Redis使用DefaultRedisScript執行Lua指令碼

@Component
public class RateLimiter {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    /**限流Key*/
    private static final String TATE_LIMITER_KEY = "rate-limit:%s";

    /**規定的時間視窗內允許的最大請求數量*/
    private static final Integer LIMIT = 100;

    /**限流策略的時間視窗長度,單位是秒*/
    private static final Integer TIME_WINDOW = 60;

    /**
     * 初始化RedisScript物件
     */
    private final DefaultRedisScript<Long> rateLimiterScript = new DefaultRedisScript<>();
    {
        rateLimiterScript.setLocation(new ClassPathResource("rate_limiter.lua"));
        rateLimiterScript.setResultType(Long.class);
    }


    /**
     * 限流方法 1分鐘內最多100次請求
     * @param userId
     * @return
     */
    public boolean allowRequest(String userId) {
        String key = String.format(TATE_LIMITER_KEY, userId);
        List<String> keys = Collections.singletonList(key);
        List<String> args = Arrays.asList(String.valueOf(LIMIT), String.valueOf(TIME_WINDOW));

        // 執行Lua指令碼
        Long result = redisTemplate.execute(rateLimiterScript, keys, args);

        // 結果為1表示允許請求,0表示請求被限流
        return Objects.equals(result, 1L);
    }
}

位運算與點陣圖應用

Redis的點陣圖(BitMap)是一種特殊的資料結構,它允許我們在單一的字串鍵(String Key)中儲存一系列二進位制位(bits),每個位對應一個布林值(0或1),並透過偏移量(offset)來定位和操作這些位。點陣圖極大地節省了儲存空間,尤其適合於大規模資料的標記、統計和篩選場景。

在點陣圖中,每一位相當於一個識別符號,例如可以用來表示使用者是否線上、商品是否有庫存、使用者是否已讀郵件等。相對於傳統的鍵值對儲存。點陣圖可以非常快速地統計滿足特定條件的元素個數,如統計線上使用者數、啟用使用者數等。

@Service
public class UserOnlineStatusService {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    private static final String ONLINE_STATUS_KEY = "online_status";
    private static final String RETENTION_RATE_KEY_PREFIX = "retention_rate:";
    private static final String DAILY_ACTIVITY_KEY_PREFIX = "daily_activity:";

    /**
     * 設定使用者線上狀態為線上
     * @param userId
     */
    public void setUserOnline(long userId) {
        redisTemplate.opsForValue().setBit(ONLINE_STATUS_KEY, userId, true);
    }

    /**
     * 設定使用者線上狀態為離線
     * @param userId
     */
    public void setUserOffline(long userId) {
        redisTemplate.opsForValue().setBit(ONLINE_STATUS_KEY, userId, false);
    }

    /**
     * 獲取使用者線上狀態
     * @param userId
     * @return
     */
    public boolean isUserOnline(long userId) {
        return redisTemplate.opsForValue().getBit(ONLINE_STATUS_KEY, userId);
    }

    /**
     * 統計線上使用者數量
     * @return
     */
    public long countOnlineUsers() {
        return  getCount(ONLINE_STATUS_KEY);
    }

    /**
     * 記錄使用者的留存情況
     * @param userId
     * @param daysAgo
     */
    public void recordUserRetention(long userId, int daysAgo) {
        String key = RETENTION_RATE_KEY_PREFIX + LocalDate.now().minusDays(daysAgo).toString();
        redisTemplate.opsForValue().setBit(key, userId, true);
    }

    /**
     * 獲取指定日期的留存率
     * @param daysAgo
     * @return
     */
    public double getRetentionRate(int daysAgo) {
        String key = RETENTION_RATE_KEY_PREFIX + LocalDate.now().minusDays(daysAgo).toString();
        long totalUsers = countOnlineUsers();
        long retainedUsers = getCount(key);
        return (double) retainedUsers / totalUsers * 100;
    }

    /**
     * 記錄使用者的每日活躍情況
     * @param userId
     */
    public void recordUserDailyActivity(long userId) {
        String key = DAILY_ACTIVITY_KEY_PREFIX + LocalDate.now().toString();
        redisTemplate.opsForValue().setBit(key, userId, true);
    }

    /**
     * 獲取指定日期的活躍使用者數量
     * @param date
     * @return
     */
    public long countDailyActiveUsers(LocalDate date) {
        String key = DAILY_ACTIVITY_KEY_PREFIX + date.toString();
        return getCount(key);
    }

    /**
     * 獲取最近幾天每天的活躍使用者數量列表
     * @param days
     * @return
     */
    public List<Long> getDailyActiveUsers(int days) {
        LocalDate currentDate = LocalDate.now();
        List<Long> results = Lists.newArrayList();
        for (int i = 0; i < days; i++) {
            LocalDate date = currentDate.minusDays(i);
            String key = DAILY_ACTIVITY_KEY_PREFIX + date.toString();
            results.add(getCount(key));
        }
        return results;
    }

    /**
     * 獲取key下的數量
     * @param key
     * @return
     */
    private long getCount(String key) {
        return (long) redisTemplate.execute((RedisCallback<Long>) connection -> connection.bitCount(key.getBytes()));
    }
}

最新列表

Redis的List(列表)是一個基於雙向連結串列實現的資料結構,允許我們在列表頭部(左端)和尾部(右端)進行高效的插入和刪除操作。
LPUSH命令:全稱是LIST PUSH LEFT,用於將一個或多個值插入到列表的最左邊(頭部),在這裡用於將最新生成的內容ID推送到列表頂部,保證列表中始終是最新的內容排在前面。

LTRIM命令用於修剪列表,保留指定範圍內的元素,從而限制列表的長度。在這個場景中,每次新增新ID後都會執行LTRIM操作,只保留最近的N個ID,確保列表始終保持固定長度,即只包含最新的內容ID。

@Service
public class LatestListService {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    private static final String LATEST_LIST_KEY = "latest_list";

    /**
     * 新增最新內容ID到列表頭部
     * @param contentId 內容ID
     */
    public void addLatestContent(String contentId) {
        ListOperations<String, String> listOps = redisTemplate.opsForList();
        listOps.leftPush(LATEST_LIST_KEY, contentId);
        // 限制列表最多儲存N個ID,假設N為100
        listOps.trim(LATEST_LIST_KEY, 0, 99);
    }

    /**
     * 獲取最新的N個內容ID
     * @param count 要獲取的數量,預設為10
     * @return 最新的內容ID列表
     */
    public List<String> getLatestContentIds(int count) {
        ListOperations<String, String> listOps = redisTemplate.opsForList();
        return listOps.range(LATEST_LIST_KEY, 0, count - 1);
    }
}

抽獎

藉助Redis的Set資料結構以及其內建的Spop命令,我們能夠高效且隨機地選定抽獎獲勝者。Set作為一種不允許包含重複成員的資料集合,其特性天然適用於防止抽獎過程中出現重複參與的情況,確保每位參與者僅擁有一個有效的抽獎資格。

由於Set內部元素的排列不具備確定性,這意味著在對集合執行隨機獲取操作時,每一次選取都將獨立且不可預測,這與抽獎活動中所要求的隨機公平原則高度契合。

Redis的Spop命令允許我們在單個原子操作下,不僅隨機選取,還會從Set中移除指定數量(預設為1)的元素。這一原子操作機制尤為關鍵,在高併發環境下,即便有多個請求同時進行抽獎,Spop也能夠確保同一時刻只有一個請求能成功獲取並移除一個元素,有效避免了重複選擇同一位參與者作為獲獎者的可能性。

@Service
public class LotteryService {

    @Autowired
    private RedisTemplate<String, String> redisTemplate;

    private static final String PARTICIPANTS_SET_KEY = "lottery:participants";

    /**
     * 新增參與者到抽獎名單
     * @param participant 參與者ID
     */
    public void joinLottery(String participant) {
        redisTemplate.opsForSet().add(PARTICIPANTS_SET_KEY, participant);
    }

    /**
     * 抽取一名幸運兒
     * @return 幸運兒ID
     */
    public String drawWinner() {
        // 使用Spop命令隨機抽取一個參與者
        return redisTemplate.opsForSet().pop(PARTICIPANTS_SET_KEY);
    }

    /**
     * 抽取N個幸運兒
     * @param count 抽取數量
     * @return 幸運兒ID列表
     */
    public List<String> drawWinners(int count) {
        return redisTemplate.opsForSet().pop(PARTICIPANTS_SET_KEY, count);
    }
}

Stream型別

Redis Stream作為一種自Redis 5.0起引入的高階資料結構,專為儲存和處理有序且持久的訊息流而設計。可視作一個分散式的、具備持久特性的訊息佇列,透過唯一的鍵名來標識每個Stream,其中容納了多個攜帶時間戳和唯一識別符號的訊息實體。

每條儲存於Stream中的訊息都具有全球唯一的message ID,該ID內嵌時間戳和序列編號,旨在確保即使在複雜的叢集部署中仍能保持訊息的嚴格時序性。這些訊息內容會持久儲存在Redis中,確保即使伺服器重啟也能安全恢復。

生產者利用XADD指令將新訊息新增到Stream中,而消費者則透過XREAD或針對多消費者組場景最佳化的XREADGROUP命令來讀取並處理訊息。XREADGROUP尤其擅長處理多消費者組間的公平分配和持久訂閱,確保訊息的公正、有序送達各個消費者。

Stream核心特性之一是支援消費者組機制,消費者組內的不同消費者可獨立地消費訊息,並透過XACK命令確認已消費的訊息,從而實現了訊息的持久化消費和至少一次(at-least-once)交付保證。當訊息量超出消費者處理能力時,未處理的訊息可在Stream中積壓,直到達到預設的最大容量限制。此外,還能設定訊息的有效期(TTL),逾期未被消費的訊息將自動剔除。即使在網路傳輸過程中訊息遭受損失,亦可透過message ID保障訊息的冪等性重新投遞。儘管網路條件可能導致訊息到達消費者的時間順序與生產者發出的順序有所偏差,但Stream機制確保了每個訊息在其內在的時間上下文中依然保持著嚴格的順序關係。

Redis Stream作為一個集訊息持久化、多消費者公平競爭、訊息追溯和排序等功能於一體的強大訊息佇列工具,已在日誌採集、實時資料分析、活動追蹤等諸多領域展現出卓越的適用性和價值。

@Component
public class LogCollector {

    private static final String LOGS_STREAM_KEY = "logs";
    private static final String GROUP_NAME = "log_consumers";
    private static final String CONSUMER_NAME = "log_consumer";

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    // 傳送日誌事件至 Redis Stream
    public void sendLogEvent(String message, Map<String, String> attributes) {
        StreamOperations<String, Object, Object> streamOperations = redisTemplate.opsForStream();
        RecordId messageId = streamOperations.add(StreamRecords.newRecord()
                .ofStrings(attributes)
                .withStreamKey(LOGS_STREAM_KEY));
    }

    // 實時消費日誌事件
    public StreamRecords<String, String> consumeLogs(int batchSize) {
        Consumer consumer = Consumer.from(CONSUMER_NAME, GROUP_NAME);
        StreamOffset<String> offset = StreamOffset.create(LOGS_STREAM_KEY, ReadOffset.lastConsumed());
        StreamReadOptions<String, String> readOptions = StreamReadOptions.empty().count(batchSize);
        return redisTemplate.opsForStream().read(readOptions, StreamOffset.create(LOGS_STREAM_KEY, ReadOffset.lastConsumed()), consumer);
    }
}

GEO型別

Redis的GEO資料型別自3.2版本起引入,專為儲存和高效操作含有經緯度座標的地理位置資訊而設計。開發人員利用這一型別可以輕鬆管理地理位置資料,同時兼顧記憶體效率和響應速度。

利用GEOADD命令,可以將帶有精確經緯度座標的資料點歸檔至指定鍵名下的集合中。

可藉助GEOPOS命令獲取某一成員的具體經緯度座標。

透過GEODIST命令,可以準確計算任意兩個地理位置成員之間的地球表面距離,支援多種計量單位,包括米、千米、英里和英尺。

使用GEORADIUS命令,系統可以根據指定的經緯度中心點及半徑範圍檢索出處於該區域內的所有成員地理位置。

GEORADIUSBYMEMBER命令也用於範圍查詢,但其查詢依據是選定成員自身的位置,以此為圓心劃定搜尋範圍。

GEO型別在許多場景下都非常有用,例如移動應用中的附近好友查詢、商店位置搜尋、物流配送中的最近司機排程等。

@Service
public class FriendService {

    private static final String FRIEND_LOCATIONS_KEY = "friend_locations";

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    @Autowired
    private GeoOperations<String, FriendLocation> geoOperations; // 自動裝配GeoOperations

    public void saveFriendLocation(FriendLocation location) {
        geoOperations.add(FRIEND_LOCATIONS_KEY, location.getLongitude(), location.getLatitude(), location);
    }

    public List<FriendLocation> findFriendsNearby(double myLongitude, double myLatitude, Distance radius) {
        Circle circle = new Circle(new Point(myLongitude, myLatitude), radius);
        return geoOperations.radius(FRIEND_LOCATIONS_KEY, circle, Metric.KILOMETERS).getContent();
    }
}

總結

Redis作為一款高效能、記憶體型的NoSQL資料庫,憑藉其豐富的資料結構、極高的讀寫速度以及靈活的資料持久化策略,在現代分散式系統中扮演著至關重要的角色。它的關鍵價值體現在以下幾個方面:

  1. 快取最佳化:Redis將頻繁訪問的資料儲存在記憶體中,顯著減少了資料庫的讀取壓力,提升了系統的整體效能和響應速度。

  2. 分散式支援:透過主從複製、哨兵和叢集模式,Redis實現了高度可擴充套件性和高可用性,滿足大規模分散式系統的需求。

  3. 資料結構多樣性:Redis支援字串、雜湊、列表、集合、有序集合、Bitmaps、HyperLogLog、Geo等多樣化的資料結構,為多種應用場景提供了便利,如排行榜、社交關係、訊息佇列、計數器、限速器等。

  4. 實時處理與分析:隨著Redis 5.0引入Stream資料結構,使得Redis在日誌收集、實時分析、物聯網資料流處理等方面有了更多的可能性。

  5. 地理位置服務:GEO型別提供了便捷的空間索引和距離計算功能,使得Redis能夠在電商、出行、社交等領域提供附近地點搜尋、路線規劃等服務。

本文已收錄於我的個人部落格:碼農Academy的部落格,專注分享Java技術乾貨,包括Java基礎、Spring Boot、Spring Cloud、Mysql、Redis、Elasticsearch、中介軟體、架構設計、面試題、程式設計師攻略等

相關文章