Hello 大家好,我是TANZAME,我们又见面了。今天我们来聊聊怎么手撸一个 Redis Cluster 集群客户端,纯手工有干货,您细品。

  随着业务增长,线上环境的QPS暴增,自然而然将当前的单机 Redis 切换到群集模式。燃鹅,我们悲剧地发现,ServiceStack.Redis这个官方推荐的 .NET 客户端并没有支持集群模式。一通度娘翻墙无果后,决定自己强撸一个基于ServiceStack.Redis的Redis集群访问组件。

SRE实战 互联网时代守护先锋,助力企业售后服务体系运筹帷幄!一键直达领取阿里云限量特价优惠。

  话不多说,先上运行效果图:

【原创】强撸基于 .NET 的 Redis Cluster 集群访问组件 Nosql 第1张

 

  Redis-Cluster集群使用 hash slot 算法对每个key计算CRC16值,然后对16383取模,可以获取key对应的 hash slot。Redis-Cluster中每个master都会持有部分 slot,在访问key时根据计算出来的hash slot去找到具体的master节点,再由当前找到的节点去执行具体的 Redis 命令(具体可查阅官方说明文档)。

  由于 ServiceStack.Redis已经实现了单个实例的Redis命令,因此我们可以将即将要实现的 Redis 集群客户端当做一个代理,它只负责计算 key 落在哪一个具体节点(寻址)然后将Redis命令转发给对应的节点执行即可。

  ServiceStack.Redis的RedisClient是非线程安全的,ServiceStack.Redis 使用缓存客户端管理器(PooledRedisClientManager)来提高性能和并发能力,我们的Redis Cluster集群客户端也应集成PooledRedisClientManager来获取 RedisClient 实例。

  同时,Redis-Cluster集群支持在线动态扩容和slot迁移,我们的Redis集群客户端也应具备自动智能发现新节点和自动刷新 slot 分布的能力。

  总结起来,要实现一个Redis-Cluster客户端,需要实现以下几个要点:

  如下面类图所示,接下来我们详细分析具体的代码实现。

【原创】强撸基于 .NET 的 Redis Cluster 集群访问组件 Nosql 第2张

  

  一、CRC16  

  CRC即循环冗余校验码,是信息系统中一种常见的检错码。CRC校验码不同的机构有不同的标准,这里Redis遵循的标准是CRC-16-CCITT标准,这也是被XMODEM协议使用的CRC标准,所以也常用XMODEM CRC代指,是比较经典的“基于字节查表法的CRC校验码生成算法”。 

 1 /// <summary>
 2 /// 根据 key 计算对应的哈希槽
 3 /// </summary>
 4 public static int GetSlot(string key)
 5 {
 6     key = CRC16.ExtractHashTag(key);
 7     // optimization with modulo operator with power of 2 equivalent to getCRC16(key) % 16384
 8     return GetCRC16(key) & (16384 - 1);
 9 }
10 
11 /// <summary>
12 /// 计算给定字节组的 crc16 检验码
13 /// </summary>
14 public static int GetCRC16(byte[] bytes, int s, int e)
15 {
16     int crc = 0x0000;
17 
18     for (int i = s; i < e; i++)
19     {
20         crc = ((crc << 8) ^ LOOKUP_TABLE[((crc >> 8) ^ (bytes[i] & 0xFF)) & 0xFF]);
21     }
22     return crc & 0xFFFF;
23 }

 

  二、读取集群节点

  从集群中的任意节点使用 CLUSTER NODES 命令可以读取到集群中所有的节点信息,包括连接状态,它们的标志,属性和分配的槽等等。CLUSTER NODES 以串行格式提供所有这些信息,输出示例:

d99b65a25ef726c64c565901e345f98c496a1a47 127.0.0.1:7007 master - 0 1592288083308 8 connected
2d71879d6529d1edbfeed546443051986245c58e 127.0.0.1:7003 master - 0 1592288084311 11 connected 10923-16383
654cdc25a5fa11bd44b5b716cdf07d4ce176efcd 127.0.0.1:7005 slave 484e73948d8aacd8327bf90b89469b52bff464c5 0 1592288085313 10 connected
ed65d52dad7ef6854e0e261433b56a551e5e11cb 127.0.0.1:7004 slave 754d0ec7a7f5c7765f784a6a2c370ea38ea0c089 0 1592288081304 9 connected
754d0ec7a7f5c7765f784a6a2c370ea38ea0c089 127.0.0.1:7001 master - 0 1592288080300 9 connected 0-5460
484e73948d8aacd8327bf90b89469b52bff464c5 127.0.0.1:7002 master - 0 1592288082306 10 connected 5461-10922
2223bc6d099bd9838e5d2f1fbd9a758f64c554c4 127.0.0.1:7006 myself,slave 2d71879d6529d1edbfeed546443051986245c58e 0 0 6 connected

  每个字段的含义如下:

  1. id:节点 ID,一个40个字符的随机字符串,当一个节点被创建时不会再发生变化(除非CLUSTER RESET HARD被使用)。

  2. ip:port:客户端应该联系节点以运行查询的节点地址。

  3. flags:逗号列表分隔的标志:myselfmasterslavefail?failhandshakenoaddrnoflags。标志在下一节详细解释。

  4. master:如果节点是从属节点,并且主节点已知,则节点ID为主节点,否则为“ - ”字符。

  5. ping-sent:以毫秒为单位的当前激活的ping发送的unix时间,如果没有挂起的ping,则为零。

  6. pong-recv:毫秒 unix 时间收到最后一个乒乓球。

  7. config-epoch:当前节点(或当前主节点,如果该节点是从节点)的配置时期(或版本)。每次发生故障切换时,都会创建一个新的,唯一的,单调递增的配置时期。如果多个节点声称服务于相同的哈希槽,则具有较高配置时期的节点将获胜。

  8. link-state:用于节点到节点集群总线的链路状态。我们使用此链接与节点进行通信。可以是connecteddisconnected

  9. slot:散列槽号或范围。从参数9开始,但总共可能有16384个条目(限制从未达到)。这是此节点提供的散列槽列表。如果条目仅仅是一个数字,则被解析为这样。如果它是一个范围,它是在形式start-end,并且意味着节点负责所有散列时隙从startend包括起始和结束值。

标志的含义(字段编号3):

  • myself:您正在联系的节点。
  • master:节点是主人。
  • slave:节点是从属的。
  • fail?:节点处于PFAIL状态。对于正在联系的节点无法访问,但仍然可以在逻辑上访问(不处于FAIL状态)。
  • fail:节点处于FAIL状态。对于将PFAIL状态提升为FAIL的多个节点而言,这是无法访问的。
  • handshake:不受信任的节点,我们握手。
  • noaddr:此节点没有已知的地址。
  • noflags:根本没有标志。
