我正在参加「掘金·启航计划」。主要内容是对于校招练手项目的解析,促进自己更好地理解项目,理解redis。
Map
KV内存数据库的核心是并发安全的哈希表。以下是常见的几种:
- sync.Map:并发安全的哈希表,适合读多写少。
- juc.ConcurrentHashMap:并发哈希表,使用分段锁实现。
- memcached hashtable
本项目中并发安全使用分段锁策略,将key分散到固定数量的shard中避免rehash操作。shard是有锁保护的map,当shard进行rehash时会阻塞shard内的读写,但不会对其他shard造成影响。
因此对于该哈希表来说,主要由RWMutex进行对并发安全的保护。
typedef struct dictht {
// 哈希表数组
dictEntry **table;
// 哈希表大小
unsigned long size;
//哈希表大小掩码,用于计算索引值
//总是等于size-1
unsigned long sizemask;
// 该哈希表已有节点的数量
unsigned long used;
} dictht;
type ConcurrentDict struct {
table []*Shard
count int32
shardCount int
}
type Shard struct {
m map[string]interface{}
mutex sync.RWMutex
}
table属性是一个数组,数组中每个元素都是指向Shard的一个指针,每个m结构保存着一个map。shardCount属性记录table数组的大小。
LockMap
虽然已经大致实现了concurrentMap,通过RWMutex保证对单个key操作的并发安全性,但是仍无法满足需求
- ❓ Incr命令->读取--做加法---写入
- ❓ MSETNX命令当且仅当所有给定键都不存在时,为所有给定键设置value,因此需要锁定所有给定的键,直到完成所有键的检查和设置
因此,我们需要实现db.Locker用于锁定一个或一组key,并在我们需要的时候释放锁。
实现db.Locker最直接的想法,是使用一个map[string]*sync.RWMutex,加锁过程分为两步:初始化锁---加锁。解锁过程也分为两步:解锁---释放对应的锁。但是会存在一个无法解决的并发问题。
| time | goroutineA | goroutineB |
|---|---|---|
| 1 | locker["a"].Unlock() | |
| 2 | locker["a"]=&sync.RWMutex{} | |
| 3 | delete(locker["a"]) | |
| 4 | locker["a"].Lock() |
即协程B在解锁并释放锁时,还未释放完,被协程A拿去加锁,导致无法成功加锁,出错。
若我们在解锁时不释放锁就可以避免异常发生,但是每个使用过的锁无法释放会导致严重的内存泄漏。
不过,hash表的长度远少于可能的键的数量,即多个键可能共用一个hash槽。如果不为单个键加锁,而是给它所在的哈希槽加锁,也不会占用太多内存。
基于这种思想,实现LockMap,哈希算法仍使用fvn32算法
func (locks *Locks) toLockIndices(keys []string, reverse bool) []uint32 {
indexMap := make(map[uint32]bool)
for _, key := range keys {
index := locks.spread(fvn32(key))
//true表示对所在的哈希槽进行上锁
indexMap[index] = true
}
indices := make([]uint32, 0, len(indexMap))
for index := range indexMap {
//将上锁的下标用[]uint32封装
indices = append(indices, index)
}
//如果哈希槽有反转的必要 可以根据需要进行排序
sort.Slice(indices, func(i, j int) bool {
if !reverse {
return indices[i] < indices[j]
} else {
return indices[i] > indices[j]
}
})
return indices
}
主要思想是采用对哈希槽进行数组封装记录,来达到上锁的目的。
但是在锁定多个key时需要注意,若协程A持有键a的锁,试图获取键b的锁,而协程B持有键b的锁,试图获得键a的锁,会形成死锁。
因此,对所有协程按照相同顺序加锁,若两个协程都想获得键a的锁和键b的锁,必须先获得键a的锁,后获取键b的锁,破坏形成死锁的循环等待条件。
TTL
time to live,为key设置失效时间。核心思路是存储key->expireTime的map和自动删除过期key的时间轮
func genExpireTask(key string) string {
return "expire" + key
}
func (db *DB) Persist(key string) {
db.ttlMap.Remove(key)
taskKey := genExpireTask(key)
timewheel.Cancel(taskKey)
}
func (db *DB) IsExpired(key string) bool {
rawExpireTime, ok := db.ttlMap.Get(key)
if !ok {
return false
}
expireTime, _ := rawExpireTime.(time.Time)
expired := time.Now().After(expireTime)
if expired {
db.Remove(key)
}
return expired
}
timewheel
时间轮算法具体解析在www.yuque.com/duohereshui…文件
传入timewheel.At函数时,job()是对应任务的job,主要用来验证过期时间
timewheel.At(expireTime, taskKey, func() {
keys := []string{key}
//对key对应的hash槽加写锁
db.RWUnLocks(keys, nil)
logger.Info("expire" + key)
//在ttlMap中获取该key值对应对过期时间 不存在直接返回
rawExpireTime, ok := db.ttlMap.Get(key)
if !ok {
return
}
expireTime, _ := rawExpireTime.(time.Time)
//返回当前时间是否在expireTime之后 如果过期则直接移除
expired := time.Now().After(expireTime)
if !expired {
db.Remove(key)
}
})
db
type DB struct {
index int
data dict.Dict //key->DataEntity
ttlMap dict.Dict //key->expireTime (time.Time) 方便ttl功能的实现
versionMap dict.Dict //key->version(uint32)
locker *lock.Locks //确保并发安全
}
注释
sync.RWMutex
基于sync.Mutex实现,互斥锁。对于sync.Mutex来说,每当有goroutine进入临界区,就会Lock;每个goroutine离开临界区会Unlock()。但其实对于读操作,往往不需要进行阻塞。因此提供RWMutex,单写多读模型。
sync.RWMutex分读锁和写锁,区别对待。读锁占用时会阻止写,写锁占用时会阻止读和写。任何一个 Lock() 或 RLock() 均需要保证有对应的 Unlock() 或 RUnlock() 调用与之对应。
FNV Hash
Fowler-Noll-Vol
hash%n==(n-1)&hash
为了数据能够分配均匀,需要将key的hash值对数组的长度进行取模运算,得到的余数是对应的map下标。hash(key)%len。由于数组长度一般为2的n次幂,减1后形成了前半部分是0后半部分是1的形式,与hash相与,恰好也是一样的结果(len-1)hash