人生最苦之事,莫过于明知要失去,但是却还没发生。------基兰
从本篇开始,将试着描述一下Java游戏服常见的架构方案(如下图),以及游戏服的各核心模块是如何分工合作的。因博主经历的游戏公司不多,对众多的游戏架构方案必定孤陋寡闻,其余的新的架构方案只能在以后的工作中如果遇到再行补充到此系列博文中来了。通过这一系列博文的讲解,希望大家能对游戏服的整体架构有个大致了解。
上图的游戏服架构在现在的中小型游戏公司还是很常见的,众多的客户端在打开游戏时,首先会从游戏公司平台(或第三方平台)获取游戏服列表,以及是否注册的账号信息;当选择游戏服后,即可登录到相应游戏服,创建账号或登录时,还会和平台交互,比如把账号信息同步到平台,验证名字合法性等,检验账号是否被封等;待平台验证通过后,即可正常游戏了;平台也会和游戏服交互,比如查询服务器注册人数,在线人数,执行一些服务器命令等;而游戏如果有跨服玩法的话,游戏服还会和跨服通信,跨服也可能有相应数据库,记录相应玩法数据,通常这些跨服都是由一个中心服管理的。
在前面的博文中,上图牵涉的某些技术已经在博文中介绍过了,比如客户端与服务端的通信,在《使用Netty+Protobuf实现游戏TCP通信》和《使用Netty+Protobuf实现游戏WebSocket通信》中有介绍;在web平台查询游戏服信息时,即处理web请求时,在《java游戏服引入jetty》中有介绍;在游戏服和跨服通信时,在《Java游戏跨服实现》中有介绍;在管理这些跨服时,在《Java游戏服跨服管理》中有介绍;剩余的技术诸如java游戏服模仿Http通信主动与平台交互以及java游戏服的数据读写等将会在后面的博文中介绍。其实,几乎整个博客的所有文章都是围绕这个图来写的。
上一段的描述,其实都是上图中那些带箭头的实线可能采用的技术,它们的作用,仅是保证数据的传输和效率,从大方面看,它们无非就是选择何种协议实现通信,是选择TCP、UDP、HTTP还是Websocket等;通信框架是选择Netty、Mina还是Hessian等;但是在上图中游戏单服、跨服和中心服内,这些数据是如何处理的,以及如何保证它们的正确性却是没有介绍的,下面即将介绍这些游戏单服的技术实现,不同单服内的实现也可以有多种方案,但从大方面看,它们也无非体现在对游戏的协议处理及分发,线程池的管理及功能,数据的读写处理等。以下将介绍第一种方案。
假设游戏服1所用的网络框架为Netty,那么在游戏服1启动时,Netty服务端会启动两个线程组,一为boss线程组,用于监听客户端的连接请求;一为worker线程组,用于监听客户端的IO事件。(Netty服务端监听及读取数据过程请见《Netty的启动过程一》和《Netty的启动过程二》)
当客户端请求连接游戏服时,Netty会为每个客户端创建一个Channel,并将之注册到worker线程,而worker线程的数量是有限的,这个数量由workerGroup = new NioEventLoopGroup(4)中参数限制。即不管多少个Channel都由这些worker线程处理,且Channel与worker线程绑定,但是每个客户端与服务端的数据交互都仅在自己的Channel处理。而一个给定 Channel 的 I/O 操作都是由相同的 Thread 执行的,因此,存在这样一种情况,多个客户端用各自的Channel与服务端交互,可能用的都是同一个worker线程。因此,这时往往会再定义一个业务线程池去处理客户端与服务端的交互业务,因为假如不这么做,当多个客户端使用同一个worker线程时,如果一个客户端处理业务的时间较长,那其他的客户端都得等待了。
这个业务线程池会定在哪呢?
我们知道,业务逻辑最终都会在pipeline中的handler中进行处理,如下代码所示:
@Override
public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception {
Packet packet = (Packet)msg;
IConnection conn = ref.get(ctx.channel());
if (conn != null) {
dispatcher.onReceive(conn, packet);
}
}
但这时,还是在worker线程中的,刚刚说了,最好是不要在worker线程中处理业务的,以防有耗时业务。那么在这里,可以定义一个业务线程池用于处理玩家的业务逻辑。
@Override
public void onReceive(IConnection conn, Packet packet) {
PlayerSession session = c2p.get(conn);
if (session == null) {
return;
}
pool.execute(new OrderedTask(session, packet) {//将Packet和session重新封装成业务线程池处理的task对象
@Override
public void run() {
try {
handlerManager.forward(getSession(), getPacket());
} catch (Throwable t) {
onException(session, new HandleException(packet.getCmd(), t));
}
}
});
}
这么一来,同一玩家的所有业务就可能分多个业务线程去执行了,但是,这个线程池可以用传统的线程池来做吗?传统线程池有以下几种:
newSingleThreadExecutor,
newFixedThreadPool,
newCachedThreadPool,
newWorkStealingPool,
newScheduledThreadPool,
单线程的线程池是首先需要排除的,这么多的玩家请求不可能排队让一个线程去处理,但用其余的多线程线程池就会存在一个数据安全问题。比如同一个玩家做先加一个好友,再做删除所有好友请求,最后结果应为没有一个好友了,但是,如果让两个线程同时去执行,如果删除所有好友的操作先完成,加一个好友的操作后完成,结果还剩下一个好友了,这就与预期结果不一致了。
那解决方案是每个数据对象处理时都加锁吗?因为一个游戏包含很多功能模块,假如一个功能模块对应一个数据对象,而一个数据对象往往又包含很多字段,如一个装备的数据对象如下:
private long id; //装备唯一id
private long rid;//玩家id
private int type;//装备类型
private int equipId;//装备配置id
private int place;//存放位置,1穿戴2背包3仓库
private int strengthenLv;//强化等级
private int isLock = 0;//是否锁定
private Map additional = new HashMap<>();//附加属性
private int blessLv;//祝福等级
当多个线程同时操作自己的装备数据时,那么就要在每个字段处理的地方都要对装备对象加锁了,这样写起的代码繁琐杂乱,容易出错。而且,游戏里大部分的功能都是操作自己的数据。因此,有些框架采用的是以下这种方案。
采用自定义的业务处理线程池,对同一个玩家的操作按序进行。
先维护一个全局的消息队列:
private final LinkedList tasks = new LinkedList<>();
每当有客户端消息发上来时,不管哪个客户端发来的消息都会加入到此消息队列里,然后采用线程池类似的方法去处理每个消息,但是对于同一个session(Channel或叫客户端),同一时间只能处理一条消息,即对于同一个session(Channel或叫客户端),它的消息总是一个接一个处理的,不会同时处理该客户端的两条或以上消息,这样在处理同一个客户端消息时,他的所有数据对象就不用加锁了,因为这个时候只有一个业务线程处理它的数据。但是,某些玩法功能数据还是要加锁的,比如好友数据,就有可能要操作别人的数据,即这条数据既可能被自己(自己线程)操作,又可能被别人(其他线程)操作,当两个线程同时操作某条数据时,这时就需要加锁同步了。否则会产生数据覆盖问题。
那么,如何实现同一时刻只处理一个session(Channel或叫客户端)消息呢?
那就是每次将客户端发给服务端的数据包Packet和服务端维持该客户端的session重新封装成业务线程池能处理的task对象(见上onReceive方法的pool.execute(new OrderedTask(session, packet)一句)。玩家一旦连上游戏服,session不会再变了,因此只要对session对象维护一个是否加锁的标志即可。如下:
public abstract class AbstractConnection implements IConnection{
private volatile boolean locked = false; //该session当前是否被加锁,如果是,说明正在处理该session消息,则不能再处理该session其他消息了,否则可以处理
}
当业务线程池循环全局的消息队列里所有消息时,如果该消息session是锁标记时,说明正在处理该session消息,否则可以处理该session消息。
消息处理搞定了,剩下的就是数据保存了。
游戏数据基本都是会频繁使用到的,因此很多框架都会有数据缓存模块,而不会直接读写数据库。把所需要查询的数据从数据库取出来后,可以按库表索引(包括联合索引)放入查询队列中;需要入库保存的数据,则放入保存队列中。保存队列可设计如下:
protected final ConcurrentHashSet list = new ConcurrentHashSet();
ConcurrentHashSet可以利用ConcurrentMap实现:
public class ConcurrentHashSet extends AbstractSet {
private final ConcurrentMap map = new ConcurrentHashMap();
public int size() {
return this.map.size();
}
public boolean contains(Object o) {
return this.map.containsKey(o);
}
public boolean add(E o) {
return this.map.putIfAbsent(o, Boolean.TRUE) == null;
}
public boolean remove(Object o) {
return this.map.remove(o) != null;
}
public void clear() {
this.map.clear();
}
public Iterator iterator() {
return this.map.keySet().iterator();
}
}
每张表都有一个这样的保存队列,保存时,也会采用公共线程池保存,提高效率,如下,可见这里采用的线程池是ScheduledThreadPool:
//持久化的线程池
private ScheduledThreadPoolExecutor[] pools;
pools = new ScheduledThreadPoolExecutor[threads];
for (int i = 0; i < threads; i++) {
pools[i] = new ScheduledThreadPoolExecutor(1, namedThreadFactory);
}
保存时,可以设置批量保存数量,及保存间隔,每次都把保存队列里所有数据对象都保存完:
protected DelayedJDBCRepository() {
CRepository repository = getAnnotation();
this.batchSize = repository.batch();
this.interval = repository.interval();
this.delay = repository.delay();
this.pool.scheduleWithFixedDelay(new DelayTask(this),
interval,
interval,
TimeUnit.SECONDS);
DBManager.getInstance().addHook(() -> delaySave(true));
}
最终,连接数据库,保存数据:
@Override
public void save(List entities) {
if (entities == null || entities.size() == 0) {
return;
}
T entity = entities.get(0);
String sql = buildUpdateSQL(entity);
Connection connection = null;
PreparedStatement ps = null;
try {
connection = db.getConnection();
connection.setAutoCommit(false);
ps = connection.prepareStatement(sql);
for (T e : entities) {
prepareStatementUpdate(ps, e);
ps.addBatch();
}
ps.executeBatch();
connection.commit();
} catch (Exception e) {
throw new RuntimeException(e.getMessage(), e);
} finally {
try {
if (ps != null) {
ps.close();
}
} catch (SQLException e) {
log.error("close statement failed", e);
}
try {
if (connection != null) {
connection.setAutoCommit(true);
connection.close();
}
} catch (SQLException e) {
log.error("close connection failed", e);
}
}
}
查询数据时,缓存队列可如下设计:
public class CachedJDBCRepository extends JDBCRepository {
protected final ICache
每张表都有一份这样的缓存(若只有一份总的,会导致总数据量太大),查询数据时,先在这两个缓存中找,有则不再从数据库取,没有则从数据库读取并放入缓存,数据取出来修改后,数据对象再放入保存队列里,到保存时间后,即可批量做入库保存了,因为从缓存中取出的数据是一个引用,这样缓存中的数据也更新了,下次就不用再从数据库里去取数据了。当然,缓存的数据也会做空闲时间检查,如果玩家下线时间过长,则将他的数据移出缓存,减少服务端总内存消耗。
private CacheManager() {
this.caches = new ConcurrentHashSet<>();
this.cleaner = new ScheduledThreadPoolExecutor(1, new NamedThreadFactory("cache-cleaner"));
this.cleaner.scheduleWithFixedDelay(new RemoveExpiredElementTask(), 60, 60, TimeUnit.SECONDS);
}
//将一个缓存加入管理
public void addCache(ICache cache) {
caches.add(cache);
}
这样,netty收到客户端的消息时,将它们放在业务线程池处理,以不阻塞客户端,同一玩家的消息,总是一个接一个处理,处理完了,才处理该玩家的下一条消息,避免数据安全问题。所有玩家的数据,都会做缓存处理,超过一定空闲时长则会移出缓存,数据修改后,将玩家的修改数据放在该数据表对象的保存队列中,超过一定时间时则做入库保存。
综上,这种架构类似下图流程:
此外,数据库表的唯一id设计时,最好提前预防合服不兼容的情况,就是合服时,把数据移过来插入即可,不需多做修改。这需要提前设计确保唯一id合服时不会冲突,通常采用服务器id为系数设计库表唯一id即可。
游戏单服的重点技术到此要完结了,它主要有这么几点:消息分发,数据安全,数据保存。本文就是围绕这几点来写的。说白了,单服的游戏架构实现无非就是网络框架的采用不同,业务线程池和数据存取池的实现不同,针对不同的业务现场池和数据存取池的实现有很多方案,将在后续博文一一道来。