1.Redis單執行緒vs多執行緒
Redis的工作執行緒是單執行緒,但整體的Redsi是多執行緒的。Redis4之後開啟了多執行緒機制,用於IO多路複用以及非同步刪除、持久化(fork子程序)等。但是Redis命令的執行依舊是由主執行緒序列執行的,因此在多執行緒下操作 Redis 不會出現執行緒安全的問題。
- Redis目前的瓶頸不在於主執行緒,而主要在於網路頻寬以及記憶體。
IO多路複用
Redis提前創造多個執行緒用處處理網路IO,將耗時的IO操作採用多執行緒執行,解析出請求命令後再由主執行緒序列執行,提高主執行緒執行的效率。
Redis6、7將網路資料讀寫、請求協議解析透過多個IO執行緒的來處理,對於真正的命令執行來說,仍然是單執行緒的。
多執行緒的開啟
1.設定io-thread-do-reads配置項為yes,表示啟動多執行緒。
2。設定執行緒個數。關於執行緒數的設定,官方的建議是如果為 4 核的 CPU,建議執行緒數設定為 2 或 3,如果為 8 核 CPU 建議執行緒數設定為 6,執行緒數一定要小於機器核數,執行緒數並不是越大越好。
Redis為什麼快?
1.Redis是記憶體操作
2.資料結構簡單,查詢和操作的時間複雜度多在O(1)
3.多路複用和非阻塞IO:多個IO執行緒監聽多個socket連線,主執行緒主要負責序列處理請求,減少了執行緒切換的開銷以及IO阻塞。
IO多路複用+epoll函式使用 先粗略瞭解,後續詳細解釋。
2.BigKey
2.1 MoreKey
環境準備
在後端服務停機狀態下,Redis插入大量資料方法:
1.利用Linux Bash生成插入大量資料的Redis命令
2.利用Redis 管道命令插入資料
方法
key * 在大量key情況下非常耗時,且Redis執行為單執行緒,會導致業務的崩潰。
利用scan命令,基於遊標,每次讀取一部分key,類似於mysql的limit。
SCAN 返回一個包含兩個元素的陣列, 第一個元素是用於進行下一次迭代的新遊標。
第二個元素則是一個陣列, 這個陣列中包含了所有被迭代的元素。如果新遊標返回零表示迭代已結束。
SCAN的遍歷順序非常特別,它不是從第一維陣列的第零位一直遍歷到末尾,而是採用了高位進位加法來遍歷。之所以使用這樣特殊的方式進行遍歷,是考慮到字典的擴容和縮容時避免槽位的遍歷重複和遺漏。
2.2 BigKey
多大算大?
BigKey一般由於日積月累的累計。String的value在10KB以上就是bigkey,集合元素個數超過5000個就是bigkey
BigKey的危害
1.會導致叢集伺服器的記憶體不均,遷移困難
2.超時刪除
3.網路流量阻塞
如何統計bigkey?
1.在服務外執行:redis-cli --bigkeys 給出每種資料結構Top 1 bigkey,同時給出每種資料型別的鍵值個數+平均大小
2.memory usage
如何刪除?
1.String
一般用del,如果過於龐大使用unlink
2.hash
使用hscan,逐步獲取一部分鍵值對,再使用hdel逐個刪除這次掃描到的field
list、set、zset都使用類似的方法,結合自己的scan及對應的刪除集合元素的命令來逐步刪除,最後再整個刪除。
BigKey調優
修改配置檔案中的一些引數
3.快取雙寫一致性
讀寫快取中的寫操作有兩種策略,同步直寫與非同步緩寫。
若想要保證快取與資料庫中的資料一致性,就要採取同步直寫策略。在更新完資料庫後同步寫redis快取。
若允許redis與資料庫資料延遲一致,如物流資訊等,可藉助kafka等訊息中介軟體,訂閱sql的binlog,利用kafka實現redis的重寫重試。
3.1雙檢加鎖
先判斷快取是否命中,若未命中,再加鎖,二次判斷快取是否命中,若仍然未命中,再讀取資料庫並寫回redis。
在高併發下,可以減少對同一不存在的key導致的資料庫的IO,且儘量保證了資料的一致性。
3.2雙寫一致性
- 不可能保證資料的強一致性,只能保證最終一致性。
- 一般會將資料庫資訊作為資料底單,若出現不一致,以資料庫資料為準。
- 若服務可停機,掛牌報錯,停機後單執行緒對redis進行資料重寫。
3.3更新策略
- 1.先更新資料庫,再更新快取
問題1:若資料庫更新成功,redis更新失敗,會導致資料不一致
問題2:執行緒1更新資料庫值100,執行緒2更新資料庫值80,執行緒2更新redis值80,執行緒1更新redis值100,導致資料不一致。 - 2.先更新快取,再更新資料庫。
問題1:一般將資料庫資訊作為資料底單,若redis寫成功資料庫寫失敗會導致資料庫的資訊不是最新的。
問題2:多執行緒下很容易導致資料不一致。 - 3.先刪除快取,再更新資料庫。
問題:刪除快取後,若更新資料庫還未完成,就有新的執行緒讀取資料庫舊值並重寫回redis,會導致資料不一致。
解決:延時雙刪。先刪除快取,再更新資料庫,再等待一段時間,再次刪除快取。
在等待的這段時間內,要保證查到舊值的執行緒重寫回了redis,這樣第二次刪除才有意義。
缺點:1.在延時雙刪的過程中,很可能讀到舊值。2.等待的這段時間不好確定。太長很降低效能,太短無效。 - 4.先更新資料庫,再刪除快取。
建議使用這種方法
問題1:若在刪除之前查詢命中,讀到的就是舊值。
問題2:若刪除快取失敗,則redis中都是舊值。
解決問題2:訊息中介軟體
總結
3.3 工程案例
問題:redis如何知道mysql有變動?
1.在業務邏輯中,mysql變動完成後刪除redis的快取,並在查詢時採用雙檢加鎖,防止mysql壓力過大。
2.延時雙刪
3.利用中介軟體canal,監測mysql的binlog日誌的變動,並將該變動同步到redis
3.3.1 mysql的主從複製
MySQL的主從複製將經過如下步驟:
1、當 master 主伺服器上的資料發生改變時,則將其改變寫入二進位制事件日誌檔案中;
2、salve 從伺服器會在一定時間間隔內對 master 主伺服器上的二進位制日誌進行探測,探測其是否發生過改變,如果探測到 master 主伺服器的二進位制事件日誌發生了改變,則開始一個 I/O Thread 請求 master 二進位制事件日誌;
3、同時 master 主伺服器為每個 I/O Thread 啟動一個dump Thread,用於向其傳送二進位制事件日誌;
4、slave 從伺服器將接收到的二進位制事件日誌儲存至自己本地的中繼日誌檔案中;
5、salve 從伺服器將啟動 SQL Thread 從中繼日誌中讀取二進位制日誌,在本地寫入,使得其資料和主伺服器保持一致;
6、最後 I/O Thread 和 SQL Thread 將進入睡眠狀態,等待下一次被喚醒;
3.3.2 canal工作原理
Canal的工作原理主要基於MySQL的binlog(binary log)機制。MySQL的binlog記錄了所有對資料庫進行更改的SQL語句,這些日誌可以用於資料恢復、主從複製等。Canal透過模擬MySQL的從庫(slave),讀取並解析這些binlog,從而實現對資料庫變更的監聽和捕獲。以下是Canal的工作原理的詳細步驟:
- 模擬MySQL Slave:
Canal偽裝成MySQL的從庫,透過MySQL的主從複製協議連線到MySQL的主庫(master)。透過這種方式,Canal能夠像MySQL的從庫一樣,從主庫獲取binlog日誌。 - 讀取Binlog:
一旦連線成功,Canal開始從MySQL主庫讀取binlog日誌。MySQL主庫會將所有的binlog事件(如INSERT、UPDATE、DELETE等)傳送給Canal。 - 解析Binlog:
Canal接收到binlog日誌後,會對這些日誌進行解析。解析的內容包括表名、操作型別(INSERT、UPDATE、DELETE)、變更的資料等。 - 資料處理和過濾:
Canal可以根據使用者的配置,對解析後的資料進行處理和過濾。使用者可以指定只監聽特定的資料庫或表,或者對資料進行特定的轉換和處理。 - 資料推送:
解析和處理後的資料可以透過多種方式推送給訂閱者。常見的推送方式包括髮送到訊息佇列(如Kafka、RabbitMQ)、寫入到其他資料庫(如Elasticsearch、HBase)等。 - 確認和回滾:
Canal支援對處理後的資料進行確認(ack)和回滾(rollback)。如果資料處理成功,Canal會傳送ack確認,表示這批資料已經成功處理。如果資料處理失敗,可以進行回滾,重新處理這批資料。 - 高可用性和容錯:
Canal支援叢集模式,可以透過多個Canal例項提供服務,增加系統的可用性和穩定性。Canal還支援斷點續傳,即使Canal服務重啟,也可以從上次中斷的地方繼續讀取binlog。
透過上述步驟,Canal實現了對MySQL資料庫變更的實時監聽和捕獲,並將變更資料推送給訂閱者,從而實現資料同步、快取更新、搜尋引擎索引更新等功能。
3.3.4 環境準備
- 配置mysql
確保你的MySQL例項已經開啟了binlog,並且binlog的格式為ROW。(修改mysql的my.cnf或my.ini)
2.mysql中新增canal使用者
DROP USER IF EXISTS 'canal'@'%';
CREATE USER 'canal'@'%' IDENTIFIED BY 'canal';
GRANT ALL PRIVILEGES ON *.* TO 'canal'@'%' IDENTIFIED BY 'canal';
FLUSH PRIVILEGES;
- 下載canal並配置
canal.instance.master.address:MySQL伺服器地址和埠。
canal.instance.dbUsername 和 canal.instance.dbPassword:用於連線MySQL的使用者名稱和密碼。
canal.instance.connectionCharset:資料庫的字符集,通常為UTF-8。
canal.instance.tsdb.enable:是否啟用表結構歷史記錄功能,建議開啟。
- 啟動canal
在Canal的根目錄下,執行bin/startup.sh指令碼啟動Canal服務 - 編寫canal客戶端程式碼
5.1 新增依賴
<dependency>
<groupId>com.alibaba.otter</groupId>
<artifactId>canal.client</artifactId>
<version>1.1.0</version>
</dependency>
5.2 yml檔案
# ========================alibaba.druid=====================
spring.datasource.type=com.alibaba.druid.pool.DruidDataSource
spring.datasource.driver-class-name=com.mysql.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/bigdata?useUnicode=true&characterEncoding=utf-8&useSSL=false
spring.datasource.username=root
spring.datasource.password=123456
spring.datasource.druid.test-while-idle=false
5.3 jedis工具類(透過jedis連線池獲取與redis的連線)
package com.atguigu.canal.utils;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;
/*
redis工具類 使用jedis連線redis
*/
public class RedisUtils
{
public static final String REDIS_IP_ADDR = "192.168.186.128";
public static final String REDIS_pwd = "111111";
public static JedisPool jedisPool;
//靜態程式碼塊,初始化連線池
static {
JedisPoolConfig jedisPoolConfig=new JedisPoolConfig();
jedisPoolConfig.setMaxTotal(20);
jedisPoolConfig.setMaxIdle(10);
jedisPool=new JedisPool(jedisPoolConfig,REDIS_IP_ADDR,6379,10000,REDIS_pwd);
}
//獲取Jedis物件
public static Jedis getJedis() throws Exception {
if(null!=jedisPool){
return jedisPool.getResource();
}
throw new Exception("Jedispool is not ok");
}
}
5.4 業務類
package com.atguigu.canal.biz;
import com.alibaba.fastjson.JSONObject;
import com.alibaba.otter.canal.client.CanalConnector;
import com.alibaba.otter.canal.client.CanalConnectors;
import com.alibaba.otter.canal.protocol.CanalEntry.*;
import com.alibaba.otter.canal.protocol.Message;
import com.atguigu.canal.utils.RedisUtils;
import redis.clients.jedis.Jedis;
import java.net.InetSocketAddress;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/*
redis mysql 雙寫一致性程式碼
*/
public class RedisCanalClientExample {
public static final Integer _60SECONDS = 60;
// redis地址
public static final String REDIS_IP_ADDR = "192.168.186.128";
//redis插入資料方法
private static void redisInsert(List<Column> columns) {
JSONObject jsonObject = new JSONObject();
for (Column column : columns) {
System.out.println(column.getName() + " : " + column.getValue() + " update=" + column.getUpdated());
jsonObject.put(column.getName(), column.getValue());
}
if (columns.size() > 0) {
try (Jedis jedis = RedisUtils.getJedis()) {
jedis.set(columns.get(0).getValue(), jsonObject.toJSONString());
} catch (Exception e) {
e.printStackTrace();
}
}
}
// redis刪除資料方法
private static void redisDelete(List<Column> columns) {
JSONObject jsonObject = new JSONObject();
for (Column column : columns) {
jsonObject.put(column.getName(), column.getValue());
}
if (columns.size() > 0) {
try (Jedis jedis = RedisUtils.getJedis()) {
jedis.del(columns.get(0).getValue());
} catch (Exception e) {
e.printStackTrace();
}
}
}
// redis更新資料方法
private static void redisUpdate(List<Column> columns) {
JSONObject jsonObject = new JSONObject();
for (Column column : columns) {
System.out.println(column.getName() + " : " + column.getValue() + " update=" + column.getUpdated());
jsonObject.put(column.getName(), column.getValue());
}
if (columns.size() > 0) {
try (Jedis jedis = RedisUtils.getJedis()) {
jedis.set(columns.get(0).getValue(), jsonObject.toJSONString());
System.out.println("---------update after: " + jedis.get(columns.get(0).getValue()));
} catch (Exception e) {
e.printStackTrace();
}
}
}
public static void printEntry(List<Entry> entrys) {
for (Entry entry : entrys) {
if (entry.getEntryType() == EntryType.TRANSACTIONBEGIN || entry.getEntryType() == EntryType.TRANSACTIONEND) {
continue;
}
RowChange rowChage = null;
try {
//獲取變更的row資料
rowChage = RowChange.parseFrom(entry.getStoreValue());
} catch (Exception e) {
throw new RuntimeException("ERROR ## parser of eromanga-event has an error,data:" + entry.toString(), e);
}
//獲取變動型別
EventType eventType = rowChage.getEventType();
System.out.println(String.format("================> binlog[%s:%s] , name[%s,%s] , eventType : %s",
entry.getHeader().getLogfileName(), entry.getHeader().getLogfileOffset(),
entry.getHeader().getSchemaName(), entry.getHeader().getTableName(), eventType));
for (RowData rowData : rowChage.getRowDatasList()) {
if (eventType == EventType.INSERT) {
redisInsert(rowData.getAfterColumnsList());
} else if (eventType == EventType.DELETE) {
redisDelete(rowData.getBeforeColumnsList());
} else {//EventType.UPDATE
redisUpdate(rowData.getAfterColumnsList());
}
}
}
}
// 主方法
public static void main(String[] args) {
System.out.println("---------O(∩_∩)O哈哈~ initCanal() main方法-----------");
//=================================
// 建立連結canal服務端
CanalConnector connector = CanalConnectors.newSingleConnector(new InetSocketAddress(REDIS_IP_ADDR,
11111), "example", "", "");
int batchSize = 1000;
//空閒空轉計數器
int emptyCount = 0;
System.out.println("---------------------canal init OK,開始監聽mysql變化------");
try {
connector.connect();
//connector.subscribe(".*\\..*");
connector.subscribe("atguigu_jdbc.t_user");
connector.rollback();//回滾到未進行ack確認的地方,確保從最後一個未確認的位置開始獲取資料。
int totalEmptyCount = 10 * _60SECONDS;
while (emptyCount < totalEmptyCount) {
System.out.println("我是canal,每秒一次正在監聽:" + UUID.randomUUID().toString());
Message message = connector.getWithoutAck(batchSize); // 獲取指定數量的資料
long batchId = message.getId();
int size = message.getEntries().size();
if (batchId == -1 || size == 0) {
emptyCount++;
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
} else {
//計數器重新置零
emptyCount = 0;
printEntry(message.getEntries());
}
connector.ack(batchId); // 提交確認
// connector.rollback(batchId); // 處理失敗, 回滾資料
}
System.out.println("已經監聽了" + totalEmptyCount + "秒,無任何訊息,請重啟重試......");
} finally {
connector.disconnect();
}
}
}
3.3.5 面試題
1、如果我想實現mysql有了更改後,立即同步到redis,如何實現?
2、如果我能容許一定的舊值讀取,應當用何種雙寫一致性策略降低資料最終不一致的風險到最低?
兩問答案一致
答:要實現MySQL有更新後立即同步到Redis,可以採用訊息佇列結合Canal的方式。首先,透過Canal監聽MySQL的binlog,一旦資料庫有變更,Canal即可捕獲這些變更事件。然後,將這些變更事件傳送到訊息佇列(如Kafka、RabbitMQ等)中。最後,編寫一個消費者程式從訊息佇列中讀取這些變更事件,並根據事件內容更新Redis快取。
相比於延時雙刪,這種方式的優點是能夠實現近乎實時的資料同步,同時透過訊息佇列解耦了資料庫變更事件的捕獲與快取更新操作,提高了系統的穩定性和擴充套件性。此外,即使Redis更新操作失敗,也可以透過訊息佇列中的事件重新觸發更新操作,增強了資料同步的可靠性。
4.布隆過濾器
布隆過濾器用於在大量資料中快速判斷某個key是否在庫中存在,使用場景例如黑白名單校驗、解決快取穿透等。
4.1 定義
布隆過濾器,bloom filter,本質上是初始值均為0的bitmap,透過將已知存在的key經過多個hash函式並對bitmap長度取餘後得到的索引位置1來標記該key的存在。由於hash衝突的出現,布隆過濾器不支援刪除操作,且有一定的誤判率。
判定有,不一定有,判定無,則一定無。
4.2 解決快取穿透
當有新的請求時,先到布隆過濾器中查詢是否存在:
如果布隆過濾器中不存在該條資料則直接返回;
如果布隆過濾器中已存在,才去查詢快取redis,如果redis裡沒查詢到則再查詢Mysql資料庫。
布穀鳥過濾器,解決了布隆過濾器不能刪除的問題(簡單瞭解)
5.快取預熱、穿透、擊穿、雪崩
5.1 快取預熱
利用@PostConstruct初始化資料
@PostConstruct註解標註方法,當Bean都載入到容器之後,會自動執行Bean中有@PostConstruct註解的方法。
5.2 快取雪崩
對於後端開發人員,快取雪崩主要指redis中有大量的key同時失效,導致mysql的壓力過大。
- 預防+解決
1.key設定為永不過期或在過期時間基礎上增加一個隨機事件,使得錯峰過期
2.多快取結合預防雪崩,redis+ehcache本地快取
3.服務降級
4.用aliyun資料庫redis版
5.3 快取穿透
若有一條資料,redis與mysql都不存在,而駭客故意大量查詢不存在的key,會導致mysql的崩盤。
5.3.1 解決方案:
1.空物件快取
定義一個約定的預設值,若redis不存在該key,mysql也查不到的話,也讓redis存入剛剛查不到的key並將值設為預設值(設定過期時間)並保護mysql。
缺陷:若每次攻擊的key不同,仍然會導致redis失效,mysql的崩盤,redis中存放大量無用資料。
可以直接從Redis中讀取default預設值返回給業務應用程式,避免了把大量請求傳送給mysql處理,打爆mysql。
2.guava過濾器
谷歌實現的布隆過濾器,減少與redis的耦合,並簡化開發。
guava過濾器實戰:
1.新增依賴
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>23.0</version>
</dependency>
2.測試類helloworld
@Test
public void testGuavaWithBloomFilter()
{
// 建立布隆過濾器物件
BloomFilter<Integer> filter = BloomFilter.create(Funnels.integerFunnel(), 100,0.03);
// 判斷指定元素是否存在
System.out.println(filter.mightContain(1));
System.out.println(filter.mightContain(2));
// 將元素新增進布隆過濾器
filter.put(1);
filter.put(2);
System.out.println(filter.mightContain(1));
System.out.println(filter.mightContain(2));
}
3.實際使用時,在專案啟動時,應該在快取預熱階段就將guava過濾器的白名單準備好。
4.建立布隆過濾器的三個引數:鍵的動態部分的資料型別,資料量,可接受的誤判率,guava過濾器會根據引數設定合適大小的bitmap以及合適個數的雜湊函式。
5.4 快取擊穿
和快取穿透不同,快取擊穿指某熱點key正好失效,而此時大量請求湧入redis,查詢不到從而打爆mysql。
預防
1.互斥更新,雙檢加鎖,當熱key失效,因為互斥鎖的存在,只有第一條會走mysql,其餘會在等待鎖釋放後再次走快取。
2.多快取,差異化失效時間
主要業務邏輯:
@Service
@Slf4j
public class JHSTaskService
{
public static final String JHS_KEY="jhs";
public static final String JHS_KEY_A="jhs:a";
public static final String JHS_KEY_B="jhs:b";
@Autowired
private RedisTemplate redisTemplate;
/**
* 模擬從資料庫讀取100件特價商品,用於載入到聚划算的頁面中
*/
private List<Product> getProductsFromMysql() {
List<Product> list=new ArrayList<>();
for (int i = 1; i <=20; i++) {
Random rand = new Random();
int id= rand.nextInt(10000);
Product obj=new Product((long) id,"product"+i,i,"detail");
list.add(obj);
}
return list;
}
@PostConstruct
public void initJHSAB(){
log.info("啟動AB定時器計劃任務淘寶聚划算功能模擬.........."+DateUtil.now());
new Thread(() -> {
//模擬定時器,定時把資料庫的特價商品,重新整理到redis中
while (true){
//模擬從資料庫讀取100件特價商品,用於載入到聚划算的頁面中
List<Product> list=this.getProductsFromMysql();
//先更新B快取
this.redisTemplate.delete(JHS_KEY_B);
this.redisTemplate.opsForList().leftPushAll(JHS_KEY_B,list);
this.redisTemplate.expire(JHS_KEY_B,20L,TimeUnit.DAYS);
//再更新A快取
this.redisTemplate.delete(JHS_KEY_A);
this.redisTemplate.opsForList().leftPushAll(JHS_KEY_A,list);
this.redisTemplate.expire(JHS_KEY_A,15L,TimeUnit.DAYS);
//間隔一分鐘 執行一遍(真實時間為更新週期)
try { TimeUnit.MINUTES.sleep(1); } catch (InterruptedException e) { e.printStackTrace(); }
log.info("runJhs定時重新整理雙快取AB兩層..............");
}
},"t1").start();
}
}
@RestController
@Slf4j
@Api(tags = "聚划算商品列表介面")
public class JHSProductController
{
public static final String JHS_KEY_A="jhs:a";
public static final String JHS_KEY_B="jhs:b";
@Autowired
private RedisTemplate redisTemplate;
@RequestMapping(value = "/pruduct/findab",method = RequestMethod.GET)
@ApiOperation("防止熱點key突然失效,AB雙快取架構")
public List<Product> findAB(int page, int size) {
List<Product> list=null;
long start = (page - 1) * size;
long end = start + size - 1;
try {
//採用redis list資料結構的lrange命令實現分頁查詢
list = this.redisTemplate.opsForList().range(JHS_KEY_A, start, end);
if (CollectionUtils.isEmpty(list)) {
log.info("=========A快取已經失效了,記得人工修補,B快取自動延續5天");
//使用者先查詢快取A(上面的程式碼),如果快取A查詢不到(例如,更新快取的時候刪除了),再查詢快取B
this.redisTemplate.opsForList().range(JHS_KEY_B, start, end);
//if B快取失效,TODO 走DB查詢
}
log.info("查詢結果:{}", list);
} catch (Exception ex) {
//這裡的異常,一般是redis癱瘓 ,或 redis網路timeout
log.error("exception:", ex);
//TODO 走DB查詢
}
return list;
}
}
總結
6.分散式鎖
若後端業務服務是分散式多臺不同的JVM,單機的執行緒鎖synchronized不再起作用,此時需要分散式鎖。
1.分散式鎖的必要特性
手寫分散式鎖
版本1
利用setnx實現分散式鎖
@Service
@Slf4j
public class InventoryService
{
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Value("${server.port}")
private String port;
public String sale()
{
String retMessage = "";
//鎖名
String key = "zzyyRedisLock";
//鎖值,隨機的id+執行緒id
String uuidValue = IdUtil.simpleUUID()+":"+Thread.currentThread().getId();
//利用setnx實現分散式鎖,若存在會返回set成功,返回true
//若分散式鎖的redis微服務當機,而鎖沒有釋放,則無法解鎖,需要給鎖加入過期時間
//給鎖加過期時間必須為原子操作,否則還未加過期時間就當機,未解決問題
while(!stringRedisTemplate.opsForValue().setIfAbsent(key, uuidValue,30L,TimeUnit.SECONDS)){
//暫停20毫秒(否則cpu壓力過大!)
try { TimeUnit.MILLISECONDS.sleep(20); } catch (InterruptedException e) { e.printStackTrace(); }
}
try
{
//1 查詢庫存資訊
String result = stringRedisTemplate.opsForValue().get("inventory001");
//2 判斷庫存是否足夠
Integer inventoryNumber = result == null ? 0 : Integer.parseInt(result);
//3 扣減庫存
if(inventoryNumber > 0) {
stringRedisTemplate.opsForValue().set("inventory001",String.valueOf(--inventoryNumber));
retMessage = "成功賣出一個商品,庫存剩餘: "+inventoryNumber;
System.out.println(retMessage);
}else{
retMessage = "商品賣完了,o(╥﹏╥)o";
}
}finally {
// 釋放鎖
//若執行緒1的業務在過期時間內未完成,此時鎖過期釋放,執行緒2拿到鎖,此時容易導致執行緒1釋放執行緒2的鎖
//解決:只允許刪除自己的鎖,不允許刪除別人的鎖
if(stringRedisTemplate.opsForValue().get(key).equalsIgnoreCase(uuidValue)){
stringRedisTemplate.delete(key);
}
}
return retMessage+"\t"+"服務埠號:"+port;
}
}
版本2
版本1問題:finally中的判斷與刪除非原子操作,未解決問題
解決:lua指令碼保證原子性
finally {
// 將判斷+刪除自己的合併為lua指令碼保證原子性
String luaScript =
"if (redis.call('get',KEYS[1]) == ARGV[1]) then " +
"return redis.call('del',KEYS[1]) " +
"else " +
"return 0 " + //當前的鎖不是我的鎖,不能刪除
"end";
stringRedisTemplate.execute(new DefaultRedisScript<>(luaScript, Boolean.class), Arrays.asList(key), uuidValue);
}
lua指令碼helloworld
EVAL "lua指令碼" keysnum key1 key2... arg1 arg2 arg3...
lua指令碼:
// lua指令碼必須有return
//set get
"redis.call('SET', KEY[1], ARGV[1]) return redis.call('GET', KEY[1])"
// if else(必須有then)
"if (redis.call('get',KEYS[1]) == ARGV[1]) then " +
"return redis.call('del',KEYS[1]) " +
"else " +
"return 0 " +
"end"
版本3
版本2已經很好了,但不支援可重入,對於一些業務邏輯要求無法滿足
setnx只能解決是否存在,無法解決可重入的問題,所以使用hset,鎖名不變,field為uuid+執行緒id,值為獲取鎖的次數,獲取一次+1.釋放一次-1,當值為0時即可釋放,實現可重入。
結合工廠設計模式,新增分散式鎖工廠元件,定義獲取不同型別的分散式鎖的方法,將隨機的uuid指放在鎖工廠中(所有不同執行緒拿到的uuid是一樣的,但是執行緒id不同),因為若每次加鎖都生成一個不同的uuid,則對鎖的+1-1時無法定位到同一個值。
//分散式鎖工廠
@Component
public class DistributedLockFactory
{
@Autowired
private StringRedisTemplate stringRedisTemplate;
private String lockName;
private String uuidValue;
public DistributedLockFactory()
{
this.uuidValue = IdUtil.simpleUUID();//UUID
}
public Lock getDistributedLock(String lockType)
{
if(lockType == null) return null;
if(lockType.equalsIgnoreCase("REDIS")){
lockName = "zzyyRedisLock";
return new RedisDistributedLock(stringRedisTemplate,lockName,uuidValue);
} else if(lockType.equalsIgnoreCase("ZOOKEEPER")){
//TODO zookeeper版本的分散式鎖實現
return new ZookeeperDistributedLock();
} else if(lockType.equalsIgnoreCase("MYSQL")){
//TODO mysql版本的分散式鎖實現
return null;
}
return null;
}
}
//分散式鎖,實現Lock介面
public class RedisDistributedLock implements Lock
{
private StringRedisTemplate stringRedisTemplate;
private String lockName;
private String uuidValue;
private long expireTime;
public RedisDistributedLock(StringRedisTemplate stringRedisTemplate, String lockName,String uuidValue)
{
this.stringRedisTemplate = stringRedisTemplate;
this.lockName = lockName;
this.uuidValue = uuidValue+":"+Thread.currentThread().getId();
this.expireTime = 30L;
}
@Override
public void lock()
{
this.tryLock();
}
@Override
public boolean tryLock()
{
try
{
return this.tryLock(-1L,TimeUnit.SECONDS);
} catch (InterruptedException e) {
e.printStackTrace();
}
return false;
}
@Override
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException
{
if(time != -1L)
{
expireTime = unit.toSeconds(time);
}
String script =
"if redis.call('exists',KEYS[1]) == 0 or redis.call('hexists',KEYS[1],ARGV[1]) == 1 then " +
"redis.call('hincrby',KEYS[1],ARGV[1],1) " +
"redis.call('expire',KEYS[1],ARGV[2]) " +
"return 1 " +
"else " +
"return 0 " +
"end";
System.out.println("lockName: "+lockName+"\t"+"uuidValue: "+uuidValue);
while (!stringRedisTemplate.execute(new DefaultRedisScript<>(script, Boolean.class), Arrays.asList(lockName), uuidValue, String.valueOf(expireTime)))
{
try { TimeUnit.MILLISECONDS.sleep(60); } catch (InterruptedException e) { e.printStackTrace(); }
}
return true;
}
@Override
public void unlock()
{
String script =
"if redis.call('HEXISTS',KEYS[1],ARGV[1]) == 0 then " +
"return nil " +
"elseif redis.call('HINCRBY',KEYS[1],ARGV[1],-1) == 0 then " +
"return redis.call('del',KEYS[1]) " +
"else " +
"return 0 " +
"end";
System.out.println("lockName: "+lockName+"\t"+"uuidValue: "+uuidValue);
Long flag = stringRedisTemplate.execute(new DefaultRedisScript<>(script, Long.class), Arrays.asList(lockName), uuidValue, String.valueOf(expireTime));
if(flag == null)
{
throw new RuntimeException("沒有這個鎖,HEXISTS查詢無");
}
}
//=========================================================
@Override
public void lockInterruptibly() throws InterruptedException
{
}
@Override
public Condition newCondition()
{
return null;
}
}
//業務程式碼
@Service
@Slf4j
public class InventoryService
{
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Value("${server.port}")
private String port;
@Autowired
private DistributedLockFactory distributedLockFactory;
public String sale()
{
String retMessage = "";
Lock redisLock = distributedLockFactory.getDistributedLock("redis");
redisLock.lock();
try
{
//1 查詢庫存資訊
String result = stringRedisTemplate.opsForValue().get("inventory001");
//2 判斷庫存是否足夠
Integer inventoryNumber = result == null ? 0 : Integer.parseInt(result);
//3 扣減庫存
if(inventoryNumber > 0) {
stringRedisTemplate.opsForValue().set("inventory001",String.valueOf(--inventoryNumber));
retMessage = "成功賣出一個商品,庫存剩餘: "+inventoryNumber;
System.out.println(retMessage);
this.testReEnter();
}else{
retMessage = "商品賣完了,o(╥﹏╥)o";
}
}catch (Exception e){
e.printStackTrace();
}finally {
redisLock.unlock();
}
return retMessage+"\t"+"服務埠號:"+port;
}
private void testReEnter()
{
Lock redisLock = distributedLockFactory.getDistributedLock("redis");
redisLock.lock();
try
{
System.out.println("################測試可重入鎖####################################");
}finally {
redisLock.unlock();
}
}
}
版本4
版本2,3的問題:若在業務未完成時鎖過期,則會出現雙執行緒持一把鎖的現象,版本2只是解決了鎖的誤刪,而為解決誤操作(如超賣)問題。
解決:自動續期,設定定時任務,每1/3過期時間檢視業務是否完成,若未完成,就將過期時間更新。
public class RedisDistributedLock implements Lock
{
private StringRedisTemplate stringRedisTemplate;
private String lockName;//KEYS[1]
private String uuidValue;//ARGV[1]
private long expireTime;//ARGV[2]
public RedisDistributedLock(StringRedisTemplate stringRedisTemplate,String lockName,String uuidValue)
{
this.stringRedisTemplate = stringRedisTemplate;
this.lockName = lockName;
this.uuidValue = uuidValue+":"+Thread.currentThread().getId();
this.expireTime = 30L;
}
@Override
public void lock()
{
tryLock();
}
@Override
public boolean tryLock()
{
try {tryLock(-1L,TimeUnit.SECONDS);} catch (InterruptedException e) {e.printStackTrace();}
return false;
}
//加鎖的
@Override
public boolean tryLock(long time, TimeUnit unit) throws InterruptedException
{
if(time != -1L)
{
this.expireTime = unit.toSeconds(time);
}
// 若key不存在,則加鎖,若存在且為我的鎖,則+1
String script =
"if redis.call('exists',KEYS[1]) == 0 or redis.call('hexists',KEYS[1],ARGV[1]) == 1 then " +
"redis.call('hincrby',KEYS[1],ARGV[1],1) " +
"redis.call('expire',KEYS[1],ARGV[2]) " +
"return 1 " +
"else " +
"return 0 " +
"end";
System.out.println("script: "+script);
System.out.println("lockName: "+lockName);
System.out.println("uuidValue: "+uuidValue);
System.out.println("expireTime: "+expireTime);
// 若未獲取鎖,則等待50ms
while (!stringRedisTemplate.execute(new DefaultRedisScript<>(script,Boolean.class), Arrays.asList(lockName),uuidValue,String.valueOf(expireTime))) {
TimeUnit.MILLISECONDS.sleep(50);
}
//重新設定過期時間(續費)
this.renewExpire();
return true;
}
/**
*幹活的,實現解鎖功能
*/
@Override
public void unlock()
{
//鎖不存在,return nil,鎖存在,則-1,若-1後==0,則刪除鎖
String script =
"if redis.call('HEXISTS',KEYS[1],ARGV[1]) == 0 then " +
" return nil " +
"elseif redis.call('HINCRBY',KEYS[1],ARGV[1],-1) == 0 then " +
" return redis.call('del',KEYS[1]) " +
"else " +
" return 0 " +
"end";
// nil = false 1 = true 0 = false
System.out.println("lockName: "+lockName);
System.out.println("uuidValue: "+uuidValue);
System.out.println("expireTime: "+expireTime);
Long flag = stringRedisTemplate.execute(new DefaultRedisScript<>(script, Long.class), Arrays.asList(lockName),uuidValue,String.valueOf(expireTime));
if(flag == null)
{
throw new RuntimeException("This lock doesn't EXIST");
}
}
//設定定時任務,每10s
private void renewExpire()
{
// 若當前執行緒持有的鎖仍存在,則重新設定過期時間(續費)
String script =
"if redis.call('HEXISTS',KEYS[1],ARGV[1]) == 1 then " +
"return redis.call('expire',KEYS[1],ARGV[2]) " +
"else " +
"return 0 " +
"end";
new Timer().schedule(new TimerTask()
{
@Override
public void run()
{
//遞迴呼叫,若仍存在則再次續費
if (stringRedisTemplate.execute(new DefaultRedisScript<>(script, Boolean.class), Arrays.asList(lockName),uuidValue,String.valueOf(expireTime))) {
renewExpire();
}
}
},(this.expireTime * 1000)/3);
}
//===下面的redis分散式鎖暫時用不到=======================================
@Override
public void lockInterruptibly() throws InterruptedException
{
}
@Override
public Condition newCondition()
{
return null;
}
}
@Service
@Slf4j
public class InventoryService
{
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Value("${server.port}")
private String port;
@Autowired
private DistributedLockFactory distributedLockFactory;
public String sale()
{
String retMessage = "";
Lock redisLock = distributedLockFactory.getDistributedLock("redis");
redisLock.lock();
try
{
//1 查詢庫存資訊
String result = stringRedisTemplate.opsForValue().get("inventory001");
//2 判斷庫存是否足夠
Integer inventoryNumber = result == null ? 0 : Integer.parseInt(result);
//3 扣減庫存
if(inventoryNumber > 0) {
stringRedisTemplate.opsForValue().set("inventory001",String.valueOf(--inventoryNumber));
retMessage = "成功賣出一個商品,庫存剩餘: "+inventoryNumber;
System.out.println(retMessage);
//暫停幾秒鐘執行緒,為了測試自動續期
try { TimeUnit.SECONDS.sleep(120); } catch (InterruptedException e) { e.printStackTrace(); }
}else{
retMessage = "商品賣完了,o(╥﹏╥)o";
}
}catch (Exception e){
e.printStackTrace();
}finally {
redisLock.unlock();
}
return retMessage+"\t"+"服務埠號:"+port;
}
private void testReEnter()
{
Lock redisLock = distributedLockFactory.getDistributedLock("redis");
redisLock.lock();
try
{
System.out.println("################測試可重入鎖####################################");
}finally {
redisLock.unlock();
}
}
}
小總結
7.Redis五大型別原始碼
RedisObject
Redis資料庫是一個全域性雜湊表,每一個鍵值對會被定義為一個DictEntry,key 是字串,但是 Redis 沒有直接使用 C 的字元陣列,而是儲存在redis自定義的 SDS中。value 儲存在redisObject 中。
- 1 4位的type表示具體的資料型別
- 2 4位的encoding表示該型別的物理編碼方式,同一種資料型別可能有不同的編碼方式。
- 3 lru欄位表示當記憶體超限時採用LRU演算法清除記憶體中的物件。
- 4 refcount表示物件的引用計數。
- 5 ptr指標指向真正的底層資料結構的指標。
相關命令:
object encoding key 返回key對應value的編碼方式
debug object key (需要在配置檔案中開啟,enable-debug-command 的值,no改為local)
7.1 String
Redis內部會根據使用者給出的不同的鍵值使用不同的編碼更格式,自適應的選擇較優的方式。
String對應的三大編碼格式:
-
1.int:當儲存的字串是一個整數值,大小在LONG_MAX 範圍內,將redisobject 的 ptr 指標(剛好8位元組64位)直接指向(儲存)資料(享元模式)
-
2.embstr:當字串的長度小於44 位元組,總的redisobject 的長度佔用是最多是64位元組(redis分配記憶體時不會產生記憶體碎片),會使用empstr 編碼,此時object 的head(RedisObject) 與sds(ptr指向的位置) 是連續的記憶體空間,申請記憶體時可以一次性申請所需要記憶體,效率更高;如果超過 44 位元組會轉為 raw 編碼格式;
-
3.row:基於動態字串(sds) 實現,儲存資料的最大上限為512mb;此時 ptr 是指向 sds 資料物件的指標; sds 物件指向獨立的記憶體空間,使用raw 儲存時需要分別申請redisobject 和sds 的記憶體空間;
SDS簡單動態字串
標記了開始位置,字串長度,以及結束符。
7.2 Hash
ziplist壓縮連結串列
壓縮列表zlentry節點結構:每個zlentry由前一個節點的長度、encoding和entry-data三部分組成
前節點:(前節點佔用的記憶體位元組數)表示前1個zlentry的長度,privious_entry_length有兩種取值情況:1位元組或5位元組。取值1位元組時,表示上一個entry的長度小於254位元組。雖然1位元組的值能表示的數值範圍是0到255,但是壓縮列表中zlend的取值預設是255,因此,就預設用255表示整個壓縮列表的結束,其他表示長度的地方就不能再用255這個值了。所以,當上一個entry長度小於254位元組時,prev_len取值為1位元組,否則,就取值為5位元組。記錄長度的好處:佔用記憶體小,1或者5個位元組
enncoding:記錄節點的content儲存資料的型別和長度。
content:儲存實際資料內容
- 優點
1.犧牲讀取的效能,獲得高效的儲存空間,因為(簡短字串的情況)儲存指標比儲存entry長度更費記憶體。這是典型的“時間換空間”。
2.連結串列在記憶體中,一般是不連續的,遍歷相對比較慢,而ziplist可以很好的解決這個問題。如果知道了當前的起始地址,因為entry是連續的,entry後一定是另一個entry,想知道下一個entry的地址,只要將當前的起始地址加上當前entry總長度。如果還想遍歷下一個entry,只要繼續同樣的操作。
3.可以直接拿到連結串列長度 - 缺點
1.連鎖更新
壓縮列表每個節點正因為需要儲存前一個節點的長度欄位,就會有連鎖更新的隱患。當壓縮列表新增某個元素或修改某個元素時,如果空間不不夠,壓縮列表佔用的記憶體空間就需要重新分配。而當新插入的元素較大時,可能會導致後續元素的 prevlen 佔用空間都發生變化,從而引起「連鎖更新」問題,造成壓縮列表效能的下降。
listpack緊湊連結串列
和ziplist 列表項類似,listpack 列表項也包含了後設資料資訊和資料本身。不過,為了避免ziplist引起的連鎖更新問題,listpack 中的每個列表項
不再像ziplist列表項那樣儲存其前一個列表項的長度。
轉換關係
在redis7開始,捨棄ziplist,使用listpack
當hash集合滿足上圖設定值時,redis6使用ziplist儲存,redis7採用listpack儲存,當任意條件被破壞,將自動轉為hashtable,且不能向下轉型。
list
redis6之前,qucikList是由zipList和雙向連結串列linkedList組成的混合體。它將linkedList按段切分,單個節點使用zipList來緊湊儲存,多個zipList之間使用雙向指標串接起來。quickList中每個ziplist節點可以儲存多個元素,quickList內部預設單個zipList長度為8k位元組,即list-max-ziplist-size為 -2,超出了這個閾值,就會重新生成一個zipList來儲存資料。quickList中可以使用壓縮演算法對zipList進行進一步的壓縮,這個演算法就是LZF演算法,可以透過配置檔案配置壓縮配置項。
redis7開始,捨棄ziplist,使用listpack,其餘部分一致。
Set
set對應兩種編碼方式,intset和hashtable
若元素都是int型別的資料,且滿足對應的大小的要求,則採用對應的intset編碼(RedisObject的ptr指標指向intset)。
整數集合是有序的。當Redis集合型別的元素都是整數並且它們的值限制在64位(bit)表示的有符號整數範圍之內時,使用該結構來儲存。增刪改查就是陣列的增刪改查,但是會保證set的非重複性以及是否需要擴容等。
當插入一個非數字時,資料結構從IntList轉變為HashTable。
當元素數量超過512時,資料結構從IntList轉變為HashTable。
Zset
skiplist跳錶
skiplist是一種以空間換取時間的結構。
由於連結串列,無法進行二分查詢,因此借鑑資料庫索引的思想,提取出連結串列中關鍵節點(索引),先在關鍵節點上查詢,再進入下層連結串列查詢,提取多層關鍵節點,就形成了跳躍表,but由於索引也要佔據一定空間的,所以,索引新增的越多,空間佔用的越多。
缺點:
維護成本相對要高,在單連結串列中,一旦定位好要插入的位置,插入結點的時間複雜度是很低的,就是O(1)
but新增或者刪除時需要把所有索引都更新一遍,為了保證原始連結串列中資料的有序性,我們需要先找到要動作的位置,這個查詢操作就會比較耗時最後在新增和刪除的過程中的更新,時間複雜度也是O(log n)。
Zset的構成
當元素個數小於128且每個元素長度小於64位元組時,使用listpack(ziplist),當不滿足某個條件時,使用skiplist(map+skiplist)
map用來儲存member到score的對映,這樣就可以在O(1)時間內找到member對應的分數
skiplist按從小到大的順序儲存分數
skiplist每個元素的值都是[score,value]對
跳錶的最底層是有序的連結串列,所以可以實現有序輸出,同時map可以快速定位到member對應的節點,以及member的非重複性。
基本操作
1.查詢
對於zrangebyscore命令:score作為查詢的物件,在跳錶中跳躍查詢,遍歷score在min與max之間的skiplist底層對應的節點。
2.插入
在map中查詢value是否已存在,如果存在現需要在skiplist中找到對應的元素刪除,再在skiplist做插入
插入過程也是用score來作為查詢位置的依據,和skiplist插入元素方法一樣。並需要更新value->score的map
3.刪除
從map中找到value所對應的score,然後再在跳錶中搜尋這個score,value對應的節點,並刪除
8.IO多路複用
阻塞與非阻塞的討論物件是呼叫者,重點在於在等待返回結果的過程中是否能做其他操作。
https://blog.csdn.net/wangbuhu/article/details/125920083?spm=1001.2101.3001.6650.2&utm_medium=distribute.pc_relevant.none-task-blog-2~default~BlogCommendFromBaidu~Rate-2-125920083-blog-104455192.235^v43^pc_blog_bottom_relevance_base7&depth_1-utm_source=distribute.pc_relevant.none-task-blog-2~default~BlogCommendFromBaidu~Rate-2-125920083-blog-104455192.235^v43^pc_blog_bottom_relevance_base7&utm_relevant_index=5
多路複用快的原因在於,作業系統提供了這樣的系統呼叫,使得原來的 while 迴圈裡多次系統呼叫,
變成了一次系統呼叫 + 核心層遍歷這些檔案描述符。
epoll是現在最先進的IO多路複用器,Redis、Nginx,linux中的Java NIO都使用的是epoll。
這裡“多路”指的是多個網路連線,“複用”指的是複用同一個執行緒。
1、一個socket的生命週期中只有一次從使用者態複製到核心態的過程,開銷小
2、使用event事件通知機制,每次socket中有資料會主動通知核心,並加入到就緒連結串列中,不需要遍歷所有的socket
在多路複用IO模型中,會有一個核心執行緒不斷地去輪詢多個 socket 的狀態,只有當真正讀寫事件傳送時,才真正呼叫實際的IO讀寫操作。因為在多路複用IO模型中,只需要使用一個執行緒就可以管理多個socket,系統不需要建立新的程序或者執行緒,也不必維護這些執行緒和程序,並且只有真正有讀寫事件進行時,才會使用IO資源,所以它大大減少來資源佔用。多路I/O複用模型是利用 select、poll、epoll 可以同時監察多個流的 I/O 事件的能力,在空閒的時候,會把當前執行緒阻塞掉,當有一個或多個流有 I/O 事件時,就從阻塞態中喚醒,於是程式就會輪詢一遍所有的流(epoll 是隻輪詢那些真正發出了事件的流),並且只依次順序的處理就緒的流,這種做法就避免了大量的無用操作。 採用多路 I/O 複用技術可以讓單個執行緒高效的處理多個連線請求(儘量減少網路 IO 的時間消耗),且 Redis 在記憶體中運算元據的速度非常快,也就是說記憶體內的操作不會成為影響Redis效能的瓶頸