项目复盘

202 阅读1分钟

项目: 使用go语言实现分布式的高速缓存

项目的功能:

  • 使用 Gossip 协议进行分布式通信
  • 加入一致性哈希,集群每个节点负责独立的数据
  • 提供 Get/Set/Delete/Status 几种调用接口
  • 提供 HTTP / TCP 两种调用服务
  • 支持获取缓存信息,比如 key 和 value 的占用空间
  • 引入内存写满保护,使用 TTL 和 LRU 两种算法进行过期
  • 引入 GC 机制,随机淘汰过期数据
  • 基于内存快照实现持久化功能

项目使用到的知识:

并发控制方面使用go语言内置的sync包来进行加锁解锁

实现并发的时候一定要加锁

案例:

package main

import (
   "fmt"
   "sync"
)

func main() {
   mu := sync.Mutex{}      //进行加锁或者解锁
    wg := sync.WaitGroup{}  //用来控制程序的关闭时间
   count := 0
   for i :=0 ; i < 1000; i++ {
      wg.Add(1)
      go func() {
         mu.Lock()
         defer mu.Unlock()
         count++
         wg.Done()
      }()
   }
   wg.Wait()
   fmt.Println("使用并发控制",count)
}

实现在HTTP服务器结构中加入我们自己想要的结构

仅仅只是demo

下面的这个demo可以直接运行,写的不很严谨,很多地方都没有使用err,这里需要注意的是github.com/julienschmidt/httprouter这个包,gin框架之前使用的应该就是这个包,现在

package main

import (
   "github.com/julienschmidt/httprouter"
   "net/http"
   "sync"
)

//底层的存储类结构,这里只是为了实现一个demo
type memory struct {
   data map[string][]byte
   lock *sync.RWMutex
}



// HTTPServer 是 HTTP 服务器结构。
type HTTPServer struct {
   cache *memory
}

func  (cache *memory) Get(key string) []byte {
   cache.lock.RLock()
   defer cache.lock.RUnlock()
   return  cache.data[key]
}



// NewHTTPServer 返回一个关于 cache 的新 HTTP 服务器。
func NewHTTPServer(cache *memory) *HTTPServer {
   return &HTTPServer{
      cache: cache,
   }
}
func NewCache() *memory {
   d :=  make(map[string][]byte, 100)
   d["go"] = []byte("go is good!!!")
   return &memory{
      data:  d,
      lock:  &sync.RWMutex{},
   }
}

// Run 启动服务器。
func (hs *HTTPServer) Run(address string) error {
   return http.ListenAndServe(address, hs.routerHandler())
}

func (hs *HTTPServer) routerHandler() http.Handler {
   router := httprouter.New()
   router.GET("/:key", hs.getHandler)
   return router
}

func (hs *HTTPServer) getHandler(writer http.ResponseWriter, request *http.Request, params httprouter.Params) {
   key := params.ByName("key")
   value := hs.cache.Get(key)
   writer.Write(value)
}


func main() {
   cache := NewCache()
   r := NewHTTPServer(cache)
   r.Run(":8889")
}

实现过期和淘汰

项目中使用到了手动淘汰和自动淘汰。

手动淘汰,在每一次获取数据的时候判断数据是否过期,进而淘汰掉过期的数据,这样的淘汰可以做到实时,因为在获取的时候把了一个关,但是坏处也很明显,那就是过期的数据可能会一直存在缓存中,因为不获取这个数据的话,就不会去判断过期,不会去淘汰数据,也就有可能导致数据的堆积,过期数据一直占用着缓存的空间。

自动淘汰,定时去执行淘汰,这样的话在获取数据之前可能数据就被淘汰了,并且定时的清理也可以避免过期数据的堆积,但是定时就意味着可能会白忙活,因为在执行淘汰任务的时候,不一定有过期的数据,而且数据的淘汰没办法做到实时,所以自动淘汰实际上会消耗缓存的性能,结果还不一定能把需要淘汰的数据淘汰掉。

go实现手动淘汰

package main

import (
   "sync/atomic"
   "time"
)

type value struct {
   data int
   ttl int64 //数据存活的时间
   createTime int64
}

func newV(data int,ttl int64) *value {
   return &value{
      data: data,
      ttl: ttl,
      createTime: time.Now().Unix(),
   }
}

//判断数据是否过期
func (v *value)alive() bool {
   return time.Now().Unix()-v.createTime < v.ttl
}

//每次使用数据的时候来修改数据的时间
func (v *value) visit() int {
   atomic.SwapInt64(&v.createTime, time.Now().Unix())
   return v.data
}

自动淘汰的其实只要实现一个定时就好了,这里给一个go语言实现定时任务的代码。

go实现自动淘汰

func AutoGc(ttl int) {
   go func() {
      ticker := time.NewTicker(time.Duration(ttl)*time.Minute)
      for {
         // 使用 select 来判断是否达到了定时器的触发点
         // 当定时器的时间还没到的时候,ticker.C 管道会被阻塞
         // 当定时器的时间到达后,就会向 ticker.C 管道中发送当前时间,停止阻塞,执行 c.gc() 代码
         select {
         case <-ticker.C:
            //到点了,开始清理!!!
         }
      }
   }()
}

实现数据的持久化