【原创】强撸基于 .NET 的 Redis Cluster 集群访问组件 Nosql 第3张
  1 // 读取集群上的节点信息
  2 static IList<InternalClusterNode> ReadClusterNodes(IEnumerable<ClusterNode> source)
  3 {
  4     RedisClient c = null;
  5     StringReader reader = null;
  6     IList<InternalClusterNode> result = null;
  7 
  8     int index = 0;
  9     int rowCount = source.Count();
 10 
 11     foreach (var node in source)
 12     {
 13         try
 14         {
 15             // 从当前节点读取REDIS集群节点信息
 16             index += 1;
 17             c = new RedisClient(node.Host, node.Port, node.Password);
 18             RedisData data = c.RawCommand("CLUSTER".ToUtf8Bytes(), "NODES".ToUtf8Bytes());
 19             string info = Encoding.UTF8.GetString(data.Data);
 20 
 21             // 将读回的字符文本转成强类型节点实体
 22             reader = new StringReader(info);
 23             string line = reader.ReadLine();
 24             while (line != null)
 25             {
 26                 if (result == null) result = new List<InternalClusterNode>();
 27                 InternalClusterNode n = InternalClusterNode.Parse(line);
 28                 n.Password = node.Password;
 29                 result.Add(n);
 30 
 31                 line = reader.ReadLine();
 32             }
 33 
 34             // 只要任意一个节点拿到集群信息,直接退出
 35             if (result != null && result.Count > 0) break;
 36         }
 37         catch (Exception ex)
 38         {
 39             // 出现异常,如果还没到最后一个节点,则继续使用下一下节点读取集群信息
 40             // 否则抛出异常
 41             if (index < rowCount)
 42                 Thread.Sleep(100);
 43             else
 44                 throw new RedisClusterException(ex.Message, c != null ? c.GetHostString() : string.Empty, ex);
 45         }
 46         finally
 47         {
 48             if (reader != null) reader.Dispose();
 49             if (c != null) c.Dispose();
 50         }
 51     }
 52 
 53 
 54     if (result == null)
 55         result = new List<InternalClusterNode>(0);
 56     return result;
 57 }
 58 
 59 /// <summary>
 60 /// 从 cluster nodes 的每一行命令里读取出集群节点的相关信息
 61 /// </summary>
 62 /// <param name="line">集群命令</param>
 63 /// <returns></returns>
 64 public static InternalClusterNode Parse(string line)
 65 {
 66     if (string.IsNullOrEmpty(line))
 67         throw new ArgumentException("line");
 68 
 69     InternalClusterNode node = new InternalClusterNode();
 70     node._nodeDescription = line;
 71     string[] segs = line.Split(' ');
 72 
 73     node.NodeId = segs[0];
 74     node.Host = segs[1].Split(':')[0];
 75     node.Port = int.Parse(segs[1].Split(':')[1]);
 76     node.MasterNodeId = segs[3] == "-" ? null : segs[3];
 77     node.PingSent = long.Parse(segs[4]);
 78     node.PongRecv = long.Parse(segs[5]);
 79     node.ConfigEpoch = int.Parse(segs[6]);
 80     node.LinkState = segs[7];
 81 
 82     string[] flags = segs[2].Split(',');
 83     node.IsMater = flags[0] == MYSELF ? flags[1] == MASTER : flags[0] == MASTER;
 84     node.IsSlave = !node.IsMater;
 85     int start = 0;
 86     if (flags[start] == MYSELF)
 87         start = 1;
 88     if (flags[start] == SLAVE || flags[start] == MASTER)
 89         start += 1;
 90     node.NodeFlag = string.Join(",", flags.Skip(start));
 91 
 92     if (segs.Length > 8)
 93     {
 94         string[] slots = segs[8].Split('-');
 95         node.Slot.Start = int.Parse(slots[0]);
 96         if (slots.Length > 1) node.Slot.End = int.Parse(slots[1]);
 97 
 98         for (int index = 9; index < segs.Length; index++)
 99         {
100             if (node.RestSlots == null)
101                 node.RestSlots = new List<HashSlot>();
102 
103             slots = segs[index].Split('-');
104 
105             int s1 = 0;
106             int s2 = 0;
107             bool b1 = int.TryParse(slots[0], out s1);
108             bool b2 = int.TryParse(slots[1], out s2);
109             if (!b1 || !b2)
110                 continue;
111             else
112                 node.RestSlots.Add(new HashSlot(s1, slots.Length > 1 ? new Nullable<int>(s2) : null));
113         }
114     }
115 
116     return node;
117 }
View Code

 

  三、为节点分配缓存客户端管理器

  在单实例的Redis中,我们通过 PooledRedisClientManager 这个管理器来获取RedisClient。借鉴这个思路,在Redis Cluster集群中,我们为每一个主节点实例化一个 PooledRedisClientManager,并且该主节点持有的 slot 都共享一个 PooledRedisClientManager 实例。以 slot 做为 key 将 slot 与 PooledRedisClientManager 一一映射并缓存起来。

