Redis 快速提高系統效能的銀彈

GitChat發表於2017-09-18

GitChat 作者:拿客_三產
原文:Redis 快速提高系統效能的銀彈
GitChat 技術雜談 ,一本正經的講技術

【不要錯過文末彩蛋】

前言

說明:閱讀該文章需要一定 Web 開發經驗,最好對 Redis 有一個基本的認知,文章最後的附錄也會為大家提供一些相關的文章,本文章只是為了讓那些對 Redis 的應用僅僅侷限於 快取 的開發人員瞭解到 Redis 更多可能的應用場景,由於篇幅限制,文中很多場景只是闡述了實現的思想及部分原理,僅僅提供了部分功能的具體實現。

###現代高併發複雜系統面臨的挑戰

現代系統隨著功能的複雜化,各種各樣需求層出不窮,面對愈加複雜話的業務系統、越來越龐大的使用者群體,以及使用者對體驗的要求越來越高,效能就變得更加重要。

拋開程式碼邏輯、伺服器效能的相關問題外,提高效能的方式有以下幾種:

  1. 動靜分離

  2. 負載均衡

  3. 分散式

  4. 叢集化

  5. 快取

  6. 限流處理

  7. 資料壓縮

  8. 其他

我們來分析一下負載均衡、分散式、叢集化涉及的問題:

  1. 配置管理變得複雜,因此需要設定配置中心來解決該問題。

  2. 同一個使用者的請求會轉發至不同的 Web 伺服器,從而導致 Session 丟失等問題。

  3. 同一個請求在分散式環境中需要不同服務來提供不同處理,從而需要分散式事務來確保資料的一致性。

  4. 分散式唯一 ID 問題。

另外針對不同部分系統中的一些特定問題又有其他的一些特殊業務需求:

  1. IP統計

  2. 使用者登入記錄統計

  3. 實時的排行榜

  4. 原子計數

  5. 最新評論

誠然,以上各種問題都有花樣繁多的解決方法,例如:

配置中心可以使用 Zookpeer、Redis 等實現。

Session 丟失可以使用 Session 同步、客戶端 token、Session 共享等解決,其中 Session 共享又可以細分不同實現方式。

面對層出不窮的概念,以及各種新興的技術,我們往往會顯得力不從心,那麼有沒有一個銀彈可以解決這些問題呢?

###Redis 非銀彈卻無比接近

我這裡為大家推薦的就是 Redis ,雖然它離真正意義的銀彈還是有些距離,但是他是為數不多的接近銀彈的解決方案:

  1. Redis 使用 C 開發,是一款記憶體 K/V 資料庫,架構設計極簡,效能卓著。

  2. Redis 採用 單執行緒 多路複用的設計,避免了併發帶來的鎖效能損耗等問題。

  3. Redis 安裝、測試、配置、運維較其他產品更為容易。

  4. Redis 是目前為止最受歡迎的 K/V 資料庫,支援持久化,value 支援多種資料結構。

  5. Redis 命令語法簡單,極易掌握。

  6. Redis 提供了一種通用的協議,使得各種程式語言都能很方便的開發出與其互動的客戶端。

  7. Redis 開放原始碼,我們可以對其進行二次開發來定製優化。

  8. Redis 目前有較好的社群維護,版本迭代有所保障,新的功能也在有條不紊的新增完善。

  9. Redis 有較好的主從複製、叢集相關支援。

  10. 最新版本提供模組化功能,可以方便的擴充套件功能。

