3. 实现内存KV数据库

348 阅读5分钟

我正在参加「掘金·启航计划」。主要内容是对于校招练手项目的解析,促进自己更好地理解项目,理解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,加锁过程分为两步:初始化锁---加锁。解锁过程也分为两步:解锁---释放对应的锁。但是会存在一个无法解决的并发问题。

timegoroutineAgoroutineB
1locker["a"].Unlock()
2locker["a"]=&sync.RWMutex{}
3delete(locker["a"])
4locker["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

en.wikipedia.org/wiki/Fowler…

hash%n==(n-1)&hash

为了数据能够分配均匀,需要将key的hash值对数组的长度进行取模运算,得到的余数是对应的map下标。hash(key)%len。由于数组长度一般为2的n次幂,减1后形成了前半部分是0后半部分是1的形式,与hash相与,恰好也是一样的结果(len-1)hash

参考

www.cnblogs.com/Finley/p/12…