1. 程式人生 > >NewLife.Redis 100億小資料使用經驗技巧分享

NewLife.Redis 100億小資料使用經驗技巧分享

NewLife.Redis 100億資料使用經驗技巧分享

  • 本文包括Redis入門,基礎知識,NewLife.Redis使用,Redis使用技巧,100億小資料使用經驗技巧分享

介紹

  • NewLife.Redis主要作者及經驗介紹來源:大石頭
  • 原始碼: https://github.com/NewLifeX/NewLife.Redis
  • Nuget:NewLife.Redis
  • NewLife.Redis是一個Redis客戶端元件,以高效能處理大資料實時計算為目標。
  • Redis協議基礎實現Redis/RedisClient位於X元件,包含基礎字串操作。完整實現由獨立開源專案NewLife.Redis
    提供。NewLife.Redis為擴充套件實現,主要增加列表結構、雜湊結構、佇列等高階功能。
  • 採取連線池加同步阻塞架構,具有超低延遲(200~600us)以及超高吞吐量的特點。在物流行業大資料實時計算中廣泛應有,經過日均100億次呼叫量驗證。

特性

  • 在ZTO大資料實時計算廣泛應用,200多個Redis例項穩定工作一年多,每天處理近1億包裹資料,日均呼叫量80億次
  • 低延遲,Get/Set操作平均耗時200~600us(含往返網路通訊)
  • 大吞吐,自帶連線池,最大支援1000併發
  • 高效能,支援二進位制序列化

基礎知識準備

相關資源地址

Redis介紹

  • Redis的意思是REmote DIctionary Server,遠端字典服務。
  • Redis 是一個開源(BSD許可)的,記憶體中的資料結構儲存系統,它可以用作資料庫、快取和訊息中介軟體。
  • Redis與其他Key-Value儲存有何不同

    • Redis有著更為複雜的資料結構並且提供對他們的原子性操作,這是一個不同於其他資料庫的進化路徑。Redis的資料型別都是基於基本資料結構的同時對程式設計師透明,無需進行額外的抽象。
    • Redis執行在記憶體中但是可以持久化到磁碟,所以在對不同資料集進行高速讀寫時需要權衡記憶體,應為資料量不能大於硬體記憶體。在記憶體資料庫方面的另一個優點是, 相比在磁碟上相同的複雜的資料結構,在記憶體中操作起來非常簡單,這樣Redis可以做很多內部複雜性很強的事情。 同時,在磁碟格式方面他們是緊湊的以追加的方式產生的,因為他們並不需要進行隨機訪問。
  • Redis其實很簡單,最主要的操作是GetSet,操作的資料就是Key-Value鍵值對,鍵為字串,值是基礎資料型別、複雜資料型別

資料型別

字串(Strings)

  • 字串是一種最基本的Redis值型別。

列表(Lists)

  • Redis列表是簡單的字串列表,按照插入順序排序。

集合(Sets)

  • Redis集合是一個無序的字串合集。

雜湊(Hashes)

  • Redis Hashes是字串欄位和字串值之間的對映,因此它們是表示物件的完美資料型別(例如,具有多個欄位的使用者,如姓名,姓氏,年齡等)。

有序集合(Sorted sets)

  • Redis有序集合和Redis集合類似,是非重複的字串集合。不同之處在於,排序集的每個成員都與得分相關聯,用於從最小得分到最高得分排序。雖然成員是獨一無二的,但可以重複分數。

其它型別

命令

  • 命令大全,方便查詢: http://www.redis.cn/commands/
  • 向Redis服務端傳送命令,對資料進行操作,客戶端只需要傳送命令,接收結果

設定 SET

  • SET key value [EX seconds] [PX milliseconds] [NX|XX]

    • EX seconds – 設定鍵key的過期時間,單位時秒
    • PX milliseconds – 設定鍵key的過期時間,單位時毫秒
    • NX – 只有鍵key不存在的時候才會設定key的值
    • XX – 只有鍵key存在的時候才會設定key的值

獲取 GET

  • GET key

刪除 DEL

  • DEL key [key ...]

搜尋 KEYS

  • KEYS pattern

