项目: 使用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中去查找。