Redis實戰原理解析

Redis簡介:Redis 是完全開源免費的,是一個高性能的key-value數據庫,目前市面上主流的no-sql數據庫有Redis、Memcache、Tair(淘寶自研發),Redis的官網:https://redis.io/

之前的博客已經寫過redis的搭建,主從複製以及哨兵機制,本篇博客側重於講解底層原理,實戰等...開始吧那就

1. Redis應用場景

相信我們程序員都用過或者聽過Redis,那麼我們首先談下它有哪些應用場景,博主總結了以下幾點:

① Token令牌的生成
② 短信驗證碼的code
③ 可以實現緩存查詢數據,減輕我們的數據庫的訪問壓力 Redis與mysql數據庫不同步的問題
④ 分佈式鎖
⑤ 延遲操作(案例:訂單超時未支付,也可以用RabbitMQ解決)
⑥ 分佈式消息中間件(發佈訂閱,一般用的很少)

2. Redis線程模型

首先Redis官方是沒有windows版本的,只有Linux版本,那麼問題來了,爲什麼我們Windows本地可以安裝? 答案:一些大牛把Linux版的epoll機制改成Select輪訓,從而發佈到github供我們下載,所以我們Windows上的Redis並不是官方的。

Redis的底層採用NIO中的多路IO複用的機制,能夠非常好的支持這樣的併發,從而保證線程安全問題;Redis單線程,也就是底層採用一個線程維護多個不同的客戶端IO操作。但是Nio在不同的操作系統上實現的方式有所不同,在我們Windows操作系統使用Selector實現輪訓時間複雜度是爲O(N),而且還存在空輪訓的情況,效率非常低, 其次是默認對我們輪訓的數據有一定限制,所以支持上萬的TCP連接是非常難。所以在Linux操作系統採用epoll實現事件驅動回調,不會存在空輪訓的情況,只對活躍的 Socket連接實現主動回調這樣在性能上有大大的提升,所以時間複雜度是爲O(1)。

所以爲什麼單線程的Nginx、Redis都能夠非常高支持高併發,最終都是Linux中的IO多路複用機制epoll。

Windows - 空輪訓,相當於在Selector寫了個for循環,一直在輪訓,萬一別人不給我發數據也去輪訓一下,效率非常低;

Linux - epoll,給每一個TCP連接註冊一個事件回調,一旦有人給我發數據,我就走我的事件回調,沒給我發數據,我就不用去輪訓,不會存在空輪訓的情況,只對活躍的 Socket連接實現主動回調這樣在性能上有大大的提升。

3. Redis - Value的五種數據類型

① String類型  -  <key,value>  【用的最多】

String是Redis最基本的類型,一個key對應一個value,String類型是二進制安全的。意思是Redis的String可以包含任何數據。比如Jpg圖片或者序列化的對象, String類型是Redis最基本的數據類型,一個鍵最大能存儲512MB。

命令:set name zhangsan,get name

② hash類型  -   <key,<key1,value>>  案例:購物車

我們可以將Redis中的Hash類型看成具有<key,<key1,value>>,其中同一個key可以有多個不同key值的<key1,value>,所以該類型非常適合於存儲值對象的信息。如Username、Password和Age等。如果Hash中包含很少的字段,那麼該類型的數據也將僅佔用很少的磁盤空間。

命令:hmset user name zhangsan  【解讀:key爲user,value爲hashmap<name,zhangsan>】,hgetall user
可以支持多個key-hmset user name zhangsan age 22

③ list類型   -  案例:秒殺時一個庫存對應多個令牌桶token

Redis列表是簡單的字符串列表,按照插入順序排序。你可以添加一個元素到列表的頭部(左邊)或者尾部(右邊)

命令:lpush userlist zhangsan lisi wangwu

④ set  -  特點:value不允許重複,如果重複則覆蓋   【基本不用】

Redis 的 Set 是 String 類型的無序集合。集合成員是唯一的,這就意味着集合中不能出現重複的數據。

