


2022年8月25日,我所在的服务器出现异常掉刻,MSPT峰值达到500+

Minecraft 1.18.2 + Lithium | 12600K 5.0Ghz | 10GiB Ram
事后,通过分析工具和对源码的挖掘,我们终于确定了MSPT占用的来源:

一只看起来人畜无害的蜜蜂
无巢的蜜蜂,会绑定未住满的蜂巢作为自己的栖息地,并保存蜂巢位置为 HivePos NBT 信息
public void addAdditionalSaveData(CompoundTag p_27823_) {
super.addAdditionalSaveData(p_27823_);
if (this.hasHive()) {
p_27823_.put("HivePos", NbtUtils.writeBlockPos(this.getHivePos()));
}
if (this.hasSavedFlowerPos()) {
p_27823_.put("FlowerPos", NbtUtils.writeBlockPos(this.getSavedFlowerPos()));
}
p_27823_.putBoolean("HasNectar", this.hasNectar());
p_27823_.putBoolean("HasStung", this.hasStung());
p_27823_.putInt("TicksSincePollination", this.ticksWithoutNectarSinceExitingHive);
p_27823_.putInt("CannotEnterHiveTicks", this.stayOutOfHiveCountdown);
p_27823_.putInt("CropsGrownSincePollination", this.numCropsGrownSincePollination);
this.addPersistentAngerSaveData(p_27823_);
}

/data get entity 获取的结果
有巢的蜜蜂,会在夜间或下雨时归巢休息
boolean wantsToEnterHive() {
if (this.stayOutOfHiveCountdown <= 0 && !this.beePollinateGoal.isPollinating() && !this.hasStung() && this.getTarget() == null) {
boolean flag = this.isTiredOfLookingForNectar() || this.level.isRaining() || this.level.isNight() || this.hasNectar();
return flag && !this.isHiveNearFire();
} else {
return false;
}
} 当有巢的蜜蜂满足了归巢的条件时会读取保存的蜂巢信息
public void readAdditionalSaveData(CompoundTag p_27793_) {
this.hivePos = null;
if (p_27793_.contains("HivePos")) {
this.hivePos = NbtUtils.readBlockPos(p_27793_.getCompound("HivePos"));
}
this.savedFlowerPos = null;
if (p_27793_.contains("FlowerPos")) {
this.savedFlowerPos = NbtUtils.readBlockPos(p_27793_.getCompound("FlowerPos"));
}
super.readAdditionalSaveData(p_27793_);
this.setHasNectar(p_27793_.getBoolean("HasNectar"));
this.setHasStung(p_27793_.getBoolean("HasStung"));
this.ticksWithoutNectarSinceExitingHive = p_27793_.getInt("TicksSincePollination");
this.stayOutOfHiveCountdown = p_27793_.getInt("CannotEnterHiveTicks");
this.numCropsGrownSincePollination = p_27793_.getInt("CropsGrownSincePollination");
this.readPersistentAngerSaveData(this.level, p_27793_);
} 读取到蜂巢信息后,蜜蜂会进一步判定蜂巢是否有效
boolean isHiveValid() {
if (!this.hasHive()) {
return false;
} else {
BlockEntity blockentity = this.level.getBlockEntity(this.hivePos);
return blockentity != null && blockentity.getType() == BlockEntityType.BEEHIVE;
}
} 在这段代码中,引用了 getBlockEntity,获取处于 HivePos 蜂巢的方块实体信息
public BlockEntity getBlockEntity(BlockPos p_46716_) {
if (this.isOutsideBuildHeight(p_46716_)) {
return null;
} else {
return !this.isClientSide && Thread.currentThread() != this.thread ? null : this.getChunkAt(p_46716_).getBlockEntity(p_46716_, LevelChunk.EntityCreationType.IMMEDIATE);
}
} getBlockEntity方法,引用了 getChunk 方法,获取蜂巢方块实体所在区块的信息
public ChunkAccess getChunk(int p_46502_, int p_46503_, ChunkStatus p_46504_, boolean p_46505_) {
ChunkAccess chunkaccess = this.getChunkSource().getChunk(p_46502_, p_46503_, p_46504_, p_46505_);
if (chunkaccess == null && p_46505_) {
throw new IllegalStateException("Should always be able to create a chunk!");
} else {
return chunkaccess;
}
}