【原创】强撸基于 .NET 的 Redis Cluster 集群访问组件 Nosql 第5张
 1 // 初始化集群管理
 2 void Initialize(IList<InternalClusterNode> clusterNodes = null)
 3 {
 4     // 从 redis 读取集群信息
 5     IList<InternalClusterNode> nodes = clusterNodes == null ? RedisCluster.ReadClusterNodes(_source) : clusterNodes;
 6 
 7     // 生成主节点,每个主节点的 slot 对应一个REDIS客户端缓冲池管理器
 8     IList<InternalClusterNode> masters = null;
 9     IDictionary<int, PooledRedisClientManager> managers = null;
10     foreach (var n in nodes)
11     {
12         // 节点无效或者
13         if (!(n.IsMater &&
14             !string.IsNullOrEmpty(n.Host) &&
15             string.IsNullOrEmpty(n.NodeFlag) &&
16             (string.IsNullOrEmpty(n.LinkState) || n.LinkState == InternalClusterNode.CONNECTED))) continue;
17 
18         n.SlaveNodes = nodes.Where(x => x.MasterNodeId == n.NodeId);
19         if (masters == null)
20             masters = new List<InternalClusterNode>();
21         masters.Add(n);
22 
23         // 用每一个主节点的哈希槽做键,导入REDIS客户端缓冲池管理器
24         // 然后,方法表指针(又名类型对象指针)上场,占据 4 个字节。 4 * 16384 / 1024 = 64KB
25         if (managers == null)
26             managers = new Dictionary<int, PooledRedisClientManager>();
27 
28         string[] writeHosts = new[] { n.HostString };
29         string[] readHosts = n.SlaveNodes.Where(n => false).Select(n => n.HostString).ToArray();
30         var pool = new PooledRedisClientManager(writeHosts, readHosts, _config);
31         managers.Add(n.Slot.Start, pool);
32         if (n.Slot.End != null)
33         {
34             // 这个范围内的哈希槽都用同一个缓冲池
35             for (int s = n.Slot.Start + 1; s <= n.Slot.End.Value; s++)
36                 managers.Add(s, pool);
37         }
38         if (n.RestSlots != null)
39         {
40             foreach (var slot in n.RestSlots)
41             {
42                 managers.Add(slot.Start, pool);
43                 if (slot.End != null)
44                 {
45                     // 这个范围内的哈希槽都用同一个缓冲池
46                     for (int s = slot.Start + 1; s <= slot.End.Value; s++)
47                         managers.Add(s, pool);
48                 }
49             }
50         }
51     }
52 
53     _masters = masters;
54     _redisClientManagers = managers;
55     _clusterNodes = nodes != null ? nodes : null;
56 
57     if (_masters == null) _masters = new List<InternalClusterNode>(0);
58     if (_clusterNodes == null) _clusterNodes = new List<InternalClusterNode>(0);
59     if (_redisClientManagers == null) _redisClientManagers = new Dictionary<int, PooledRedisClientManager>(0);
60 
61     if (_masters.Count > 0)
62         _source = _masters.Select(n => new ClusterNode(n.Host, n.Port, n.Password)).ToList();
63 }
View Code

 

  四、将 hash slot 路由到正确的节点

  在访问一个 key 时,根据第三步缓存起来的 PooledRedisClientManager ,用 key 计算出来的 hash slot 值可以快速找出这个 key 对应的 PooledRedisClientManager 实例,调用 PooledRedisClientManager.GetClient() 即可将 hash slot 路由到正确的主节点。

 1 // 执行指定动作并返回值
 2 private T DoExecute<T>(string key, Func<RedisClient, T> action) => this.DoExecute(() => this.GetRedisClient(key), action);
 3 
 4 // 执行指定动作并返回值
 5 private T DoExecute<T>(Func<RedisClient> slot, Func<RedisClient, T> action, int tryTimes = 1)
 6 {
 7     RedisClient c = null;
 8     try
 9     {
10         c = slot();
11         return action(c);
12     }
13     catch (Exception ex)
14     {
15         // 此处省略 ...
16     }
17     finally
18     {
19         if (c != null)
20             c.Dispose();
21     }
22 }
23 
24 // 获取指定key对应的主设备节点
25 private RedisClient GetRedisClient(string key)
26 {
27     if (string.IsNullOrEmpty(key))
28         throw new ArgumentNullException("key");
29 
30     int slot = CRC16.GetSlot(key);
31     if (!_redisClientManagers.ContainsKey(slot))
32         throw new SlotNotFoundException(string.Format("No reachable node in cluster for slot {{{0}}}", slot), slot, key);
33 
34     var pool = _redisClientManagers[slot];
35     return (RedisClient)pool.GetClient();
36 }

   

  五、自动发现新节点和自动刷新slot分布

  在实际生产环境中,Redis 集群经常会有添加/删除节点、迁移 slot 、主节点宕机从节点转主节点等,针对这些情况,我们的 Redis Cluster 组件必须具备自动发现节点和刷新在 第三步  缓存起来的 slot 的能力。在这里我的实现思路是当节点执行 Redis 命令时返回 RedisException 异常时就强制刷新集群节点信息并重新缓存 slot 与 节点之间的映射。

