SpringCloud + Feign + MySQL:跨服竞技场的玩家匹配(等级、战力相近度算法)
作为一名摸爬滚打八年的 Java后端,我对 “跨服匹配” 的感情很复杂 —— 它是游戏拉留存、提付费的核心功能,但也是最容易出问题的 “重灾区”。早期做单服竞技场时,匹配逻辑三行 SQL 就能搞定;后来做跨服,没考虑服务间数据同步,玩家匹配到 “幽灵账号”(数据没拉全);再到后来,战力相近度算法写得太简单,高战玩家匹配到新手,直接把人家打退游……
直到用了 SpringCloud + Feign + MySQL 的组合,配合自定义的 “等级 - 战力加权算法”,才彻底解决了这些问题。今天就结合最新项目实战,聊聊如何搭建一套 “低延迟、高公平、易扩展” 的跨服竞技场匹配系统,从跨服数据拉取到相近度计算,每一步都穿插踩过的坑和实战技巧,拒绝空谈理论。
一、为什么是这 trio 组合?从跨服匹配的 “痛点暴击” 说起
做技术选型前,得先扒光跨服匹配的 “痛点底裤”—— 八年经验告诉我,玩家对跨服竞技场的核心诉求就一个:公平,而开发和运维更关心:不卡、不崩、好维护。对应到技术上,就是 “跨服数据互通”“低延迟匹配”“相近度精准”。
先对比下常见的跨服匹配方案,看看为什么最终选了 SpringCloud + Feign + MySQL:
| 方案 | 优势 | 短板 | 游戏场景适配度 |
|---|---|---|---|
| 单服匹配(本地 MySQL) | 实现简单、延迟低 | 跨服 = 空谈,服务器负载不均(老区高战扎堆) | (仅适合测试 / 小体量单服游戏) |
| Redis 集群(全服数据缓存) | 匹配池查询快、支持高并发 | 数据一致性差(缓存同步延迟导致 “数据脏读”)、存储成本高(全服玩家数据存 Redis) | (适合超高频匹配,但游戏跨服更需数据可靠) |
| MQ 异步同步(Kafka + 本地库) | 解耦数据同步和匹配 | 延迟高(同步一次数据 1-2 秒)、匹配时可能拿到旧数据(玩家战力刚涨,MQ 还没同步) | (不适合实时匹配,会出现 “战力虚低”) |
| SpringCloud + Feign + MySQL | 1. 微服务拆分,匹配 / 数据服务独立扩容;2. Feign 跨服调用实时拉取数据,避免缓存脏读;3. MySQL 存玩家基础数据 + 匹配记录,数据可靠;4. 支持自定义相近度算法 | 跨服调用需处理超时 / 熔断(Feign 可配置)、MySQL 查询需优化(索引要做好) | (游戏跨服匹配最优解,平衡实时性、可靠性、公平性) |
可能有人会问:“为什么不用 Redis 做匹配池?”—— 早期我也试过,全服玩家战力存 Redis Sorted Set,匹配时按分数范围取。但问题来了:玩家战力实时变化(比如穿了新装备),Redis 缓存和 MySQL 主库同步总有 100-200ms 延迟,导致匹配到的 “战力相近” 是 “假相近”;而且跨服场景下,Redis 集群同步数据的成本比 Feign 实时拉取还高。
至于 Feign,它的优势在于 “轻量、易集成”—— 不用自己写 HTTP 客户端,配合 SpringCloud 的负载均衡,跨服调用时能自动选最优节点,比手写 OkHttp 省心太多。
二、架构设计:从 “玩家发起匹配” 到 “对手确定” 的全链路
先放一张简化的架构图,让大家直观理解三个技术的角色:
整个链路的核心逻辑是 “实时拉取 + 精准计算 + 状态锁定”:
- 实时拉取:用 Feign 跨服调用各服数据服务,拿到最新的玩家等级、战力(避免缓存脏读);
- 精准计算:先按 “等级 ±5、战力 ±10%” 做粗筛,再用加权算法算相近度得分,确保公平;
- 状态锁定:匹配成功后立即更新 MySQL 的 “匹配状态”,避免玩家被重复匹配。
这种设计的好处很明显:玩家匹配到的对手是 “真・相近”,跨服调用有熔断兜底(不怕某服宕机),MySQL 存记录方便后续查问题(比如玩家投诉 “匹配不到人”,直接查匹配池日志)。
三、核心实现:代码 + 经验,每一行都藏着避坑技巧
接下来分模块讲实现,每个部分都贴关键代码 —— 这些代码都是生产环境跑过半年的,踩过的坑都帮你填好了。
3.1 第一步:基础准备:SpringCloud + Feign 环境搭建
跨服匹配的核心是 “跨服调用”,先搞定 Feign 的配置,确保调用可靠。
3.1.1 引入依赖
<dependency>
<groupId>com.alibaba.cloudgroupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discoveryartifactId>
<version>2.2.7.RELEASEversion>
dependency>
<dependency>
<groupId>org.springframework.cloudgroupId>
<artifactId>spring-cloud-starter-openfeignartifactId>
<version>2.2.9.RELEASEversion>
dependency>
<dependency>
<groupId>org.springframework.cloudgroupId>
<artifactId>spring-cloud-starter-circuitbreaker-resilience4jartifactId>
<version>2.2.9.RELEASEversion>
dependency>
<dependency>
<groupId>mysqlgroupId>
<artifactId>mysql-connector-javaartifactId>
<version>8.0.30version>
dependency>
3.1.2 Feign 客户端配置(跨服调用 “玩家数据服务”)
/**
* Feign客户端:调用各服的“玩家数据服务”(每个服的服务名格式:player-data-service-{服ID})
* 八年经验:用“服务名+服ID”的格式,方便路由到指定服
*/
@FeignClient(
name = "player-data-service-{serverId}", // 服务名占位符,动态替换服ID
fallbackFactory = PlayerDataFeignFallbackFactory.class, // 熔断降级
configuration = FeignConfig.class // 自定义配置(超时、日志)
)
public interface PlayerDataFeignClient {
/**
* 拉取指定服的“待匹配玩家列表”
* @param serverId 服ID(路径参数)
* @param condition 匹配条件(等级范围、战力范围)
* @return 符合条件的玩家列表
*/
@PostMapping("/api/v1/player/data/match-candidates/{serverId}")
R> getMatchCandidates(
@PathVariable("serverId") Integer serverId,
@RequestBody PlayerMatchCondition condition
);
}
/**
* Feign 配置:超时+日志(关键!跨服调用最容易栽在超时上)
*/
@Configuration
public class FeignConfig {
// 连接超时:500ms(跨服网络波动大,别设太短)
@Bean
public Request.Options feignOptions() {
return new Request.Options(500, TimeUnit.MILLISECONDS, 2000, TimeUnit.MILLISECONDS, true);
}
// 日志级别:仅打印错误日志(避免日志太多占磁盘)
@Bean
Logger.Level feignLoggerLevel() {
return Logger.Level.ERROR;
}
}
/**
* 熔断降级:跨服调用失败时返回空列表,避免匹配服务崩溃
*/
@Component
public class PlayerDataFeignFallbackFactory implements FallbackFactory<PlayerDataFeignClient> {
private static final Logger log = LoggerFactory.getLogger(PlayerDataFeignFallbackFactory.class);
@Override
public PlayerDataFeignClient create(Throwable cause) {
return (serverId, condition) -> {
log.error("拉取服{}的匹配候选玩家失败", serverId, cause);
// 降级策略:返回空列表(不影响其他服的数据拉取)
return R.success(Collections.emptyList());
};
}
}
八年经验技巧:
- Feign 服务名一定要带 “服 ID” 占位符:早期没这么做,用 “player-data-service” 通配,结果 Feign 把请求随机发给任意服,导致拉取的数据不对;
- 超时时间别太抠:跨服调用走公网,500ms 连接超时 + 2000ms 读取超时是黄金比例,太短容易频繁熔断,太长会让玩家等得不耐烦;
- 熔断降级要 “温和”:某服宕机时,返回空列表即可,别直接抛异常,否则整个匹配流程会中断。
3.2 第二步:MySQL 表设计:玩家数据 + 匹配记录,缺一不可
跨服匹配需要两类数据:一是玩家实时的等级、战力(从各服 MySQL 拉取);二是匹配记录(存到跨服 MySQL,避免重复匹配)。
3.2.1 核心表结构(跨服 MySQL)
-- 1. 跨服匹配记录表(存匹配结果,避免重复匹配)
CREATE TABLE `cross_server_match_record` (
`id` bigint NOT NULL COMMENT '匹配记录ID(雪花算法)',
`player_id` bigint NOT NULL COMMENT '发起匹配的玩家ID',
`player_server_id` int NOT NULL COMMENT '发起玩家的服ID',
`opponent_id` bigint DEFAULT NULL COMMENT '对手玩家ID',
`opponent_server_id` int DEFAULT NULL COMMENT '对手服ID',
`match_status` tinyint NOT NULL COMMENT '匹配状态:0-匹配中,1-匹配成功,2-匹配失败',
`player_level` int NOT NULL COMMENT '发起玩家等级',
`player_power` bigint NOT NULL COMMENT '发起玩家战力',
`opponent_level` int DEFAULT NULL COMMENT '对手等级',
`opponent_power` bigint DEFAULT NULL COMMENT '对手战力',
`create_time` datetime NOT NULL COMMENT '发起匹配时间',
`finish_time` datetime DEFAULT NULL COMMENT '匹配完成时间',
`expire_time` datetime NOT NULL COMMENT '匹配超时时间(10秒未匹配成功则失效)',
PRIMARY KEY (`id`),
-- 关键索引:按“玩家ID+状态”查,避免重复发起匹配
UNIQUE KEY `idx_player_status` (`player_id`,`match_status`) COMMENT '玩家ID+匹配状态(0-匹配中)唯一',
-- 过期索引:定时清理超时未匹配的记录
KEY `idx_expire_time` (`expire_time`) COMMENT '匹配超时时间索引'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='跨服匹配记录表';
-- 2. 各服玩家核心数据表(每个服的本地MySQL,Feign拉取数据时查这张表)
CREATE TABLE `player_core_data` (
`player_id` bigint NOT NULL COMMENT '玩家ID',
`server_id` int NOT NULL COMMENT '服ID',
`level` int NOT NULL COMMENT '等级',
`power` bigint NOT NULL COMMENT '战力',
`online_status` tinyint NOT NULL COMMENT '在线状态:0-离线,1-在线',
`match_cd_time` datetime DEFAULT NULL COMMENT '匹配CD时间(避免频繁匹配)',
`update_time` datetime NOT NULL COMMENT '数据更新时间',
PRIMARY KEY (`player_id`),
-- 关键索引:按“在线状态+等级+战力”查,匹配时快速筛选候选玩家
KEY `idx_online_level_power` (`online_status`,`level`,`power`) COMMENT '在线状态+等级+战力索引'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='玩家核心数据表';
关键索引说明:
cross_server_match_record的idx_player_status索引:避免玩家在 “匹配中” 状态下重复发起匹配(早期没加这个索引,玩家双击匹配按钮,生成两条匹配记录,导致匹配到两个对手);player_core_data的idx_online_level_power索引:Feign 拉取候选玩家时,SQL 是 “where online_status=1 and level between ? and ? and power between ? and ?”,这个联合索引能让查询走索引,避免全表扫描(10 万玩家的表,加索引后查询时间从 500ms 降到 50ms)。
3.3 第三步:核心逻辑:跨服数据拉取 + 相近度算法
这是整个系统的 “心脏”—— 先通过 Feign 拉取各服符合条件的玩家,再用自定义算法计算 “相近度”,最终选出最优对手。
3.3.1 跨服数据拉取服务
/**
* 跨服数据拉取服务:调用各服Feign,获取候选玩家列表
*/
@Service
public class CrossServerDataPullService {
@Autowired
private PlayerDataFeignClient playerDataFeignClient;
// 配置中心获取“开放跨服的服ID列表”(比如[1,2,3,4])
@Value("${cross.server.open-list}")
private List openServerList;
/**
* 拉取全服符合条件的候选玩家
* @param initiator 发起匹配的玩家信息
* @return 全服候选玩家列表
*/
public List pullAllServerCandidates(PlayerMatchDTO initiator) {
// 1. 计算匹配条件:等级±5,战力±10%(粗筛,减少后续计算量)
PlayerMatchCondition condition = buildMatchCondition(initiator);
// 2. 并行拉取各服数据(用CompletableFuture,减少总耗时)
List>> futureList = openServerList.stream()
.map(serverId -> CompletableFuture.supplyAsync(() -> {
// 动态替换Feign的服务名(关键:指定拉取哪个服的数据)
FeignContext feignContext = ApplicationContextHolder.getBean(FeignContext.class);
PlayerDataFeignClient contextClient = feignContext.getTarget(
PlayerDataFeignClient.class,
"player-data-service-" + serverId // 拼接服务名:player-data-service-1
);
// 调用Feign拉取数据
R> response = contextClient.getMatchCandidates(serverId, condition);
return response.isSuccess() ? response.getData() : Collections.emptyList();
}))
.collect(Collectors.toList());
// 3. 等待所有异步任务完成,合并结果
return futureList.stream()
.flatMap(future -> {
try {
return future.get().stream();
} catch (Exception e) {
log.error("拉取服数据异常", e);
return Stream.empty();
}
})
// 排除自己(避免匹配到自己)
.filter(candidate -> !candidate.getPlayerId().equals(initiator.getPlayerId()))
.collect(Collectors.toList());
}
/**
* 构建匹配条件:等级±5,战力±10%
*/
private PlayerMatchCondition buildMatchCondition(PlayerMatchDTO initiator) {
PlayerMatchCondition condition = new PlayerMatchCondition();
// 等级范围:initiator.getLevel() ±5
condition.setMinLevel(Math.max(1, initiator.getLevel() - 5));
condition.setMaxLevel(initiator.getLevel() + 5);
// 战力范围:initiator.getPower() ±10%(向下取整,避免负数)
long power10Percent = initiator.getPower() / 10;
condition.setMinPower(Math.max(1, initiator.getPower() - power10Percent));
condition.setMaxPower(initiator.getPower() + power10Percent);
// 只拉取在线且无匹配CD的玩家
condition.setOnlineStatus(1);
condition.setMatchCdTime(new Date());
return condition;
}
}
3.3.2 相近度算法:等级 - 战力加权得分(公平性核心)
/**
* 匹配相近度算法服务:核心是“等级加权40% + 战力加权60%”
* 八年经验:战力权重比等级高,因为玩家更在意战力是否相近(等级能肝,战力靠练度)
*/
@Service
public class MatchSimilarityService {
// 权重配置:等级40%,战力60%(可从配置中心拉取,动态调整)
private static final double LEVEL_WEIGHT = 0.4;
private static final double POWER_WEIGHT = 0.6;
/**
* 计算候选玩家与发起者的相近度得分(0-100分,分数越高越相近)
* @param initiator 发起者
* @param candidate 候选玩家
* @return 相近度得分
*/
public double calculateSimilarityScore(PlayerMatchDTO initiator, PlayerMatchDTO candidate) {
// 1. 计算等级相近度(0-100分)
int levelDiff = Math.abs(initiator.getLevel() - candidate.getLevel());
double levelSimilarity = calculateSingleDimensionSimilarity(
levelDiff,
5 // 等级最大允许差异(和粗筛条件一致)
);
// 2. 计算战力相近度(0-100分)
long powerDiff = Math.abs(initiator.getPower() - candidate.getPower());
long maxPowerDiff = initiator.getPower() / 10; // 战力最大允许差异(10%)
double powerSimilarity = calculateSingleDimensionSimilarity(
powerDiff,
maxPowerDiff
);
// 3. 加权得分:等级*40% + 战力*60%
return levelSimilarity * LEVEL_WEIGHT + powerSimilarity * POWER_WEIGHT;
}
/**
* 单维度相近度计算(核心公式:100 - (差异值/最大允许差异)*100)
* 差异值越小,得分越高;差异值=0时得100分,差异值=最大允许差异时得0分
*/
private double calculateSingleDimensionSimilarity(long diff, long maxDiff) {
if (maxDiff == 0) { // 避免除以0(发起者战力为0的极端情况)
return diff == 0 ? 100.0 : 0.0;
}
// 差异值超过最大允许值,得0分
if (diff >= maxDiff) {
return 0.0;
}
// 计算得分(保留1位小数,避免精度问题)
return Math.round((100.0 - (double) diff / maxDiff * 100.0) * 10) / 10.0;
}
/**
* 从候选列表中筛选Top3最优对手(得分降序,且排除已匹配中的玩家)
*/
public List filterTop3Opponents(
PlayerMatchDTO initiator,
List candidates,
CrossServerMatchMapper matchMapper
) {
// 1. 排除“已在匹配中”的玩家(查跨服匹配表)
List candidateIds = candidates.stream()
.map(PlayerMatchDTO::getPlayerId)
.collect(Collectors.toList());
List matchingIds = matchMapper.selectMatchingPlayerIds(candidateIds);
Set matchingIdSet = new HashSet<>(matchingIds);
// 2. 计算得分并排序(降序)
return candidates.stream()
.filter(candidate -> !matchingIdSet.contains(candidate.getPlayerId()))
.map(candidate -> {
double score = calculateSimilarityScore(initiator, candidate);
candidate.setSimilarityScore(score);
return candidate;
})
.sorted((a, b) -> Double.compare(b.getSimilarityScore(), a.getSimilarityScore()))
.limit(3) // 取Top3,给玩家选择空间(或直接选Top1)
.collect(Collectors.toList());
}
}
3.3.3 匹配主服务(串联全流程)
/**
* 跨服匹配主服务:串联“状态校验→数据拉取→算法筛选→结果锁定”
*/
@Service
@Transactional
public class CrossServerMatchService {
@Autowired
private CrossServerDataPullService dataPullService;
@Autowired
private MatchSimilarityService similarityService;
@Autowired
private CrossServerMatchMapper matchMapper;
@Autowired
private SnowflakeIdGenerator idGenerator;
/**
* 发起跨服匹配
* @param playerId 玩家ID
* @param serverId 玩家服ID
* @return 匹配结果(Top3对手)
*/
public MatchResultDTO startMatch(Long playerId, Integer serverId) {
// 1. 校验玩家状态:是否在匹配中、是否有CD、是否在线
checkPlayerStatus(playerId, serverId);
// 2. 获取发起者的等级、战力(从本服MySQL拉取,实时数据)
PlayerMatchDTO initiator = getInitiatorData(playerId, serverId);
// 3. 拉取全服候选玩家
List candidates = dataPullService.pullAllServerCandidates(initiator);
if (CollectionUtils.isEmpty(candidates)) {
throw new BusinessException("当前跨服玩家较少,请稍后再试");
}
// 4. 筛选Top3对手
List top3Opponents = similarityService.filterTop3Opponents(
initiator, candidates, matchMapper
);
if (CollectionUtils.isEmpty(top3Opponents)) {
throw new BusinessException("暂未找到合适对手,请稍后再试");
}
// 5. 锁定匹配结果(更新MySQL匹配记录)
MatchResultDTO result = buildMatchResult(initiator, top3Opponents);
saveMatchRecord(result);
return result;
}
/**
* 校验玩家状态:避免重复匹配、CD内匹配
*/
private void checkPlayerStatus(Long playerId, Integer serverId) {
// 检查是否在“匹配中”状态
int matchingCount = matchMapper.countMatchingByPlayerId(playerId);
if (matchingCount > 0) {
throw new BusinessException("您已在匹配中,请耐心等待");
}
// 检查是否有匹配CD(比如匹配失败后10秒CD)
Date lastMatchFailTime = matchMapper.selectLastMatchFailTime(playerId);
if (lastMatchFailTime != null) {
long diff = System.currentTimeMillis() - lastMatchFailTime.getTime();
if (diff < 10 * 1000) { // 10秒CD
throw new BusinessException("匹配失败后有10秒冷却,请稍后再试");
}
}
// 检查是否在线(从本服MySQL拉取)
PlayerDataFeignClient feignClient = buildFeignClient(serverId);
R onlineResponse = feignClient.checkPlayerOnline(serverId, playerId);
if (!onlineResponse.isSuccess() || !onlineResponse.getData()) {
throw new BusinessException("玩家未在线,无法发起匹配");
}
}
// 其他辅助方法(getInitiatorData、buildMatchResult、saveMatchRecord)省略...
}
实战优化点:
- 用
CompletableFuture并行拉取各服数据:早期串行拉取 4 个服,总耗时 2 秒;并行后降到 500ms 以内,玩家几乎感觉不到延迟; - 相近度算法加 “动态权重”:后续可从配置中心拉取等级 / 战力权重(比如节假日活动时,等级权重降为 30%,战力权重升为 70%),不用改代码;
- 匹配记录加 “超时时间”:定时任务每分钟清理
expire_time < 当前时间的记录,避免表数据膨胀(早期没加,半年表数据到 1000 万条,查询变慢)。
四、踩坑实录:这些坑我替你踩过了
这套系统从测试到上线,踩了不少跨服特有的坑,分享几个印象最深的,帮你少走弯路:
4.1 坑 1:Feign 动态服务名失效,拉取数据到错误的服
问题:拉取服 2 的数据时,Feign 总是把请求发给服 1,导致候选玩家列表全是服 1 的人。
原因:早期没理解 Feign 的@FeignClient(name)是 “服务名”,不是 “动态路由键”—— 直接用@FeignClient(name = "player-data-service"),Feign 会从注册中心找所有叫这个名字的服务,随机路由,不会按服 ID 区分。
解决方案:
- 各服 “玩家数据服务” 的注册名改为 “player-data-service-{服 ID}”(比如服 1 是 “player-data-service-1”);
- 用
FeignContext动态获取对应服的 Feign 客户端(如 3.3.1 中的contextClient),而不是直接注入PlayerDataFeignClient。
4.2 坑 2:MySQL 索引没建好,拉取候选玩家超时
问题:某服玩家数达 50 万后,Feign 拉取候选玩家时,SQL 执行时间从 50ms 涨到 800ms,超过 Feign 的 2000ms 读取超时,频繁熔断。
原因:player_core_data表只加了player_id主键索引,Feign 拉取时的 SQL“where online_status=1 and level between ? and ? and power between ? and ?” 走了全表扫描。
解决方案:
-
加联合索引
idx_online_level_power(online_status,level,power); -
SQL 中按
update_time排序(取最近活跃的玩家),避免匹配到 “僵尸在线”(挂着但不操作)的玩家:select player_id, level, power from player_core_data where online_status=1 and level between ? and ? and power between ? and ? order by update_time desc limit 100; -- 限制拉取数量,避免数据太多
4.3 坑 3:相近度算法太 “死板”,高战玩家匹配不到人
问题:战力前 10 的玩家,总是匹配不到对手 —— 因为符合 “战力 ±10%” 的玩家太少,候选列表为空。
原因:粗筛条件(战力 ±10%)太严格,高战玩家的 “10% 战力” 范围太大,但符合条件的人少。
解决方案:
- 动态调整粗筛范围:战力越高,允许的百分比越大(比如战力 10 万以下 ±10%,10-50 万 ±15%,50 万以上 ±20%);
- 加 “匹配超时降级”:如果 10 秒内没找到符合条件的玩家,自动放宽范围(比如从 ±10% 放宽到 ±15%)。
五、总结:跨服匹配的 “三大核心原则”
做了八年游戏后端,我越来越觉得:跨服功能的难点,不在于 “跨服” 本身,而在于 “跨服后的数据一致性和用户体验” —— 匹配慢、对手不相近、频繁熔断,这些问题比单服的 bug 更影响玩家留存。
这套基于 SpringCloud + Feign + MySQL 的跨服匹配系统,正是遵循了三个核心原则:
- 实时性优先:用 Feign 实时拉取各服数据,避免缓存脏读;并行拉取减少总耗时,玩家等待不超过 1 秒;
- 公平性为本:自定义 “等级 - 战力加权算法”,动态调整权重和筛选范围,避免高战 “孤独”、新手 “被虐”;
- 可靠性兜底:Feign 熔断降级、MySQL 索引优化、匹配记录超时清理,每一步都留好 “后路”,避免系统崩掉。
最后给游戏后端同行几个建议:
- 跨服调用一定要做熔断:公网网络波动比内网大 10 倍,没熔断的跨服服务,早晚要栽跟头;
- 算法别太复杂:玩家要的是 “感觉公平”,不是 “绝对数学公平”—— 加权得分比机器学习模型更实用,还容易排查问题;
- 定时压测跨服链路:新服开服、节假日活动前,一定要模拟 3 倍峰值压测,看看 Feign 会不会超时、MySQL 能不能扛住。
如果你的项目也在做跨服竞技场,希望这篇文章能帮你少走弯路。有其他问题的话,欢迎在评论区交流 —— 游戏后端的坑,我们一起踩,一起填!