Java游戏服务器编程

Java游戏数据缓存与存储

2020-03-29  本文已影响0人  小圣996

树叶的一生,只是为了归根吗? --亚索

在前面的博文中,我们讲了客户端和游戏服之间的通信,分别讲了使用Netty实现客户端和服务端的tcp通信及webSocket通信;后面又讲了跨服通信,分别讲了使用Netty和(Hessian+Jetty)实现服务端和服务端之间的通信;再后来又讲了游戏服的业务线程模型,当收到来自客户端的消息后,如何使用线程池高效处理客户端数据请求。C/S通信,跨服通信,业务线程模型,及数据缓存与存储,构成了Java游戏编程的核心技术,这篇即将介绍游戏数据缓存与存储。

在《游戏架构方案》中,已经大致介绍了一种缓存方案,那个缓存方案是这样的,针对每个数据库表,它都维护了三个缓存,如下:

// 数据缓存
// 主键 -> 记录
ICache<Object, T> cache = new SimpleCache<>(repository.timeToIdle(), repository.timeToLive());
// 联合索引 -> 表名_联合索引各字段名 -> 主键
Map<CacheMeta, ICache<String, List<Object>>> indexCache = new HashMap<>();
this.meta.getCache().forEach(n -> indexCache.put(n, new SimpleCache<>(repository.timeToIdle(), repository.timeToLive())));

// 数据存储
// 入库保存队列 - 记录
ConcurrentHashSet<T> list = new ConcurrentHashSet<>();

其中SimpleCache实现为:

public class SimpleCache<K, V> implements ICache<K, V> {
    private final ConcurrentMap<K, Element<K, V>> map = new ConcurrentHashMap<>();
    private final int timeToIdle; //缓存的最大空闲时间
    private final int timeToLive; //缓存的最大存活时间

    public SimpleCache(int timeToIdle, int timeToLive) {
        this.timeToIdle = timeToIdle;
        this.timeToLive = timeToLive;
        CacheManager.getInstance().addCache(this);
    }
}

SimpleCache中有一个ConcurrentMap属性,保存主键 -> 记录的,由此可见,缓存使用是线程安全的。

为什么不设计一个总得缓存对象,而分表缓存呢?因为一个表的数据可能很大的,而一个大型游戏完全可能包含很多张表的,后期合服可能导致表的数据更多,上面三个缓存队列都是必须有的,如果放在一个总得缓存对象里,可能会导致这个对象很大,但是它的大小又是伸缩的,可能需要频繁扩容和减容,而大对象还可能直接放入老年代的,但是有时它又不是大对象,这就让JVM有点难堪了。

业务线程当需要查询数据时,首先在缓存里找,查找主键的就在主键缓存里找,查找联合索引的就在联合索引缓存里找,如果没找到的话就去数据库里load,load出来后再放到缓存里;当业务线程修改数据后,再把这条数据(记录)放到存储队列里,因为都是记录的引用,缓存中的记录其实也相应修改了。

然后在外层再搞个专门保存数据的线程池,如ScheduledThreadPoolExecutor pool,可以设置比如每10s检查一下保存队列的记录,如果该记录已被更新超过一定时间了,比如60s,就把它保存到数据库里去。保存到数据库那就是数据库连接池的事了。

此外,所有的主键缓存都会放入一个总的主键缓存管理中,如:

//玩家离线30分钟,则把他的数据移出缓存
ScheduledExecutorService cleaner = new ScheduledThreadPoolExecutor(1, new NamedThreadFactory("cache-cleaner"));
//所有主键缓存队列
ConcurrentHashSet<ICache> caches = new ConcurrentHashSet<>();
public void addCache(ICache cache) {
    caches.add(cache);
}

这里再设置一个清理缓存的定时器,每分钟检查玩家下线是否超过一定时间,比如30分钟,就会把玩家数据移出主键缓存,节省内存。这个过程其实在《游戏架构方案》中的游戏业务架构一图中已经描绘出来了。

