樹葉的一生罕模,只是為了歸根嗎? --亞索
在前面的博文中,我們講了客戶端和游戲服之間的通信帘瞭,分別講了使用Netty實現(xiàn)客戶端和服務端的tcp通信及webSocket通信淑掌;后面又講了跨服通信,分別講了使用Netty和(Hessian+Jetty)實現(xiàn)服務端和服務端之間的通信蝶念;再后來又講了游戲服的業(yè)務線程模型抛腕,當收到來自客戶端的消息后,如何使用線程池高效處理客戶端數(shù)據(jù)請求媒殉。C/S通信担敌,跨服通信,業(yè)務線程模型廷蓉,及數(shù)據(jù)緩存與存儲全封,構成了Java游戲編程的核心技術,這篇即將介紹游戲數(shù)據(jù)緩存與存儲刹悴。
在《游戲架構方案》中颂跨,已經(jīng)大致介紹了一種緩存方案,那個緩存方案是這樣的恒削,針對每個數(shù)據(jù)庫表,它都維護了三個緩存躯砰,如下:
// 數(shù)據(jù)緩存
// 主鍵 -> 記錄
ICache<Object, T> cache = new SimpleCache<>(repository.timeToIdle(), repository.timeToLive())琢歇;
// 聯(lián)合索引 -> 表名_聯(lián)合索引各字段名 -> 主鍵
Map<CacheMeta, ICache<String, List<Object>>> indexCache = new HashMap<>();
this.meta.getCache().forEach(n -> indexCache.put(n, new SimpleCache<>(repository.timeToIdle(), repository.timeToLive())));
// 數(shù)據(jù)存儲
// 入庫保存隊列 - 記錄
ConcurrentHashSet<T> list = new ConcurrentHashSet<>();
其中SimpleCache實現(xiàn)為:
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屬性李茫,保存主鍵 -> 記錄的肥橙,由此可見,緩存使用是線程安全的宠互。
為什么不設計一個總得緩存對象予跌,而分表緩存呢善茎?因為一個表的數(shù)據(jù)可能很大的,而一個大型游戲完全可能包含很多張表的汁掠,后期合服可能導致表的數(shù)據(jù)更多集币,上面三個緩存隊列都是必須有的,如果放在一個總得緩存對象里乞榨,可能會導致這個對象很大,但是它的大小又是伸縮的考榨,可能需要頻繁擴容和減容鹦倚,而大對象還可能直接放入老年代的,但是有時它又不是大對象掀鹅,這就讓JVM有點難堪了媒楼。
業(yè)務線程當需要查詢數(shù)據(jù)時划址,首先在緩存里找,查找主鍵的就在主鍵緩存里找痢缎,查找聯(lián)合索引的就在聯(lián)合索引緩存里找世澜,如果沒找到的話就去數(shù)據(jù)庫里load,load出來后再放到緩存里;當業(yè)務線程修改數(shù)據(jù)后抚恒,再把這條數(shù)據(jù)(記錄)放到存儲隊列里络拌,因為都是記錄的引用,緩存中的記錄其實也相應修改了混萝。
然后在外層再搞個專門保存數(shù)據(jù)的線程池萍恕,如ScheduledThreadPoolExecutor pool,可以設置比如每10s檢查一下保存隊列的記錄崭倘,如果該記錄已被更新超過一定時間了,比如60s琅坡,就把它保存到數(shù)據(jù)庫里去残家。保存到數(shù)據(jù)庫那就是數(shù)據(jù)庫連接池的事了。
此外茴晋,所有的主鍵緩存都會放入一個總的主鍵緩存管理中晃跺,如:
//玩家離線30分鐘毫玖,則把他的數(shù)據(jù)移出緩存
ScheduledExecutorService cleaner = new ScheduledThreadPoolExecutor(1, new NamedThreadFactory("cache-cleaner"));
//所有主鍵緩存隊列
ConcurrentHashSet<ICache> caches = new ConcurrentHashSet<>();
public void addCache(ICache cache) {
caches.add(cache);
}
這里再設置一個清理緩存的定時器,每分鐘檢查玩家下線是否超過一定時間烹玉,比如30分鐘阐滩,就會把玩家數(shù)據(jù)移出主鍵緩存,節(jié)省內存继效。這個過程其實在《游戲架構方案》中的游戲業(yè)務架構一圖中已經(jīng)描繪出來了装获。
對于這種數(shù)據(jù)緩存方案,它能很好的適應于某一時刻凡简,只處理某個玩家的一條請求協(xié)議情形精肃,因為游戲中大部分的業(yè)務數(shù)據(jù)處理司抱,都是處理那個玩家自己的,所以他的那條業(yè)務記錄浊竟,在同一時刻,就只有一個業(yè)務線程處理振定,這樣,他的數(shù)據(jù)就是安全的梳庆。但是膏执,他的有些業(yè)務數(shù)據(jù)記錄露久,不會總是只有他的業(yè)務線程處理,有可能別的玩家的業(yè)務線程也在處理征峦,這時這條數(shù)據(jù)記錄就必須加鎖了消请,比如好友記錄(加一個玩家做好友時,不僅要修改自己的好友記錄蛉加,還要修改好友的好友記錄缸逃,這時有可能這兩個玩家同時都在操作自己的好友數(shù)據(jù)的)。
這是其一一種緩存方案打厘。即按需把數(shù)據(jù)放入緩存贺辰,不再需要時再把它移除饲化。
為什么需要這么做呢吗伤?
還有的游戲緩存方案,把所有的數(shù)據(jù)庫表數(shù)據(jù)都放到內存里巢块,且不再卸載,這樣就不用去數(shù)據(jù)庫中查找了姥闭,因為讀內存中的數(shù)據(jù)肯定比去磁盤上讀數(shù)據(jù)快的越走;還有的游戲緩存方案,把游戲中公共的數(shù)據(jù)放入到內存中铜跑,而玩家個人的數(shù)據(jù)锅纺,不對其他玩家可見的數(shù)據(jù)則會下線超時移除肋殴。比如玩家基本數(shù)據(jù),裝備數(shù)據(jù)嚼锄,公會數(shù)據(jù)蔽豺,好友數(shù)據(jù),這些可能會在排行榜中或離線好友中被其他玩家查看裝備沧侥,等級宴杀,公會等常用信息的拾因,就算不上線也可能被其他玩家頻繁查看,所以不如把它們作為常駐內存數(shù)據(jù)扁达,即使下線也不移除這部分數(shù)據(jù)蠢熄,而僅僅把那些對其他玩家不可見的,如任務數(shù)據(jù)叉讥,副本數(shù)據(jù)等作下線超時移除。如果一個游戲火爆罐盔,那么常駐內存數(shù)據(jù)可能很多的透绩,這意味著內存可能占用很大,如果內存占用過大碳竟,則擠壓別的游戲服內存空間了莹桅,導致一臺物理機上能支撐的游戲服數(shù)量變少烛亦,這樣運營商可能有意見了,他們會說铐达,為什么別的游戲一臺服務器上能開那么多個服檬果,你的游戲就只能開那么幾個服?因此程序猿在設計游戲功能時杭抠,也應考慮內存占用大小偏灿,在后期壓測服務器時钝的,也應知道一個玩家在線大致會平均占用多大內存。
因此緩存方案的主要目的是為了查找效率和權衡內存占用大小而設計的沮峡。
再看上面的存儲隊列亿柑,它是一個線程安全的ConcurrentHashSet望薄,同一條記錄數(shù)據(jù),在里面只會存在一份颁虐,當把它里面的所有記錄分給不同的數(shù)據(jù)庫連接池中的線程保存時卧须,同一條記錄數(shù)據(jù)不會分配給兩條及以上的數(shù)據(jù)連接線程處理,這樣就保證了數(shù)據(jù)安全笋籽。
在《Java游戲服業(yè)務線程模型二》中车海,玩家的消息都是在自己的消息隊列中的隘击,同一時刻,也是僅由一條線程處理的州叠,這種方案它的數(shù)據(jù)存儲凶赁,是可以直接在自己的玩家線程里處理的,即不需要再構建一個數(shù)據(jù)保存的線程池了楼熄。而上述方案可岂,在數(shù)據(jù)庫連接池和業(yè)務線程池中間翰灾,還多了一個數(shù)據(jù)保存池的。
數(shù)據(jù)存儲時平斩,需要注意這條記錄是否可能同時被兩個以上的連接池線程保存咽块,特別是這條記錄有多個對象存在時,這時可能產(chǎn)生數(shù)據(jù)覆蓋的情況晚凿。(數(shù)據(jù)庫那邊雖然有鎖機制瘦馍,但是這條記錄(多個對象)的來源可能就已經(jīng)錯了)
到這里,大家應該已經(jīng)知道情组,數(shù)據(jù)交由數(shù)據(jù)庫保存時院崇,中間還有個數(shù)據(jù)庫連接池的,連接池就如線程池一樣做院,可以自己管理連接線程,通過對連接線程的合理分配與釋放键耕,從而提高連接的復用度屈雄,降低建立新連接的開銷官套,加快用戶的訪問速度。
數(shù)據(jù)庫連接池的用法.jpg
圖中的JNDI(Java命名與文件夾接口-Java Naming and Directory Interface主要用來管理數(shù)據(jù)源的惋嚎,如mysql站刑,oracle的绞旅,其余了解請百度)
Java中,是可以用很多其他第三方數(shù)據(jù)庫連接池的因悲,常見的有DBCP(DBCP2),C3P0讯检,Druid,BoneCP绣否,在游戲框架中挡毅,我見過使用DBCP2和BoneCP的暴构,其余的還沒見到過取逾。
DBCP2的主要使用方式如下:
//傳入DBCP配置文件,驅動及連接數(shù)误阻,及空閑其他配置都在配置文件中
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{
//獲取數(shù)據(jù)源對象
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");
//初始連接數(shù)
dataSource.setMaxTotal(30);
//最大空閑數(shù)
dataSource.setMaxIdle(10);
//最小空閑數(shù)
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();
當利用連接池獲得連接后究反,就可以用連接來做數(shù)據(jù)的增刪改查了精耐,有三種執(zhí)行sql語句的方式:
1)Statement
用于執(zhí)行不帶參數(shù)的簡單sql語句琅锻,每次執(zhí)行sql語句時,數(shù)據(jù)庫都要編譯該sql語句惊完。因為每次都要編譯sql語句处硬,因此這種方式在游戲中用得少,在執(zhí)行不常用的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對象執(zhí)行sql命令時案站,命令會被數(shù)據(jù)庫進行編譯和解析,能夠有效提高系統(tǒng)性能承边,當一條sql語句可能會多次使用時可以用它。在不使用存儲過程的數(shù)據(jù)存儲方案中它是用得最多的险污。PreparedStatement還能夠預防SQL注入攻擊富岳。是游戲服常用的一種方式。
PreparedStatement ps = connection.prepareStatement(sql);
ps.executeUpdate();
ps.close();
connection.close();
3)CallableStatement
當想要訪問數(shù)據(jù)庫存儲過程時使用蚁飒。CallableStatement接口也可以接受運行時輸入?yún)?shù)萝喘。在使用存儲過程的數(shù)據(jù)存儲方案中用得最多。效率也很高阁簸。是游戲服常用的一種方式强窖,只不過還需要額外寫存儲過程,增加了游戲開發(fā)工作量翅溺。
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();
使用時要特別注意連接狀態(tài)的關閉咙崎,有些連接池配置會導致沒關閉的連接一直存在的褪猛,當連接都占用時,其余數(shù)據(jù)就保存不了了伊滋,這會導致內存溢出服務器掛掉笑旺,而數(shù)據(jù)沒保存將是一起嚴重的運營事故。
此外关噪,還需注意connection.setAutoCommit(bool); 方法,它在游戲數(shù)據(jù)存儲中也用得比較多使兔,它的作用是將此連接的自動提交模式設置為給定狀態(tài)虐沥。參數(shù)true表示啟用自動提交模式,false表示禁用自動提交模式奈搜,如果連接處于自動提交模式下盯荤,則它的所有 SQL 語句將被執(zhí)行并作為單個事務提交秋秤。否則灼卢,它的 SQL 語句將聚集到事務中来农,直到調用commit 方法或 rollback 方法為止沃于。在自動提交模式下,調用commit繁莹,rollback方法會拋出異常咨演。在批量處理數(shù)據(jù)的情況下,應該設置為手動提交(設置autocommit=false)薄风,當批量執(zhí)行完所有的SQL語句后遭赂,再調用commit手動提交,報錯則調用rollback回滾嵌牺。因為在自動提交的情況下打洼,會在執(zhí)行完每一條SQL語句后就會提交到數(shù)據(jù)庫,大大增加了數(shù)據(jù)庫的操作量炫惩,降低了效率他嚷。
數(shù)據(jù)存儲的講解就到此為止芭毙。