Post

Harvest

这段实习应该把什么东西写到简历上?

新体验

  1. 游戏项目与传统web项目的架构区别

    Go语言本身:游戏服务器大多都已转型为go,而web项目的主体仍是java占大头。故在此讨论java与go的一些区别。go作为一门09年才正式发布的年轻语言,设计理念围绕“简洁”二字。其特点有:静态类型和编译型、跨平台、自动GC、原生并发支持、风格强统一、多范式编程

    通过Go的特点我们就可以发现其与java的不同:编译型决定其速度接近cpp,原生并发支持能够轻松地实现无痛并发。多范式编程体现最为明显,go语法像命令式语言,但是其支持面向对象,函数式编程。弱化oop使用组合代替继承,隐式接口实现真正的解耦,避免了导包导致循环依赖。此外go本身大量使用func(){}()匿名函数,这类函数引用外部变量时捕获其引用而不是值拷贝。使其出现了闭包的特性,也有点函数式编程的意味。

    持久化架构差别:游戏服务器大多数游戏业务数据都是非结构化数据,不适合使用mysql之类关系数据库处理,本项目中大量采取redis Hash 结构充当非关系型数据库。对于写操作来说,都是直接写Hash缓存,被写的redis结构的key计入sync_set,由定时任务每7分钟,执行异步任务:根据set里存储的key扫一遍,存入redis。“Web服务器的数据流大多直接会到数据库中。而游戏服务器的数据流首先会到内存中,然后定期的写入数据库(落地)。”

    7分钟同步间隔,redis和mysql的数据必然不一致。但用户访问与修改的数据都是redis,mysql数据对用户是无感的。也就是说,数据一致性问题不存在,本架构下数据本来就不一致。那mysql存在的意义是什么?这里又回到了redis这NOSQL数据库与SQL库的区别。

    • 作为基于内存实现的数据库,redis存储容量天然有限(相比硬盘持久化)。游戏的全量数据不可能全部存储到内存里,需要一个过期时间,保证低频游玩的玩家数据能正常存储且不消耗宝贵的内存资源。在本项目中,读数据使用旁路缓存策略:re dis未命中,读mysql,写回redis(为空则写空标识,redis上锁)。
    • 若redis宕机,靠rdb\aof持久化机制恢复。最后还可以靠mysql兜底
    • mysql作为关系型数据库,天然支持事务、复杂查询。对于重要的需要事务机制的业务(如订单)必须使用mysql而不是redis.(redis事务实现很差,lua脚本也只能保证原子性无法达到事务的回滚效果)

    Apollo配置中心:游戏业务对配置数据极为敏感。多使用Apollo中excel格式进行不同模块的复杂配置。使用apollo配置的优势在于其支持多种发布方式,各类语言数据结构转化支持很好(先excel导出为json然后由ide自动转换)。此外,ApolloExcel支持层级嵌套,解析后,配置数据的结构非常清晰。

    传输协议:服务器部分使用http,大部分使用rpc。http请求出现在admin服发送gm指令,goodgle apple支付等。rpc请求与响应,全部使用protobuf进行二进制(反)序列化

    Protocol Buffers (protobuf) 是 Google 的二进制序列化格式,用于结构化数据的序列化和反序列化。相比于json序列化格式,性能更好,效率更高,体积更小。类型安全 且 向后兼容。这样的小数据包更适合游戏服务器高频通信。但不如json可读性强,基本上只用于游戏行业。

  2. 学习到的处理方式

    Redis数据结构的使用场景:常用Hash结构,基于Hash实现的MapDB、HashDB,都是可以落地(入库)的结构。HashDB基本上可以当作一个非结构化库来使用,一个Hash就是一个行对象;MapDB的field为id,value为其对应对象。也就是说一个MapDB Hash更像一整张表。这里的 行数据、表 都是类比关系型数据所做出的说法。 其次是set,集合类型存uid执行定时任务。应用范围其实很广。但是落不了库,如果想落库,其实可以用MapDB,field承当set的元素存储。最后是string ,用处很广,分布式行锁的实现(不支持重入、甚至Go提供的mutex也不支持,某种意义上实现了一致的语意。)自增操作可以限流。setnxex可以做时间操作限制(比如点赞、冷却时间)。

    批量读写而不是并发读写:在可以使用并发处理大规模数据时,应该先考虑,这里的操作的数据有没有批量操作(MSet,MGet)。而不是开一大堆协程,每个协程执行单次操作。这样的好处很明显,大幅减少网络io,不论操作对象是数据库还是redis。

    Dlq:延迟对列(timer定时器)。非常好用的实现定点时间逻辑的工具。到点触发,若提前操作可删除定时器。在使用的时候要保证TaskId的唯一性,此外,注册到dlq到回调函数中遇到了err会重试,这里需要注意,只有redis操作err这类可以恢复的错误才retrun err使其重试。对于一些业务数据,配置数据,其抛出的err重试后是无法恢复的,不如直接返回nil,打印一个日志即可。

    事件发布与订阅:事件作为一种服务间解耦的方式,我认为在一定程度上可以取代内部rpc方法调用。具体使用哪种解耦方式取决于开发场合。如果需要执行的逻辑与外部rpc方法极为类似,可以抽一个内部rpc方法供其他进程调用。事件独特之处在与,其分为跨服事件与本服事件。本服事件之间进程间通信,用户态操作(使用内存 map 存储订阅关系)。需要事件的派发者能调用事件消费者定义的逻辑。本服事件的生产者和消费者是同一进程。(如果一个本服事件,无法避免的需要调用他服逻辑,可以执行内部rpc调用。)如果是跨服事件,事件订阅信息存储在 Redis,可以通过rpc调用跨进程传输找到对应的消费者。事件的发布者只应该发布事件本身,具体逻辑由订阅者去处理谁订阅,谁执行(即实际执行 相关事件处理逻辑 的进程是订阅者进程)

    特性 本服事件 全局事件
    类型 Topic0, Topic1, Topic2 等 GlobeTopic
    存储 内存 map Redis Set
    通信方式 进程内函数调用 RPC 跨进程/跨服务器
    作用范围 当前进程(本服发布 本服消费) 所有订阅的服务器(本服发布 全员可消费)
    性能 高(无网络开销) 较低(有网络开销)
    使用场景 本服内业务逻辑 跨服务器通知

    拼接日期key而非过期时间:隔离不同日期的数据,用日期key拼接redis_key,不同的日期访问不同的manager,自然就实现了数据的时间隔离。

    策略模式|模板方法:项目中很多地方使用到了模板方法以及策略模式。rpc层传递特征参数,业务层根据特征参数->策略接口。映射关系用一个map记录。怎么注册进map?需要各个服务根据自身业务场合,重写策略接口的方法,根据特征参数注册自身实例进入map。

    Option模式:结构体的默认属性,有时需要灵活的得到修改。我们无论是提供一大堆构造方法, 还是提供一些Set方法在go中都不可取。于是Option模式应运而生。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    
    type Server struct { //结构体
        addr string
        tls  bool
    }
       
    type ServerOption  func(*Server) //Option函数
       
    //返回的Option函数为闭包 , 自由变量是外层函数入参,本地变量是结构体指针
    func WithAddr(a string) ServerOption{ 
      return  func(s *Server){
        s.addr = a
      }
    }
    func WithTls(t bool) ServerOption{
      return func(s *Server){
        s.tls = t
      }
    }
       
    //构造器
    func NewServer(opts ...ServerOption) *Server{
      s := new(Server)        
      //也可以加默认值  s := &Server{addr: ":8080",tls:  false,}
      for _,opt := range opts{
        opt(s)
      }
      return s
    }
       
    //示例
    server:= NewServer(WithAddr(":8080"),WithTls(true))
    

    git规范