接下來我們就來說說怎麼使用 Redis 解決之前提到的問題:

  1. 配置中心

    ​Redis 本身就是記憶體 K/V 資料庫,支援 雜湊、集合、列表等五種資料結構,從而配置資訊的儲存、讀取速度都能夠得到滿足,Redis 還提供訂閱/釋出功能從而可以在配置發生改變時通知不同伺服器來進行更新相關配置。

  2. 分散式鎖

    ​使用 Redis 的 SETNX 命令或者 SET 命令配合 NX 選項的方式以及過期時間等功能可以很方便的實現一個效能優越的分散式鎖。

  3. 快取

    ​Redis 支援多種過期淘汰機制,本身效能的優勢也使 Redis 在快取方面得到廣泛使用。

  4. Lua 指令碼

    Lua 是一種輕量小巧的指令碼語言,用標準C語言編寫並開放原始碼。Redis 支援 Lua 指令碼的執行,從而可以擴充套件 Redis 中的命令實現很多複雜功能。

    Redis 支援使用 Lua 指令碼來實現一些組合命令邏輯處理,從而可以使用 Redis 做為限流、分散式唯一 ID 相關技術的實現。

  5. Redis 支援 BitMaps

    點陣圖(bitmap)是一種非常常用的結構,在索引,資料壓縮等方面有廣泛應用,能同時保證儲存空間和速度最優化(而不必空間換時間)。

    使用 Redis 的 BitMaps 做為使用者登入記錄統計,不僅統計速度極快,而且記憶體佔用極低。

  6. Redis 支援 HyperLogLog 演算法

    Redis HyperLogLog是一種使用隨機化的演算法,以少量記憶體提供集合中唯一元素數量的近似值。

    HyperLogLog 可以接受多個元素作為輸入,並給出輸入元素的基數估算值:

    • 基數:集合中不同元素的數量。比如 {‘apple’, ‘banana’, ‘cherry’, ‘banana’, ‘apple’} 的基數就是3。

    • 估算值:演算法給出的基數並不是精確的,可能會比實際稍微多一些或者稍微少一些,但會控制在合理的範圍之內。

    HyperLogLog 的優點是,即使輸入元素的數量或者體積非常非常大,計算基數所需的空間總是固定的、並且是很小的。

    在 Redis 裡面,每個 HyperLogLog 鍵只需要花費 12 KB 記憶體,就可以計算接近 2^64 個不同元素的基數。這和計算基數時,元素越多耗費記憶體就越多的集合形成鮮明對比。使用 HyperLogLog 演算法,我們可以輕而易舉的實現 IP 統計等對資料容許些許誤差的統計功能。

  7. Redis 支援 Geo 功能

    ​我們可以使用基於 Redis 來實現地理位置相關管理,附近的人、兩地理位置間距離計算等功能變得極為容易實現。

  8. 簡單訊息佇列

    Redis 列表 + 釋出/訂閱功能可以很方便的實現一個簡單的訊息佇列,將訊息存入 Redis 列表中,通過 釋出/訂閱功能通知指定成員,成員獲取到通知後可以根據通知內容進行對應處理。

  9. 全文檢索

    Redis 官方團隊開發了 RediSearch 模組,可以實現使用 Redis 來做全文檢索的功能。

  10. 分散式唯一ID

    Redis 的設計使其可以避免併發的多種問題,使其命令都是原子執行,這些特性都天生匹配分散式唯一ID生成器的要求。

    而且通過與 Lua 指令碼的結合使用更是能生成複雜的有某些規律的唯一ID。

###部分程式碼實現

下面我們以 Java程式碼作為演示(程式語言實現方式原理類似只是具體實現方式有些許差別而已)講解幾個功能的實現:

####Session 共享

原理:將不同 Web 伺服器的 Session 資訊統一儲存在 Redis 中,並且獲取 Session 也是從 Redis 中獲取

實現方法:

方法一:基於 Tomcat 實現 Sessioin 共享:

Tomcat 配置步驟(相關程式碼資源可以從 gitee.com/coderknock/… 獲取):

  1. 將 commons-pool2-2.4.2.jar、jedis-2.9.0.jar、commons-pool2-2.4.2.jar 三個 jar 包放到 Tomcat 下的 lib 目錄下(注意:不是專案的 lib 目錄)。

  2. 修改 Tomcat conf 下 context.xml:

   XML
   <Context>
       ......
       <Valve className="com.orangefunction.tomcat.redissessions.RedisSessionHandlerValve" />  
       <Manager className="com.orangefunction.tomcat.redissessions.RedisSessionManager"  
           host="127.0.0.1"  
           port="6379"  
           database="0"  
           maxInactiveInterval="60"
           password="admin123" />
         ......
   </Context>複製程式碼

方法二:基於 Fileter 、 自行實現 HttpServletRequestWrapper 、 HttpSession :

關鍵程式碼:

HttpSessionWrapper.java