⑤ zset  -  【基本不用】

Redis 有序集合和集合一樣也是string類型元素的集合,且不允許重複的成員。不同的是每個元素都會關聯一個double類型的分數。redis正是通過分數來爲集合中的成員進行從小到大的排序。

拓展:Redis如何存放一個java對象?

答案: ① json(即String類型),② 二進制

方式①實現:Redis Desktop Manager工具不會亂碼

@Component
public class RedisUtils {
    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    public void setString(String key, String value) {
        setString(key, value, null);
    }
    public void setString(String key, String value, Long timeOut) {
        stringRedisTemplate.opsForValue().set(key, value);
        if (timeOut != null) {
            stringRedisTemplate.expire(key, timeOut, TimeUnit.SECONDS);
        }
    }
    public String getString(String key) {
        return stringRedisTemplate.opsForValue().get(key);
    }
}
@RestController
public class RedisController {
    @Autowired
    private RedisUtils redisUtils;

    @GetMapping("/addUser")
    public String addUser(UserEntity userEntity) {
        String json = JSONObject.toJSONString(userEntity);
        redisUtils.setString("userEntity", json);
        return "success";
    }
    @RequestMapping("/getUser")
    public UserEntity getUser(String key) {
        String json = redisUtils.getString(key);
        UserEntity userEntity = JSONObject.parseObject(json, UserEntity.class);
        return userEntity;
    }
}

方式②實現:(注意:需要序列化的對象(如UserEntity)一定要實現Serializable接口) Redis Desktop Manager會亂碼

@Component
public class RedisTemplateUtils {
    @Resource //@Resource通過名稱注入,注意這裏不能用@Autowired
    private RedisTemplate<String, Object> redisTemplate;

    public void setObject(String key, Object value) {
        setObject(key, value, null);
    }
    public void setObject(String key, Object value, Long timeOut) {
        redisTemplate.opsForValue().set(key, value);
        if (timeOut != null) {
            redisTemplate.expire(key, timeOut, TimeUnit.SECONDS);
        }
    }
    public Object getObject(String key) {
        return redisTemplate.opsForValue().get(key);
    }
}
@RestController
public class RedisController {
    @Autowired
    private RedisTemplateUtils redisTemplateUtils;

    @GetMapping("/addUser")
    public String addUser(UserEntity userEntity) {
        redisTemplateUtils.setObject("userEntity", userEntity);
        return "success";
    }
    @RequestMapping("/getUser")
    public UserEntity getUser(String key) {
        return (UserEntity) redisTemplateUtils.getObject(key);
    }
}

兩種方式區別:二進制只適合於Java對象,json是通用的。

4. Mysql與Redis數據同步解決方案

方式①:當數據庫有數據變動時,直接清空Redis對應的該數據,此次或下次再同步到Redis
方式②:直接採用MQ訂閱MySQL binlog日誌文件增量同步到Redis中,整個過程採用最終一致性方案。類似於之前博客的主從複製
方式③:使用阿里巴巴的開源框架Canal(後期單獨拿一篇博客講述)

5. Redis的持久化機制

Redis因爲某種原因的情況下宕機之後,數據是不會丟失的,原理就是持久化機制 ~

同理,我們用的ehcache也有持久化機制,大部分的緩存框架都會有基本功能:淘汰策略、持久機制。

Redis的持久化的機制有兩種:aof、rdb(默認),下面分別講解:

Redis提供了兩種持久化的機制,分別爲RDB、AOF實現,RDB採用定時(全量)持久化機制,但是服務器因爲某種原因宕機後可能數據會丟失,AOF是基於數據日誌操作實現的持久化(增量)。

RDB:(全量同步)

Redis已經幫助我默認開啓了rdb存儲,以快照的形式將數據持久化到磁盤的是一個二進制的文件dump.rdb, 可以在redis.conf中搜索到,生成的rdb文件爲,存放的目錄爲

