Redis與Java - 實踐

Redis與Java - 實踐

標籤 : Java與NoSQL


Transaction

Redis事務(transaction)是一組命令的集合,同命令一樣也是Redis的最小執行單位, Redis保證一個事務內的命令執行不被其他命令影響.

`MULTI`
    SADD user:1:following 2
    SADD user:2:follower 1
`EXEC`

vs. RDBMS

事務操作 MySQL Redis
開啓 start transaction MULTI
語句 DML 普通命令
取消 rollback DISCARD
執行 commit EXEC
  • MySQL的rollback與Redis的DISCARD有一定的區別.
    假設現在已經成功執行了事務內的前2條語句, 第3條語句出錯:
    1. MySQLrollback後,前2條的語句影響消失.
    2. Redis可以分爲兩種情況:
      • 語法錯誤: 事務中斷, 所有語句均得不到執行;
      • 運行錯誤: (如語法正確,但適用數據類型不對: 像ZADD操作List), EXEC會執行前2條語句, 並跳過第3條語句.
        這樣的部分成功會導致數據不一致, 而這一點需要由開發人員負責, 比如提前規劃好緩存key的設計.

樂觀鎖與WATCH

悲觀鎖(Pessimistic Lock): 很悲觀,每次讀寫數據都認爲別人會修改,所以每次讀數據都會上鎖,這樣如果別人也想讀寫這條數據就會阻塞, 直到加鎖的人把鎖釋放. 傳統的RDBMS中用到了很多這種鎖機制, 如行鎖表鎖讀鎖寫鎖等.
樂觀鎖(Optimistic Lock): 顧名思義非常樂觀, 每次讀寫數據時候都認爲別人不會修改,所以不再上鎖,但在更新數據時會判斷一下在此期間有沒有人更新了這條數據, 這個判斷過程可以使用版本號等機制實現, 而Redis默認就對樂觀鎖提供了支持 –WATCH命令.

WATCH命令可以監控一個/多個key, 一旦其中有一個被修改/刪除, 則之後的事務就不會執行,如用WATCH命令來模擬搶票場景:

SET ticket 1        # 現在假設只有一張票了
`WATCH` ticket      # 監控票數變化
`MULTI`
    DECRBY username 400
    DECR ticket
        [DECR ticket]   # 現在假設有另外一個用戶直接把這張票買走了
`EXEC`
    -> `(nil)`  # 則這條事務執行就不會成功

小結

  • WATCH命令的作用只是當被監控的key值修改後阻止事務執行,並不能阻止其他Client修改. 所以一旦EXEC執行失敗, 可以重新執行整個方法或使用UNWATCH命令取消監控.

  • 樂觀鎖適用於讀多寫少情景,即衝突真的很少發生,這樣可以省去大量鎖的開銷. 但如果經常產生衝突,上層應用需要不斷的retry,反倒是降低了性能,所以這種情況悲觀鎖比較適用.


Expire & Cache

Redis可以使用EXPIRE命令設置key的過期時間, 到期後Redis會自動刪除它.

命令 作用
EXPIRE key seconds Set a timeout on key.
TTL key Get the time to live for a key
PERSIST key Remove the expiration for a key

除了PERSIST命令之外,SET/GETSETkey賦值的同時也會清除key的過期時間.另外如果WATCH監控了一個擁有過期時間的key,key到期自動刪除並不會被WATCH認爲該key被修改.

  • 緩存DB數據
    爲了提高網站負載能力,常需要將一些訪問頻率較高但對CPU/IO消耗較大的操作結果緩存起來,並希望讓這些緩存過期自動刪除, 下面我們就使用Redis緩存DB數據, 場景介紹可以參考:Memcached - In Action:緩存DB查詢數據.
/**
 * @author jifang.
 * @since 2016/6/13 20:08.
 */
public class RedisDAO {

    private static final int _1M = 60 * 1000;

    private static final DataSource dataSource;

    private static final Jedis redis;