从写简历的角度来看,我应该去做什么?

我做了什么?

  1. 名片系统:CRUD,简单的增删改。性别修改的冷却时间实现是记录一个时间戳到hash结构。
  2. 英雄招募:对着幸存者招募抄一遍。统一了接口。抽奖逻辑基于配置表权重。可以去了解一下。
  3. 迁城&保护罩:开始时间结束时间全部用时间戳记录。
  4. 挂机系统:还是用时间戳记录挂机时间。这里挂机时间到计算逻辑相对比较复杂。在这个地方第一次使用事件发布机制,体会到了其对模块的解耦的作用。
  5. 英雄升星:逻辑很简单,服务端维护一个档位记录即可。
  6. 充值进度:再次使用了事件发布,对ordersvc里的每一次createorder都发布充值事件,给用户加积分。(此时积分还没有使用中间道具机制。)积分到达一定的档位即可领取奖励,未领取的奖励在活动刷新后发邮件。这里针对天刷新和永不刷新两套逻辑采用了不同类型的limiter。运用了cron表达式、dlq进行刷新补发操作。
  7. 先锋指挥官:假活动,没什么好说的。
  8. 钻石商店|每日必买|每周特惠:全部是都是商业化内容,这部分需要处理的内容难点在于配置表结构。其他都简单,根据shop_id发起付费的逻辑已被他人实现。
  9. 大世界精英怪自动响应集结:使用了dlq触发自动响应集结。了解了大世界的一些数据结构。
  10. 同盟红包:应该是实习两个月以来接手的相对最难的功能。接入了聊天系统,使用聊天系统已经定义好的方法进行服务器主动推送(Notify ——Push/Pull)。可以详细了解聊天系统的实现,了解notify的定义与使用方式,了解对应客户端的联通场景。重点在于其对消息同步的方案——游标。
  11. 红点添加:实现红点计算接口即可,策略模式。客户端可主动Pull,也可以监听服务端的NotifyPush。资源领取数量红点非常容易实现,新增礼包类型红点稍微复杂。需要配合跨天登陆上线时事件。玩家跨天上线Push红点,每个礼包槽位记录一个view状态位。
  1. 每个槽位新增状态位。 默认False
  2. GetInfo时,把每个槽位标志置True。Push红点
  3. 登陆时,Push红点
  • 新增槽位时,该槽位在内存中的状态肯定是默认零值False。
  • 停服重启后,玩家上线,Push红点,就会算到当前槽位的状态false,就会累积红点。
  • 等用户点进去调用GetInfo时,就会转化状态位为True,再推红点Push红点就会消失。