Redis會將數據集的快照dump到dump.rdb文件中。此外,我們也可以通過配置文件來修改Redis服務器dump快照的頻率,在打開6379.conf文件之後,我們搜索save,可以看到下面的配置信息: 


save  900   1          # 在900秒(15分鐘)之後,如果至少有1個key發生變化,則dump內存快照。
save  300   10        # 在300秒(5分鐘)之後,如果至少有10個key發生變化,則dump內存快照。
save    60   10000  # 在60秒(1分鐘)之後,如果至少有10000個key發生變化,則dump內存快照。


AOF:(增量同步)

AOF在Redis的配置文件中存在三種同步策略,它們分別是:


appendfsync always       #每次有數據修改發生時都會寫入AOF文件,能夠保證數據不丟失,但是效率非常低。

appendfsync everysec   #每秒鐘同步一次,可能會丟失1s內的數據,但是效率非常高。【默認 - 建議使用】

appendfsync no              #從不同步。高效但是數據不會被持久化。


Redis默認沒有開啓aof存儲,那麼如何開啓aof? 非常簡單,只需要在redis.conf中把默認的配置改爲yes即可,aof同步的文件爲

原理:aof是以執行命令的形式實現同步。每次執行都會記錄寫的操作,如set name zhangsan等都會被記錄下來,appendfsync everysec一秒後從緩衝區同步到aof文件中去。

拓展:全量同步(rdb)與增量同步(aof)區別:

① 就是每天定時(避開高峯期)或者是採用一種週期的實現將數據拷貝另外一個地方。頻率不是很大,但是可能會造成數據的丟失。
② 增量同步採用行爲操作對數據實現同步,頻率非常高、對服務器同步的壓力也是非常大的、保證數據不丟失。

6. Redis內存淘汰策略

概念:將Redis用作緩存時,如果內存空間用滿,就會自動驅逐老的數據,防止內存撐爆。

Redis默認有六種淘汰策略:


noeviction:當內存使用達到閾值的時候,執行命令直接報錯 

allkeys-lru:在所有的key中,優先移除最近未使用的key。(推薦)

volatile-lru:在設置了過期時間的鍵空間中,優先移除最近未使用的key。

allkeys-random:在所有的key中,隨機移除某個key。

volatile-random:在設置了過期時間的鍵空間中,隨機移除某個key。

volatile-ttl:在設置了過期時間的鍵空間中,具有更早過期時間的key優先移除。


在redis.conf文件中,設置Redis 內存大小的限制,比如:

當數據達到限定大小後,會選擇配置的策略淘汰數據,通過配置maxmemory-policy置Redis的淘汰策略,默認也是注掉的,如果自定義內存大小的話,建議使用allkeys-lru,即
【關於maxmemory的設置,如果redis的應用場景是作爲db使用,那不要設置這個選項,因爲db是不能容忍丟失數據的。

如果作爲cache使用,則可以啓用這個選項(其實既然有淘汰策略,那就是cache了。。。),默認官方沒有對maxmemory做限制,理論上默認最大內存限制爲當前機器可用內存】

7. Redis自動過期機制

在實際開發過程中經常會遇到一些有時效性數據,比如訂單超時功能,30分鐘未支付應該將訂單改爲已失效狀態。在關係型數據庫中一般都要增加一個字段記錄數據的到期時間,然後通過定時任務週期性的進行檢查,這種方式性能太差了。Redis本身就對鍵過期提供了很好的支持。

實現需求:處理訂單過期自動取消,比如下單30分鐘未支付自動更改訂單狀態爲已失效(超時)

解決方案:① 定時器   ② RabbitMQ的死信/延時隊列   ③ 利用Redis的過期機制

Redis過期機制:在Redis中可以使用expire命令設置一個鍵的存活時間(ttl: time to live),過了這段時間,該鍵就會自動被刪除,EXPIRE命令的使用方法如下:
expire key ttl(單位秒)  【命令返回1表示設置ttl成功,返回0表示鍵不存在或者設置失敗。】

