无状态游戏服框架tars2go--如何做本地缓存摆脱Redis降低IO

方案目标

实际上,一开始仅仅为了避免游戏业务通过sql相关传统接口去获取数据库的数据,尝试通过一种对象包装的形式,通过对sql相关接口的包装,来快速提升写业务逻辑的效率,同时想尝试减少对数据库的访问次数,由于tars2go框架业务处理是多协程并发处理,涉及到数据竞争的问题,所以游戏业务又不得不关心锁的问题,但是我们又需要兼顾业务开发的效率,同时还需要让开发人员减少对锁的关心,避免在写业务逻辑中频繁使用锁容产生一些不必要的死锁问题,所以我希望设计出一种方案,在访问mysql的数据既可以包装成对象,又可以无需关心sql接口,还需要提高开发效率,同时可解决频繁访问远程数据库的问题,而且可以将锁的使用也封装到该对象里面,让开发人员无需关心锁的问题,所以就有下述的方案思考与实现。

方案思考

从很多常规的业务使用场景,为了降低数据库压力,我们比较常规的解决方案是使用了第三方的cache组件,如:redis或者 memcache,也就是说无状态服务的终极形态是将当前进程的所有数据状态都保存到 redis 或者 memcache 中,这样的方案确实可以让业务服务非常轻松地进行横向拓展,但是相比于在本地内存的cache而言, 依然无法避免业务逻辑频繁与这些第三方 cache 进行网络io交互。可能有人会反驳,如果通过本地内存做 cache 的话,那这些业务进程就不是无状态,而是有状态了,这样还如何便捷地进行横向拓展?并且横向拓展的业务进程对请求来说已经不是幂等了。

那针对上述的问题,有没有一种折中方案去解决这种,既是将缓存做在本地有状态,又可便捷横向拓展的方案呢?答案当然是有的,我们大部分情况下都作为一个用户或者一个玩家的身份去请求服务器,比如我们某个业务的请求,在无状态的情况下,我们将请求通过网关的某些负载均衡算法,分配给横向拓展的任意服务器节点处理都可以,因为是这些节点是满足幂等的,当我们在本地加了缓存后,就破环了这种幂等条件,所以,我们可以通过玩家首次登录通过用户唯一标识,将其请求绑定到固定的业务进程上处理即可,因为在该业务进程上有其对应的缓存状态数据,如果该进程崩溃down掉,该玩家的请求就会被负载均衡分配到另外的业务进程上处理,当然本地缓存会在新的进程上随着业务的请求而被重新建立。

通过上述折中方案,我们需要做的就是将网关原来的轮询算法均衡分配请求到不同的进程上,改成按用户唯一标识符去分发请求,可以确保用户的请求能够落到指定的进程上处理,只要当前进程一直存活,该用户的数据缓存就不会在第二个进程上被创建,同一时刻,确保一个用户的这份缓存数据仅在一个进程上被创建,避免多份缓存需要考虑数据一致性问题。这不是一种通用的解决方案,所以这里可能会有人提出另外一种质疑,如果请求是按用户唯一标识转发请求,那么也许这些业务进程就无法得到比较好的负载均衡?实际使用上,确实可能会存在这种问题,我们在设计这个按用户唯一标识去转发请求的负载均衡算法的时候,就需要充分考虑到这点,这里就不展开说明如何设计这个算法,因为这不是我们这个文章的主要讨论方向。

方案实现前后对比

方案实现之前,写业务方式如下:

新方案之前,我们是直接通过类似sql的FindWhere接口,传入一些查询条件就直接去msyql里面,一次性查询数据回来,并且将数据塞到UserHeroInfoEntity指定的数据结构里面,返回这样的一个对象,每次调用 FindWhere 都会完成对远程mysql的一次堵塞的查询访问,同时,会存在一个问题,由于框架层支持并发处理该 ChangeHeroHp 接口,如果并发通过 FindWhere 接口从 mysql 里面拿数据回来,并且对这块数据有修改,同时需要 update 回去数据库,这样就存在数据覆盖,将会产生数据错误的问题,所以下面这种写法,如果没引入 mutex的话,在同一个用户并发请求下也许会出现数据错误题。

