四年前, 我进入现在这家公司, 之后我一直在做一款网页游戏的服务器开发. 前不久, 我调到了另一个项目. 趁这个机会, 我把这几年的开发和维护经验做一下总结.
首先说一下项目的情况. 为了避嫌, 项目名字我就不说了, 项目是一款模拟经营类的网页游戏, 用户量很大. 目前总用户数超过两亿. 日活跃用户上千万, 同时在线百万左右. 月流水七八百万.
我在项目里一直从事服务器端开发, 因为我们没有专门的技术人员做运维, 所以这部分工作也由我负责. 四年下来, 也有了一些心得. 下面我从两方面来谈一谈. 首先是我们在项目中使用的一些技术的分析, 然后是对如何写好代码的一些体会.
游戏简介
客户端和服务器使用长连接. 游戏没有分区的概念, 对玩家来说, 游戏只有一个区, 所有玩家相互都可以进行互动. 玩家间的互动并不十分频繁, 不存在mmorpg里需要广播同屏玩家信息的情况. 客户端向服务器请求数据, 服务器一般不会主动向客户端发送数据.
系统架构
服务器是一个分布式系统, 整个系统由若干个独立的服务器组成. 每个服务器是一个进程, 每个进程都可以根据需要部署在相同或不同的物理机上. 进程间用 socket 通讯. 所有的进程都在一个局域网中. 整个系统有以下几种服务器组成(每种服务器都有若干台), 1. 网关服务器, 2. 逻辑服务器. 3. 连接服务器, 4. 功能服务器, 5. 数据库服务器. 下面逐一介绍.
1. 网关服务器
网关服务器, 客户端和网关服务器连接, 对每一个连接的玩家, 网关服务器分别向游戏服务器建立一条独立的连接. 网关服务器没有任何逻辑处理, 它只负责建立连接和转发消息. 网关服务器在搭建完成后, 基本不需要在做任何变化. 网关服务器有这几个作用:
* 为游戏服务器组提供一个统一并且安全的接口, 供客户端使用.
* 根据游戏负载, 游戏服务器的数量会增加或减少, 网关服务器向客户端隔离了这个变化.
2. 逻辑服务器
顾名思义, 逻辑服务器处理所有的玩家逻辑. 玩家通过一个简单的 hash 算法, uid % logicServerNum, 分配到某一台逻辑服务器上. 这台服务器加载玩家数据, 操作它们, 然后写入数据库. 逻辑服务器是多线程的, 包括网络线程, 数据库读写线程, 逻辑线程等等. 逻辑线程负责处理玩家发送的所有消息. 为了降低复杂度, 逻辑线程只有一个.
3. 连接服务器
假设玩家A和玩家B被分配到不同的逻辑服务器上, A要和B有互动, 就需要用到连接服务器. A把消息发送到连接服务器, 连接服务器把消息发送到B所在的逻辑服务器. B逻辑服务器处理完毕, 将消息返回给连接服务器, 连接服务器在把消息发给A逻辑服务器.
4. 功能服务器
功能服务器处理一些特殊功能, 这些功能通常需要用到全局数据. 比如排行榜功能和公会功能.
5. 数据库服务器
一个数据库服务器实际上就是一个 ttserver 进程. ttserver是一个key-value数据库. 可以根据启动参数把数据保存在内存, 或是硬盘. 数据库服务器根据使用方式的不同, 分成这几种:
* 内存数据库, 只把数据保存在内存, 数据条目是有限的, 条目到达上限后, 老的数据会被删除.
* 物理数据库, 直接读写硬盘.
逻辑服务器读数据时, 先从内存数据库读, 如果没有, 再从物理数据库读.
写数据时, 先写入内存数据库, 再写入物理数据库.
内存数据库的作用就是减少读写数据库的时间.
网络
除了数据库服务器外, 其它的服务器的网络层都是一样的. 使用 epoll 做多路复用. 为每个socket fd 维护一个读缓存和一个写缓存.
逻辑线程向玩家发送数据时, 把数据放到写缓存.
读缓存里的数据够一条完整的消息后, 取出这条消息, 放入消息队列.
消息处理
用一个消息队列来作为网络线程和逻辑线程的沟通. 往这个队列里pop和push消息时, 需要加锁.
逻辑线程在一个循环里中消息队列里pop出消息, 处理它.
---------------------------------------------------------------------------------------
以上简单介绍了服务器的架构. 下面说说最近对写代码的一些体会.
避免动态内存
虽然我们使用 tcmalloc 来做内存分配, 还是应该尽可能的避免动态申请内存. 一方面可以提高代码的运行速度, 更重要的是可以减少bug的产生.
一个比较好的方法是为某些对象建立内存池.
慎重修改别人的代码
程序员写下的每行代码都有他的理由, 如果没有充分理解别人的意图, 绝不要修改别人的代码.
重构, 再重构
开发常常是迭代进行的: 先写出大概的框架, 再一遍一遍的逐步完善. 在迭代的过程中, 如果原来的设计有问题, 不能用 "打补丁" 的方式让程序正常工作, 应该重新设计. 只有这样, 才能避免系统出现 "坏味道". 最终完成一个简洁一致的设计.
一次又一次的检查自己的代码
测试能够发现的问题是有限的. 要想在功能上线之后能够正确的运行, 做代码审核是必须的, 如果可能的话, 同事之间互相来做审核. 如果只能自己审核自己的代码的话, 有一个提高效率的小技巧: 自己写的代码思路记得越清楚, 越难看出问题. 代码写好后, 过几天再审核, 思路淡了, 往往更能发现问题.
不要只审核一次就完事了. 如果有时间, 看第二次, 第三次.
慎用技巧
技巧是有副作用的, 最大的副作用就是增加的程序的复杂度, 复杂度越高的设计一定越容易出现问题, 同时以后维护起来也更难.
在效率要求不是那么高的时候, 简单粗暴的设计往往更好.
先写出一个简单的设计, 再根据要求进行优化. 简单的设计更容易实现的正确. 把一个正确的系统修改得更快, 比把一个有问题的系统修改正确要容易得多.
消灭重复的代码
重复的代码应该用函数封装起来. 相似但是有细微不同的逻辑, 是有问题的, 要想办法重构.
在同一个工程里 复制 粘贴 代码, 是在作践自己的职业.
系统运维
运维事故是大大的不应该, 它们完全是可以避免的. 因为它们绝大多数是由于粗心导致的.
好的运维流程应该是按部就班的进行. 使用提前设计好的脚本.
运维脚本可以用 shell 和 expect 来编写. 应该提前考虑好需要的脚本, 早早的准备好.
编写功能之外的辅助代码
完成一个功能后, 要考虑如何在功能上线后, 监控它是否正常运行, 如果出现bug, 如何关闭功能, 如何修复错误的数据, 如何重新上线... 为这些情况编写功能之外的辅助代码.
化繁为简, 分而治之
编程其实就是把现实问题抽象成逻辑模型, 再用计算机语言来实现这个模型. 这个模型应该是简单而直观的, 它的边界条件和隐藏规则越少越好. 它可能又若干个模块组成, 模块之间必须是低耦合的. 每个模块只完成一个简单的任务, 这个任务应该能用一两句话描述出来.