key失效監聽實戰:

   把RedisMessageListenerContainer注入到Spring容器

@Configuration
public class RedisListenerConfig {
    @Bean
    RedisMessageListenerContainer container(RedisConnectionFactory connectionFactory) {
        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
        container.setConnectionFactory(connectionFactory);
        return container;
    }
}

   編寫Redis監聽器

@Component
public class RedisKeyExpirationListener extends KeyExpirationEventMessageListener {

    public RedisKeyExpirationListener(RedisMessageListenerContainer listenerContainer) {
        super(listenerContainer);
    }
    @Autowired
    private OrderMapper orderMapper;
    /**
     * 【監聽】 - 當我們key失效的時候執行該方法
     */
    @Override
    public void onMessage(Message message, byte[] pattern) {
        String expireKey = message.toString();
        System.out.println(expireKey + "失效啦~");
        // 前綴判斷 - 每個服務,前綴事先商量好,防止衝突 eg:orderToken_XXXXXXX  // 或者是根據庫區分
        OrderEntity orderEntity = orderMapper.getOrderNumber(expireKey);
        if (orderEntity == null) {
            return;
        }
        // 獲取訂單狀態
        Integer orderStatus = orderEntity.getOrderStatus();
        // 如果該訂單狀態爲待支付的情況下,直接將該訂單修改爲已經超時 (0待支付 1已支付 2已失效)
        if (orderStatus == 0) {
            orderMapper.updateOrderStatus(expireKey, 2);
            // 庫存在加上1...此處省略,自行實現
        }
        // 不設置超時時間,但是可能被淘汰回收策略回收掉,也會走回調方法
    }
}
@Data
public class OrderEntity {
    private Long id;
    private String orderName;
    private Integer orderStatus;
    private String orderToken;
    private String orderNumber;
    public OrderEntity(Long id, String orderName, String orderNumber, String orderToken) {
        this.id = id;
        this.orderName = orderName;
        this.orderNumber = orderNumber;
        this.orderToken = orderToken;
    }
}
@Mapper
public interface OrderMapper {
    @Insert("insert into order_table values (null,#{orderName},0,#{orderToken},#{orderNumber})")
    int insertOrder(OrderEntity OrderEntity);

    @Select("SELECT id,order_name,order_status,order_token,order_number  FROM order_table " +
            "where order_token=#{orderToken};")
    OrderEntity getOrderNumber(String orderToken);

    @Update("update order_table set order_status=#{orderStatus} where order_token=#{orderToken}")
    int updateOrderStatus(String orderToken, Integer orderStatus);
}
@Component
public class RedisUtils {
    @Autowired
    private StringRedisTemplate stringRedisTemplate;

    public void setString(String key, String value) {
        setString(key, value, null);
    }
    public void setString(String key, String value, Long timeOut) {
        stringRedisTemplate.opsForValue().set(key, value);
        if (timeOut != null) {
            stringRedisTemplate.expire(key, timeOut, TimeUnit.SECONDS);
        }
    }
    public String getString(String key) {
        return stringRedisTemplate.opsForValue().get(key);
    }
}
@RestController
public class OrderController {
    @Autowired
    private OrderMapper orderMapper;
    @Autowired
    private RedisUtils redisUtils;

    @RequestMapping("/addOrder")
    public String addOrder() {
        // 1.提前生成訂單token 臨時且唯一
        String orderToken = UUID.randomUUID().toString();
        Long orderNumber = System.currentTimeMillis();
        // 2.將我們的token存放到redis中
        redisUtils.setString(orderToken, orderNumber + "", 60L); // 60秒
        OrderEntity orderEntity = new OrderEntity(null, "騰訊視頻年度vip", orderNumber + "", orderToken);
        return orderMapper.insertOrder(orderEntity) > 0 ? "success" : "fail";
    }
}

  啓動項目,調用addOrder接口,會看到數據庫新增一條數據:

60秒後,redis中的該key刪除,進入監聽器,並根據該key判斷訂單狀態,如果爲1,則不操作,如果依舊爲0,則改爲2:

【原理】:

① 創建訂單的時候綁定一個訂單token 存放在redis中(有效期只有30分鐘)  key爲token,value爲訂單編號。

② 對該key綁定過期事件回調,判斷狀態從而修改訂單狀態。

8. Redis緩存雪崩&緩存擊穿&緩存穿透

【緩存穿透】

緩存穿透是值指定key不存在的key,頻繁的高併發查詢,導致緩存是無法命中,如上圖所示,當黑客利用for循環,隨機生成orderNumber去訪問getOrder接口,則每次大概率會穿透Redis進入MySQL數據庫,導致大量IO操作,從而會導致數據庫的壓力非常大。

解決方案:

① 接口實現api的限流、防禦ddos(模擬請求)、接口頻率限制、網關實現黑名單(核心)
② 從數據庫和Redis如果都查詢不到數據的情況下,將數據庫的空值寫入到緩存中,加上短時間的有效期 【針對於黑客使用相同的key進行攻擊,一定程度上可以減輕頻繁數據庫IO操作】
③ 布隆過濾器

  

默認情況下,數組值爲0,數組取值(0或1)
爲什麼要有三個hash函數計算出3個hash值?防止衝突,減少重疊的概率

布隆過濾器:布隆過濾器適用於判斷一個元素在集合中是否存在,但是可能會存在誤判的問題。

Bloom Filter基本實現原理採用位數組與聯合函數一起實現,實現的原理採用二進制向量數組和隨機映射hash函數。

爲什麼存在誤判問題?

因爲有可能在查詢的時候,會根據key計算hash值,該值可能與布隆過濾器存放的其它元素的hash值產生重疊,即上圖中的sex,hash值數組對應的下表都爲1,即代表布隆過濾器中存在該key,而實際上該key是不存在的。

如何減少衝突(誤判)概率?

二進制數組長度設置大一點,代碼直接把fpp調低一點即可:

BloomFilter<Integer> integerBloomFilter = BloomFilter.create(Funnels.integerFunnel(), size, 0.001);

代碼實現&應用場景:

在上圖代碼中我們可以用redis很好的降低數據庫訪問壓力,那麼用戶頻繁訪問redis,也會對redis造成很大壓力,這時候我們就引入布隆過濾器,如果不存在直接不走redis,直接返回null給客戶端即可:

<!--引入布隆過濾器 -->
<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>22.0</version>
</dependency>
@Mapper
public interface OrderMapper {

    @Insert("insert into order_table values (null,#{orderName},0,#{orderToken},#{orderNumber})")
    int insertOrder(OrderEntity OrderEntity);

    @Select("select * from order_table where id=#{id}")
    OrderEntity getById(Integer orderId);

    @Select("select id from order_table")
    List<Integer> getOrderIds();
}
@RestController
public class OrderController {

    @Autowired
    private OrderMapper orderMapper;
    @Autowired
    private RedisUtils redisUtils;
    @Autowired
    private RedisTemplateUtils redisTemplateUtils;

    BloomFilter<Integer> integerBloomFilter;

    @RequestMapping("/addOrder")
    public String addOrder() {
        // 1.提前生成訂單token 臨時且唯一
        String orderToken = UUID.randomUUID().toString();
        Long orderNumber = System.currentTimeMillis();
        // 2.將我們的token存放到rdis中
        redisUtils.setString(orderToken, orderId + "", 10L);
        OrderEntity orderEntity = new OrderEntity(null, "QQ黃鑽", orderNumber + "", orderToken);
        return orderMapper.insertOrder(orderEntity) > 0 ? "success" : "fail";
    }