所以这个旧方案,不仅写业务逻辑效率不高,可能需要在业务执行过程中,有可能又会调用这个模块的另外一个接口,而这个接口刚好又访问到这块数据(同一个模块的接口有大概率会访问同一块数据),同一块数据去多次去访问数据库(计算资源浪费),而且还需要开发者考虑锁的问题。

func ChangeHeroHp(req *ChangeHeroHpReq, rsp *ChangeHeroHpRsp) error {
    userHeroInfoObj := userHeroInfoDao.New(DB)
    // 业务会通过一个非通用dao对象调用FindByWhere的类sql方法完成数据库的远程访问,
    // 并且返回将简单的数据 userHeroList,这种对象仅仅是一个简单 struct,
    // 也没有一些好用的成员方法,便捷的操作  userHeroList 内部数据
    userHeroList, _, _ := userHeroInfoObj.FindByWhere(&userHeroInfoEntity.UserHeroInfoEntity{Uid: int(req.Uid)})
    heroMap := map[int]userHeroInfoEntity.UserHeroInfoEntity{}
    for _, heroInfo :=range userHeroList {
        heroMap[heroInfo.HeroID] = heroInfo
    }
    var heroNewestList []userTars.HeroNewestInfo
    for _, hero :=range req.Heros {
        hp := int(hero.Hp)
        maxHp := heroMap[int(hero.HeroId)].MaxHp
        if hp > 0 {
            hp += maxHp * int(req.Rate) / 10000
        } else {
            hp = 0
        }
        if hp > maxHp {
            hp = maxHp
        }
     }
    return nil
}
方案实现之后,写业务方式如下:

新方案之后,我们无需通过一个非通用的dao对象,去调用 FindWhere 类似的sql接口,而是通过调用接口 NewPstEntity() 创建 PstEntity对象的时候,将待构建对象的数据结构、通用的数据访问commDaoObj 对象、查询条件、用户id等参数, 传到NewPstEntity()接口,完成 heroInfoObj 的内存可持久话对象的创建,这种对象的封装,可以同时解决我们三个诉求:

第一、满足开发者快速开发业务逻辑,统一数据访问的形式;

第二、内部封装本地缓存,一次加载构建本地缓存后,后面访问无需再次访问数据库;

第三、封装并发锁,可以让业务层无需关心锁的问题,降低死锁的问题;

func UpgradeStar(uid int64, heroID int, grade int) {
    commDaoObj := commDao.New(DB)
    // 通过 NewPstEntity 接口构造 UserHeroInfoEntity 的 pstEntity 对象
    // 同时,NewPstEntity 接口内部封装了本地 cache,如果当前数据模块已在内存
    // 下面的 heroInfoObj.Load() 动作不会重新请求远程数据库获取数据,仅仅执行加锁操作
    heroInfoObj := commDao.NewPstEntity(
        &userHeroInfoEntity.UserHeroInfoEntity{},
        commDaoObj, &userHeroInfoEntity.UserHeroInfoEntity{Uid:int(uid), 
        HeroID: heroID}, uid)
    // 同时将远程数据的加载与当前业务的持久化、当前数据模块的加锁和解锁封装到 Load 和 Save
    // 从而,我们可以看出,业务层无需关心加锁和解锁问题,因为其已封装到 PstEntity 组件上
    err := heroInfoObj.Load()
    defer heroInfoObj.Save()
    if nil != err {
        return
    }
    // pstEntity 对象可通过统一的Get和Set接口完成对内部属性的数值修改
    heroInfoObj.SetAttrInt("Grade", grade)
    defer func(){
        EvHeroGradeUp.AsyncNotify(uid, heroID, grade)
    }()
}

方案实现

如何封装玩家数据模块的 PstEntity 组件

了解这个组件的实现,需要先看看其主体实现的数据结构:

// 通用持久化通用基础类
type CommPstEntityBase struct {
    //CommPstEntityIF
    cond    CommEntityIF     // 绑定查询DB的条件
    obj     CommEntityIF     // 映射在内存的业务对象数据结构
    dao     *CommDao         // 绑定通用的dao对象
    dirty   bool      // 标记数据是否为脏
    loaded  bool      // 标记数据是否从DB加载完成    
    deleted bool      // 标记数据是否删除(保留未用)
    // RfSelf 模拟面向对象的多态(this标识当前类的成分,RfSelf表示当前完整对象)
    RfSelf *reflect.Value
    err    error
}

// 持久化对象类
type PstEntity struct {
    *CommPstEntityBase
    uid     int64        // 所属对象ID(拥有者ID)
    id        int        // 对象自身ID(所在容器ID,摆入容器时,才赋值)
    RfVal   *reflect.Value
    KeyName string       // 通常是:ExtData; (除时间容器外,为 CycData)
    data    map[string]interface{} // ExtData/CycData Json反序列后数据存储
    isInitData    bool   // 是否初始化赋值data
    // 并发锁相关饿属性
    SubTag string        // 子项标签
    CtnTag string        // 容器标签
}
如何将并发锁封装到 PstEntity 组件

并发锁里面会包含了容器锁和子项锁,先说明一下这两个锁概念以及它们之间的关系,可能举个例子比较形象,例如:

所谓的容器:

通过某个角色ID,可以批量查询不同的英雄记录回来,这样可以通过,NewPstCtn() 去构建一个PstEntity 容器,如下面代码展示:

func ChooseHero(req *ChooseHeroReq, rsp *ChooseHeroRsp) bool {
    var heroSkillIds []int
    var otherSkillArr []string

    uid := req.Uid
    commDaoObj := commDao.New(DB)
    // 通过 NewPstCtn 接口,可以构建多一个 PstEntity 为集合的容器
    heroCtnObj := commDao.NewPstCtn(
        &userHeroInfoEntity.UserHeroInfoEntity{Uid:int(uid)},
        commDaoObj, &[]*userHeroInfoEntity.UserHeroInfoEntity{}, 
        uid, "Uid", "HeroID")
    err := heroCtnObj.Load()
    defer heroCtnObj.Save()
    if nil !=err {
        return false
    }
    var HeroSort uint = 0
    for _, heroObj :=range heroCtnObj.GetAllItem() {
        if heroObj.GetAttrInt("HeroID") == int(req.HeroId) {
            rsp.Grade = int32(heroObj.GetAttrInt("Grade"))
            rsp.Code = -1
            rsp.Reason = "already choose hero"
            return false
        }
        if heroObj.GetAttrUint("HeroSort") > HeroSort {
            HeroSort = heroObj.GetAttrUint("HeroSort")
        }
    }
    // ..... todo something ....
}

所谓的子项:

就是通过指定角色ID和指定英雄id作为查询条件, NewPstEntity() 创建单个PstEntity,实例代码前面已展示,这里就不重复展示。

容器锁和子项锁的关系:

按字面意思,容器锁是将整个容器都加一个mutex锁,只要访问容器里面任意的一个子项,都需要加容器锁。而访问子项的时候,也需要针对这个子项加另外一个mutex锁,这就是子项锁。为了让正常并发访问子项或者有可能与之关联的容器时,所以不管访问容器还是子项,都需要先加容器锁,再加子项锁,下面代码会展示加锁的过程:

// 子项加载
//     不定参数:params...
//         canAdd:bool    是否在查询为nil时,插入新数据
func (this *PstEntity) Load(params... interface{}) error {
    if 2 == GetPstEntityImpVer() {
        lock.MgrCtn().Lock(this.CtnTag) // 加当前PstEntity对象的容器锁
        lock.MgrSub().Lock(this.SubTag) // 加当前PstEntity对象的子项锁
    }
    // ***** todo something ******
    // .......................
    // .......................
}

func (this *PstEntity) Save() error {
    if 2 == GetPstEntityImpVer() {
        defer lock.MgrCtn().UnLock(this.CtnTag) // 当前PstEntity对象容器锁释放
        defer lock.MgrSub().UnLock(this.SubTag) // 当前PstEntity对象子项锁释放
    }
    // ***** todo something ******
    // .......................
    // .......................
}