【原创】强撸基于 .NET 的 Redis Cluster 集群访问组件 Nosql 第7张
  1 // 执行指定动作并返回值
  2 private T DoExecute<T>(Func<RedisClient> slot, Func<RedisClient, T> action, int tryTimes = 1)
  3 {
  4     RedisClient c = null;
  5     try
  6     {
  7         c = slot();
  8         return action(c);
  9     }
 10     catch (Exception ex)
 11     {
 12         if (!(ex is RedisException) || tryTimes == 0) throw new RedisClusterException(ex.Message, c != null ? c.GetHostString() : string.Empty, ex);
 13         else
 14         {
 15             tryTimes -= 1;
 16             // 尝试重新刷新集群信息
 17             bool isRefresh = DiscoveryNodes(_source, _config);
 18             if (isRefresh)
 19                 // 集群节点有更新过,重新执行
 20                 return this.DoExecute(slot, action, tryTimes);
 21             else
 22                 // 集群节点未更新过,直接抛出异常
 23                 throw new RedisClusterException(ex.Message, c != null ? c.GetHostString() : string.Empty, ex);
 24         }
 25     }
 26     finally
 27     {
 28         if (c != null)
 29             c.Dispose();
 30     }
 31 }
 32 
 33 // 重新刷新集群信息
 34 private bool DiscoveryNodes(IEnumerable<ClusterNode> source, RedisClientManagerConfig config)
 35 {
 36     bool lockTaken = false;
 37     try
 38     {
 39         // noop
 40         if (_isDiscoverying) { }
 41 
 42         Monitor.Enter(_objLock, ref lockTaken);
 43 
 44         _source = source;
 45         _config = config;
 46         _isDiscoverying = true;
 47 
 48         // 跟上次同步时间相隔 {MONITORINTERVAL} 秒钟以上才需要同步
 49         if ((DateTime.Now - _lastDiscoveryTime).TotalMilliseconds >= MONITORINTERVAL)
 50         {
 51             bool isRefresh = false;
 52             IList<InternalClusterNode> newNodes = RedisCluster.ReadClusterNodes(_source);
 53             foreach (var node in newNodes)
 54             {
 55                 var n = _clusterNodes.FirstOrDefault(x => x.HostString == node.HostString);
 56                 isRefresh =
 57                     n == null ||                        // 新节点                                                                
 58                     n.Password != node.Password ||      // 密码变了                                                                
 59                     n.IsMater != node.IsMater ||        // 主变从或者从变主                                                                
 60                     n.IsSlave != node.IsSlave ||        // 主变从或者从变主                                                                
 61                     n.NodeFlag != node.NodeFlag ||      // 节点标记位变了                                                                
 62                     n.LinkState != node.LinkState ||    // 节点状态位变了                                                                
 63                     n.Slot.Start != node.Slot.Start ||  // 哈希槽变了                                                                
 64                     n.Slot.End != node.Slot.End ||      // 哈希槽变了
 65                     (n.RestSlots == null && node.RestSlots != null) ||
 66                     (n.RestSlots != null && node.RestSlots == null);
 67                 if (!isRefresh && n.RestSlots != null && node.RestSlots != null)
 68                 {
 69                     var slots1 = n.RestSlots.OrderBy(x => x.Start).ToList();
 70                     var slots2 = node.RestSlots.OrderBy(x => x.Start).ToList();
 71                     for (int index = 0; index < slots1.Count; index++)
 72                     {
 73                         isRefresh =
 74                             slots1[index].Start != slots2[index].Start ||   // 哈希槽变了                                                                
 75                             slots1[index].End != slots2[index].End;         // 哈希槽变了
 76                         if (isRefresh) break;
 77                     }
 78                 }
 79 
 80                 if (isRefresh) break;
 81             }
 82 
 83             if (isRefresh)
 84             {
 85                 // 重新初始化集群
 86                 this.Dispose();
 87                 this.Initialize(newNodes);
 88                 this._lastDiscoveryTime = DateTime.Now;
 89             }
 90         }
 91 
 92         // 最后刷新时间在 {MONITORINTERVAL} 内,表示是最新群集信息 newest
 93         return (DateTime.Now - _lastDiscoveryTime).TotalMilliseconds < MONITORINTERVAL;
 94     }
 95     finally
 96     {
 97         if (lockTaken)
 98         {
 99             _isDiscoverying = false;
100             Monitor.Exit(_objLock);
101         }
102     }
103 }
View Code

 

  六、配置访问组件调用入口

  最后我们需要为组件提供访问入口,我们用 RedisCluster 类实现 字符串、列表、哈希、集合、有序集合和Keys的基本操作,并且用 RedisClusterFactory 工厂类对外提供单例操作,这样就可以像单实例 Redis 那样调用 Redis Cluster 集群。调用示例:

var node = new ClusterNode("127.0.0.1", 7001);
var redisCluster = RedisClusterFactory.Configure(node, config);
string key = "B070x14668";
redisCluster.Set(key, key);
string value = redisCluster.Get<string>(key);
redisCluster.Del(key);
【原创】强撸基于 .NET 的 Redis Cluster 集群访问组件 Nosql 第9张
 1 /// <summary>
 2 /// REDIS 集群工厂
 3 /// </summary>
 4 public class RedisClusterFactory
 5 {
 6     static RedisClusterFactory _factory = new RedisClusterFactory();
 7     static RedisCluster _cluster = null;
 8 
 9     /// <summary>
10     /// Redis 集群
11     /// </summary>
12     public static RedisCluster Cluster
13     {
14         get
15         {
16             if (_cluster == null)
17                 throw new Exception("You should call RedisClusterFactory.Configure to config cluster first.");
18             else
19                 return _cluster;
20         }
21     }
22 
23     /// <summary>
24     /// 初始化 <see cref="RedisClusterFactory"/> 类的新实例
25     /// </summary>
26     private RedisClusterFactory()
27     {
28     }
29 
30     /// <summary>
31     /// 配置 REDIS 集群
32     /// <para>若群集中有指定 password 的节点,必须使用  IEnumerable&lt;ClusterNode&gt; 重载列举出这些节点</para>
33     /// </summary>
34     /// <param name="node">集群节点</param>
35     /// <returns></returns>
36     public static RedisCluster Configure(ClusterNode node)
37     {
38         return RedisClusterFactory.Configure(node, null);
39     }
40 
41     /// <summary>
42     /// 配置 REDIS 集群
43     /// <para>若群集中有指定 password 的节点,必须使用  IEnumerable&lt;ClusterNode&gt; 重载列举出这些节点</para>
44     /// </summary>
45     /// <param name="node">集群节点</param>
46     /// <param name="config"><see cref="RedisClientManagerConfig"/> 客户端缓冲池配置</param>
47     /// <returns></returns>
48     public static RedisCluster Configure(ClusterNode node, RedisClientManagerConfig config)
49     {
50         return RedisClusterFactory.Configure(new List<ClusterNode> { node }, config);
51     }
52 
53     /// <summary>
54     /// 配置 REDIS 集群
55     /// </summary>
56     /// <param name="nodes">集群节点</param>
57     /// <param name="config"><see cref="RedisClientManagerConfig"/> 客户端缓冲池配置</param>
58     /// <returns></returns>
59     public static RedisCluster Configure(IEnumerable<ClusterNode> nodes, RedisClientManagerConfig config)
60     {
61         if (nodes == null)
62             throw new ArgumentNullException("nodes");
63 
64         if (nodes == null || nodes.Count() == 0)
65             throw new ArgumentException("There is no nodes to configure cluster.");
66 
67         if (_cluster == null)
68         {
69             lock (_factory)
70             {
71                 if (_cluster == null)
72                 {
73                     RedisCluster c = new RedisCluster(nodes, config);
74                     _cluster = c;
75                 }
76             }
77         }
78 
79         return _cluster;
80     }
81 }
View Code

 

  总结

  今天我们详细介绍了如何从0手写一个Redis Cluster集群客户端访问组件,相信对同样在寻找类似解决方案的同学们会有一定的启发,喜欢的同学请点个 star。在没有相同案例可以参考的情况下笔者通过查阅官方说明文档和借鉴 Java 的 JedisCluster 的实现思路,虽说磕磕碰碰但最终也初步完成这个组件并投入使用,必须给自己加一个鸡腿!!在此我有一个小小的疑问,.NET 的同学们在用 Redis 集群时,你们是用什么组件耍的,为何网上的相关介绍和现成组件几乎都没有?欢迎讨论。

  GitHub 代码托管:https://github.com/TANZAME/ServiceStack.Redis.Cluster

  技术交流 QQ 群:816425449

 

扫码关注我们
微信号:SRE实战
拒绝背锅 运筹帷幄