手游Java游戏服务器线上真实案例分析
灵域
内存泄露
- 现象:项目上线一周左右,客服反馈玩家操作反映很卡,而在线玩家并不多
- 后台:top发现CPU占用接近100%(单核)
- 排查问题:
- 初步推断内存泄露或者内存不足引起大量fullgc,导致gc线程占用大量cpu
- 通过:jstat -gc pid 查看gc情况
- 从下面输出可以看到fullgc次数达到81次,fullgc的时间差不多124秒,即2分多钟
- 初步断定cpu过高的原因是因为大量fullgc,而fullgc的原因通常是因为内存占用过高
- 通过:jmap -heap pid 查看堆内存占用信息
- 从下面的输出可以看到concurrent mark-sweep generation(老年代)的内存占用已经使用了98%
- 通过:jmap -histo pid 查看对内存的对象数量和占用大小
- 从下面的输出可以看到:
- 第一位的是[B,即byte[]数组,差不多占用了1个多G,因为是shallow size,所以这里是实实在在的byte[]数组申请
- 在程序中,直接搜索byte[]的引用相关,通常可以确定泄露的对象
- 通过程序查找(有一些第三方库没有关联源代码),发现引用byte[]的对象,结合输出发现远远达不到60多万个实例的数量级(如ByteBuffer/HeapByteBUffer)
- 总共有60多万个byte[],在直接导出的对象中实例中查找类似数量级的,找到一个对象:
22: 599961 14399064 com.google.protobuf.LiteralByteString
- 查看LiteralByteString的源代码(protobuf的源代码),其持有一个byte数组的,而其被调用是通过ByteString#copyFrom调用,调用一次方法都会new一个LiteralByteString对象
- 进而查找ByteString#copyFrom的调用层次,排查到了BFResult#buildReplaydata
- 即对于实际业务来说,要缓存每个玩家15天的战报,而战报的BattleReplayData都会持有一个ByteString.copyFrom返回的LiteralByteString(bindata),从而确定泄露的主要原因是内存的战报数据过大
- 另外注意protobuf中协议对象中的String类型的字段实现都是利用ByteString,所以也会持有LiteralByteString
- 总结:
- 本地cache使用lru_cache,将近期最少使用的数据移除内存,保证本地cache在一个比较稳定的数值
- 将战报数据放在远程的redis中,从而避免本地jvm内存过大从而引起频繁gc
YGC YGCT FGC FGCT GCT
171 12.978 81 123.925 136.903
Heap Usage:
New Generation (Eden + 1 Survivor Space):
capacity = 314048512 (299.5MB)
used = 188444480 (179.71466064453125MB)
free = 125604032 (119.78533935546875MB)
60.00489503991027% used
Eden Space:
capacity = 279183360 (266.25MB)
used = 164671168 (157.04266357421875MB)
free = 114512192 (109.20733642578125MB)
58.983160027875584% used
From Space:
capacity = 34865152 (33.25MB)
used = 23773312 (22.6719970703125MB)
free = 11091840 (10.5780029296875MB)
68.18645735432331% used
To Space:
capacity = 34865152 (33.25MB)
used = 0 (0.0MB)
free = 34865152 (33.25MB)
0.0% used
concurrent mark-sweep generation:
capacity = 2872311808 (2739.25MB)
used = 2842293544 (2710.6223526000977MB)
free = 30018264 (28.627647399902344MB)
98.9549092853919% used
num #instances #bytes class name
----------------------------------------------
1: 609212 1170636376 [B
2: 40153651 642458416 java.lang.Float
3: 8681504 347260160 san.game.attribute.value.complexValue
4: 2229486 204888400 [Ljava.lang.Object;
5: 6010272 144246528 san.game.attribute.value.byteValue
6: 5008560 120205440 san.game.attribute.value.floatValue
7: 4063436 97522464 java.util.ArrayList
8: 6010272 96164352 san.game.attribute.changeProcessor.attrChangeProcessor$esProcessor
9: 5728549 91656784 java.lang.Byte
10: 875024 42001152 san.game.talent.TalentSubitem
11: 333904 37397248 san.game.character.GameCharacter
12: 1001712 32054784 san.game.attribute.AttributeModifyer
13: 1268096 30434304 san.game.character.AttrModifySet$Attr
14: 598886 28746528 san.proto.SanCommon$BattleReplayData
15: 633698 25347920 san.game.skill.impls.NormalSkill
16: 1001712 24041088 san.game.attribute.value.intValue
17: 715739 22903648 java.util.HashMap$Node
18: 502983 19150384 [C
19: 272713 16792672 [I
20: 634048 15217152 san.game.character.AttrModifySet
21: 604388 14505312 java.lang.Long
22: 599961 14399064 com.google.protobuf.LiteralByteString
public static ByteString copyFrom(byte[] bytes, int offset, int size) {
byte[] copy = new byte[size];
System.arraycopy(bytes, offset, copy, 0, size);
return new LiteralByteString(copy);
}
public java.lang.String getPlayerName() {
java.lang.Object ref = playerName_;
if (ref instanceof java.lang.String) {
return (java.lang.String) ref;
} else {
com.google.protobuf.ByteString bs =
(com.google.protobuf.ByteString) ref;
java.lang.String s = bs.toStringUtf8();
if (bs.isValidUtf8()) {
playerName_ = s;
}
return s;
}
}
public com.google.protobuf.ByteString getPlayerNameBytes() {
java.lang.Object ref = playerName_;
if (ref instanceof java.lang.String) {
com.google.protobuf.ByteString b =
com.google.protobuf.ByteString.copyFromUtf8(
(java.lang.String) ref);
playerName_ = b;
return b;
} else {
return (com.google.protobuf.ByteString) ref;
}
}
如何计算对象大小
- 在上一个例子中,通过jmap可以查看堆内存中对象的实例和大小,如
num #instances #bytes class name
----------------------------------------------
1: 8095738 129531808 java.lang.Float
3: 1731886 69275440 san.game.attribute.value.complexValue
- 第二列是实例的数目,第三列是实例占用的字节数,第四列是类的名字
- HotSpot的对齐方式为8字节对齐:
- (对象头 + 实例数据 + padding) % 8等于0 且 0 <= padding < 8
- Float对象大小计算
- Float类中只有一个 private final float value,4个字节,即实例数据为4个字节
- 32位对象头8个字节,64位16个字节
- 通过:jinfo pid查看是否开启指针压缩(64bit 1.8 JVM),可以看到默认开启了指针压缩,即-XX:+UseCompressedOops,所以对象头变为了12字节
- 所以对象大小:对象头:12字节 + 实例数据:4字节 = 16字节 = 129531808 / 8095738
- complexValue对象大小计算
- 同上,对象头:12字节
- 当前类有3个Float引用+1个iRelateCalculator引用
- 引用在32bit上是4个字节、64bit是8个字节、开启指针压缩后是4个字节,即当前类的实例数据是:4 * 4 = 16字节
- 父类:iSimpleValue中有1个Float引用和一个iAttrChangeProcessor引用,即父类的实例数据是:2 * 4 = 8个字节
- 所以对象大小:12 + 16 + 8 = 36
- 加上对其padding = 40(8的倍数)= 69275440 / 1731886
- 总结:
- 通过jmap -histo查看的对象内存占用大小指的是shallow size
- HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是对象的大小必须是8字节的整数倍。对象头部分正好似8字节的倍数(1倍或者2倍),因此当对象实例数据部分没有对齐的话,就需要通过对齐填充来补全
- 要考虑是否开启指针压缩
VM Flags:
Non-default VM flags:
-XX:CICompilerCount=3
-XX:+HeapDumpOnOutOfMemoryError
-XX:InitialHeapSize=4294967296 -XX:MaxHeapSize=4294967296
-XX:MaxNewSize=348913664 -XX:MaxTenuringThreshold=6
-XX:MinHeapDeltaBytes=196608 -XX:NewSize=348913664
-XX:OldPLABSize=16 -XX:OldSize=3946053632
-XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseConcMarkSweepGC -XX:+UseParNewGC
如何找到java进程占用cpu最高的线程调用
- jps:找到java进程id
- top -H -p pid:列出pid下线程占用情况 或者top -Hp pid
- printf 0x%x tid:找到那个线程pid,转为16进制
- jstack pid > pid_stack.log :打印线程堆栈并重定向文件
- 在pid_stack.log中查询上面找到的线程tid
- 本例来看:可以看到nio的这个线程cpu占用很高:
- 因为该项目网络层不是是直接用nio2这个库写的,而非用网络层框架netty等
- JDK NIO的BUG,例如臭名昭著的epoll bug,它会导致Selector空轮询,最终导致CPU 100%。官方声称在JDK1.6版本的update18修复了该问题,但是直到JDK1.7版本该问题仍旧存在,只不过该bug发生概率降低了一些而已,它并没有被根本解决
- 关于这个bug相关的文章以及其他框架如netty是如何解决这个问题
- cpu 100% 通常的思路是查看runnable的线程
"pool-1-thread-5" #16 prio=5 os_prio=0 tid=0x00007f5c94383800 nid=0x6004 runnable [0x00007f5c6dffe000]
java.lang.Thread.State: RUNNABLE
at sun.nio.ch.EPoll.epollWait(Native Method)
at sun.nio.ch.EPollPort$EventHandlerTask.poll(EPollPort.java:194)
at sun.nio.ch.EPollPort$EventHandlerTask.run(EPollPort.java:268)
at sun.nio.ch.AsynchronousChannelGroupImpl$1.run(AsynchronousChannelGroupImpl.java:112)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1142)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617)
at java.lang.Thread.run(Thread.java:745)
登陆压力测试
- QA测试流程(python脚本)
- 从connect开始,到登陆中涉及到每一条协议,都做一个计时(从发送某条协议到收到该协议的reply的时间),同时会对每个协议做响应计数,即收到多少响应
- 压测n人,如500人,循环登陆,1人登陆完退出才有新的加入,始终保持500人
- 跑n分钟,如5分钟,计算5分钟之内处理了多少登陆所有的请求,即最终收到了多少个done,即登陆完成的reply,然后用这个值 / 5 * 60 即得到每秒处理的登陆的数目
- 消息流程:
- CHECK_VERSION --- CHECK_VERSION_REPLY 检查版本
- LOGIN --- LOGIN_REPLY 登陆验证
- MAIN_CHAR_CREATE --- MAIN_CHAR_CREATE_REPLY + LOAD_DATA_OVE 创建角色
- 流程分析:
- 检查版本,这个没有耗时
- 登陆验证,因为是内部dev登陆,所以没有直接返回登陆成功
- 登陆成功后,去数据库加载账号角色信息
- 如果账号角色下没有信息,客户端会发送创建角色
- 创建角色
- 去账号中心获取一个唯一id
- 存库
- 存库成功后向客户端返回创建角色回复和加载数据结束的消息
- 优化及改进
- 数据库部分
- 该项目最初的数据层这部分就是一个单线程+一个数据库操作队列+1个数据库连接
- 登陆压测的时候执行几十万次加载账号角色的数据库操作,全部阻塞在了这块导致大量等待
- 解决:使用数据库连接池+多线程+多队列,改进后处理数量级直线上升
- 逻辑优化
- 创建角色存库的时候,调用的是通用的存库方法,该存库方法大约执行了8,9条的数据库操作(角色其他相关信息等)
- 修正这里:创建角色存库的时候只需要存储角色基本信息
- 对于创建角色回复和加载数据完毕回复两条消息不需要等待创建角色存库回调返回后再发送给客户端,在账号中心获取完id后直接回包
- 另外在加载账号信息后server这边可以直接主动创建角色,而非是给客户端回一个包客户单再发送创建角色消息(历史原因:上一个项目的游戏是可以让玩家选择创建角色的),需要客户端配合修改
- 其他优化
- 数据库连接数目、多线程数、多队列数 不断的进行调优 确定一个合适的值
- 网络层这块在压测部分出现了epollWait,即经常cpu飙满,原因上面已经解释了,建议网络层这部分修改为netty等nio框架
- 将数据库操作细化,如对于加载角色数据这样的操作,属于逻辑数据数据库操作,会影响玩家感受的操作,这样的操作会一个单独的数据库线程池去操作;而对于类似玩家下线存盘的操作放到另外一个数据库线程池去操作;当然必须要考虑到顺序的问题,如A玩家的存储是在A线程,而加载是在B线程,二者的顺序如果不确定的话会造成严重的问题
- 必须使用缓存,建议使用redis
- 其他问题
- 项目中有一个策略即玩家下线后不从内存移除,这个时间默认是10分钟
- 而持续压测(连续5分钟)倒进了大约4,5w人,相当于4,5w同时在线,此时内存撑不住了,导致大量的fullgc,从而因为cpu飙满
- 解决:
- 修改断线存库的时间,由10分钟改为了2分钟,但是处理大约30000多个请求后,压测客户端基本收不到请求了
- 怀疑原因是因为过了2分钟,大量的数据开始从内存移除(解决了fullgc问题),开始大量的执行存盘操作,从而登陆load这种数据库操作一直在等待
- 所以需要平衡内存、效率等问题,结合调优数据做出最好的选择
IOS刷单
- 充值流程
- 客户发起充值-> SDK -> ios返回订单
- 金山通行证去苹果验证 -> 验证通过 -> 给xg(有效订单)
- xg再次去苹果验证 -> 验证通过-> 回调游戏
- 如何刷单
- 玩家利用软件利用一个原始订单伪造多个订单 -> 发起了充值 -> 伪造ios验证中心返回一个伪订单
- 而金山通行证服务器端未做排重处理 ->导致验证通过从转xg -> xg验证该订单也存在
- 回调游戏 -> 造成刷单
- 解决
礼包码问题
- 现象:运营测试礼包码时一直失败,而其他人测试礼包码则没有任何问题
- 异常:
ERROR] GmVerifyGiftCard error : java.lang.IllegalArgumentException: Illegal character in query at index 72: http://charge.ly.xoyo.com/gm_center/gift_card_check.php?cardnum=000223ec
&serverid=10006&username=meizu%26meizu__117598875&channelid=meizu
- 即一致提示礼包码参数异常
- 解决:
- 通过cat -A 2016-04-07_error.log
- -A, --show-all equivalent to -vET
- -E, --show-ends display $ at end of each line
- 即通过cat -A参数可以在行尾打印$
- 此时查看:发现carnum后面多了一$,即输入的礼包码有一个回车换行
- 则只需要在server中对输入的礼包码进行过滤即可
- 原因:
- 运营为测试游戏方便,是在pc使用的安卓模拟器进行的礼包码测试
- 而礼包码测试是从excel粘贴而来的,但是运营粘贴的是单元格,而不是单元格内容,粘贴单元格就会多一个换行
- 已在linux#vim测试并通过cat -A测试(即粘贴excel单元格确实会多一个换行)
http://charge.ly.xoyo.com/gm_center/gift_card_check.php?cardnum=000223ec$
&serverid=10006&username=meizu%26meizu__117598875&channelid=meizu $
金山云LB问题
- 现象:线上大量的登陆验证超时-SocketTimeoutException
- 原因:
- 登陆验证的url在外网可以访问
- 但是ssh登陆游戏服务器后,用curl则无法访问
- 总结:
- 理论上和xg一点关系没有,但是xg用的金山云,使用的外网负载.如果你从外网访问西瓜的负载不会有任何问题的
- 简单来说:就是金山云内部机器相互访问(内部的机器访问内部机器的外网负载)有问题
- 金山云忽略了,可能内网访问一个没有内网策略的外网负载,他的路由没有走公网,而是在金山云内部的路由,造成response有问题
- 金山云自己判断了你访问的是外网,但是实际请求就没有出外网,直接在内部判断了
- 临时解决方案:
- 西瓜的入访添加一个我们的内网ip
- xg: 防火墙这块加好后,需要提供ip给金山云的同事 修改一下底层配置
- 是金山云LB(负载均衡)的bug
- 建议:新服上线前,都可能需要测试网络连通性以及添加内网防火墙了
线上玩家利用WPE抓包修改协议包
- 真实玩家利用服务器逻辑漏洞,利用wpe修改包,达到作弊目的(运营同学打入玩家内部,是一个15岁的00后)
- 如:
- 卡牌游戏上阵可以设置一个先锋技,先锋技可以加怒气
- 但服务器逻辑未判断只能有一张卡牌用先锋技能
- 玩家利用wpe修改协议包,修改为5张上阵卡牌都使用了先锋技(正常客户端已经屏蔽掉只能使用一个先锋技),这样服务器计算怒气很大,从而达到无敌
- 总结:
- 服务器逻辑一定要严谨,The Server is the man
- 可在协议设计这块做的更好一些,让作弊的成本最大化,可参考
合服后启动server失败
- 现象:启动一段时间后,查看log(tail -f)一直停留在加载竞技场玩家数据中,没有继续启动下去;而正常的log会有加载竞技场玩家数据完毕的log,且会继续启动
- 排查
- jmap/top/jstat 查看内存 cpu gc等都没有任何问题
- jstack导出线程堆栈,也没发现有线程阻塞,不过导出线程堆栈之后 发现没有主线程
- 反思
- 其实在用jstack查看线程堆栈的时候没有main,即说明主线程退出了,肯定是有error-所以当时就应该直接查询error,而不是对比启动成功的日志(会输出加载竞技场数据完毕的消息)和启动失败的日志
- 因为启动成功后,主线程还会在,因为会监听kill命令,启动后的主线程的堆栈类似如下
- 即如果发现主线程不存在,则说明中间逻辑出了问题
- 而且出问题的时,应该直接查询日志的error或者异常信息,第一时间排查,而不是tail -f
- 原因
- 因为合服(数据库几十万条数据)导致加载竞技场数据(5000人)时间过长,超过10分钟
- 主线程LogicServerMngr#loadGlobalData 有一个防御式编程
- 即主线程会每隔100s去检查是否已经加载了5000人,如果没有加载完则一直while -> 如果加载完毕则直接返回 -> 主线程逻辑继续跑;如果加载时间超过了10分钟则直接返回
- 查看日志的时候有一个疏漏,即运维通过tail -f查看日志,只看到了加载竞技场玩家的日志,但是其实在之前,主线程就已经有error了:
- error:global data load timeout.
- error: Logic server manager startup failed!
- 因为这个日志是在主线程输出的,而加载竞技场玩家完毕的消息是在逻辑线程输出的
- 是先从数据库加载了竞技场的5000个玩家id
- 然后扔到db线程依次去加载这5000个玩家
- 主线程一个while,一直去轮训去检查是否已经加载了5000个玩家并做timeout判断,主线程判断超过了10分钟,则timeout返回
- 此时异步线程依然在加载,一直在输出,导致后续的输出覆盖了之前的timeout输出 -> 从而排查问题不好排查
- 总结
- 看来合服的话,导致数据库非常大,几十万的数据库量级,从而使查询变慢
- 做好优化/用数据库连接池/去掉部分垃圾数据
- 优化启动,不在启动加载这么多少数据 -> 或者直接加入到cache中
- 重启以后,某个服成功启动,这个应该只是概率的,因为数据库处理速度谁都不能保证,重启之后可能速度处理快了一点.根本原因还是数据库合服后过于庞大 --> 导致加载时间过长
- 目前的解决办法是修改这个timeout,改为30分钟后,则合服后的sever启动成功
"main" prio=10 tid=0x00007fca1c008800 nid=0x3a24 in Object.wait() [0x00007fca2471e000]
java.lang.Thread.State: WAITING (on object monitor)
at java.lang.Object.wait(Native Method)
- waiting on <0x000000078a404548> (a java.lang.Object)
at java.lang.Object.wait(Object.java:503)
at san.server.QuitListener.waitBySignal(QuitListener.java:62)
- locked <0x000000078a404548> (a java.lang.Object)
at san.server.QuitListener.waitQuit(QuitListener.java:23)
at san.server.MainEntry.main(MainEntry.java:37)
long loadTime = System.currentTimeMillis();
while (!globalDataPersistence.checkArenaLoadOver()) {
if (System.currentTimeMillis() - loadTime >= 10 * 60 * 1000) {
LogMessage.error("global data load timeout.");
return false;
}
try {
Thread.sleep(100);
} catch (InterruptedException e) {
}
}
return true;
}
线上刷元宝(防御式编程)
- 现象
- 客服同事举报某玩家vip等级有异常,刚开服不久便达到了vip顶级
- 原因:因为某个事件处理抛出异常(该异常非毕现),导致很多逻辑出错
- 如领取邮件逻辑,邮件中是充值元宝,玩家增加元宝,同时增加vip经验,提升vip等级 ->(vip等级变化)此时抛出一个事件处理 -> 异常
- 导致玩家邮件未正常删除,玩家再次进入游戏会继续领取邮件,从而造成刷元宝
- 同时增加元宝这个统计也因为异常没有统计到数据库中,给排查人员造成了很大的困难
- 直到VIP等级达到上限,不继续走异常逻辑从而结束,数据库中只记录了一条最后的充值元宝记录
- 解决
- 当一个逻辑很复杂,含有多个子逻辑的时候,线上环境最好防御式编程,每个子逻辑都try/catch
- 本例这个异常是因为一个记录日志引起的异常,而通常这种异常绝对不应该让程序逻辑出错,所以这种记录日志的接口最好做一个包装类,然后记录日志的方法本身做try/catch
- 引申
- 想起了之前西游线上的一个类似问题,就是调度程序的问题,如0点的时候会有很多调度子逻辑,而如果其中一个调度子逻辑出现异常的时候,则影响了后面的自逻辑从而造成逻辑错误
- 和上面一样,对于这种调度逻辑,子逻辑一定要try/catch,尤其是对于这种一个方法内子逻辑非常多的情况
域名扩散
- 因重新部署环境需要将域名指向的ip替换为新的负载均衡地址
- 域名扩散问题:即域名解析换后,有一个扩散问题,即有一部分网络还会访问旧的解析地址,为了保证能访问,则旧的服务器还需要维护两三天
- 原因:
- dns是多级解析,每一级dns都可能缓存记录;即使修改了dns的记录,要使其生效也需要较长时间,这段时间dns仍然会将域名解析到旧的服务器,导致用户访问失败
mysql的连接允许的闲置时间
- 现象:
- 当seerver运行一段时间后,抛出异常:java.sql.SQLException: Could not retrieve transation read-only status server
- 原因:
- 使用了HikariCP,但是某些参数设置有一些问题
- Configure your HikariCP idleTimeout and maxLifeTime settings to be one minute less than the wait_timeout of MySQL
- mysql的连接允许的闲置时间,当超过闲置时间以后,database端就会将此连接单方面废弃,这时如果使用jdbc继续使用之前的连接则抛异常
- 解决:
- config.setMaxLifetime(86400000 - TimeUnit.MINUTES.toMillis(1))
- config.setIdleTimeout(86400000 - TimeUnit.MINUTES.toMillis(1))
西游降魔篇3D
数据库更新方式
- 服务器开服时用的数据库表脚本必须是包括最新的数据库表
- 只有当真正线上数据库表需要变化的时候,才需要发送更新邮件,将线上旧的服务器表更新,而不要提前发送更新数据表表的邮件 -> 即代码版本和数据表的版本要一致
- 原因:
- 上一个大版本1.7.6在更新的时候,因为先更新测试服,所以测试服的数据库表更至最新。但是本人当时(提前)发了一封邮件,同时将线上的所有服务器提前增加新增的数据库表
- 但是此时线上的大版本为1.7.0,后续新开的服务器很多服务器都会清档,用1.7.0的包新建服务器,而1.7.0的服务器中的数据库表还是旧的.从而导致后续1.7.6版本更新的时候未做数据库更新(1.7.6版本未发送数据库更新邮件)
- 而ios的1.7.6版本又和android大区的更新时间不一致,导致我忽略了ios的大版本更新时间从而使ios新开的一些服务器的数据表是旧的
- 总结:
- 注意运维新建服务器的时候都会进行清档操作,在此之前对该服务器做的所有操作都会被清除
- 一定要明确线上ios和android通常版本更新的时间都不一样,ios通常会晚一些
- 运维通常会提前准备好服务器,部署环境,正式开服的时候只需要清档即可
- 新版本线上更新时,如果增加了新的sql,需要通知运维更新相关大区线上的游戏服务器同步更新数据库
- 因为ios版本和android版本更新时间不同,通常是android版本先更新.所以会出现如下问题
- android 1.8.0 更新,数据库更新,通知运维更新所有android大区游戏服务器数据库
- 此时ios大区还是1.7.5版本,数据库也是1.7.5版本
- 因为ios大区和android大区的数据库是混在一起的 -> 所以运维希望在更新android 1.8.0时候,顺便将ios大区的数据库也更新为1.8.0 -> 目前操作也是这样的
- 如果不更新1.7.5版本的数据库 -> 即使将ios大区的数据库提前更新至1.8.0 ->但是因为后续ios大区开新服,会清档 -> 会重新用1.7.5下的数据库更新 -> 从而以后在更新1.8.0的时候,运维需要查询哪些服务器是新开的(更新数据库的时候会将已部署的所有数据库均进行更新,但是因为新开的服务器会进行回档,所以要找出这些新开的服务器更新至1.8.0),然后同步更新至1.8.0 -> 非常麻烦
- 总结:
- 新版本如1.8.0更新数据库的时候,请顺便将还在运营如1.7.5版本的数据库更新至1.8.0(即1.7.5的建库脚本更新至1.8.0)
- 即使1.7.5版本开新服的时候,数据库也能保证是最新的 -> 即使清档 -> 也没有问题
- 注意这种方式均是增量更新,而不是对已有的sql进行修改 -> 如果是对原有sql修改的话,必须要保证代码版本和sql版本一致
IOS正版无法登陆(小米为发行方)
- 小米SDK的BUG,匿名用户绑定帐号后,继续用匿名帐号去绑定帐号的接口验证,所以登陆失败
- 部分网络环境下,访问 account.xiaomi.com 域名失败,导致小米帐号无法登陆
- 玩家绑定小米帐号后,修改密码后,无法登陆,需要修改为原来的密码才可以正常登陆
- 解决:
Cause: com.mysql.jdbc.exceptions.MySQLTimeoutException: Statement cancelled due to timeout or client request
- 原因:
- SQL: select count(*) from player where vip >= ?
- 执行这个sql的时候超时,则直接抛出了异常
- player表数据过大,没有索引导致查询超时
- player_data和player二者合二为一了,将player_data中的text字段拷贝到了player表中,导致player表的数据激增,因为text是二进制字段,是玩家数据,非常大
- 解决
- player表增加索引
- 将player和player_data分开
大量玩家登陆上线频繁gc
- 战报数据较大,为了节省流量,在encode的时候使用7z进行压缩
- 7z算法实现的很糟糕-7z库,里面每次会给自己分配8M内存
- 可能会造成大量玩家登陆上线引起的频繁GC,导致性能严重下降
- 解决:
关于新手引导问题引起的客户端卡死
- 客户端卡死问题太严重,后果也很严重
- 所以最好可以从代码层次避免,即引导失败或者一些代码执行失败不会影响游戏业务流程,保证游戏客户端稳定性
线上数据库某时刻流量极大而且服务器卡
- 原因:
- 玩家改名每次去数据库查询是否有重名,while条件则写错了 -> 导致没有重名继续do。。。一直没有重名。。一直do..死循环。。
- 从而也造成了这一时刻数据库流量极大,因为一直去查数据库
- 分析:
- 服务端用了大量的while,已经有很多地方出现了死循环
- 很多同学已经打了很多补丁,类似如果循环超过10000次。。就break等
- 反思:禁用while
IOS Emoji表情存储
- Emoji 字符的特殊之处是,在存储时,需要用到 4 个字节。而 MySQL 中常见的 utf8 字符集的 utf8_general_ci 这个 collate 最大只支持 3 个字节。所以为了能够存储 Emoji,你需要改用 utf8mb4 字符集
- 对 utf8mb4 字符集的支持是 MySQL 5.5 的新功能,所以你需要确保你使用的 MySQL 版本至少是 5.5
- 如果UTF8字符集且是Java服务器的话,当存储含有emoji表情时,会抛出类似如下异常即字符集不支持的异常,因为UTF-8编码有可能是两个、三个、四个字节,其中Emoji表情是4个字节,而Mysql的utf8编码最多3个字节,所以导致了数据插不进去
- 解决:
- 起名的时候过滤掉emoji表情,判断字符是否在unicode BMP区域即可
- 改动数据库版本则相对影响较大
java.sql.SQLException: Incorrect string value: '\xF0\x9F\x92\x94' for column 'name' at row 1
at com.mysql.jdbc.SQLError.createSQLException(SQLError.java:1073)
at com.mysql.jdbc.MysqlIO.checkErrorPacket(MysqlIO.java:3593)
at com.mysql.jdbc.MysqlIO.checkErrorPacket(MysqlIO.java:3525)
at com.mysql.jdbc.MysqlIO.sendCommand(MysqlIO.java:1986)
at com.mysql.jdbc.MysqlIO.sqlQueryDirect(MysqlIO.java:2140)
at com.mysql.jdbc.ConnectionImpl.execSQL(ConnectionImpl.java:2620)
at com.mysql.jdbc.StatementImpl.executeUpdate(StatementImpl.java:1662)
at com.mysql.jdbc.StatementImpl.executeUpdate(StatementImpl.java:1581)
protobuf
- protobuf协议中的集合是一个UnmodifiableCollection(非常符合协议的定义,只读属性)
- 不能进行移除等修改操作,否则会抛出UnsupportedOperationException
上线回档
- 原因:
- 每周四10点例行维护,策划提交了一版配置
- 但是奖励配置出了问题,正常奖励元宝是1,现在是10000;正常奖励灵魂是1,现在是10000
- 而这些资源都是比较稀缺的,而又有拍卖行可以交易
- 大量的玩家在刷这些资源,如果影响范围较大的话,可能会影响所有服务器的经济系统
- 解决:
- 问题:
- 因为这次例行维护,只是更新配置表,所以没有停服
- 没有停服则没有备份数据库
- 而最近的一次备份库是上午5点的多(现在备份是每天才备份一次,每天5点多备份一次)
- 只能通过binlog查询数据操作日志,然后进行回档,回档到上午10点
- 反思:
- 例行维护的时候最好停服,运维那边停服的时候备份一下所有的库
- 维护前一定要QA测试策划修改的东西,程序修改的东西
- 即一定有一个人知道这个版本修改了哪些东西,尤其是策划修改的重要表的数值
- 能否将数据库备份的周期再缩短一下
线上防刷(防御式编程)
- 先扣玩家元宝或者道具,然后再给玩家奖励
- 因为先给玩家奖励,没问题,再扣玩家元宝或者道具的时候可能会抛出异常
- 从而导致玩家刷/复制
- 采用第一种方案则保证即使没有给玩家奖励,后续也可以进行补偿
- 对于客户端传过来的参数,必须要强制校验,否则如果直接使用外挂(直接发网络包)或者客户端有bug则也会导致刷的问题出现(如使用wpe)
classloader
- Player对象不能持有XXManager这样的东西
- 因为Player对象是由系统类加载器加载的 --> 而XXManager是自定义加载器加载的(path自定义,不在系统类加载器加载的path),按照双亲原则,Player对象是找不到XXManager的
- 而XXManager是可以持有Player对象的
- 因为XXManager是自定义类加载加载的,当加载Player的时候找不到,则会按照双亲原则由系统类加载器加载Player,而恰恰可以加载到
在线执行脚本
- 游戏服务器逻辑支持hotswap,动态更新在线service业务时,为什么还需要在线执行脚本
- 如9.17的争霸赛问题,因为异常,造成执行某个重要的方法抛出异常
- hotswap只支持将这段代码进行修补,但是不能再次执行这个方法
- 而脚本更新则可以直接更新一个脚本,脚本内容为再次执行这个方法;该方法可以做类似修改一些线上玩家错误数据,配置表数据等
排序bug
- 如果list有两个A1,A2,A1的robtime为0,而A2的robTime = System.currentMillis
- 做compareto比较时,因为要强转为int...所以溢出,造成A2排序的时候变成了负数
- 即: (int)(o.time - time) 这种方式不建议,建议大小做判断
- 参考effective java相关章节
A
{
long time;
}
compareto(A o)
{
return (int)(o.time - time)
}
if (robTime > o.robTime) {return -1;}
if (robTime < o.robTime) {return 1;}
return 0;
因为客户端无法热更而需要做的一些妥协
- 设计的时候除了考虑性能问题,还要考虑客户端是否支持热更新
- 如果不支持热更新,则一些设计如以前的设计是给客户端数据,客户端拼接数据,如文本消息,但是策划要改文本消息,消息中的参数都发生了变化
- 而客户端不支持更新 -> 而服务器可以热更,可能没办法的措施就是服务器发送给客户端拼接后的文本,如果策划要改,则直接服务器修改
- 则客户端无法热更的情况下,不修改协议的情况下实现需求的变更
- 解决:
服务器提示文本国际化
- 服务器代码国内版本和国际版本用一套代码,包括配置文件
- 所以不能将语言这个变量不要和服务器代码维护耦合在一起(如配置文件中有一个选项是多语言,国内版本是cn,国外版本如越南用vn,但是如果这样做的话,就相当于维护了多套代码)
- 解决
- 将lang这个变量放在具体的部署环境中
- 如运维搭建越南游戏服务器的时候手动env.sh中的lang=VN
- 而这个env.sh只会在第一次搭建的时候用到 -> 后续代码更新或者版本更新的时候不会影响这个env.sh
- 旧方案
- 服务器包中有一个配置来描述语种
- 打包的时候指定语种 -> 用来覆盖这个配置,相对比较麻烦
其他
- 涉及到给玩家东西的逻辑必须加上log,否则和GM,玩家沟通则没有证据
- 关键游戏逻辑业务加log,便于排查线上问题
- 在一个已经运行了一段很长时间的代码上面增加代码 -> 这段代码最好try/catch -> 否则可能会影响之前的代码
- server端支持逻辑热更新是必须的,会很方便的解决一些问题;不过如果逻辑是无状态的话,也可以将这些逻辑放在一个单独的进程,需要修改逻辑的时候直接kill再重启
网友评论