我可以做(了解)什么?

  1. 服务TCP协议: FirstwarTcp协议

    Request&Push消息结构类似,前4字节记录消息总长度(不包含自身4字节)2字节记录消息头长度。后面是protobuf序列化的消息头、消息体二进制数据。

    • Push的时候需要传递ctx\serverType\route\uid\body。uid哈希后找到对应的connector连接实例,body由proto协议序列化,最后通过 RPC 调用 connector 的 gate.Push,通过 conn.Write() 直接写入 TCP 连接。
    • pull就是正常请求,客户端发一个tcp包,proto反序列化,根据请求头中的route反射找到对应实例方法,由connector转发请求body.
  2. 聊天系统:仔细了解聊天系统的实现。 技术实现

    聊天系统针对用户未读的新消息,服务端Push客户端Pull。

    • UpdateNotify,某个meessage内容被改变时触发,服务端将messageId加入Push消息体,推送新消息id。服务端会针对用户的游标(记录用户读到的最大消息id)判断,如果新消息id>游标则不Push,反之,如果用户在线,客户端push该新消息id,客户端根据messageId去查消息(Pull),用户不在线,服务端则把消息id加入一个zset。其score为update_cursor游标,记录玩家的未在线时的更新消息,从1开始唯一id自增作游标。上线时查看zset中被修改的消息id,拉取修改内容。

    • AddNotify,新增消息时,读用户在线状态,比较游标,算出用户未读数量,客户端根据用户游标和未读数量Pull新消息。若用户不在线则服务端不Push(也没办法Push)。玩家上线时,根据自身游标和未读数量拉取最新信息。

    • TOASK:服务器维持TCP长连接的方式。检验玩家是否在线的方式。

      Tcp长连接,客户端5s发一个心跳请求,超时60s,定期检查过期会话。比较前后两次心跳是否跨天0点。

      在线状态检查:SessionManager(内存,检查内存中的 uidSessionMap 是否存在该玩家的会话,存在即在线。

      Redis is_online(跨服务),从 user_misc Hash 读取 is_online 字段

  3. Lua的使用

    带db的lua脚本实现,都需执行一下这个,不然无法同步到数据库

    defer mgr.AutoSync.CallWriteCallback()

  4. 内购实现。技术实现

This post is licensed under CC BY 4.0 by the author.

Trending Tags