// 容器加载
func (this *PstEntityCtnBase) Load() error {
    if 2 == GetPstEntityImpVer() {
        lock.MgrCtn().Lock(this.ctnTag)
        defer lock.MgrCtn().UnLock(this.ctnTag)
    }
    // ***** todo something ******
    // .......................
    // 分别加载子项 PstEntity的时候会调用
    // PstEntity.Load(), 所以在 PstEntityCtnBase.Save() 的时候也会调用子项的Save()
    // 从这个流程我们可以看出容器锁会在Load这个方法里面短暂的进行加锁和解锁,
    // 最终还是将真正锁锁到子项身上,当子项触发Save的时候才真正释放子项锁
}

// 容器数据持久化
func (this *PstEntityCtnBase) Save() error {
    for _, pstObj := range(this.dKeyMapItem) {
        pstObj.Save() // pstObj内部会判断是否脏,是则执行持久化
        if 0 < pstObj.GetID() { // 并且从容器中移除
            delete(this.dKeyMapItem, pstObj.GetID())
        }
    }
    return nil
}

如何将实现PstEntity内部的并发锁

这个并发锁为什么需要封装?难道不可以直接使用?

这里封装主要是为了将实现的复杂细节封装到 LockMgr 里面,这里细节主要体现在需要分别处理同一个协程处理与不同协程处理的区分,也就是说,如果我同一个数据块,在同一个协程里面多次访问该数据库是无需真正多次加锁的,仅仅增加锁次数即可,但是如果是不同的协程尝试去访问这个数据块,这个时候就必须调用加锁的接口,判断该锁是否已被其他协程占用,所以这里就能够说明为什么不直接调用 metux加锁与解锁就完事了,下面展示这种并发锁的具体实现代码:

// 封装并发锁类(仅互斥锁的封装,实际上线程不安全,需要上层加锁访问,例如:LockMgr)
type Lock struct {
    lockTag string // 锁的标签
    lock    sync.Mutex
    goid    int64 // 占用锁的协程ID
    lockCnt int   // 同一协程被锁次数
    boInit  int32 // 是否初始化(0:未初始化;1:已初始化)
}

// 初始化锁
func (this *Lock) Init(tag string) *Lock {
    this.lockTag = tag
    //this.goid = 0
    this.lockCnt = 0
    atomic.StoreInt64(&this.goid, 0)
    atomic.CompareAndSwapInt32(&this.boInit, 0, 1)
    return this
}

// 反初始化
func (this *Lock) UnInit() *Lock {
    this.lockTag = ""
    //this.goid = 0
    this.lockCnt = 0
    atomic.StoreInt64(&this.goid, 0)
    atomic.CompareAndSwapInt32(&this.boInit, 1, 0)
    return this
}

// 上锁
func (this *Lock) Lock() int {
    curGoid := goroutine.GetID()
    lockGoid := atomic.LoadInt64(&this.goid)
    if curGoid == lockGoid && 0 < lockGoid { // 同一个协程访问锁,上锁失败,返回1
        this.lockCnt++
        log.PST.Infof("并发锁 Lock.Lock(),同协程访问同对象,无需上锁:"+
            "curGoid=%+v, goid=%+v, lockCnt=%+v, lockTag=%+v", 
            curGoid, lockGoid, this.lockCnt, this.lockTag)
        return 1
    }
    this.lock.Lock()
    //this.goid = curGoid // 当前协程占用当前锁
    atomic.StoreInt64(&this.goid, curGoid)
    this.lockCnt++
    // 10秒后自动解锁,防止死锁(如果当前业务执行超过10秒,定时器执行解锁,
    // 实际上,业务逻辑继续执行 UnLock() 的话,会将其他协程在排队获取的锁时会在意料之外
    // 获取到锁,就会出现问题,但是如果一个业务超过10秒还没执行完毕,需要排查与优化该业务)
    // todo: 这里考虑不加这个定时器,应为业务访问DB时间过长会导致这里超时,
    //         提前解锁,会直接导致数据不安全
    // todo:如果这里非加上这个定时器,就得想办法,让当前业务终止,相当于抛弃当前数据包的处理
    err := timer.Mgr().Start(this.lockTag+"_UnLock",
        UNLOCK_FORCE_TIME, 1, func() {
            this.UnlockByTimer(curGoid)
        })
    if nil != err {
        log.PST.Errorf("并发锁 Lock.Lock(), 不同协程访问同DB对象,加定时器异常:"+
            "curGoid=%+v, goid=%+v, lockCnt=%+v, lockTag=%+v, err=%+v", 
            curGoid, this.goid, this.lockCnt, this.lockTag, err.Error())
    }
    log.PST.Debugf("并发锁 Lock.Lock(),不同协程访问同DB对象,上锁成功:"+
        "curGoid=%+v, goid=%+v, lockCnt=%+v, lockTag=%+v", 
        curGoid, this.goid, this.lockCnt, this.lockTag)
    if "" == this.lockTag {
        log.PST.Errorf("并发锁 Lock.Lock(),不同协程访问同DB对象,上锁异常:"+
            "curGoid=%+v, goid=%+v, lockCnt=%+v, stack=%+v", 
            curGoid, this.goid, this.lockCnt, string(debug.Stack()))
    }
    return 0
}