    static {
        Properties properties = new Properties();
        try {
            properties.load(ClassLoader.getSystemResourceAsStream("db.properties"));
        } catch (IOException ignored) {
        }

        /** 初始化連接池 **/
        HikariConfig config = new HikariConfig();
        config.setDriverClassName(properties.getProperty("mysql.driver.class"));
        config.setJdbcUrl(properties.getProperty("mysql.url"));
        config.setUsername(properties.getProperty("mysql.user"));
        config.setPassword(properties.getProperty("mysql.password"));
        dataSource = new HikariDataSource(config);

        /** 初始化Redis **/
        redis = new Jedis(properties.getProperty("redis.host"), Integer.valueOf(properties.getProperty("redis.port")));
    }

    public List<Map<String, Object>> executeQuery(String sql) {
        List<Map<String, Object>> result;
        try {
            /** 首先請求Redis **/
            String key = sql.replace(' ', '-');
            String string = redis.get(key);

            // 如果key未命中, 再請求DB
            if (string == null || string.trim().isEmpty()) {
                ResultSet resultSet = dataSource.getConnection().createStatement().executeQuery(sql);

                /** 獲得列數/列名 **/
                ResultSetMetaData meta = resultSet.getMetaData();
                int columnCount = meta.getColumnCount();
                List<String> columnName = new ArrayList<>();
                for (int i = 1; i <= columnCount; ++i) {
                    columnName.add(meta.getColumnName(i));
                }

                /** 填充實體 **/
                result = new ArrayList<>();
                while (resultSet.next()) {
                    Map<String, Object> entity = new HashMap<>(columnCount);
                    for (String name : columnName) {
                        entity.put(name, resultSet.getObject(name));
                    }
                    result.add(entity);
                }

                /**寫入Redis**/
                String value = JSON.toJSONString(result);
                redis.set(key, value, "NX", "PX", _1M);
            } else {
                result = JSON.parseObject(string, List.class);
            }
        } catch (SQLException e) {
            throw new RuntimeException(e);
        }
        return result;
    }

    public static void main(String[] args) {
        RedisDAO dao = new RedisDAO();
        List<Map<String, Object>> execute = dao.executeQuery("select * from user");
        System.out.println(execute);
    }
}

當服務器內存有限時,如果大量使用緩存而且過期時間較長會導致Redis佔滿內存; 另一方面爲了防止佔用內存過大而設置過期時間過短, 則有可能導致緩存命中率過低而使系統整體性能下降.因此爲緩存設計一個合理的過期時間是很糾結的, 在Redis中可以限制能夠使用的最大內存,並讓Redis按照一定規則的淘汰不再需要的key: 修改maxmemory參數,當超過限制會依據maxmemory-policy參數指定的策略來刪除不需要的key:

maxmemory-policy 規則說明
volatile-lru 只對設置了過期時間的key使用LRU算法刪除
allkey-lru 使用LRU刪除一個key
volatile-random 只對設置了過期時間的key隨機刪除一個key
allkey-random 隨機刪除一個key
volatile-ttl 刪除過期時間最近的一個key
noevication 不刪除key, 只返回錯誤(默認)

Sort

Redis的SORT命令可以對ListSetSorted-Set類型排序, 並且可以完成與RDBMS 連接查詢 類似的任務:

SORT key    [BY pattern] 
            [LIMIT offset count] 
            [GET pattern [GET pattern ...]] 
            [ASC|DESC] 
            [ALPHA] 
            [STORE destination]
參數 描述
ALPHA SORT默認會將所有元素轉換成雙精度浮點數比較,無法轉換則會提示錯誤,而使用ALPHA參數可實現按字典序比較.
DESC 降序排序(SORT默認升序排序).
LIMIT 指定返回結果範圍.
STORE SORT默認直接返回排序結果, STORE可將排序後結果保存爲List.

注: SORT在對Sorted-Set排序時會忽略元素分數,只針對元素自身值排序.


BY

很多情況下key實際存儲的是對象ID, 有時單純對ID自身排序意義不大,這就用到了BY參數, 對ID關聯的對象的某個屬性進行排序:

[BY pattern]

pattern可以是字符串類型keyHash類型key的某個字段(表示爲鍵名 -> 字段名).如果提供了BY參數, SORT將使用ID值替換參考key中的第一個*並獲取其值,然後根據該值對元素排序.

SORT mi.blog:1:my BY mi.blog:*:data->time DESC
  • 注意:
    • pattern不包含*時, SORT將不會執行排序操作;
    • 當ID元素的參考key不存在時,默認設置爲0;
    • 如果幾個ID元素的pattern值相同,則會再比較元素本身值排序.

GET

GET參數不影響排序過程,它的作用是使SORT返回結果不再是元素自身的值,而是GET參數指定的鍵值:

[GET pattern [GET pattern ...]]

BY一樣, GET參數也支持String類型和Hash類型, 並使用*作爲佔位符.

SORT mi.blog:1:my BY mi.blog:*:data->time GET mi.blog:*:data->content GET mi.blog:*:data->time

注: GET參數獲取自身值需要使用#: GET #


性能

SORT的時間複雜度爲O(N+M*log(M)):

where N is the number of elements in the list or set to sort, and M the number of returned elements. When the elements are not sorted, complexity is currently O(N) as there is a copy step that will be avoided in next releases.
  • 所以開發過程中使用SORT需要注意:
    1. 儘可能減小待排序key中元素數量(減小N);
    2. 使用LIMIT參數限制結果集大小(減小M);
    3. 如果待排序數據量較大,儘可能使用STORE將結果緩存.

Message

1. 消息隊列

消息隊列就是”傳遞消息的隊列”,與消息隊列進行交互的實體有兩類, 一是生產者: 將需要處理的消息放入隊列; 一是消費者: 不斷從消息隊列中讀出消息並處理.

使用消息隊列有如下好處:
鬆耦合: 生產者和消費者無需知道彼此的實現細節, 只需按照協商好的消息格式讀/寫, 即可實現不同進程間通信,這就使得生產者和消費者可以由不同的團隊使用不同的開發語言編寫.
易擴展: 消費者可以有多個,且可以分佈在不同的Server中, 降低單臺Server負載, 橫向擴展業務.

Redis提供了BRPOP/BLPOP命令來實現消息隊列:

命令 描述
BRPOP key [key ...] timeout Remove and get the last element in a list, or block until one is available
BLPOP key [key ...] timeout Remove and get the first element in a list, or block until one is available
BRPOPLPUSH source destination timeout Pop a value from a list, push it to another list and return it; or block until one is available

注: 若Redis同時監聽多個key, 且每個key均有元素可取,則Redis按照從左到右的順序去挨個讀取key第一個元素.


2. 消息訂閱

前面的BRPOP/BLPOP實現的消息隊列有一個限制: 如果一個隊列被多個消費者監聽, 生產者發佈一條消息只會被其中一個消費者獲取. 因此Redis還提供了一組命令實現“發佈/訂閱”模式, 同樣可用於進程間通信:

“發佈/訂閱”模式也包含兩種角色: 發佈者與訂閱者. 訂閱者可以訂閱一個/多個頻道, 而發佈者可向指定頻道發送消息, 所有訂閱此頻道的訂閱者都會收到此消息.

命令 描述
PUBLISH channel message Post a message to a channel
SUBSCRIBE channel [channel ...] Listen for messages published to the given channels
UNSUBSCRIBE [channel [channel ...]] Stop listening for messages posted to the given channels
PSUBSCRIBE pattern [pattern ...] Listen for messages published to channels matching the given patterns
PUNSUBSCRIBE [pattern [pattern ...]] Stop listening for messages posted to channels matching the given patterns
  • MessagesQueue
/**
 * @author jifang
 * @since 16/7/11 下午2:36.
 */
public class MessageQueue<T> {

    private Jedis redis;

    private String chanel;

    public MessageQueue(Jedis redis, String chanel) {
        this.redis = redis;
        this.chanel = chanel;
    }

