分类: Go

通过WPeMatico自动添加.

  • Go编程模式 : 泛型编程

    Go编程模式 : 泛型编程

    Go语言的1.17版本发布了,其中开始正式支持泛型了。虽然还有一些限制(比如,不能把泛型函数export),但是,可以体验了。我的这个《Go编程模式》的系列终于有了真正的泛型编程了,再也不需要使用反射或是go generation这些难用的技术了。周末的时候,我把Go 1.17下载下来,然后,体验了一下泛型编程,还是很不错的。下面,就让我们来看一下Go的泛型编程。(注:不过,如果你对泛型编程的重要性还不是很了解的话,你可以先看一下之前的这篇文章《Go编程模式:Go Generation》,然后再读一下《Go编程模式:MapReduce》)

    本文是全系列中第10 / 10篇:Go编程模式

    初探

    我们先来看一个简单的示例:

    package main
    
    import "fmt"
    
    func print[T any] (arr []T) {
      for _, v := range arr {
        fmt.Print(v)
        fmt.Print(" ")
      }
      fmt.Println("")
    }
    
    func main() {
      strs := []string{"Hello", "World",  "Generics"}
      decs := []float64{3.14, 1.14, 1.618, 2.718 }
      nums := []int{2,4,6,8}
    
      print(strs)
      print(decs)
      print(nums)
    }

    上面这个例子中,有一个 print() 函数,这个函数就是想输出数组的值,如果没有泛型的话,这个函数需要写出 int 版,float版,string 版,以及我们的自定义类型(struct)的版本。现在好了,有了泛型的支持后,我们可以使用 [T any] 这样的方式来声明一个泛型类型(有点像C++的 typename T),然后面都使用 T 来声明变量就好。

    上面这个示例中,我们泛型的 print() 支持了三种类型的适配—— int型,float64型,和 string型。要让这段程序跑起来需要在编译行上加上 -gcflags=-G=3编译参数(这个编译参数会在1.18版上成为默认参数),如下所示:

    $ go run -gcflags=-G=3 ./main.go

    有了个操作以后,我们就可以写一些标准的算法了,比如,一个查找的算法

    func find[T comparable] (arr []T, elem T) int {
      for i, v := range arr {
        if  v == elem {
          return i
        }
      }
      return -1
    }

    我们注意到,我们没有使用 [T any]的形式,而是使用 [T comparable]的形式,comparable是一个接口类型,其约束了我们的类型需要支持 == 的操作, 不然就会有类型不对的编译错误。上面的这个 find() 函数同样可以使用于 int, float64或是string类型。

    从上面的这两个小程序来看,Go语言的泛型已基本可用了,只不过,还有三个问题:

    • 一个是 fmt.Printf()中的泛型类型是 %v 还不够好,不能像c++ iostream重载 >> 来获得程序自定义的输出。
    • 另外一个是,go不支持操作符重载,所以,你也很难在泛型算法中使用“泛型操作符”如:== 等
    • 最后一个是,上面的 find() 算法依赖于“数组”,对于hash-table、tree、graph、link等数据结构还要重写。也就是说,没有一个像C++ STL那样的一个泛型迭代器(这其中的一部分工作当然也需要通过重载操作符(如:++ 来实现)

    不过,这个已经很好了,让我们来看一下,可以干哪些事了。

    数据结构

    Stack 栈

    编程支持泛型最大的优势就是可以实现类型无关的数据结构了。下面,我们用Slices这个结构体来实现一个Stack的数结构。

    首先,我们可以定义一个泛型的Stack

    type stack [T any] []T

    看上去很简单,还是 [T any] ,然后 []T 就是一个数组,接下来就是实现这个数据结构的各种方法了。下面的代码实现了 push()pop()top()len()print()这几个方法,这几个方法和 C++的STL中的 Stack很类似。(注:目前Go的泛型函数不支持 export,所以只能使用第一个字符是小写的函数名)

    func (s *stack[T]) push(elem T) {
      *s = append(*s, elem)
    }
    
    func (s *stack[T]) pop() {
      if len(*s) > 0 {
        *s = (*s)[:len(*s)-1]
      } 
    }
    func (s *stack[T]) top() *T{
      if len(*s) > 0 {
        return &(*s)[len(*s)-1]
      } 
      return nil
    }
    
    func (s *stack[T]) len() int{
      return len(*s)
    }
    
    func (s *stack[T]) print() {
      for _, elem := range *s {
        fmt.Print(elem)
        fmt.Print(" ")
      }
      fmt.Println("")
    }

    上面的这个例子还是比较简单的,不过在实现的过程中,对于一个如果栈为空,那么 top()要么返回error要么返回空值,在这个地方卡了一下。因为,之前,我们返回的“空”值,要么是 int 的0,要么是 string 的 “”,然而在泛型的T下,这个值就不容易搞了。也就是说,除了类型泛型后,还需要有一些“值的泛型”(注:在C++中,如果你要用一个空栈进行 top() 操作,你会得到一个 segmentation fault),所以,这里我们返回的是一个指针,这样可以判断一下指针是否为空。

    下面是如何使用这个stack的代码。

    func main() {
    
      ss := stack[string]{}
      ss.push("Hello")
      ss.push("Hao")
      ss.push("Chen")
      ss.print()
      fmt.Printf("stack top is - %vn", *(ss.top()))
      ss.pop()
      ss.pop()
      ss.print()
    
      
      ns := stack[int]{}
      ns.push(10)
      ns.push(20)
      ns.print()
      ns.pop()
      ns.print()
      *ns.top() += 1
      ns.print()
      ns.pop()
      fmt.Printf("stack top is - %vn", ns.top())
    
    }

     

    LinkList 双向链表

    下面我们再来看一个双向链表的实现。下面这个实现中实现了 这几个方法:

    • add() – 从头插入一个数据结点
    • push() – 从尾插入一个数据结点
    • del() – 删除一个结点(因为需要比较,所以使用了 compareable 的泛型)
    • print() – 从头遍历一个链表,并输出值。
    type node[T comparable] struct {
      data T
      prev *node[T]
      next *node[T]
    }
    
    type list[T comparable] struct {
      head, tail *node[T]
      len int
    }
    
    func (l *list[T]) isEmpty() bool {
      return l.head == nil && l.tail == nil
    }
    
    func (l *list[T]) add(data T) {
      n := &node[T] {
        data : data,
        prev : nil,
        next : l.head,
      }
      if l.isEmpty() {
        l.head = n
        l.tail = n
      }
      l.head.prev = n
      l.head = n
    }
    
    func (l *list[T]) push(data T) { 
      n := &node[T] {
        data : data,
        prev : l.tail,
        next : nil,
      }
      if l.isEmpty() {
        l.head = n
        l.tail = n
      }
      l.tail.next = n
      l.tail = n
    }
    
    func (l *list[T]) del(data T) { 
      for p := l.head; p != nil; p = p.next {
        if data == p.data {
          
          if p == l.head {
            l.head = p.next
          }
          if p == l.tail {
            l.tail = p.prev
          }
          if p.prev != nil {
            p.prev.next = p.next
          }
          if p.next != nil {
            p.next.prev = p.prev
          }
          return 
        }
      } 
    }
    
    func (l *list[T]) print() {
      if l.isEmpty() {
        fmt.Println("the link list is empty.")
        return 
      }
      for p := l.head; p != nil; p = p.next {
        fmt.Printf("[%v] -> ", p.data)
      }
      fmt.Println("nil")
    }

    上面这个代码都是一些比较常规的链表操作,学过链表数据结构的同学应该都不陌生,使用的代码也不难,如下所示,都很简单,看代码就好了。

    func main(){
      var l = list[int]{}
      l.add(1)
      l.add(2)
      l.push(3)
      l.push(4)
      l.add(5)
      l.print() //[5] -> [2] -> [1] -> [3] -> [4] -> nil
      l.del(5)
      l.del(1)
      l.del(4)
      l.print() //[2] -> [3] -> nil
      
    }

    函数式范型

    接下来,我们就要来看一下我们函数式编程的三大件 map()reduce()filter() 在之前的《Go编程模式:Map-Reduce》文章中,我们可以看到要实现这样的泛型,需要用到反射,代码复杂到完全读不懂。下面来看一下真正的泛型版本。

    泛型Map
    func gMap[T1 any, T2 any] (arr []T1, f func(T1) T2) []T2 {
      result := make([]T2, len(arr))
      for i, elem := range arr {
        result[i] = f(elem)
      }
      return result
    }

    在上面的这个 map函数中我使用了两个类型 – T1T2

    • T1 – 是需要处理数据的类型
    • T2 – 是处理后的数据类型

    T1T2 可以一样,也可以不一样。

    我们还有一个函数参数 –  func(T1) T2 意味着,进入的是 T1 类型的,出来的是 T2 类型的。

    然后,整个函数返回的是一个 []T2

    好的,我们来看一下怎么使用这个map函数:

    nums := []int {0,1,2,3,4,5,6,7,8,9}
    squares := gMap(nums, func (elem int) int {
      return elem * elem
    })
    print(squares)  //0 1 4 9 16 25 36 49 64 81 
    
    strs := []string{"Hao", "Chen", "MegaEase"}
    upstrs := gMap(strs, func(s string) string  {
      return strings.ToUpper(s)
    })
    print(upstrs) // HAO CHEN MEGAEASE 
    
    
    dict := []string{"零", "壹", "贰", "叁", "肆", "伍", "陆", "柒", "捌", "玖"}
    strs =  gMap(nums, func (elem int) string  {
      return  dict[elem]
    })
    print(strs) // 零 壹 贰 叁 肆 伍 陆 柒 捌 玖
    泛型 Reduce

    接下来,我们再来看一下我们的Reduce函数,reduce函数是把一堆数据合成一个。

    func gReduce[T1 any, T2 any] (arr []T1, init T2, f func(T2, T1) T2) T2 {
      result := init
      for _, elem := range arr {
        result = f(result, elem)
      }
      return result
    }

    函数实现起来很简单,但是感觉不是很优雅。

    • 也是有两个类型 T1T2,前者是输出数据的类型,后者是佃出数据的类型。
    • 因为要合成一个数据,所以需要有这个数据的初始值 init,是 T2 类型
    • 而自定义函数 func(T2, T1) T2,会把这个init值传给用户,然后用户处理完后再返回出来。

    下面是一个使用上的示例——求一个数组的和

    nums := []int {0,1,2,3,4,5,6,7,8,9}
    sum := gReduce(nums, 0, func (result, elem int) int  {
        return result + elem
    })
    fmt.Printf("Sum = %d n", sum)
    泛型 filter

    filter函数主要是用来做过滤的,把数据中一些符合条件(filter in)或是不符合条件(filter out)的数据过滤出来,下面是相关的代码示例

    func gFilter[T any] (arr []T, in bool, f func(T) bool) []T {
      result := []T{}
      for _, elem := range arr {
        choose := f(elem)
        if (in && choose) || (!in && !choose) {
          result = append(result, elem)
        }
      }
      return result
    }
    
    func gFilterIn[T any] (arr []T, f func(T) bool) []T {
      return gFilter(arr, true, f)
    }
    
    func gFilterOut[T any] (arr []T, f func(T) bool) []T {
      return gFilter(arr, false, f)
    }

    其中,用户需要提从一个 bool 的函数,我们会把数据传给用户,然后用户只需要告诉我行还是不行,于是我们就会返回一个过滤好的数组给用户。

    比如,我们想把数组中所有的奇数过滤出来

    nums := []int {0,1,2,3,4,5,6,7,8,9}
    odds := gFilterIn(nums, func (elem int) bool  {
        return elem % 2 == 1
    })
    print(odds)

    业务示例

    正如《Go编程模式:Map-Reduce》中的那个业务示例,我们在这里再做一遍。

    首先,我们先声明一个员工对象和相关的数据

    type Employee struct {
      Name     string
      Age      int
      Vacation int
      Salary   float32
    }
    
    var employees = []Employee{
      {"Hao", 44, 0, 8000.5},
      {"Bob", 34, 10, 5000.5},
      {"Alice", 23, 5, 9000.0},
      {"Jack", 26, 0, 4000.0},
      {"Tom", 48, 9, 7500.75},
      {"Marry", 29, 0, 6000.0},
      {"Mike", 32, 8, 4000.3},
    }

    然后,我们想统一下所有员工的薪水,我们就可以使用前面的reduce函数

    total_pay := gReduce(employees, 0.0, func(result float32, e Employee) float32 {
      return result + e.Salary
    })
    fmt.Printf("Total Salary: %0.2fn", total_pay) // Total Salary: 43502.05

    我们函数这个 gReduce 函数有点啰嗦,还需要传一个初始值,在用户自己的函数中,还要关心 result 我们还是来定义一个更好的版本。

    一般来说,我们用 reduce 函数大多时候基本上是统计求和或是数个数,所以,是不是我们可以定义的更为直接一些?比如下面的这个 CountIf(),就比上面的 Reduce 干净了很多。

    func gCountIf[T any](arr []T, f func(T) bool) int {
      cnt := 0
      for _, elem := range arr {
        if f(elem) {
          cnt += 1
        }
      }
      return cnt;
    }

    我们做求和,我们也可以写一个Sum的泛型。

    • 处理 T 类型的数据,返回 U类型的结果
    • 然后,用户只需要给我一个需要统计的 TU 类型的数据就可以了。

    代码如下所示:

    type Sumable interface {
      type int, int8, int16, int32, int64,
            uint, uint8, uint16, uint32, uint64,
            float32, float64
    }
    
    func gSum[T any, U Sumable](arr []T, f func(T) U) U {
      var sum U
      for _, elem := range arr {
        sum += f(elem)
      }
      return sum
    }

    上面的代码我们动用了一个叫 Sumable 的接口,其限定了 U 类型,只能是 Sumable里的那些类型,也就是整型或浮点型,这个支持可以让我们的泛型代码更健壮一些。

    于是,我们就可以完成下面的事了。

    1)统计年龄大于40岁的员工数

    old := gCountIf(employees, func (e Employee) bool  {
        return e.Age > 40
    })
    fmt.Printf("old people(>40): %dn", old) 
    // ld people(>40): 2

    2)统计薪水超过 6000元的员工数

    high_pay := gCountIf(employees, func(e Employee) bool {
      return e.Salary >= 6000
    })
    fmt.Printf("High Salary people(>6k): %dn", high_pay) 
    //High Salary people(>6k): 4

    3)统计年龄小于30岁的员工的薪水

    younger_pay := gSum(employees, func(e Employee) float32 {
      if e.Age < 30 {
          return e.Salary
      } 
      return 0
    })
    fmt.Printf("Total Salary of Young People: %0.2fn", younger_pay)
    //Total Salary of Young People: 19000.00

    4)统计全员的休假天数

    total_vacation := gSum(employees, func(e Employee) int {
      return e.Vacation
    })
    fmt.Printf("Total Vacation: %d day(s)n", total_vacation)
    //Total Vacation: 32 day(s)

    5)把没有休假的员工过滤出来

    no_vacation := gFilterIn(employees, func(e Employee) bool {
      return e.Vacation == 0
    })
    print(no_vacation)
    //{Hao 44 0 8000.5} {Jack 26 0 4000} {Marry 29 0 6000}

    怎么样,你大概了解了泛型编程的意义了吧。

    (全文完)

    (转载本站文章请注明作者和出处 酷 壳 – CoolShell ,请勿用于任何商业用途)

    The post Go编程模式 : 泛型编程 first appeared on 酷 壳 – CoolShell.

  • ETCD的内存问题

    今天跟大家分享一个etcd的内存大量占用的问题,这是前段时间在我们开源软件Easegress中遇到的问题,问题是比较简单的,但是我还想把前因后果说一下,包括,为什么要用etcd,使用etcd的用户场景,包括etcd的一些导致内存占用比较大的设计,以及最后一些建议。希望这篇文章不仅仅只是让你看到了一个简单的内存问题,还能让你有更多的收获。当然,也欢迎您关注我们的开源软件,给我们一些鼓励。

    为什么要用ETCD

    先说一下为什么要用etcd。先从一个我们自己做的一个API网关 – Easegress(源码)说起。

    Easegress 是我们开发并开源的一个API应用网关产品,这个API应用网关不仅仅只是像nginx那样用来做一个反向代理,这个网关可以做的事很多,比如:API编排、服务发现、弹力设计(熔断、限流、重试等)、认证鉴权(JWT,OAuth2,HMAC等)、同样支持各种Cloud Native的架构如:微服务架构,Service Mesh,Serverless/FaaS的集成,并可以用于扛高并发、灰度发布、全链路压力测试、物联网……等更为高级的企业级的解决方案。所以,为了达到这些目标,在2017年的时候,我们觉得在现有的网关如Nginx上是无法演进出来这样的软件的,必需重新写一个(后来其他人也应该跟我们的想法一样,所以,Lyft写了一个Envoy。只不过,Envoy是用C++写的,而我用了技术门槛更低的Go语言)

    另外,Easegress最核心的设计主要有三个:

    • 一是无第三方依赖的自己选主组集群的能力
    • 二是像Linux管道命令行那样pipeline式的插件流式处理(支持Go/WebAssembly)
    • 三是内置一个Data Store用于集群控制和数据共享。

    对于任何一个分布式系统,都需要有一个强一制性的基于Paxos/Raft的可以自动选主机制,并且需要在整个集群间同步一些关键的控制/配置和相关的共享数据,以保证整个集群的行为是统一一致的。如果没有这么一个东西的话,就没有办法玩分布式系统的。这就是为什么会有像Zookeeper/etcd这样的组件出现并流行的原因。注意,Zookeeper他们主要不是给你存数据的,而是给你组集群的。

    Zookeeper是一个很流行的开源软件,也被用于各大公司的生产线,包括一些开源软件,比如:Kafka。但是,这会让其它软件有一个依赖,并且在运维上带来很大的复杂度。所以,Kafka在最新的版本也通过内置了选主的算法,而抛弃了外挂zookeeper的设计。Etcd是Go语言社区这边的主力,也是kubernetes组建集群的关键组件。Easegress在一开始(5年前)使用了gossip协议同步状态(当时想的过于超前,想做广域网的集群),但是后发现这个协议太过于复杂,而且很难调试,而广域网的API Gateway也没遇到相应的场景。所以,在3年前的时候,为了稳定性的考量,我们把其换成了内嵌版本的etcd,这个设计一直沿用到今天。

    Easegress会把所有的配置信息都放到etcd里,还包括一些统计监控数据,以及一些用户的自定义数据(这样用户自己的plugin不但可以在一条pipeline内,还可以在整个集群内共享数据),这对于用户进行扩展来说是非常方便的。软件代码的扩展性一直是我们追求的首要目标,尤其是开源软件更要想方设法降低技术门槛让技术易扩展,这就是为什么Google的很多开源软件都会选使用Go语言的原因,也是为什么Go正在取代C/C++的做PaaS基础组件的原因。

    背景问题

    好了,在介绍完为什么要用etcd以后,我开始分享一个实际的问题了。我们有个用户在使用 Easegress 的时候,在Easegress内配置了上千条pipeline,导致 Easegress的内存飙升的非常厉害- 10+GB 以上,而且长时间还下不来。

    用户报告的问题是——

    在Easegress 1.4.1 上创建一个HTTP对象,1000个Pipeline,在Easegres初始化启动完成时的内存占用大概为400M,运行80分钟后2GB,运行200分钟后达到了4GB,这期间什么也没有干,对Easegress没有进行过一次请求。

    一般来说,就算是API再多也不应该配置这么多的处理管道pipeline的,通常我们会使用HTTP API的前缀把一组属于一个类别的API配置在一个管道内是比较合理的,就像nginx下的location的配置,一般来说不会太多的。但是,在用户的这个场景下配置了上千个pipeline,我们也是头一次见,应该是用户想做更细粒度的控制。

    经过调查后,我们发现内存使用基本全部来自etcd,我们实在没有想到,因为我们往etcd里放的数据也没有多少个key,感觉不会超过10M,但不知道为什么会占用了10GB的内存。这种时候,一般会怀疑etcd有内存泄漏,上etcd上的github上搜了一下,发现etcd在3.2和3.3的版本上都有内存泄露的问题,但都修改了,而 Easegress 使用的是3.5的最新版本,另外,一般来说内存泄漏的问题不会是这么大的,我们开始怀疑是我们哪里误用了etcd。要知道是否误用了etcd,那么只有一条路了,沉下心来,把etcd的设计好好地看一遍。

    大概花了两天左右的时间看了一下etcd的设计,我发现了etcd有下面这些消耗内存的设计,老实说,还是非常昂贵的,这里分享出来,避免后面的同学再次掉坑。

    首当其冲是——RaftLog。etcd用Raft Log,主要是用于帮助follower同步数据,这个log的底层实现不是文件,而是内存。所以,而且还至少要保留 5000 条最新的请求。如果key的size很大,这 5000条就会产生大量的内存开销。比如,不断更新一个 1M的key,哪怕是同一个key,这 5000 条Log就是 5000MB = 5GB 的内存开销。这个问题在etcd的issue列表中也有人提到过  issue #12548 ,不过,这个问题不了了之了。这个5000还是一个hardcode,无法改。(参看 DefaultSnapshotCatchUpEntries 相关源码

    // DefaultSnapshotCatchUpEntries is the number of entries for a slow follower
    // to catch-up after compacting the raft storage entries.
    // We expect the follower has a millisecond level latency with the leader.
    // The max throughput is around 10K. Keep a 5K entries is enough for helping
    // follower to catch up.
    DefaultSnapshotCatchUpEntries uint64 = 5000

    另外,我们还发现,这个设计在历史上etcd的官方团队把这个默认值从10000降到了5000,我们估计etcd官方团队也意识到10000有点太耗内存了,所以,降了一半,但是又怕follwer同步不上,所以,保留了 5000条……(在这里,我个人感觉还有更好的方法,至少不用全放在内存里吧……)

    另外还有下面几项也会导致etcd的内存会增加

    1. 索引。etcd的每一对 key-value 都会在内存中有一个 B-tree 索引。这个索引的开销跟key的长度有关,etcd还会保存版本。所以B-tree的内存跟key的长度以及历史版本号数量也有关系。
    2. mmap。还有,etcd 使用 mmap 这样上古的unix技术做文件映射,会把他的blotdb的内存map到虚拟内存中,所以,db-size越大,内存越大。
    3. Watcher。watch也会占用很大的内存,如果watch很多,连接数多,都会堆积内存。

    (很明显,etcd这么做就是为了一个高性能的考虑)

    Easegress中的问题更多的应该是Raft Log 的问题。后面三种问题我们觉得不会是用户这个问题的原因,对于索引和mmap,使用 etcd 的 compact 和 defreg (压缩和碎片整理应该可以降低内存,但用户那边不应该是这个问题的核心原因)。

    针对用户的问题,大约有1000多条pipeline,因为Easegress会对每一条pipeline进行数据统计(如:M1, M5, M15, P99, P90, P50等这样的统计数据),统计信息可能会有1KB-2KB左右,但Easegress会把这1000条pipeline的统计数据合并起来写到一个key中,这1000多条的统计数据合并后会导致出现一个平均尺寸为2MB的key,而5000个in-memory的RaftLog导致etcd要消耗了10GB的内存。之前没有这么多的pipeline的场景,所以,这个内存问题没有暴露出来。

    于是,我们最终的解决方案也很简单,我们修改我们的策略,不再写这么大的Value的数据了,虽然以前只写在一个key上,但是Key的值太大,现在把这个大Key值拆分成多个小的key来写,这样,实际保存的数据没有发生变化,但是RaftLog的每条数据量就小了,所以,以前是5000条 2M(10GB),现在是5000条 1K(500MB),就这样解决了这个问题。相关的PR在这里 PR#542

    总结

    要用好 etcd,有如下的实践

    • 避免大尺寸的key和value,一方面会通过一个内存级的 Raft Log 占大量内存,另一方面,B-tree的多版本索引也会因为这样耗内存。
    • 避免DB的尺寸太大,并通过 compact和defreg来压缩和碎片整理降低内存。
    • 避免大量的Watch Client 和 Watch数。这个开销也是比较大的。
    • 最后还有一个,就是尽可能使用新的版本,无论是go语言还是etcd,这样会少很多内存问题。比如:golang的这个跟LInux内核心相关的内存问题 —— golang 1.12的版sget的是 MADV_FREE 的内存回收机制,而在1.16的时候,改成了 MADV_DONTNEED ,这两者的差别是,FREE表示,虽然进程标记内存不要了,但是操作系统会保留之,直到需要更多的内存,而 DONTNEED 则是立马回收,你可以看到,在常驻内存RSS 上,前者虽然在golang的进程上回收了内存,但是RSS值不变,而后者会看到RSS直立马变化。Linux下对 MADV_FREE 的实现在某些情况下有一定的问题,所以,在go 1.16的时候,默认值改成了 MADV_DONTNEED 。而 etcd 3.4 是用 来1.12 编译的。

    最后,欢迎大家关注我们的开源软件! https://github.com/megaease/ 

    (全文完)

    (转载本站文章请注明作者和出处 酷 壳 – CoolShell ,请勿用于任何商业用途)

    The post ETCD的内存问题 first appeared on 酷 壳 – CoolShell.