NewLife.Redis

  • 有了以上知識,那麼你就可以很容易地理解NewLife.Redis了
  • NewLife.Redis實際上就是實現了常用資料型別,傳送命令給Redis服務端對資料進行操作

安裝

一般安裝例項在伺服器,建議埠號從6001開始,一路排下去,方便計數。

  • 安裝完開啟命令列視窗,輸入redis-cli回車,即可進入redis環境,輸入命令可進行Redis操作,如果輸入正確的命令,它會自動出現提示,空格後輸入下一個引數即可
PS C:\Users\12504> redis-cli
127.0.0.1:6379> KEYS *
(empty list or set)
127.0.0.1:6379> KEYS *
(empty list or set)
127.0.0.1:6379>

連線

  • 例程位於原始碼Test專案

image

  • 連線Redis可以設定密碼,有兩種寫法,可以不用密碼和埠
  • 第二個引數是資料庫,0-15號,共16個,不寫預設是0號
// 例項化Redis,預設埠6379可以省略,密碼有兩種寫法
var ic1 = Redis.Create("127.0.0.1", 7);
var ic2 = Redis.Create("[email protected]:6379", 7);
var ic3 = Redis.Create("server=127.0.0.1:6379;password=pass", 7);
ic1.Log = XTrace.Log; // 除錯日誌。正式使用時註釋掉

基礎操作

  • 使用之前,進行註冊,將FullRedis註冊到物件容器,此物件容器2010年就已經存在了。不註冊將使用基礎Redis,無法使用高階功能
  • 檢視說明:使用了日誌輸出,=>代表執行結果,=>的上一行代表傳送到Redis執行的命令。所有時間開頭、數字、字母,比如22:32:33.354 1 N - 為NewLife.Caching.Redis自動註冊這種格式都是X元件的日誌輸出格式。
// 啟用FullRedis,否則Redis.Create會得到預設的Redis物件
FullRedis.Register();
  • 集合操作的 GetList/GetDictionary/GetQueue/GetSet 四個型別集合,分別代表Redis的列表、雜湊、佇列、Set集合等。基礎版Redis不支援這四個集合,完整版NewLife.Redis支援,MemoryCache則直接支援。

簡單操作

執行

XTrace.UseConsole(); // 將操作日誌重定向到控制檯

// 啟用FullRedis,否則Redis.Create會得到預設的Redis物件
FullRedis.Register();

var ic = Redis.Create("127.0.0.1:6379", 3);
ic.Log = XTrace.Log;

// 簡單操作
Console.WriteLine("共有快取物件 {0} 個", ic.Count);

ic.Set("name", "大石頭");
Console.WriteLine(ic.Get<String>("name"));

ic.Set("time", DateTime.Now, 1);
Console.WriteLine(ic.Get<DateTime>("time").ToFullString());
Thread.Sleep(1100);
Console.WriteLine(ic.Get<DateTime>("time").ToFullString());

輸出

22:32:33.354  1 N - 為NewLife.Caching.Redis自動註冊NewLife.Caching.FullRedis
22:32:33.441  1 N - SELECT 3
22:32:33.444  1 N - => OK
22:32:33.446  1 N - FullRedisPool.Init NewLife.Caching.RedisClient Min=2 Max=1000 IdleTime=20s AllIdleTime=120s
22:32:33.446  1 N - FullRedisPool.Acquire Create Free=0 Busy=1
22:32:33.447  1 N - DBSIZE
22:32:33.449  1 N - => 5
共有快取物件 5 個
22:32:33.456  1 N - SET name 大石頭
22:32:33.458  1 N - => OK
22:32:33.459  1 N - GET name
22:32:33.463  1 N - => 大石頭
大石頭
22:32:33.467  1 N - SETEX time 1 2018-11-12 22:32:33
22:32:33.470  1 N - => OK
22:32:33.472  1 N - GET time
22:32:33.474  1 N - => 2018-11-12 22:32:33
2018-11-12 22:32:33
22:32:34.584  1 N - GET time
0001-01-01 00:00:00
  • Set方法第一個引數是key;第二個引數是value,可以是任意型別;第三個是過期時間,單位是秒