对于这种数据缓存方案,它能很好的适应于某一时刻,只处理某个玩家的一条请求协议情形,因为游戏中大部分的业务数据处理,都是处理那个玩家自己的,所以他的那条业务记录,在同一时刻,就只有一个业务线程处理,这样,他的数据就是安全的。但是,他的有些业务数据记录,不会总是只有他的业务线程处理,有可能别的玩家的业务线程也在处理,这时这条数据记录就必须加锁了,比如好友记录(加一个玩家做好友时,不仅要修改自己的好友记录,还要修改好友的好友记录,这时有可能这两个玩家同时都在操作自己的好友数据的)。

这是其一一种缓存方案。即按需把数据放入缓存,不再需要时再把它移除。

为什么需要这么做呢?
还有的游戏缓存方案,把所有的数据库表数据都放到内存里,且不再卸载,这样就不用去数据库中查找了,因为读内存中的数据肯定比去磁盘上读数据快的;还有的游戏缓存方案,把游戏中公共的数据放入到内存中,而玩家个人的数据,不对其他玩家可见的数据则会下线超时移除。比如玩家基本数据,装备数据,公会数据,好友数据,这些可能会在排行榜中或离线好友中被其他玩家查看装备,等级,公会等常用信息的,就算不上线也可能被其他玩家频繁查看,所以不如把它们作为常驻内存数据,即使下线也不移除这部分数据,而仅仅把那些对其他玩家不可见的,如任务数据,副本数据等作下线超时移除。如果一个游戏火爆,那么常驻内存数据可能很多的,这意味着内存可能占用很大,如果内存占用过大,则挤压别的游戏服内存空间了,导致一台物理机上能支撑的游戏服数量变少,这样运营商可能有意见了,他们会说,为什么别的游戏一台服务器上能开那么多个服,你的游戏就只能开那么几个服?因此程序猿在设计游戏功能时,也应考虑内存占用大小,在后期压测服务器时,也应知道一个玩家在线大致会平均占用多大内存。

因此缓存方案的主要目的是为了查找效率和权衡内存占用大小而设计的。

再看上面的存储队列,它是一个线程安全的ConcurrentHashSet,同一条记录数据,在里面只会存在一份,当把它里面的所有记录分给不同的数据库连接池中的线程保存时,同一条记录数据不会分配给两条及以上的数据连接线程处理,这样就保证了数据安全。

在《Java游戏服业务线程模型二》中,玩家的消息都是在自己的消息队列中的,同一时刻,也是仅由一条线程处理的,这种方案它的数据存储,是可以直接在自己的玩家线程里处理的,即不需要再构建一个数据保存的线程池了。而上述方案,在数据库连接池和业务线程池中间,还多了一个数据保存池的。

数据存储时,需要注意这条记录是否可能同时被两个以上的连接池线程保存,特别是这条记录有多个对象存在时,这时可能产生数据覆盖的情况。(数据库那边虽然有锁机制,但是这条记录(多个对象)的来源可能就已经错了)

到这里,大家应该已经知道,数据交由数据库保存时,中间还有个数据库连接池的,连接池就如线程池一样,可以自己管理连接线程,通过对连接线程的合理分配与释放,从而提高连接的复用度,降低建立新连接的开销,加快用户的访问速度。

数据库连接池的用法.jpg

图中的JNDI(Java命名与文件夹接口-Java Naming and Directory Interface主要用来管理数据源的,如mysql,oracle的,其余了解请百度)

Java中,是可以用很多其他第三方数据库连接池的,常见的有DBCP(DBCP2),C3P0,Druid,BoneCP,在游戏框架中,我见过使用DBCP2和BoneCP的,其余的还没见到过。

DBCP2的主要使用方式如下:

//传入DBCP配置文件,驱动及连接数,及空闲其他配置都在配置文件中
Properties properties = new Properties();
DataSource dataSource = BasicDataSourceFactory.createDataSource(properties);

//获取连接
public Connection getConnection() throws SQLException {
        return dataSource.getConnection();
    }