java
   import com.alibaba.fastjson.JSON;
   import com.alibaba.fastjson.JSONException;
   import com.coderknock.jedis.executor.JedisExecutor;
   import com.coderknock.pojo.User;
   import org.apache.commons.lang3.StringUtils;
   import org.apache.logging.log4j.LogManager;
   import org.apache.logging.log4j.Logger;

   import javax.servlet.ServletContext;
   import javax.servlet.http.HttpServletRequest;
   import javax.servlet.http.HttpServletResponse;
   import javax.servlet.http.HttpSession;
   import javax.servlet.http.HttpSessionContext;
   import java.util.Enumeration;

   /**
    * <p></p>
    *
    * @author 三產
    * @version 1.0
    * @date 2017-08-26
    * @QQGroup 213732117
    * @website http://www.coderknock.com
    * @copyright Copyright 2017 拿客 coderknock.com  All rights reserved.
    * @since JDK 1.8
    */
   public class HttpSessionWrapper implements HttpSession {
       protected final Logger logger = LogManager.getLogger(HttpSessionWrapper.class);
       private String sid = "";
       private HttpServletRequest request;
       private HttpServletResponse response;
       private final long creationTime = System.currentTimeMillis();
       private final long lastAccessedTime = System.currentTimeMillis();
       //過期時間單位秒
       private int expire_time = 60;

       public HttpSessionWrapper() {
       }

       public HttpSessionWrapper(String sid, HttpServletRequest request,
                                 HttpServletResponse response) {
           this.sid = sid;
           this.request = request;
           this.response = response;
       }

       public Object getAttribute(String name) {
           logger.info(getClass() + "getAttribute(),name:" + name);
           try {
               Object obj = JedisExecutor.execute(jedis -> {
                   String jsonStr = jedis.get(sid + ":" + name);
                   if (jsonStr != null || StringUtils.isNotEmpty(jsonStr)) {
                       jedis.expire(sid + ":" + name, expire_time);// 重置過期時間
                   }
                   return jsonStr;
               });
               return obj;
           } catch (JSONException je) {
               logger.error(je);
           } catch (Exception e) {
               logger.error(e.getMessage());
           }
           return null;
       }

       public void setAttribute(String name, Object value) {
           logger.info(getClass() + "setAttribute(),name:" + name);
           try {
               JedisExecutor.executeNR(jedis -> {
                   if (value instanceof String) {
                       String value_ = (String) value;
                       jedis.set(sid + ":" + name, value_);//普通字串物件
                   } else {
                       jedis.set(sid + ":" + name, JSON.toJSONString(value));//序列化物件
                   }

                   jedis.expire(sid + ":" + name, expire_time);// 重置過期時間
               });
           } catch (Exception e) {
               logger.error(e);
           }

       }


       public void removeAttribute(String name) {
           logger.info(getClass() + "removeAttribute(),name:" + name);
           if (StringUtils.isNotEmpty(name)) {
               try {
                   JedisExecutor.executeNR(jedis -> {
                       jedis.del(sid + ":" + name);
                   });
               } catch (Exception e) {
                   logger.error(e);
               }
           }

       }
       //...... 省略部分程式碼
   }複製程式碼

SessionFilter.java

java
   import com.coderknock.wrapper.DefinedHttpServletRequestWrapper;
   import org.apache.commons.lang3.StringUtils;
   import org.apache.logging.log4j.LogManager;
   import org.apache.logging.log4j.Logger;

   import javax.servlet.*;
   import javax.servlet.http.Cookie;
   import javax.servlet.http.HttpServletRequest;
   import javax.servlet.http.HttpServletResponse;
   import java.io.IOException;
   import java.util.UUID;

   /**

    * <p></p>

    *

    * @author 三產

    * @version 1.0

    * @date 2017-08-26

    * @QQGroup 213732117

    * @website http://www.coderknock.com

    * @copyright Copyright 2017 拿客 coderknock.com  All rights reserved.

    * @since JDK 1.8

    */
   public class SessionFilter implements Filter {
       protected final Logger logger = LogManager.getLogger(getClass());
       private static final String host = "host";
       private static final String port = "port";
       private static final String seconds = "seconds";

       public void init(FilterConfig filterConfig) throws ServletException {
           logger.debug("init filterConfig info");
       }

       public void doFilter(ServletRequest request, ServletResponse response,
                            FilterChain chain) throws IOException, ServletException {
           //從cookie中獲取sessionId,如果此次請求沒有sessionId,重寫為這次請求設定一個sessionId

           HttpServletRequest httpRequest = (HttpServletRequest) request;
           HttpServletResponse httpResponse = (HttpServletResponse) response;
           String sid = null;
           if (httpRequest.getCookies() != null) {
               for (Cookie cookie : httpRequest.getCookies()) {
                   if (cookie.getName().equals("JSESSIONID")) {
                       sid = cookie.getValue();
                       break;
                   }
               }
           }
           if (StringUtils.isEmpty(sid)) {
               try {
                   Cookie cookie = new Cookie("JSESSIONID", httpRequest.getLocalAddr() + ":" + request.getLocalPort() + ":" + UUID.randomUUID().toString().replaceAll("-", ""));
                   httpResponse.addCookie(cookie);
               } catch (Exception e) {
                   e.printStackTrace();
               }
           }

           logger.info("JSESSIONID:" + sid);
           chain.doFilter(new DefinedHttpServletRequestWrapper(sid, httpRequest, httpResponse), response);
       }

       public void destroy() {
       }

   }複製程式碼