上面说到,getBlockEntity 会引用 getChunk 方法来试图获取蜂巢信息
如果蜂巢此时处于一个未加载的区块会怎样呢?
在实际游戏中,将蜂巢位置放置于区块[62,62];蜜蜂绑定蜂巢后,放置于区块[100,100]
使用 在实际游戏中查看

/log ticket 的输出结果
可以看到,[62,62] 即蜂巢所在区块,确实被加载

unknown加载票的详细信息
[62,62] 区块被添加一个类型 unknown(未知)、等级 33、持续时间 1gt 的加载票
当未加载区块被加载了,蜜蜂才能成功获取中蜂巢的信息,从而判断蜂巢是否有效

加载票系统,是 Minecraft 1.14 +,Mojang 引入的全新区块加载机制
加载票的加载类型不少,我们熟知的高版本地狱门加载器,就是通过实体穿过下届传送门时,添加 portal(传送门)类型的加载票,实现双向加载

一个经典的高版本地狱门加载器
unknown(未知)加载票,几乎只在调用getChunk方法、AI寻路判断上表面完整方块时使用
I haven't put much investigation into the "unknown" ticket. It's the ticket used by the game when an arbitrary piece of game code calls "getChunk". If the game code says the chunk should be loaded, it places this ticket on that chunk (after possibly creating it). It has a time-to-live of one (1) game tick. The load level depends on what kind of chunk the game attempts to load (a fully world-generated chunk or not). It will be at least 33 (border), but in many cases it could beyond 33, which will involve world-gen in some way. ——1.14.x Chunk Loading by Drovolon
蜜蜂AI判断蜂巢是否有效时,引用了 getChunk 方法
我们知道,getChunk给予的unknown(未知)加载票等级33

“计算实体”旧称强加载、“基础运算”旧称弱加载
而加载等级决定了给定区块中,哪些任务能够被运算
等级为 33,属于“边界”,意味着红石元器件和实体都不被计算,但实体仍算入刷怪上限
Load levels propagate to neighboring chunks. For each ticket, the load level "flood fills" outward from the ticket, increasing the chunk's load level by one for each step outwards, until the max level (44) is reached. ——1.14.x Chunk Loading by Drovolo
加载等级会传播到相邻(8邻接)的区块,也就是17*17的范围

加载等级的传播
这些区块的加载等级从33传播到41停止
高于33的加载等级属于不可访问、不会有任何的红石、实体运算,但是他们仍然算作被加载,每一次加载都会消耗服务器资源
MSPT是毫秒/游戏刻的缩写
MSPT越高,证明游戏处理每gt的时间越长,游戏也就越卡顿
理想情况下20gt=现实中的1秒,满足理想情况的MSPT应当小于50
蜜蜂每20gt执行一次自身AI的循环
public void aiStep() {
super.aiStep();
if (!this.level.isClientSide) {
if (this.stayOutOfHiveCountdown > 0) {
--this.stayOutOfHiveCountdown;
}
if (this.remainingCooldownBeforeLocatingNewHive > 0) {
--this.remainingCooldownBeforeLocatingNewHive;
}
if (this.remainingCooldownBeforeLocatingNewFlower > 0) {
--this.remainingCooldownBeforeLocatingNewFlower;
}
boolean flag = this.isAngry() && !this.hasStung() && this.getTarget() != null && this.getTarget().distanceToSqr(this) < 4.0D;
this.setRolling(flag);
if (this.tickCount % 20 == 0 && !this.isHiveValid()) {
this.hivePos = null;
}
}
} 每次循环,蜜蜂都会判断自己保存的蜂巢信息是否有效
每次判断,都会加载一次17*17范围的区块

加载周期20gt,加载时间为1gt的17*17区域
每次加载,都只有1gt的有效时间,这些区块会被立刻卸载

实时MSPT
F3界面显示的实时MSPT周期性的波动,周期大致为一秒

资源占用的分析结果
推测具体原因为,区块相关的方法反复执行,造成线程阻塞,从而吃掉了近90%的服务器资源

尝试在单人存档复现问题时,一只蜜蜂就占用了42mspt
蜜蜂,很神奇吧?

[1] 1.14.x Chunk Loading
https://gist.github.com/Drovolon/24bfaae00d57e7a8ca64b792e14fa7c6


感谢 ishland 在代码挖掘上的指导和帮助
感谢 Archi Tech Co-op 提供的测试服务器
笔者水平有限,如有错误欢迎指出讨论
——obtuseAng1e
2022/08/31