或者直接自己设置配置:

public class DB{
    private static BasicDataSource dataSource=new BasicDataSource();
     static{
        //获取数据源对象
        try{
            dataSource.setUrl("jdbc:mysql://127.0.0.1:3306/gameServer?useUnicode=true&characterEncoding=utf-8");
            dataSource.setUsername("root");
            dataSource.setPassword("");
            dataSource.setDriverClassName("com.mysql.jdbc.Driver");
            //初始连接数
            dataSource.setMaxTotal(30);
            //最大空闲数
            dataSource.setMaxIdle(10);
            //最小空闲数
            dataSource.setMinIdle(5);
            //最长等待时间(ms)
            dataSource.setMaxWaitMillis(10000);
            ……
        }catch(Exception e){
            e.printStackTrace();
        }
     }
}

BoneCP的主要使用方式如下:

Class.forName("com.mysql.jdbc.Driver");
BoneCPConfig config = new BoneCPConfig(properties);
config.setJdbcUrl(param[0]);
config.setUsername(param[1]);
config.setPassword(param[2]);
BoneCP connectionPool = new BoneCP(config);

Connection conn = connectionPool.getConnection();

当利用连接池获得连接后,就可以用连接来做数据的增删改查了,有三种执行sql语句的方式:
1)Statement
用于执行不带参数的简单sql语句,每次执行sql语句时,数据库都要编译该sql语句。因为每次都要编译sql语句,因此这种方式在游戏中用得少,在执行不常用的sql语句时,才会用到它。

Statement statement = dataSource.getConnection().createStatement();
for (String sql : sqlList){
  statement.addBatch(sql );
}
statement.executeBatch(); //statement.executeUpdate(sql);
statement.close();
connection.close();

2)PreparedStatement
在使用PreparedStatement对象执行sql命令时,命令会被数据库进行编译和解析,能够有效提高系统性能,当一条sql语句可能会多次使用时可以用它。在不使用存储过程的数据存储方案中它是用得最多的。PreparedStatement还能够预防SQL注入攻击。是游戏服常用的一种方式。

PreparedStatement ps = connection.prepareStatement(sql);
ps.executeUpdate();
ps.close();
connection.close();

3)CallableStatement
当想要访问数据库存储过程时使用。CallableStatement接口也可以接受运行时输入参数。在使用存储过程的数据存储方案中用得最多。效率也很高。是游戏服常用的一种方式,只不过还需要额外写存储过程,增加了游戏开发工作量。

CallableStatement call = null;
String _sql = "call add_items(?, ?, ?, ?);";
call = connection.prepareCall(_sql); // 构造一个句子
call.setInt(1, params.getInt());
call.setInt(2, params.getInt());
call.setInt(3, params.getInt());
call.setString(4, params.getString());
call.executeQuery();
call.close();
connection.close();

使用时要特别注意连接状态的关闭,有些连接池配置会导致没关闭的连接一直存在的,当连接都占用时,其余数据就保存不了了,这会导致内存溢出服务器挂掉,而数据没保存将是一起严重的运营事故。

此外,还需注意connection.setAutoCommit(bool); 方法,它在游戏数据存储中也用得比较多,它的作用是将此连接的自动提交模式设置为给定状态。参数true表示启用自动提交模式,false表示禁用自动提交模式,如果连接处于自动提交模式下,则它的所有 SQL 语句将被执行并作为单个事务提交。否则,它的 SQL 语句将聚集到事务中,直到调用commit 方法或 rollback 方法为止。在自动提交模式下,调用commit,rollback方法会抛出异常。在批量处理数据的情况下,应该设置为手动提交(设置autocommit=false),当批量执行完所有的SQL语句后,再调用commit手动提交,报错则调用rollback回滚。因为在自动提交的情况下,会在执行完每一条SQL语句后就会提交到数据库,大大增加了数据库的操作量,降低了效率。

数据存储的讲解就到此为止。

上一篇下一篇

猜你喜欢

热点阅读