青训营大项目互动接口评论部分(本地缓存初步实现) | 青训营笔记

77 阅读5分钟

这是我参与「第五届青训营」伴学笔记创作活动的第 16 天

本地缓存的并发访问控制

虽然我们大项目的最初定下来是大单体的单机服务程序,但是gin框架本身处理http请求的过程是多线程的,因此在设计本地缓存时也要考虑并发访问的安全性。因此我才会在本地缓存选型时使用现有的缓存库,它们无一例外都是线程安全的map结构实现,只是又加入了缓存的淘汰和过期功能。但是我设计的本地缓存我想做到类似redis中的数据结构的操作,redis最有名的一个特点就是在业务处理逻辑上是单线程的,也就是在完成一个命令或脚本之前其他的命令和脚本一般是不能插进来执行从而导致问题的(表遍历除外,因为表遍历本身是分批次处理传输的),但是现有的本地缓存库都只是单个map,没有其他数据结构,而访问map的value时对value内部数据的操作是没有并发控制管理的,不想redis你可以指定一个脚本进行执行或者是redis提供的数据结构可以直接操作redis中的数据结构,因此我如果想对评论列表进行更新,如插入删除操作就必须保证其他人不会对这部分内存空间进行操作,鉴于ristretto没有提供获取value的同时删除value的操作我不得不在所有的本地缓存操作上直接增加一个互斥锁来保护这部分内存操作不会丢失数据。同时还要保证我的本地缓存是一个单例存在的对象。因此,我当前的本地缓存使用的结构和全局变量分别为如下代码。

// 本地缓存的操作互斥锁
var localCacheLock sync.Mutex
// 本地缓存实例指针
var localCache *ristretto.Cache
// 为了保证本地缓存实例只初始化一次
var cacheInitOnce sync.Once
// 本地缓存初始化函数
func cacheInit() {
	var err error
	localCache, err = ristretto.NewCache(&ristretto.Config{
		NumCounters: 1e7,     // number of keys to track frequency of (10M).
		MaxCost:     1 << 30, // maximum cost of cache (1GB).
		BufferItems: 64,      // number of keys per Get buffer.
		KeyToHash: func(key interface{}) (uint64, uint64) {
			k := key.(uint)
			return uint64(k), 0
		},
	})
	if err != nil {
		panic(err)
	}
	rand.Seed(time.Now().UnixNano())
}

可以看出当前的本地缓存实现真的就只是通过视频ID检索其评论列表的逻辑。在检索视频评论时,如果从本地缓存中读到了评论,就复制一份缓存中的评论然后从MySQL中读到一个用户的关注列表填写评论的被关注状态返回结果;如果没读到评论,就调用之前写的业务代码逻辑从MySQL中分别查询完整的评论和评论用户数据再从MySQL中读取浏览用户评论的关注列表对评论用户赋被关注标识最后将读出结果存入本地缓存并返回结果。

// 带缓存的评论读取
func getVideoCommentsWithCache(videoID, userID uint, logined bool) (res []responses.Comment, err error) {
	//如果没有初始化过缓存本地缓存,初始化本地缓存
	cacheInitOnce.Do(cacheInit)
	// 本地缓存操作
	localCacheLock.Lock()
	// 查询缓存
	cachedObj, ok := localCache.Get(videoID)
	cachedComments, ok := cachedObj.([]responses.Comment)
	if !ok {
		// 如果缓存中没有,访问数据库得到
		localCacheLock.Unlock()
		res, err = getVideoComments(videoID, -1, -1, logined, userID)
		if err != nil {
			return
		}
		localCacheLock.Lock()
		localCache.SetWithTTL(videoID, res, int64(len(res)), time.Minute*BASE_CACHE_TTL_MINUTES)
		localCacheLock.Unlock()
		return
	}
	res = make([]responses.Comment, len(cachedComments))
	copy(res, cachedComments)
	localCacheLock.Unlock()
	//  如果视频没有评论或是浏览者未登录,无需进一步修改
	if len(res) == 0 || !logined {
		return
	}
	var userFollowed = map[uint]struct{}{}
	// 如果浏览评论的是已登录的用户需要得到它关注的用户
	followedUsers, err := models.QueryFollowedUsersByUserID(userID)
	if err != nil {
		logrus.Error(err)
		err = ErrFollowingFetchFailed
		return nil, err
	}
	for _, usr := range followedUsers {
		userFollowed[usr] = struct{}{}
	}
	for idx := range res {
		// 如果用户登录了且发表评论的用户是浏览者关注的要标注
		_, following := userFollowed[uint(res[idx].User.ID)]
		res[idx].User.IsFollow = following
	}
	return
}

之所以是从本地缓存复制副本是因为要对关注关系进行操作。而为什么在没有读到缓存时,使用的是释放本地缓存操作锁之后得到结果再申请锁操作本地缓存,这样不是由double check lock引入之前的多次初始化的问题吗?这里主要考虑访问两次数据库的操作实在太耗时,严重影响本地缓存的可用性因此放弃了,这里还是可能发生缓存穿透的,是一个未来改进的目标。

有了读取本地缓存的操作那么如何为本地缓存内容进行更新呢?我使用的是先让操作数据库的部分完成更新成功后再尝试进行本地缓存的修改,这样做保证了缓存不会在数据库更新数据失败时对数据进行修改,增加了一致性,但是由于在业务代码中已经检查了修改数据库操作的合法性,先修改缓存以保证时效性也是很有可能的。而这部分本地缓存修改由于设计的是本地内存的修改一般不会造成长时间的锁占用,是可行的。

没有使用用户信息和评论信息分开存储带来的问题

由于我在当前实现的本地缓存直接存放response中的comment结构体,因此存在着完全无法得知评论者有哪些用户和评论用户的当前关注粉丝状态。因此,为了保证数据的时效性,在这种结构的设计上,我不得不在任意关注状态修改成功时,将整个本地缓存中的内容清空,这样势必会带来大量的性能降级,因此未来是一定要修改的。

用户的关注关系缓存

由于要求中有当用户登录时,查看评论列表可以看出评论用户的关注状态,因此需要进行一次MySQL的following表的访问,要消减这种访问就需要要为它也进行缓存,在我的实测中可以发现,其实返回评论列表的大部分耗时在数据通过MySQL获取上,内存中的运算相比来说不是特别耗时,因此未来缓存这部分信息还是相当有必要的。