数据持久化的原理: 就是将内存数据存到硬盘上,在恢复的时候从硬盘中读取数据到内存。对于一个缓存服务来说,这个是最基本的功能,也是必要的功能。

  • 一般为我们都会使用序列化的方式来将数据存放到硬盘,在需要的时候,从硬盘当中读取出序列化后的文件,进行反序列化,来读取数据.

  • 大部分时候,我们都会使用json来进行序列化,因为json很容易读懂,但是为了速度,我们可以采用go的Gob,它会直接将结构序列化为二进制文件,所以Gob的序列化速度非常快.

go语言实现持久化

package main

import (
 "encoding/gob"
 "fmt"
 "io"
 "io/ioutil"
)

func main() {

 // 要被序列化的数据
 m := map[string]int{
    "A": 1,
    "B": 2,
    "C": 3,
 }

 // 创建一个临时文件用于保存序列化的数据
 // TempFile 会创建一个临时文件并返回,第一个参数如果是 "" 就会把文件创建在临时目录下
 // 第二个参数是临时文件的名字模板,* 将被替换为一个随机生成的字符串
 file, err := ioutil.TempFile("", "gob_test_*")
 if err != nil {
    panic(err)
 }

 // 创建序列化器,后面就是用这个序列化器进行序列化
 encoder := gob.NewEncoder(file)
 err = encoder.Encode(m)
 if err != nil {
    panic(err)
 }

 // 创建反序列化器,后面就是用这个反序列化复原数据
 // 写入数据后这个 file 的文件指针会到文件末尾,这时候去读取会直接报 EOF 错误
 // 所以需要将文件指针恢复到文件开始的位置才可以开始读取
 file.Seek(0, io.SeekStart)
 decoder := gob.NewDecoder(file)

 // 进行反序列化
 newM := map[string]int{}
 err = decoder.Decode(&newM)
 if err != nil {
    panic(err)
 }

 // 查看复原的数据
 fmt.Println(newM) // map[A:1 B:2 C:3]
}

优化锁的粒度

这里感觉直接看代码更容易理解,代码行数越多,执行的时间就越长,执行的时间越长,意味着上锁的时间越长,而我们知道,使用写锁(互斥锁)的时候,代码会以同步的方式执行,没办法发挥并发的优势,所以我们要尽可能地减少锁之间的代码数量(更严格地说应该是代码的执行时间),以此来发挥并发的优势。

package main

import (
   "fmt"
   "sync"
   "time"
)

func bigGranularity() {
   count := 0

   lock := sync.Mutex{}
   lock.Lock() // 上锁
   fmt.Println(time.Now())
   count++
   fmt.Println(time.Now())
   lock.Unlock() // 解锁
}

func smallGranularity() {
   count := 0

   lock := sync.Mutex{}
   fmt.Println(time.Now())
   lock.Lock() // 上锁
   count++
   lock.Unlock() // 解锁
   fmt.Println(time.Now())
}

其实这是在代码层面上的优化,当我们将代码优化后,性能可能还是得不到大的提升,让我们换一个思路,在项目可以直接多加几把锁,这样就能让锁的粒度上升了。思路大概是这样的:

  • 假设我的项目的底层的缓存为cache

  • 那么我可以多增加几个cache,或者说是增加几个副本,然后使用一个算法,让数据在存放的时候存放到这几个数据中的一个,然后取数据的时候通过算法,到指定的cache中去取数据,这样不就让锁的粒度上升了吗?

  • 其实这里也可以直接增加几个副本,然后每一次存放的时候每一个副本都增加,每一个副本存放的数据也都一样,然后取的时候采用轮询的方式,但是我感觉这样插入会慢一些,而且这样会需要很大的内存.

使用到的算法

package main

import "fmt"

// index 是选择 segment 的“特殊算法”。
// 这里参考了 Java 中的哈希生成逻辑,尽可能避免重复。不用去纠结为什么这么写,因为没有唯一的写法。
// 为了能使用到哈希值的全部数据,这里使用高位和低位进行异或操作。
func index(key string) int {
   index := 0
   keyBytes := []byte(key)
   for _, b := range keyBytes {
      index = 31*index + int(b&0xff)
   }
   return index ^ (index >> 16)
}

// segmentOf 返回 key 对应的 segment。
// 使用 index 生成的哈希值去获取 segment,这里使用 & 运算也是 Java 中的奇淫技巧。
func (a *c) segmentOf(key string) map[string]int {
   if  index(key)&(3-1) == 0 {
      return a.m1
   }else if index(key)&(3-1) ==1 {
      return a.m2
   }else {
      return a.m3
   }
}

type c struct {
   m1 map[string]int
   m2 map[string]int
   m3 map[string]int
   size int
}
func main() {
   cc := &c{
      m1: make(map[string]int,100),
      m2: make(map[string]int,100),
      m3:  make(map[string]int,100),
      size: 3,
   }
   res:=cc.segmentOf("a")
   res["a"]=33
   res2:=cc.segmentOf("a")
   fmt.Println(res2["a"])
   
}

这里的代码写的很不好,它的主要的作用就是如果存放一个东西到cache的时候,会在几个cache中选择一个,查找的时候不是依次便利,而是通过算法直接到存放这个key的cache中去查找。