    public Long publish(T message) {
        String json = JSON.toJSONString(message);
        return redis.publish(chanel, json);
    }

    public void subscribe(final MessageHandler<T> handler) {
        redis.subscribe(new JedisPubSub() {
            @Override
            public void onMessage(String channel, String message) {
                for (Type type : handler.getClass().getGenericInterfaces()) {
                    if (type instanceof ParameterizedType) {
                        ParameterizedType pType = (ParameterizedType) type;
                        Type handlerClass = pType.getActualTypeArguments()[0];
                        T result = JSONObject.parseObject(message, handlerClass);
                        handler.handle(result);
                    }
                }
            }
        }, chanel);
    }
}
public interface MessageHandler<T> {
    void handle(T object);
}

注: 發送的消息不會持久化,一個訂閱者只能接收到後續發佈的消息,之前發送的消息就接收不到了.


持久化

Redis支持兩種持久化方式: RDB與AOF. RDB: Redis根據指定的規則“定時”將內存數據快照到硬盤; AOF:Redis在每次執行命令後將命令本身記錄下來存放到硬盤.兩種持久化方式可結合使用.


RDB

  • 快照執行過程:
    • Redis使用fork()函數複製一份當前進程副本;
    • 父進程繼續接收並處理客戶端請求, 而子進程將所有內存數據寫入磁盤臨時文件;
    • 當子進程將所有數據寫完會用該臨時文件替換舊的RDB文件, 至此一次快照完成(可以看到自始至終RDB文件都是完整的).

Redis會在以下幾種情況下對數據進行快照:

  • 根據配置規則
    配置由兩個參數構成: 時間窗口M和改動key個數N; 當時間M內被改動的key的個數大於N時, 即符合自動快照條件:
save 900 1
save 300 10
save 60 10000
  • 用戶執行SAVE/BGSAVE/FLUSHALL命令:
    除了讓Redis自動快照, 當進行服務重啓/手動遷移以及備份時也需要我們手動執行快照.
命令 描述
SAVE SAVE命令會使Redis同步地執行快照操作(過程中會阻塞所有來自客戶端的請求, 因此儘量避免線上使用)
BGSAVE 在後臺異步執行快照操作,Redis還可繼續響應請求
FLUSHALL FLUSHALL會清空所有數據,無論是否觸發了自動快照條件(只要有配置了),Redis都會執行一次快照
LASTSAVE 獲取最近一次成功執行快照時間
  • 執行復制
    當設置了主從模式, Redis會在複製初始化時執行快照,即使沒有配置自動快照條件.

通過RDB方式實現持久化, Redis在啓動後會讀取RDB快照文件, 將數據從硬盤導入內存, 但如果在持久化過程中Redis異常退出, 就會丟失最後一次快照以後更改的所有數據.


RDB其他配置參數

dir ./                  # 設置工作目錄,RDB文件(以及後面的AOF文件)會寫入該目錄
dbfilename dump.rdb     # 設置RDB文件名
rdbcompression yes      # 導出RDB是否壓縮
rdbchecksum yes         # 存儲和加載RDB校驗完整性
stop-writes-on-bgsave-error yes     # 後臺備份進程出錯時,主進程停止寫入.

AOF

AOF將Redis執行的每一條命令追加到硬盤文件中.然後在啓動Redis時逐條執行AOF文件中的命令將數據載入內存.

Redis默認沒有開啓AOF, 需要以如下參數啓用:

appendonly yes
no-appendfsync-on-rewrite  yes: # 正在導出RDB快照的過程中,停止同步AOF.

AOF重寫

開啓AOF後, Redis會將每一條有可能更改數據的命令寫入AOF文件,這樣就導致AOF文件越來越大,即使有可能內存中實際存儲的數據並沒多少. 因此Redis每當達到一定條件就自動重寫AOF文件,這個條件可以在配置文件中設置:

auto-aof-rewrite-percentage 100 # 比起上次重寫時的大小,AOF增長率100%時重寫
auto-aof-rewrite-min-size 64mb  # AOF大小超過64M時重寫

