电池贪吃蛇
48.39MB · 2025-09-24
作为一名摸爬滚打八年的 Java 老司机,我经手过不少高并发系统,但游戏后端的挑战始终是独一档 —— 尤其是玩家行为日志的 “海量吞吐” 和反外挂的 “实时精准”,两者凑在一起简直是 “地狱级” 需求。今天就结合近期落地的项目,聊聊如何用 SpringCloud 生态 + Sentinel+ClickHouse+Redis,搭建一套能扛住千万级玩家、毫秒级反外挂响应的日志分析与反作弊系统。
做游戏后端前,我总觉得 “高并发” 就是电商秒杀那套 —— 但真正接手后才发现,游戏日志和反外挂的需求,简直是 “降维打击”:
踩过 N 次坑后,我们最终敲定了SpringCloud + Sentinel + ClickHouse + Redis的组合,每个组件都精准解决一个核心痛点:
技术组件 | 核心作用 | 解决的游戏场景痛点 |
---|---|---|
SpringCloud | 微服务拆分与协同(网关、日志服务、反外挂服务) | 日志采集、分析、反作弊解耦,便于独立扩容 |
Sentinel | 流量控制与熔断降级 | 防止开服 / 活动日志峰值压垮下游,保护核心接口 |
ClickHouse | 海量日志存储与 OLAP 分析 | 秒级查询千万级玩家的行为数据,支持复杂聚合 |
Redis | 实时黑名单缓存与热点数据存储 | 毫秒级拦截外挂账号,缓存高频查询的玩家行为特征 |
可能有人会问:“为什么不用 Elasticsearch 存日志?为什么不用 Flink 做实时计算?”—— 八年经验告诉我,技术选型不是 “选最好的”,而是 “选最适配的”:ES 查日志快,但聚合分析能力不如 ClickHouse;Flink 实时性强,但游戏反外挂的核心是 “规则匹配”,用 Redis + 本地内存规则引擎足够,没必要增加复杂度。
先放一张简化的架构图,让大家直观感受下数据流转:
整个链路的核心逻辑是 “实时拦截 + 离线分析” 双轨并行:
这套架构的好处是 “松耦合、高可用”—— 比如日志采集服务挂了,Kafka 会暂存数据;ClickHouse 写入慢了,不会影响玩家正常游戏,完美契合游戏 “可用性优先” 的原则。
游戏开服时,玩家集中上线,日志量会从每秒 5000 条暴涨到 2 万条 —— 如果直接打给下游服务,大概率会雪崩。这时候 Sentinel 的 “流量控制” 就成了第一道防线。
先在网关服务引入依赖:
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-sentinel-gateway</artifactId>
<version>2.2.7.RELEASE</version>
</dependency>
<dependency>
<groupId>com.alibaba.csp</groupId>
<artifactId>sentinel-datasource-nacos</artifactId> <!-- 规则存Nacos,支持动态更新 -->
</dependency>
然后在 Nacos 配置限流规则,针对 “日志上报接口” 做精细化控制:
[
{
"resource": "/api/v1/game/log/upload", // 日志上报接口
"limitApp": "default",
"grade": 1, // 1=QPS限流,0=线程数限流
"count": 15000, // 单网关节点QPS上限(根据机器配置调整)
"strategy": 0, // 0=直接限流
"controlBehavior": 2, // 2=匀速排队,避免流量突刺
"burstCount": 2000, // 突发流量容忍度
"clusterMode": true, // 集群限流,避免单节点过载
"clusterConfig": {
"flowId": 1001,
"thresholdType": 1, // 1=全局阈值均分
"fallbackToLocalWhenFail": true // 集群通信失败时用本地规则兜底
}
}
]
刚开始我们只对 “日志接口” 做了全局限流,结果新服开服时,老服的正常日志也被限流了。后来优化成 “按游戏服 + 日志类型” 双维度限流:
// 自定义Sentinel的资源名生成器,按“游戏服ID+接口+日志类型”拆分
@Component
public class GameLogResourceExtractor implements GatewayResourceExtractor {
@Override
public String extract(ServerWebExchange exchange) {
// 从请求头获取游戏服ID(如s1001)
String serverId = exchange.getRequest().getHeaders().getFirst("X-Game-Server-Id");
// 从请求参数获取日志类型(如move/skill/trade)
String logType = exchange.getRequest().getQueryParams().getFirst("logType");
// 生成资源名:/api/v1/game/log/upload:s1001:move
return "/api/v1/game/log/upload:" + serverId + ":" + logType;
}
}
这样新服的 “move 日志” 限流不影响老服的 “trade 日志”,灵活性直接拉满。
游戏日志的查询需求很 “刁钻”—— 比如 “查 s1001 服玩家 10086 在 2024-09-01 18:00-20:00 的技能释放次数”,传统 MySQL 查这种范围 + 聚合,至少要几秒,ClickHouse 却能做到 100ms 内返回。
ClickHouse 的表结构设计是 “性能关键”,必须按 “分区 + 排序键” 优化:
-- 玩家行为日志表,按日期分区,按玩家ID+时间戳排序
CREATE TABLE game.player_behavior_log (
log_id String COMMENT '日志唯一ID',
player_id String COMMENT '玩家ID',
server_id String COMMENT '游戏服ID',
log_type String COMMENT '日志类型:move/skill/trade/chat',
behavior_data JSON COMMENT '行为详情(如技能ID、交易金额)',
ip String COMMENT '玩家IP',
device_id String COMMENT '设备ID',
create_time DateTime COMMENT '日志产生时间'
) ENGINE = MergeTree()
PARTITION BY toDate(create_time) -- 按日期分区,查指定日期直接定位分区
ORDER BY (player_id, create_time) -- 主键:玩家ID+时间戳,优化玩家单天行为查询
TTL toDate(create_time) + INTERVAL 90 DAY DELETE -- 日志保留90天,自动清理
SETTINGS index_granularity = 8192; -- 索引粒度,默认8192即可
玩家日志是 “持续高吞吐”,不能单条写入,必须批量处理:
@Service
public class ClickHouseLogService {
@Autowired
private ClickHouseTemplate clickHouseTemplate;
// 批量写入缓冲区,1000条或100ms触发一次写入
private final BlockingQueue<PlayerBehaviorLog> logQueue = new ArrayBlockingQueue<>(10000);
// 初始化线程池,处理批量写入
@PostConstruct
public void initBatchWriter() {
ScheduledExecutorService executor = Executors.newSingleThreadScheduledExecutor();
// 每100ms检查一次队列,有数据就批量写入
executor.scheduleAtFixedRate(() -> {
List<PlayerBehaviorLog> batchLogs = new ArrayList<>(1000);
logQueue.drainTo(batchLogs, 1000); // 最多取1000条
if (!batchLogs.isEmpty()) {
try {
// ClickHouseTemplate批量写入
clickHouseTemplate.batchInsert("game.player_behavior_log", batchLogs);
} catch (Exception e) {
log.error("ClickHouse批量写入失败,重试一次", e);
// 失败重试(避免数据丢失,实际项目可加重试次数限制)
clickHouseTemplate.batchInsert("game.player_behavior_log", batchLogs);
}
}
}, 0, 100, TimeUnit.MILLISECONDS);
}
// 接收日志,加入队列
public void addLog(PlayerBehaviorLog log) {
try {
// 队列满了就阻塞,避免OOM(Sentinel已限流,队列不会一直满)
logQueue.put(log);
} catch (InterruptedException e) {
log.error("日志加入队列失败", e);
Thread.currentThread().interrupt();
}
}
}
比如运营要查 “s1001 服 2024-09-01 的 TOP10 技能释放玩家”:
public List<SkillTopVO> getSkillTop10(String serverId, LocalDate date) {
String sql = "SELECT " +
"player_id, " +
"JSONExtractString(behavior_data, 'skillId') as skill_id, " +
"count(*) as release_count " +
"FROM game.player_behavior_log " +
"WHERE server_id = ? " +
"AND toDate(create_time) = ? " +
"AND log_type = 'skill' " +
"GROUP BY player_id, skill_id " +
"ORDER BY release_count DESC " +
"LIMIT 10";
// ClickHouseTemplate查询,返回结果
return clickHouseTemplate.query(sql,
new Object[]{serverId, Date.valueOf(date)},
(rs, rowNum) -> new SkillTopVO(
rs.getString("player_id"),
rs.getString("skill_id"),
rs.getLong("release_count")
));
}
实测这条 SQL 查 1000 万条日志,返回时间稳定在 80-120ms,完全满足运营需求。
反外挂的核心是 “快”—— 玩家刚释放一个 “异常技能”(比如 1 秒内释放 10 次大招),必须立刻拦截,否则金币、装备就刷出去了。这里我们用 Redis 缓存黑名单和行为特征,配合本地规则引擎实现实时判断。
@Service
public class AntiCheatRedisService {
@Autowired
private StringRedisTemplate redisTemplate;
// 黑名单Key:anti_cheat:blacklist:{player_id},值为封禁截止时间戳
private static final String BLACKLIST_KEY = "anti_cheat:blacklist:%s";
// 行为计数器Key:anti_cheat:behavior:{player_id}:{behavior_type},ZSet存储时间戳
private static final String BEHAVIOR_COUNTER_KEY = "anti_cheat:behavior:%s:%s";
// 1. 检查玩家是否在黑名单
public boolean isBlacklisted(String playerId) {
String key = String.format(BLACKLIST_KEY, playerId);
String banEndTimeStr = redisTemplate.opsForValue().get(key);
if (banEndTimeStr == null) {
return false;
}
// 对比当前时间,若未到封禁截止时间,返回true
long banEndTime = Long.parseLong(banEndTimeStr);
return System.currentTimeMillis() < banEndTime;
}
// 2. 记录玩家行为,用于规则判断(如1秒内技能释放次数)
public void recordBehavior(String playerId, String behaviorType) {
String key = String.format(BEHAVIOR_COUNTER_KEY, playerId, behaviorType);
long now = System.currentTimeMillis();
// 用ZSet存储时间戳,score和value都是时间戳
redisTemplate.opsForZSet().add(key, String.valueOf(now), now);
// 清理10秒前的旧数据,避免内存溢出
redisTemplate.opsForZSet().removeRangeByScore(key, 0, now - 10000);
// 设置Key过期时间,1分钟无行为自动清理
redisTemplate.expire(key, 1, TimeUnit.MINUTES);
}
// 3. 查询玩家最近N秒内的行为次数
public long getBehaviorCount(String playerId, String behaviorType, int seconds) {
String key = String.format(BEHAVIOR_COUNTER_KEY, playerId, behaviorType);
long now = System.currentTimeMillis();
long startTime = now - seconds * 1000;
// 统计ZSet中时间戳在[startTime, now]范围内的元素数量
return redisTemplate.opsForZSet().count(key, startTime, now);
}
// 4. 添加玩家到黑名单(如封禁24小时)
public void addToBlacklist(String playerId, int banHours) {
String key = String.format(BLACKLIST_KEY, playerId);
long banEndTime = System.currentTimeMillis() + banHours * 3600 * 1000;
redisTemplate.opsForValue().set(key, String.valueOf(banEndTime), banHours, TimeUnit.HOURS);
}
}
规则不能写死在代码里 —— 运营发现新外挂后,要能实时添加规则。我们用 SpringCloud Config + 本地内存规则引擎实现:
@Service
public class AntiCheatRuleEngine {
@Autowired
private AntiCheatRedisService redisService;
@Autowired
private ConfigService configService; // SpringCloud Config客户端
// 从ConfigServer获取最新规则(如:skill:10:1 → 技能行为1秒内超过10次算外挂)
private Map<String, Rule> ruleMap = new ConcurrentHashMap<>();
// 定时拉取最新规则(每30秒一次)
@Scheduled(fixedRate = 30000)
public void refreshRules() {
try {
// 从ConfigServer获取规则配置(格式:behaviorType:maxCount:seconds)
String ruleStr = configService.getConfig("anti-cheat-rules.properties", "master", null);
Map<String, Rule> newRuleMap = new HashMap<>();
for (String line : ruleStr.split("n")) {
if (line.isEmpty()) continue;
String[] parts = line.split(":");
String behaviorType = parts[0];
int maxCount = Integer.parseInt(parts[1]);
int seconds = Integer.parseInt(parts[2]);
newRuleMap.put(behaviorType, new Rule(behaviorType, maxCount, seconds));
}
ruleMap = newRuleMap;
log.info("反外挂规则刷新成功,当前规则数:{}", ruleMap.size());
} catch (Exception e) {
log.error("规则刷新失败,沿用旧规则", e);
}
}
// 执行规则判断:是否命中外挂
public boolean judgeCheat(String playerId, String behaviorType) {
// 1. 先查黑名单,已封禁直接返回true
if (redisService.isBlacklisted(playerId)) {
return true;
}
// 2. 无对应规则,直接放行
Rule rule = ruleMap.get(behaviorType);
if (rule == null) {
return false;
}
// 3. 统计最近N秒内的行为次数
long count = redisService.getBehaviorCount(playerId, behaviorType, rule.getSeconds());
// 4. 超过阈值,判定为外挂
if (count > rule.getMaxCount()) {
// 添加到黑名单(封禁24小时)
redisService.addToBlacklist(playerId, 24);
// 记录外挂日志到ClickHouse(用于后续分析)
logCheatBehavior(playerId, behaviorType, count, rule);
return true;
}
// 5. 正常行为,记录到计数器
redisService.recordBehavior(playerId, behaviorType);
return false;
}
// 记录外挂行为到ClickHouse
private void logCheatBehavior(String playerId, String behaviorType, long count, Rule rule) {
// 构造外挂日志,调用ClickHouse服务写入
CheatLog log = new CheatLog();
log.setPlayerId(playerId);
log.setBehaviorType(behaviorType);
log.setActualCount(count);
log.setRuleMaxCount(rule.getMaxCount());
log.setRuleSeconds(rule.getSeconds());
log.setCreateTime(new Date());
clickHouseLogService.addCheatLog(log);
}
// 规则实体类
@Data
static class Rule {
private String behaviorType;
private int maxCount;
private int seconds;
// 构造函数省略
}
}
最后在 SpringCloud Gateway 加一个全局过滤器,拦截所有玩家请求:
@Component
public class AntiCheatFilter implements GlobalFilter, Ordered {
@Autowired
private AntiCheatRuleEngine ruleEngine;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 1. 获取玩家ID和行为类型(从请求头或参数中获取)
String playerId = exchange.getRequest().getHeaders().getFirst("X-Player-Id");
String behaviorType = exchange.getRequest().getHeaders().getFirst("X-Behavior-Type");
if (playerId == null || behaviorType == null) {
return forbidden(exchange, "缺少玩家信息");
}
// 2. 调用规则引擎判断是否外挂
boolean isCheat = ruleEngine.judgeCheat(playerId, behaviorType);
if (isCheat) {
// 3. 命中外挂,返回403,拦截请求
return forbidden(exchange, "检测到异常行为,账号已临时封禁");
}
// 4. 正常请求,放行
return chain.filter(exchange);
}
// 返回403响应
private Mono<Void> forbidden(ServerWebExchange exchange, String msg) {
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(HttpStatus.FORBIDDEN);
response.getHeaders().add("Content-Type", "application/json");
String body = String.format("{"code":403,"msg":"%s"}", msg);
DataBuffer buffer = response.bufferFactory().wrap(body.getBytes());
return response.writeWith(Mono.just(buffer));
}
@Override
public int getOrder() {
return -100; // 优先级高于路由过滤器,确保先拦截再路由
}
}
实测这个过滤器的响应时间稳定在 15-30ms,完全不影响玩家正常操作。
这套系统从测试到上线,踩了不少 “游戏行业特有的坑”,分享几个印象最深的,帮你少走弯路:
问题:刚开始按 “小时” 分区,每天生成 24 个分区,半个月后有 360 个分区,批量写入突然变慢,甚至超时。原因:ClickHouse 的 MergeTree 引擎,分区越多,后台合并线程压力越大,写入性能会断崖式下降。解决方案:改成按 “天” 分区,每天一个分区,同时定期清理 90 天前的旧分区,分区数控制在 100 以内,写入性能立刻恢复。
问题:刚开始只按 playerId 封禁,玩家注销账号后换个设备重新注册,继续作弊。原因:忽略了 “设备 ID” 这个唯一标识 —— 同一个设备可能注册多个账号。解决方案:Redis 黑名单同时存储 “playerId” 和 “deviceId”,拦截时同时检查两者;另外,在 ClickHouse 中按 deviceId 聚合查询,发现同一设备多个账号作弊,直接封禁设备。
问题:新服开服时,日志量暴涨,Sentinel 的全局限流规则没拦住,导致 Kafka 队列满了。原因:Sentinel 的 “集群限流” 配置错了 ——flowId 重复,多个规则共用一个 flowId,导致限流阈值计算错误。解决方案:每个限流规则的 flowId 必须唯一,同时在 Nacos 中配置 “集群限流阈值” 时,按 “游戏服在线人数” 动态调整(比如在线 1 万玩家,QPS 设 1 万;在线 5 万,设 5 万)。
做了八年开发,我越来越觉得:好的技术方案,不是 “堆最牛的组件”,而是 “用最合适的工具解决最痛的问题” 。
这套系统中:
最后给游戏后端同行几个建议:
如果你的项目也面临 “海量日志” 或 “实时反外挂” 的需求,希望这篇文章能给你带来启发。有其他问题的话,欢迎在评论区交流 —— 游戏后端的坑,我们一起踩,一起填!