// 解锁
//     参数说明:
//        unlockKey为解锁钥匙,使用锁时获得key钥匙
func (this *Lock) UnLock() int {
    curGoid := goroutine.GetID()
    lockGoid := atomic.LoadInt64(&this.goid)
    // 锁已被其他协程占用,直接返回(被定时器解锁后又被其他协程占有锁)
    if curGoid != lockGoid && 0 < lockGoid {
        log.PST.Errorf("并发锁 Lock.UnLock(),锁已被协程%+v占用,"+
            "入侵尝试解锁协程curGoid=%+v, lockTag=%+v", 
            lockGoid, curGoid, this.lockTag)
        return -1
    // 同一协程解锁,扣减锁次数,当所次数等于零时,则去真正解锁
    } else if curGoid == lockGoid && 0 < lockGoid {
        this.lockCnt--
        if 0 < this.lockCnt {
            log.PST.Infof("并发锁 Lock.UnLock(),同协程访问同对象,无需解锁:"+
                "curGoid=%+v, goid=%+v, lockCnt=%+v, lockTag=%+v", 
                curGoid, lockGoid, this.lockCnt, this.lockTag)
            return 1
        }
    } else {
        log.PST.Errorf("并发锁 Lock.UnLock(),逻辑执行异常,锁可能已超时释放:"+
            "curGoid=%+v, goid=%+v, lockTag=%+v", 
            curGoid, lockGoid, this.lockTag)
        return -2
    }
    err := timer.Mgr().Stop(this.lockTag+"_UnLock")
    if nil != err {
        log.PST.Errorf("并发锁 Lock.UnLock(),停止定时器异常:"+
            "curGoid=%+v, goid=%+v, lockTag=%+v, err=%+v", curGoid,
            lockGoid, this.lockTag, err.Error())
    }
    // 二次检查锁是否被其他协程占用
    if !atomic.CompareAndSwapInt64(&this.goid, curGoid, 0) {
        lockGoid = atomic.LoadInt64(&this.goid)
        log.PST.Errorf("并发锁 Lock.UnLock(),二次检查,锁已被协程%+v占用,"+
            "入侵尝试解锁协程curGoid=%+v, lockTag=%+v", 
            lockGoid, curGoid, this.lockTag)
        return -1
    }
    //atomic.StoreInt64(&this.goid, 0)
    lockGoid = atomic.LoadInt64(&this.goid)
    this.lockCnt = 0
    // 准备释放锁,说明该对象在当前业务内生命周期需要回收
    // 实际上,将对象从缓存中移除,对象生命周期重新托管给GC处理,否则会出现内存泄漏
    // 在V2版本的cache的Remove实现里面,不会马上从容器中删除,而是延迟删除,直到数据冷却为止
    cache.MgrSub().Remove(curGoid, this.lockTag)
    // 这里锁对象需要延迟回收到pool池子,因为可能有n个协程等待该锁对象使用,
    // 否则可能后续的协程在在LockMgr.Unlock中无法找到该锁对象,导致无法成功解锁
    err2 := timer.Mgr().ReStart(this.lockTag+"_LockRemove", 
        LOCK_CACHE_TIME, 1, func() {
            this.Remove(curGoid)
        })
    if nil != err2 {
        log.PST.Errorf("并发锁 Lock.UnLock(),重启定时器异常,移除lockRemove失败:"+
            "curGoid=%+v, goid=%+v, lockTag=%+v, err=%+v", curGoid,
            lockGoid, this.lockTag, err2.Error())
    }
    // 最后正真释放对象锁
    this.lock.Unlock()
    log.PST.Debugf("并发锁 Lock.UnLock(),不同协程访问同DB对象,解锁成功:"+
        "curGoid=%+v, goid=%+v, lockGoid=%+v, lockCnt=%+v, lockTag=%+v",
         curGoid, this.goid, lockGoid, this.lockCnt, this.lockTag)
    return 0
}