字串和位元組陣列是特殊處理,原封不動傳到Redis儲存。其它複雜型別預設進行Json序列化,傳過去的是Json。所以取回來的時候根據型別處理,字串或位元組資料原樣返回,其它複雜型別進行Json反序列化處理。
Set命令一定一定要指定過期時間,不然一直留在記憶體裡很麻煩,寧願過期後重新寫入也不要讓它一直留在資料庫。

儲存物件

執行

    class Program
    {
        static void Main(String[] args)
        {
            XTrace.UseConsole();

            // 啟用FullRedis,否則Redis.Create會得到預設的Redis物件
            FullRedis.Register();

            Test5();

            Console.ReadKey();
        }
        class User
        {
            public String Name { get; set; }
            public DateTime CreateTime { get; set; }
        }
        static void Test5()
        {
            var user = new User { Name = "NewLife", CreateTime = DateTime.Now };
            var rds = Redis.Create("127.0.0.1",2);
            rds.Log = XTrace.Log;
            rds.Set("user", user, 3600);
            var user2 = rds.Get<User>("user");
            XTrace.WriteLine("Json: {0}", user2.ToJson());
            XTrace.WriteLine("Json: {0}", rds.Get<String>("user"));
            if (rds.ContainsKey("user")) XTrace.WriteLine("存在!");
            rds.Remove("user");
        }
    }

輸出

23:01:36.447  1 N - 為NewLife.Caching.Redis自動註冊NewLife.Caching.FullRedis
23:01:36.531  1 N - SELECT 2
23:01:36.534  1 N - => OK
23:01:36.536  1 N - FullRedisPool.Init NewLife.Caching.RedisClient Min=2 Max=1000 IdleTime=20s AllIdleTime=120s
23:01:36.536  1 N - FullRedisPool.Acquire Create Free=0 Busy=1
23:01:36.540  1 N - SETEX user 3600 [53]
23:01:36.544  1 N - => OK
23:01:36.546  1 N - GET user
23:01:36.550  1 N - => [53]
23:01:36.556  1 N - Json: {"Name":"NewLife","CreateTime":"2018-11-12 23:01:36"}
23:01:36.556  1 N - GET user
23:01:36.559  1 N - => [53]
23:01:36.560  1 N - Json: {"Name":"NewLife","CreateTime":"2018-11-12 23:01:36"}
23:01:36.561  1 N - EXISTS user
23:01:36.563  1 N - => 1
23:01:36.564  1 N - 存在!
23:01:36.565  1 N - DEL user
23:01:36.568  1 N - => 1
  • 儲存複雜物件時,預設採用Json序列化,所以上面可以按字串把結果取回來,發現正是Json字串。Redis的strings,實質上就是帶有長度字首的二進位制資料,[53]表示一段53位元組長度的二進位制資料。
  • 所以這個Set操作,在Redis那邊對應的資料型別都是strings。

列表List操作

執行

// 列表
var list = ic.GetList<DateTime>("list");
list.Add(DateTime.Now);
list.Add(DateTime.Now.Date);
list.RemoveAt(1);
Console.WriteLine(list[list.Count - 1].ToFullString());

輸出

16:09:44.571  1 N - RPUSH list 2018-11-14 16:09:44
16:09:44.576  1 N - => 5
16:09:44.613  1 N - RPUSH list 2018-11-14 00:00:00
16:09:44.618  1 N - => 6
16:09:44.619  1 N - LINDEX list 1
16:09:44.623  1 N - => 2018-11-12 00:00:00
16:09:44.624  1 N - LREM list 1 2018-11-12 00:00:00
16:09:44.628  1 N - => 1
16:09:44.634  1 N - LLEN list
16:09:44.645  1 N - => 5
16:09:44.646  1 N - LINDEX list 4
16:09:44.651  1 N - => 2018-11-14 00:00:00
2018-11-14 00:00:00
  • 通過GetList返回一個IList結構,這一操作沒有向Redis傳送命令,只有AddRemove或者計算Count的時候會向Redis傳送命令
  • 用途,舉個物聯網的栗子:物聯網裝置源源不斷上傳資料,如果每次上傳資料都寫入資料,資料庫可能會受不了的,怎麼辦?這時候就可以把每一條資料放到Redis,放到上面說的List裡面,湊到一定程度,然後整批拿出來。比如一分鐘來了一萬行資料,從Redis裡面拿出來,再來個批操作把這些資料一次寫入資料庫。這個功能XCode有實現,如何提升批操作效能?後面XCode教程會講。

  • 技巧,key構建:根據自己的資料構造,比如一分鐘或者十分鐘插入一次,以這個時間為單位,用一個字首,加上年月日時分作為一個key,新的資料寫入新的key。這樣在資料寫入資料庫的時候,新的資料寫入新的key,兩邊都不影響。在資料都寫入資料庫之後,再通過這個key幹掉這一萬資料。