####排行榜

原理:通過 Redis 有序集合可以很便捷的實現該功能

關鍵命令:

ZADD key [NX|XX][CH][INCR] score member [score member ...]: 初始化排行榜中成員及其分數。

ZINCRBY key increment member:為某個成員增加分數,如果該成員不存在則會新增該成員並設定分數為 increment

ZUNIONSTORE destination numkeys key [key ...][WEIGHTS weight [weight ...]][AGGREGATE SUM|MIN|MAX]: 可以合併多個排行榜,該操作會將幾個集合的並集儲存到 destination 中,其中各個集合相同成員分數會疊加或者取最大、最小、平均值等(根據 [AGGREGATE SUM|MIN|MAX] 引數決定,預設是疊加),從而可以實現根據多個分排行榜來計算總榜排行的功能。

ZREVRANGE key start stop [WITHSCORES]:該命令就是最關鍵的獲取排行資訊的命令,可以獲取從高到低的成員。

Redis 命令演示(“#”之後為說明):

   # 1、儲存幾個排行榜成員資料(這裡可以理解為把自己系統已有資料載入到 Redis 中)
   ZADD testTop 23 member1 25 member2
   # 2、增加某個人的分數(這裡的分數就是排行的依據可以是浮點型別)
   ZINCRBY  testTop 20 member1   # 此時 testTop 中 member1 的分數就程式設計了 43
   ZINCRBY  testTop -10 member2  # 此時 testTop 中 member2 的分數就程式設計了 15
   ZINCRBY  testTop 20 member3   # 此時向 testTop 中新增了 member3 成員,分數為 20
   # 3、查詢排行榜前兩名,並且查詢出其分數【WITHSCORES 選項用於顯示分數,不帶該引數則只會查出成員名稱】
   ZREVRANGE testTop 0 1 WITHSCORES
   #結果:
   # 1) "member1"
   # 2) "43"
   # 3) "member3"
   # 4) "20"
   # 假設此時還有一個 排行榜
   ZADD testTop2  100 member2 200 member3 123 member4
   # 將 testTop testTop2 合成一個總榜 top
   ZUNIONSTORE  top 2 testTop testTop2
   # 查詢總榜所有成員排行情況
   ZREVRANGE top 0 -1 WITHSCORES
   1) "member3"
   2) "220"
   3) "member4"
   4) "123"
   5) "member2"
   6) "115"
   7) "member1"
   8) "43"複製程式碼

Java 相關實現程式碼(模擬了 sf.gg 的名望榜)可以檢視。

gitee.com/coderknock/… /src/test/java/TopDemo.java 有具體測試用例

####Geo 相關功能

Redis 的 Geo 功能提供了查詢兩個成員距離、某個成員附近範圍成員等功能可以用其實現一個簡單的附近的人

Java 相關實現程式碼可以檢視:

gitee.com/coderknock/… /src/test/java/GeoDemo.java 有具體測試用例。

####快取

原理:將經常會訪問的資料根據一定規則設定一個 Key 後存入 Redis,每次查詢時先查詢 Redis 中是否包含匹配資料,如果快取不存在再查詢資料庫。

注意點:對於不存在的資料應該存入一個自己設定的空值並設定過期時間,這樣可以避免快取擊穿(由於資料不存在,所以設定 Key 對應的值為 null(Java中的表示形式),因為 Redis 會移除值為 null 的 key 這樣會導致,每次查詢還是會訪問資料庫)。

Java 相關實現程式碼可以檢視:

gitee.com/coderknock/…

結束語

本文只是問了發散大家的思維,如對具體功能實現由興趣可以在之後的交流中共同探討。

由於個人的侷限性,文中可能存在錯誤表述,大家可以在評論區中提出共同探討。

附錄

Redis環境搭建

線上體驗: try.redis.io/

Windows版本: github.com/MSOpenTech/…

Linux安裝: www.coderknock.com/blog/2016/0…

Redis 配置

www.coderknock.com/blog/2017/0…

Redis 支援的五大資料結構

enter image description here
enter image description here

Redis 基礎知識擴充套件閱讀

Redis 基礎知識擴充套件閱讀

Redis 釋出訂閱圖解

 Redis 釋出訂閱圖解
Redis 釋出訂閱圖解


實錄:《拿客_三產:解析 Redis 如何快速提高系統效能》

這裡寫圖片描述
這裡寫圖片描述

相關文章