此外, 我們還可以使用BGREWRITEAOF命令手動執行AOF重寫.


硬盤數據同步

執行AOF持久化時, 由於操作系統緩存機制, 數據並沒有真正寫入磁盤,而是進入了磁盤緩存, 默認情況下系統每30S執行一次同步操作, 將緩存內容真正寫入磁盤, 如果在這30S的系統異常退出則會導致磁盤緩存數據丟失, 如果應用無法忍受這樣的損失, 可通過appendfsync參數設置同步機制:

# appendfsync always    # 每次執行寫入都執行同步
appendfsync everyse     # 每秒執行一次同步操作
# appendfsync no        # 不主動進行同步, 而是完全由操作系統執行.

集羣

1. Replication

複製(replication)中,Redis的角色可以分爲兩類, Master:可以執行讀/寫操作,當寫操作導致數據修改時會自動將數據同步給Slave; Slave:一般是隻讀的,並接受Master同步過來的數據(Slave自身也可以作爲Master存在, 如圖):

  • replication複製時序
    • Slave啓動後向Master發送SYNC命令;Master收到後在後臺保存RDB快照, 並將快照期間接收到的所有命令緩存.
    • 快照執行完, Master將快照文件與所有緩存的命令發送給Slave;
    • Slave接收並載入快照, 然後執行所有收到的緩存命令,這一過程稱爲複製初始化.
    • 複製初始化完成後,Master每接收到寫命令就同步給Slave,從而保證主從數據一致.

  • 通過Redis的複製功能可以實現以下應用:
    • 讀寫分離:
      通過複製可實現讀寫分離, 以提高服務器的負載能力, 可以通過複製建立多個Slave節點, Master只進行寫操作, 而由Slave負責讀操作, 這種一主多從的結構很適合讀多寫少的場景.
    • Slave持久化
      持久化是一個相對耗時的操作, 爲了提高性能, 可以通過複製功能建立一個/多個Slave, 並在Salve中啓用持久化, Master禁用持久化. 當Master崩潰後:
      1. 在Slave使用SLAVEOF NO ONE命令將Slave提升成Master繼續服務;
      2. 啓用之前崩潰的Master, 然後使用SLAVEOF將其設置爲新Master的Slave, 即可將數據同步回來.

注意: 當開啓複製且Master關閉持久化時, Master崩潰後一定不能直接重啓Master, 這是因爲當Master重啓後, 因爲沒有開啓持久化, 所以Redis內的所有數據都會被清空, 這時Salve從Master接受數據, 所有的Slave也會被清空, 導致Slave持久化失去意義.

關於Redis複製的詳細介紹以及配置方式可參考博客:Redis研究 -主從複製.


2. Sentinel

當Master遭遇異常中斷服務後, 需要手動選擇一個Slave升級爲Master, 以使系統能夠繼續提供服務. 然而整個過程相對麻煩且需要人工介入, 難以實現自動化. 爲此Redis提供了哨兵Sentinel.

Sentinel哨兵是Redis高可用性解決方案之一: 由一個/多個Sentinel實例組成的Sentinel系統可以監視任意多個Master以及下屬Slave, 並在監控到Master進入下線狀態時, 自動將其某個Slave提升爲新的Master, 然後由新的Master代替已下線的Master繼續處理命令請求.

  • 如圖: 若此時Master:server1進入下線狀態, 那麼Slave: server2,server3,server4對Master的複製將被迫中止,並且Sentinel系統也會察覺到server1已下線, 當下線時長超過用戶設定的下線時長時, Sentinel系統就會對server1執行故障轉移操作:
    此處輸入圖片的描述
    • Sentinel會挑選server1下屬的其中一臺Slave, 將其提升爲新Master;
    • 然後Sentinel向server1下屬的所有Slave發送新的複製指令,讓他們成爲新Master的Salve, 當所有Salve都開始複製新Master時, 故障轉移操作完成.
    • 另外, Sentinel還會繼續監視已下線的server1, 並在他重新上線時, 將其設置爲新Master的Slave.