字典操作

執行

var dic = ic.GetDictionary<DateTime>("dic");
dic.Add("xxx", DateTime.Now);
Console.WriteLine(dic["xxx"].ToFullString());

輸出

17:03:42.526  1 N - HSET dic xxx 2018-11-14 17:03:42
17:03:42.578  1 N - => 0
17:03:42.639  1 N - HGET dic xxx
17:03:42.664  1 N - => 2018-11-14 17:03:42
2018-11-14 17:03:42
  • GetDictionary方法也是返回IDictionary介面型別變數,此型別適合存物件,比如使用者物件,有很多個屬性。相比存json,它的優勢是按需讀取。當物件的屬性特別多時,優勢更加明顯。

佇列操作

執行

var mq = ic.GetQueue<String>("queue");
mq.Add(new[] { "abc", "g", "e", "m" });
var arr = mq.Take(3);
Console.WriteLine(arr.Join(","));

輸出

17:03:42.710  1 N - RPUSH queue abc g e m
17:03:42.781  1 N - => 9
17:03:42.917  1 N - LPOP queue
17:03:43.096  1 N - => abc
17:03:43.101  1 N - LPOP queue
17:03:43.105  1 N - => g
17:03:43.106  1 N - LPOP queue
17:03:43.118  1 N - => e
abc,g,e
  • 佇列其實也是List實現的,這裡做了個優化,可以新增一批。示例加了一批資料,也拿了一批。
  • 一個使用場景是消峰、錯峰。上下游系統中,上游資料量突然爆發,下游一時處理不了,最簡單的方案就是就Redis佇列。上游往佇列推資料,下游慢慢消費、處理資料。

  • 另一個變態的用途,是可以用來實現跨語言網路通訊。所有語言都支援Redis,使用佇列,一個接收資料放入佇列一個消費資料寫入資料庫等。比如前面使用go語言,消耗記憶體少,接收訊息推進佇列;後面使用C#或者Java從佇列拿出來處理業務,寫入資料庫。這樣就實現了跨語言的高效通訊,效率極高。此功能雖然沒實踐過,不過挺好用,有需要的可以試下。

集合操作

執行

var set = ic.GetSet<String>("181110_1234");
set.Add("xx1");
set.Add("xx2");
set.Add("xx3");
Console.WriteLine(set.Count);
Console.WriteLine(set.Contains("xx2"));

輸出

17:03:43.129  1 N - SADD 181110_1234 xx1
17:03:43.134  1 N - => 0
17:03:43.140  1 N - SADD 181110_1234 xx2
17:03:43.150  1 N - => 0
17:03:43.166  1 N - SADD 181110_1234 xx3
17:03:43.185  1 N - => 0
17:03:43.191  1 N - SCARD 181110_1234
17:03:43.198  1 N - => 3
3
17:03:43.249  1 N - SISMEMBER 181110_1234 xx2
17:03:43.254  1 N - => 1
True
  • 集合也比較常用,它其實是個Set結構,往裡面新增資料,然後判斷下是否包含。所以集合比較合適精確判斷的去重功能的場景。比如業務上有幾千萬訂單一天,訂單號可能會重複,想要統計一下今天一共有多少訂單,如果在資料庫執行GroupBy分組不太方便,所以業務統計可以用這個Set結構去重,實際使用可能還要更復雜一點。一般我們做五千萬級別的去重,所佔記憶體也不少,也就是寫入五千萬個訂單號,後面處理的時候判斷一下這個訂單號處理過沒有。
  • 實戰經驗:有一個功能是快遞攬收,就是商家發貨了,快遞網點要把它收回來,但是收回來之前,網點不知道它有多少貨。所以做一個功能,商家發貨了就把訂單號發到快遞公司,以時間和網點編號為key,比如key為上面的181110_1234。也就是編號為1234的網點在18-11-10這天快遞公司收到所有的訂單都放在這個key裡面,然後利用Set結構的去重功能,寫過一次的訂單不會再次新增,所以訂單重複提交都沒有問題。這是第一個功能,第二個功能是,網點攬收之後,再告訴快遞公司這個單被攬收了,這時候把這個訂單從181110_1234這個key裡面刪掉,最後Set裡面剩下的訂單,就是18-11-10這天1234網點未攬收訂單。

  • 另外,如果網點太多,訂單太多,可以用網點id做個雜湊,再分攤到32甚至64臺Redis上,這樣不管多少網點多少訂單都可以把資料攤開。

  • Redis還有個型別HyperLogLogs可以去重,能達到百億級別,但是有一定機率誤判。還有一個去重過濾的是布隆過濾器(Bloom Filter),可用於爬蟲url去重等。