// 并发锁管理类
type LockMgr struct {
    lockMap sync.Map    // 仅仅同步map保护数据,无法保护过程
    elemNum int32       // 已出发申请对象次数
    pool    *sync.Pool  // 锁对象池子
    lockNum    int32    // 使用中的锁数量
}

var initOnceSub sync.Once
var initOnceCtn sync.Once
var gLockMgrSub *LockMgr
var gLockMgrCtn *LockMgr

// 子项锁管理器(单例)
func MgrSub() *LockMgr {
    initOnceSub.Do(func() {
        gLockMgrSub = newLockMgr()
    })
    return gLockMgrSub
}

// 容器锁管理器(单例)
func MgrCtn() *LockMgr {
    initOnceCtn.Do(func() {
        gLockMgrCtn = newLockMgr()
    })
    return gLockMgrCtn
}

// 指定tag上锁
func (this *LockMgr) Lock(tag ...interface{}) int {
    tagCnt := len(tag)
    tagFormatList := []string{}
    for i := 0; i < tagCnt; i++ {
        tagFormatList = append(tagFormatList, "%+v")
    }
    tagFormat := strings.Join(tagFormatList, "_")
    lockTag := fmt.Sprintf(tagFormat, tag...)
    curGoid := goroutine.GetID()
    // lock, ok := this.lockMap.Load(lockTag)
    newLock := this.pool.Get().(*Lock).Init(lockTag)
    lock, ok := this.lockMap.LoadOrStore(lockTag, newLock)
    if !ok {
        atomic.AddInt32(&this.lockNum, 1)
        log.PST.Infof("锁管理对象 LockMgr.Lock(),新创建锁:"+
            "curGoid=%+v, lockTag=%+v, lock=%+v, lockNew=%+v, lockNum=%+v",
            curGoid, lockTag, lock.(*Lock), this.elemNum, this.lockNum)
    } else {
        this.pool.Put(newLock.UnInit()) // 锁对象已存在,pool需要回收锁对象
        log.PST.Infof("锁管理对象 LockMgr.Lock(),无需创建:"+
            "curGoid=%+v, lockTag=%+v, lock=%+v, lockNew=%+v, lockNum=%+v",
            curGoid, lockTag, lock.(*Lock), this.elemNum, this.lockNum)
    }
    return lock.(*Lock).Lock()
}

// 指定tag解锁
func (this *LockMgr) UnLock(tag ...interface{}) {
    tagCnt := len(tag)
    tagFormatList := []string{}
    for i := 0; i < tagCnt; i++ {
        tagFormatList = append(tagFormatList, "%+v")
    }
    tagFormat := strings.Join(tagFormatList, "_")
    lockTag := fmt.Sprintf(tagFormat, tag...)
    lock, ok := this.lockMap.Load(lockTag)
    if ok {
        lock.(*Lock).UnLock()
    } else {
        curGoid := goroutine.GetID()
        log.PST.Errorf("锁管理对象 LockMgr.UnLock(),"+
            "锁对象不存在:curGoid=%+v, lockTag=%+v, stack=%+v",
            curGoid, lockTag, string(debug.Stack()))
    }
}// 封装并发锁类(仅互斥锁的封装,实际上线程不安全,需要上层加锁访问,例如:LockMgr)