關於Redis哨兵的詳細介紹以及配置方式可參考博客:Redis Sentinel(哨兵):集羣解決方案.


3. Cluster

Cluster是Redis提供的另一高可用性解決方案:Redis集羣通過分片(sharding)來進行數據共享, 並提供複製故障轉移功能.

一個 Redis 集羣通常由多個節點組成, 最初每個節點都是相互獨立的,要組建一個真正可工作的集羣, 必須將各個獨立的節點連接起來.連接各個節點的工作可以使用CLUSTER MEET命令完成:

CLUSTER MEET <ip> <port>

向一個節點發送CLUSTER MEET命令,可以使其與ip+port所指定的節點進行握手,當握手成功時, 就會將目標節點添加到當前節點所在的集羣中.

  • 案例
    假設現在有三個獨立的節點 127.0.0.1:7000 、 127.0.0.1:7001 、 127.0.0.1:7002:
    此處輸入圖片的描述
    • 通過向節點 7000 發送CLUSTER MEET 127.0.0.1 7001命令,可將節點7001添加到節點7000所在的集羣中:
      此處輸入圖片的描述
    • 繼續向節點7000發送CLUSTER MEET 127.0.0.1 7002命令,同樣也可將節點7002也拉進來:
      此處輸入圖片的描述
    • 至此, 握手成功的三個節點處於同一個集羣:
      此處輸入圖片的描述

關於Redis-Cluster的詳細介紹以及更多配置方式可參考博客:redis-cluster研究和使用.


管理

1. 數據庫密碼

通過在配置文件中使用requirepass參數可爲Redis設置密碼:

requirepass œ∑´®†¥¨ˆøπ

這樣客戶端每次連接都需要發送密碼,否則Redis拒絕執行客戶端命令:

AUTH œ∑´®†¥¨ˆøπ

2. 重命名

Redis支持在配置文件中將命令重命名, 以保證只有自己的應用可以使用該命令:

rename-command FLUSHALL qwertyuiop

如果希望禁用某個命令,可將命令重命名爲空字符串.


3. 工具

  • SLOWLOG
    當一條命令執行超過時間限制時,Redis會將其執行時間等信息加入耗時統計日誌, 超時時間等可通過以下配置實現:
slowlog-log-slower-than 10000   # 超時限制(單位微秒)
slowlog-max-len 128             # 記錄條數限制
  • MONITOR : 監控Redis執行的所有命令

    注意: MONITOR命令非常影響Redis性能, 一個客戶端使用MONITOR會降低Redis將近一半的負載能力. Instagram團隊開發了一個基於MONITOR命令的Redis查詢分析工具redis-faina, 可根據MONITOR的監控結果分析出最常用的命令/訪問最頻繁的key等信息, 詳細可參考博客:關於 Redis 的性能分析工具 Redis Faina.

  • 其他常用管理工具

TIME        # 系統時間戳與微秒數
DBSIZE      # 當前數據庫的key數量
INFO        # Redis服務器信息
CONFIG GET  # 獲取配置信息
CONFIG SET  # 設置配置信息
CONFIG REWRITE  # 把值寫到配置文件
CONFIG RESTART  # 更新INFO命令信息
CLIENT LIST # 客戶端列表
CLIENT KILL # 關閉某個客戶端
CLIENT SETNAME  # 爲客戶端設置名字
CLIENT GETNAME  # 獲取客戶端名字
DEBUG OBJECT key    # 調試選項,查看一個key的信息
DEBUG SEGFAULT      # 模擬段錯誤,使服務器崩潰
OBJECT (refcount|encoding|idletime) key 

參考&拓展
高可用、開源的Redis緩存集羣方案
Twemproxy——針對MemCached與Redis的代理
Redis 3.0正式版發佈,正式支持Redis集羣
Redis應用實踐:小紅書海量Redis存儲之道
Redis內存優化實踐
視頻: Raft 教程
使用Redis作爲時間序列數據庫:原因及方法
Redis複製與可擴展集羣搭建

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章