批量操作

執行:

var dic = new Dictionary<String, Object>
{
    ["name"] = "NewLife",
    ["time"] = DateTime.Now,
    ["count"] = 1234
};
rds.SetAll(dic, 120);

var vs = rds.GetAll<String>(dic.Keys);
XTrace.WriteLine(vs.Join(",", e => $"{e.Key}={e.Value}"));

結果:

MSET name NewLife time 2018-09-25 15:56:26 count 1234
=> OK
EXPIRE name 120
EXPIRE time 120
EXPIRE count 120
MGET name time count
name=NewLife,time=2018-09-25 15:56:26,count=1234
  • GetAll/SetAll 在Redis上是很常用的批量操作,同時獲取或設定多個key,一般有10倍以上吞吐量。
  • 一次GetAll的時間大概是一次Get的一點幾倍,一般建議如果需要兩次以上的Get操作,直接用GetAll。

高階操作

執行:

var flag = rds.Add("count", 5678);
XTrace.WriteLine(flag ? "Add成功" : "Add失敗");
var ori = rds.Replace("count", 777);
var count = rds.Get<Int32>("count");
XTrace.WriteLine("count由{0}替換為{1}", ori, count);

rds.Increment("count", 11);
var count2 = rds.Decrement("count", 10);
XTrace.WriteLine("count={0}", count2);

結果:

SETNX count 5678
=> 0
Add失敗
GETSET count 777
=> 1234
GET count
=> 777
count由1234替換為777
INCRBY count 11
=> 788
DECRBY count 10
=> 778
count=778

效能測試

執行:

var ic = Redis.Create("127.0.0.1:6379", 5);
//var ic = new MemoryCache();
ic.Bench();

輸出:

10:39:56.509  1 N - 為NewLife.Caching.Redis自動註冊NewLife.Caching.FullRedis
10:39:56.512  1 N - 目標伺服器:127.0.0.1:6379/5
10:39:56.514  1 N - FullRedis效能測試[隨機],批大小[100],邏輯處理器 4 個 3,192MHz-Intel(R) Core(TM) i5-6500 CPU @ 3.20GHz
10:39:56.515  1 N -
10:39:56.515  1 N - 測試 100,000 項,  1 執行緒
10:39:57.063  1 N - 賦值 100,000 項,  1 執行緒,耗時     457ms 速度   218,818 ops
10:39:58.227  1 N - 讀取 100,000 項,  1 執行緒,耗時   1,162ms 速度    86,058 ops
10:39:58.854  1 N - 刪除 100,000 項,  1 執行緒,耗時     625ms 速度   160,000 ops
10:39:59.518  1 N - 累加 100,000 項,  1 執行緒,耗時     662ms 速度   151,057 ops
10:39:59.529  1 N -
10:39:59.536  1 N - 測試 200,000 項,  2 執行緒
10:40:00.407  1 N - 賦值 200,000 項,  2 執行緒,耗時     829ms 速度   241,254 ops
10:40:02.110  1 N - 讀取 200,000 項,  2 執行緒,耗時   1,688ms 速度   118,483 ops
10:40:03.244  1 N - 刪除 200,000 項,  2 執行緒,耗時   1,133ms 速度   176,522 ops
10:40:04.502  1 N - 累加 200,000 項,  2 執行緒,耗時   1,256ms 速度   159,235 ops
10:40:04.502  1 N -
10:40:04.502  1 N - 測試 800,000 項,  8 執行緒
10:40:07.641  1 N - 賦值 800,000 項,  8 執行緒,耗時   3,132ms 速度   255,427 ops
10:40:13.937  1 N - 讀取 800,000 項,  8 執行緒,耗時   6,282ms 速度   127,347 ops
10:40:18.735  1 N - 刪除 800,000 項,  8 執行緒,耗時   4,796ms 速度   166,805 ops
10:40:23.519  1 N - 累加 800,000 項,  8 執行緒,耗時   4,782ms 速度   167,294 ops
10:40:23.523  1 N -
10:40:23.523  1 N - 測試 400,000 項,  4 執行緒
10:40:24.999  1 N - 賦值 400,000 項,  4 執行緒,耗時   1,466ms 速度   272,851 ops
10:40:28.035  1 N - 讀取 400,000 項,  4 執行緒,耗時   3,019ms 速度   132,494 ops
10:40:30.318  1 N - 刪除 400,000 項,  4 執行緒,耗時   2,282ms 速度   175,284 ops
10:40:32.694  1 N - 累加 400,000 項,  4 執行緒,耗時   2,375ms 速度   168,421 ops
10:40:32.695  1 N -
10:40:32.695  1 N - 測試 400,000 項, 64 執行緒
10:40:34.342  1 N - 賦值 400,000 項, 64 執行緒,耗時   1,639ms 速度   244,051 ops
10:40:37.460  1 N - 讀取 400,000 項, 64 執行緒,耗時   3,106ms 速度   128,783 ops
10:40:40.201  1 N - 刪除 400,000 項, 64 執行緒,耗時   2,739ms 速度   146,038 ops
10:40:42.737  1 N - 累加 400,000 項, 64 執行緒,耗時   2,535ms 速度   157,790 ops
  • 測試效能和機器配置有關,Bench方法用不同執行緒數量分多組進行添刪改壓力測試,
  • rand引數設定是否隨機讀寫
  • batch設定批大小,分批執行操作,藉助GetAll/SetAll進行優化
  • 管道,StartPipeline方法開啟管道,StopPipeline結束管道,Commit方法提交變更,傳送那兩個方法中間的所有進入管道的命令。可用AutoPipeline屬性,設定自動管道,預設設定100,達到設定值自動提交,無分批時開啟管道操作,對添刪改優化。

經驗技巧總結

抄自原始碼的README:

  • 在Linux上多例項部署,例項個數等於處理器個數,各例項最大記憶體直接為本機實體記憶體,避免單個例項記憶體撐爆
  • 把海量資料(10億+)根據key雜湊(Crc16/Crc32)存放在多個例項上,讀寫效能成倍增長
  • 採用二進位制序列化,而非常見Json序列化
  • 合理設計每一對Key的Value大小,包括但不限於使用批量獲取,原則是讓每次網路包控制在1.4k位元組附近,減少通訊次數
  • Redis客戶端的Get/Set操作平均耗時200~600us(含往返網路通訊),以此為參考評估網路環境和Redis客戶端元件
  • 使用管道Pipeline合併一批命令
  • Redis的主要效能瓶頸是序列化、網路頻寬和記憶體大小,濫用時處理器也會達到瓶頸
  • 以上經驗,源自於300多個例項4T以上空間一年多穩定工作的經驗,並按照重要程度排了先後順序,可根據場景需要酌情采用!

Redis的兄弟姐妹

  • Redis實現ICache介面,它的孿生兄弟MemoryCache,記憶體快取,千萬級吞吐率。各應用強烈建議使用ICache介面編碼設計,小資料時使用MemoryCache實現;資料增大(10萬)以後,改用Redis實現,不需要修改業務程式碼。

寫在最後

  • 切不可道聽途說,不可完全照搬,真假自己試一下就知道啦,試一下比什麼都強!
  • 不常用功能沒有封裝,暫不支援叢集,後面一定會支援。