从上述代码的实现,我们可以看出,同一个协程里面,首次上锁执行除了mutex.lock()同时还执行lockCnt++,非首次上锁的则仅仅执行lockCnt++,解锁的时候则lockCnt--, 有且仅当lockCnt减到0的时才执行mutex.unlock(),而不同协程去访问锁的时候,通过判断锁是否被其他协程占用,也就是当前访问锁的协程的ID1不等于锁被占用的协程的ID2,上锁才真正调用 mutex.lock(),解锁的时候,也需要同时判断当前访问锁的协程的ID1如果不等于锁被占用的协程的ID2,则认为是非法访问锁,这是非法尝试解锁。

如何将本地cache 封装到 PstEntity 组件中

本地缓存的实现感觉不是非常复杂,主要有两种方式去查找本地缓存中是否存在目标对象,匹配方式主要有两种,一种是精准匹配,也就是直接通过key作为索引从map中直接获取缓存对象,另外一种是模糊匹配,如果目标key的前缀基础部分如果能够与cache中的所有key的前缀遍历匹配,同时也能通过 CompareEntity() 接口再进一步进准核实是否等于目标对象,是则认为模糊匹配成功,返回缓存。

本地cache如何封装到PstEntity里面
func NewPstEntity(obj CommEntityIF, dao *CommDao, cond CommEntityIF,
    uid int64, params... interface{}) *PstEntity {
    var curGoid int64
    var CtnTag, SubTag string
    if 2 == GetPstEntityImpVer() { // 这里主要通过版本号管理,兼容是否启用本地缓存的判断
        // 通过cond查询条件以及obj结构,通过制定标签规则生成tag,规则通过GetTag接口实现
        // 生成缓存的容器tag和子项tag,这些规则可以根据具体项目去定义,可以参考GetTag的实现
        CtnTag, SubTag = GetTag(cond, obj)
        curGoid = goroutine.GetID()

        // 通过子项tag,尝试去子项缓存容器里面查找缓存
        // 找到本地缓存,则直接返回 object_if.(*PstEntity), 无需再去DB拿数据
        object_if := cache.MgrSub().Get(curGoid, SubTag)
        if nil != object_if { // 返回缓存中的对象(精确匹配)
            return object_if.(*PstEntity)
        }
    }
    // ........
本地缓存管理类的数据结构
// 持久化对象管理器类(根据协程ID进行协程内的对象管理)
//     备注:
//        用途一:同一个协程执行逻辑里面,包含跨函数执行逻辑,同一个uid访问同一个数据库表,
//                同一份(key1, key2, ....), 映射数据同一行记录,所以在同一个协程里面访问
//                的应该是同一个PstEntity对象,这里通过 协程ID和uid_tblName_key1_key2
//                 映射同一份 PstEntity,协程执行完毕后解锁时会调用Remove清理临时缓存
type ObjectCacheMgr struct {
    objCacheMap sync.Map        // map[协程ID][objectTag] ===>> *object
    objMgrPool *sync.Pool         // 对象管理池子
    poolNewCnt    int32            // 调用New次数统计
}

type ObjectTagMapSt struct {
    Count int32                // 记录 ObjectTagMap 元素个数
    ObjectTagMap sync.Map    // map[objectTag] ===>>> *object
}
精准匹配的实现
// 指定协程获取tag指定的pst对象
func (this *ObjectCacheMgr) Get(curGoid int64, tag... interface{}) interface{} {
    // 这里使用不定参数,主要是前期涉及,这个Get接口也许外部可传入多个构成tag的成份
    // 后期才发现,这种不定参数需求没真正用上,代码暂时还保留了支持不定参数的版本
    tagCnt := len(tag)
    tagFormatList := []string{}
    for i := 0; i < tagCnt; i++ {
        tagFormatList = append(tagFormatList, "%+v")
    }
    tagFormat := strings.Join(tagFormatList, "_")
    objectTag := fmt.Sprintf(tagFormat, tag...)
    objectMap_if, ok := this.objCacheMap.Load(curGoid)
    if ok {
        object, ok2 := objectMap_if.(*ObjectTagMapSt).ObjectTagMap.Load(objectTag)
        if ok2 {
            log.PST.Infof("对象管理器ObjectCacheMgr.Get(),缓存命中,返回缓存: "+
                "curGoid=%+v, objectTag=%+v", curGoid, objectTag)
            return object
        }
    }
    log.PST.Debugf("对象管理器ObjectCacheMgr.Get(),无命中缓存:"+
        "curGoid=%+v, objectTag=%+v", curGoid, objectTag)
    return nil
}
模糊匹配的实现
// 指定协程获取tag指定pst对象(模糊匹配tag)
func (this *ObjectCacheMgr) GetFuzzyMatch(curGoid int64, obj interface{}, tblName string, tag... interface{}) interface{} {
    // 这里使用不定参数,主要是前期涉及,这个Get接口也许外部可传入多个构成tag的成份
    // 后期才发现,这种不定参数需求没真正用上,代码暂时还保留了支持不定参数的版本
    tagCnt := len(tag)
    tagFormatList := []string{}
    for i := 0; i < tagCnt; i++ {
        tagFormatList = append(tagFormatList, "%+v")
    }
    tagFormat := strings.Join(tagFormatList, "_")
    objectTag := fmt.Sprintf(tagFormat, tag...)
    tagList := strings.Split(objectTag, "_")
    tagList = tagList[0:2]
    tagList = append(tagList, tblName)
    // 获取对象tag的根部分,如: SUB_UID_TBLName
    objectTagBase := strings.Join(tagList, "_") 
    // 这个版本是按协程ID作为一级索引,目的是缓存仅在该协程的生命周期内有效,
    // 但是发现当前业务协程执行完后,就销毁对应缓存,当这个数据是热数据的时候,
    // 这样的操作无疑导致缓存频繁创建,也就是频繁去读取DB,所以缓存出了 CacheV2
    // 第二个版本的本地缓存组件,一级索引有协程ID改成玩家uid,也就是说,该缓存
    // 只要在规定时间内不断有请求访问,则认为是热数据,则缓存一直生效,直到无请求
    // 访问,数据冷却下来,则使用定时器将其从内存中清除掉,具体可看 CacheV2 的代码
    objectMap_if, ok := this.objCacheMap.Load(curGoid)
    if ok {
        var object interface{} = nil
        objectMap := objectMap_if.(*ObjectTagMapSt)
        objectMap.ObjectTagMap.Range(func(mapTag, object_t interface{}) bool {
            // 必须是模糊匹配,所以这里需要判断不等于
            if mapTag.(string) != objectTag { 
                if strings.Contains(mapTag.(string), objectTagBase) {
                    // CompareEntity函数中用了大量反射,todo:性能有优化空间
                    if CompareEntity(object_t, obj) {
                        object = object_t
                        return false // 返回false中止便利
                    }
                }
            }
            return true
        })
        return object
    }
    return nil
}
缓存cache组件备注

相对于第一代缓存组件cache,还开发了第二代缓存组件cacheV2,因为第一代组件的局限性,所以开发了第二代组件,第一代组件缓存的是按协程ID作为索引,索引数据缓存的生命周期仅限于当前业务的执行内,所以当第二业务请求进来,访问相同数据,还需要重新去数据库Load数据,而第二代缓存组件的设计初衷真是解决这种数据缓存短生命周期导致缓存不命中的,导致流量直接打到数据库上的问题,第二代缓存组件以uid为索引,可以通过延长Remove的缓存的时间,只要不断有业务访问该数据对象,该数据对象可认为是热数据,一直被缓存持有,等待该数据模块无请求访问,冷却下来后,定时器会在未来某个时间点将其从缓存中移除,这样可以大大降低热数据多次去访问数据库,提高数据访问性能。

方案开源

github 或者 gitee上开源:

https://gitee.com/kxbwiner/go-util

备注:该开源项目仅仅提供一些如何封装相关组件的思路,而且在实现过程中比较仓促,相信还有很多写法或者包装的方式,还有很多不完善的地方,欢迎留言交流。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值