    @RequestMapping("/getOrder")
    public OrderEntity getOrder(Integer orderId) {
        // 0.判斷我們的布隆過濾器
        if (!integerBloomFilter.mightContain(orderId)) {
            System.out.println("從布隆過濾器中查詢不存在");
            return null;
        }
        // 如果正好碰巧,orderId在redis和mysql都沒有,也沒關係,
        // 就算布隆過濾器產生了誤判的情況,也不會影響整個業務,頂多穿透了布隆而已

        // 1.先查詢Redis中數據是否存在
        OrderEntity orderRedisEntity = (OrderEntity) redisTemplateUtils.getObject(orderId + "");
        if (orderRedisEntity != null) {
            System.out.println("直接從Redis中返回數據");
            return orderRedisEntity;
        }
        // 2. 查詢數據庫的內容
        System.out.println("從DB查詢數據");
        OrderEntity orderDBEntity = orderMapper.getOrderById(orderId);
        if (orderDBEntity != null) {
            System.out.println("將Db數據放入到Redis中");
            redisTemplateUtils.setObject(orderId + "", orderDBEntity);
        }
        return orderDBEntity;
    }

    /**
     * 從數據庫預熱id到布隆過濾器中(預熱機制)
     * @return
     */
    @RequestMapping("/dbToBulong")
    public String dbToBulong() {
        List<Integer> orderIds = orderMapper.getOrderIds();
        integerBloomFilter = BloomFilter.create(Funnels.integerFunnel(), orderIds.size(), 0.01);
        for (int i = 0; i < orderIds.size(); i++) {
            // 添加到我們的布隆過濾器中
            integerBloomFilter.put(orderIds.get(i));
        }
        return "success";
    }
}

首先用預熱機制,把所有訂單id存到布隆過濾器

  

分別請求:http://localhost:8080/getOrder?orderId=1,http://localhost:8080/getOrder?orderId=2,

http://localhost:8080/getOrder?orderId=1,http://localhost:8080/getOrder?orderId=2,會發現控制檯打印信息:

        

請求一個不存在的id,http://localhost:8080/getOrder?orderId=3,會發現直接返回null,並打印從布隆查詢不存在。

由此可見,使用布隆過濾器可以有效減少redis的壓力。

【緩存擊穿】

在高併發的情況下,當一個熱點key(經常使用到key)過期時,因爲訪問該key請求過多,多個請求同時發現該緩存key過期,這時候同時查詢數據庫,同時向Redis寫入緩存數據,對我們數據庫壓力非常大。

做電商項目的時候,把這個熱點key就稱爲"爆款"。

解決方案:

分佈式集羣環境 - 使用分佈式鎖技術:多個請求同時只要誰能夠獲取到鎖,誰就能夠去數據庫查詢將數據查詢的結果放入Redis中(直接在上面代碼的紅色箭頭處上鎖即可)
單機環境用Lock鎖或者Synchronized
軟過期 - 對熱點key設置無限有效期或者異步延長時間

【緩存雪崩】

緩存雪崩,是指在某一個時間段,緩存集中過期失效,突然給數據庫產生了巨大的壓力,甚至擊垮數據庫的情況。

產生雪崩的原因之一,比如馬上就要到雙十一零點,很快就會迎來一波搶購,這波商品時間比較集中的放入了緩存,假設緩存一個小時。那麼到了凌晨一點鐘的時候,這批商品的緩存就都過期了。而對這批商品的訪問查詢,都落到了數據庫上,對於數據庫而言,就會產生週期性的壓力波峯。

解決方案:對不用的數據使用不同的失效時間,或者採用失效時間加上隨機因子。

例如:做電商項目的時候,一般是採取不同分類商品,緩存不同週期。在同一分類中的商品,加上一個隨機因子。這樣能儘可能分散緩存過期時間,而且,熱門類目的商品緩存時間長一些,冷門類目的商品緩存時間短一些,也能節省緩存服務的資源。

小結:緩存穿透是指key不存在情況下,緩存擊穿是指擊穿 單個熱點key失效的在併發的查詢的情況下,緩存雪崩是指多個key失效的情況下(在某一個時間段,緩存集中過期失效)。

 9. Redis哨兵原理,分片,平滑遷移,縮容擴容...

等待更新ing...

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