游戏运营
1. 游戏活动系统
游戏活动系统是游戏运营的核心组件,负责各类活动配置、触发、执行和结算的全生命周期管理。
1.1 活动配置体系
1.1.1 活动类型
| 活动类型 | 说明 | 典型场景 |
|---|---|---|
| 签到活动 | 每日签到领取奖励,支持连续签到加成 | 月签到、七日签到 |
| 充值活动 | 累计充值达到指定档位领取奖励 | 首充双倍、累计充值返利 |
| 限时活动 | 在指定时间段内开放的特殊玩法 | 限时折扣、限时副本 |
| 抽卡活动 | 消耗道具进行随机抽取 | 卡池UP、限定招募 |
| BOSS活动 | 全服或公会挑战世界BOSS | 世界BOSS、个人BOSS |
| 公会战 | 公会之间进行周期性的对抗 | 公会联赛、公会攻城 |
| 跨服战 | 跨服务器玩家之间的竞技活动 | 跨服竞技场、跨服战场 |
| PVP赛季 | 周期性PVP竞技,按赛季结算奖励 | 天梯赛季、竞技场赛季 |
| 新手活动 | 针对新用户引导和成长的系列活动 | 新手任务、开服冲刺 |
| 返利活动 | 消费后按比例返还资源 | 充值返利、消费返利 |
| 节日活动 | 配合节日上线的主题活动 | 春节活动、周年庆 |
| 兑换码活动 | 通过输入兑换码领取奖励 | 礼包码、渠道福利码 |
| 转盘活动 | 消耗抽奖次数转动转盘随机获得奖励 | 幸运转盘、大转盘抽奖 |
| 任务活动 | 完成指定任务链领取阶段性奖励 | 每日任务、成就任务、限时任务 |
| 拼团活动 | 多名玩家组队达到条件后共同领取奖励 | 团购礼包、组队任务 |
1.1.2 活动触发条件
时间触发
yaml
# 活动时间配置示例
ActivityTimeTrigger:
start_time: "2026-07-15 00:00:00" # 活动开始时间
end_time: "2026-07-22 23:59:59" # 活动结束时间
server_timezone: "Asia/Shanghai" # 服务器时区
use_local_time: false # 是否使用玩家本地时间
time_segments: # 每日时间段配置(可选)
- begin: "10:00:00"
end: "12:00:00"
- begin: "19:00:00"
end: "22:00:00"
weekly_schedule: [1, 3, 5, 7] # 每周几开启(1=周一, 7=周日)条件触发
yaml
# 条件触发配置
ConditionTrigger:
type: "player_level" # 触发条件类型
operator: "GTE" # GTE/LTE/EQ/IN/RANGE
value: 30 # 目标值
# 其他条件类型示例
# type: "vip_level" value: 5
# type: "total_recharge" value: 1000
# type: "login_days" value: 7
# type: "signin_days" value: 3
# type: "completed_tasks" value: 50手动触发:运营人员通过GM后台手动开启或关闭活动,适用于紧急活动或灰度测试。
服务器时间 vs 玩家本地时间
- 服务器时间:以游戏服务器时间为准,所有活动统一时间轴,避免跨时区时间不一致问题。适用于全服性活动(BOSS战、跨服战、限时折扣)。
- 玩家本地时间:以玩家设备本地时间为准,使用玩家所在时区进行结算。适用于个人活动(签到、每日任务),提升用户体验。
- 混合策略:活动开始和结束以服务器时间为准,活动内日常刷新使用玩家本地时间。
java
// 时间计算示例
public class ActivityTimeUtil {
/**
* 计算活动当前状态
*/
public static ActivityStatus calculateStatus(ActivityConfig config, long serverTime, long playerLocalTime) {
long startTime = config.getStartTime();
long endTime = config.getEndTime();
long preheatTime = config.getPreheatTime(); // 预热时间
if (serverTime < startTime) {
if (preheatTime > 0 && serverTime >= preheatTime) {
return ActivityStatus.PREHEAT;
}
return ActivityStatus.NOT_STARTED;
}
if (serverTime >= startTime && serverTime < endTime) {
return ActivityStatus.IN_PROGRESS;
}
return ActivityStatus.ENDED;
}
/**
* 检查每日刷新(使用玩家本地时间)
*/
public static boolean isDailyRefresh(long lastRefreshTime, long playerLocalTime) {
LocalDate lastDate = Instant.ofEpochMilli(lastRefreshTime)
.atZone(ZoneId.systemDefault()).toLocalDate();
LocalDate currentDate = Instant.ofEpochMilli(playerLocalTime)
.atZone(ZoneId.systemDefault()).toLocalDate();
return !lastDate.equals(currentDate);
}
}1.1.3 活动模板
活动模板系统支持通过配置化方式快速创建活动,无需修改服务器代码。
JSON 模板示例
json
{
"activity_id": 1001,
"template_type": "signin",
"template_version": "1.0",
"rules": {
"cycle_type": "monthly",
"max_days": 30,
"miss_allow": 3,
"bonus_multiplier": {
"consecutive_3": 1.5,
"consecutive_7": 2.0,
"consecutive_15": 3.0,
"consecutive_30": 5.0
},
"rewards": [
{"day": 1, "items": [{"id": "gold", "count": 100}]},
{"day": 7, "items": [{"id": "item_1001", "count": 1}]},
{"day": 30, "items": [{"id": "skin_001", "count": 1}]}
]
}
}YAML 模板示例
yaml
template_type: recharge
cycle_type: single
rules:
tiers:
- amount: 6
rewards:
- { id: gold, count: 60 }
- { id: item_2001, count: 1 }
- amount: 30
rewards:
- { id: gold, count: 330 }
- { id: item_2002, count: 3 }
- amount: 98
rewards:
- { id: gold, count: 1100 }
- { id: item_2003, count: 5 }
- amount: 328
rewards:
- { id: gold, count: 3600 }
- { id: item_2004, count: 10 }
first_double: true
repeatable: false1.1.4 活动状态机
活动生命周期通过状态机进行管理,确保状态流转清晰可控。
┌──────────────────────────────────────┐
| 活动状态机 |
└──────────────────────────────────────┘
未开始 ──→ 预热 ──→ 进行中 ──→ 结束 ──→ 发奖 ──→ 关闭
↑ | | | | |
| | | | | |
└─────────┴─────────┴─────────┴─────────┴─────────┘
(状态不可逆)
状态说明:
- 未开始(NOT_STARTED): 活动已创建,但未到开始时间
- 预热(PREHEAT): 活动即将开始,可展示活动预告,不可参与
- 进行中(IN_PROGRESS): 活动开放,玩家可正常参与
- 结束(ENDED): 活动参与截止,未领取奖励的玩家仍可领取
- 发奖(AWARDING): 系统自动结算并发放排行类奖励
- 关闭(CLOSED): 活动完全关闭,所有数据归档java
public enum ActivityStatus {
NOT_STARTED(0, "未开始"),
PREHEAT(1, "预热"),
IN_PROGRESS(2, "进行中"),
ENDED(3, "结束"),
AWARDING(4, "发奖中"),
CLOSED(5, "关闭");
private final int code;
private final String desc;
private static final Map<ActivityStatus, Set<ActivityStatus>> TRANSITIONS = Map.of(
NOT_STARTED, Set.of(PREHEAT, IN_PROGRESS),
PREHEAT, Set.of(IN_PROGRESS),
IN_PROGRESS, Set.of(ENDED),
ENDED, Set.of(AWARDING, CLOSED),
AWARDING, Set.of(CLOSED),
CLOSED, Set.of()
);
public boolean canTransitionTo(ActivityStatus target) {
return TRANSITIONS.getOrDefault(this, Collections.emptySet()).contains(target);
}
}1.2 活动数据模型
1.2.1 核心数据库表设计
活动主表 (activity)
sql
CREATE TABLE `activity` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '活动ID',
`name` VARCHAR(128) NOT NULL COMMENT '活动名称',
`type` TINYINT NOT NULL COMMENT '活动类型 1-签到 2-充值 3-限时 4-抽卡 5-BOSS 6-公会战 7-跨服战 8-PVP赛季 9-新手 10-返利 11-节日 12-兑换码 13-转盘 14-任务 15-拼团',
`template_type` VARCHAR(32) NOT NULL COMMENT '模板类型 signin/recharge/gacha/boss/...',
`config_json` JSON NOT NULL COMMENT '活动配置JSON',
`start_time` DATETIME NOT NULL COMMENT '活动开始时间',
`end_time` DATETIME NOT NULL COMMENT '活动结束时间',
`preheat_time` DATETIME DEFAULT NULL COMMENT '预热开始时间',
`server_timezone` VARCHAR(32) DEFAULT 'Asia/Shanghai' COMMENT '服务器时区',
`priority` INT DEFAULT 0 COMMENT '活动优先级,数值越大优先级越高',
`status` TINYINT DEFAULT 0 COMMENT '活动状态 0-未开始 1-预热 2-进行中 3-结束 4-发奖中 5-关闭',
`version` INT DEFAULT 0 COMMENT '乐观锁版本号',
`created_by` VARCHAR(64) DEFAULT NULL COMMENT '创建人',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_type_status` (`type`, `status`),
KEY `idx_time_range` (`start_time`, `end_time`),
KEY `idx_status_priority` (`status`, `priority`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动配置表';活动规则表 (activity_rule)
sql
CREATE TABLE `activity_rule` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '规则ID',
`activity_id` BIGINT NOT NULL COMMENT '活动ID',
`rule_type` VARCHAR(32) NOT NULL COMMENT '规则类型 threshold/times/condition/group',
`rule_key` VARCHAR(64) NOT NULL COMMENT '规则键名',
`rule_value` TEXT NOT NULL COMMENT '规则值JSON',
`group_id` INT DEFAULT 0 COMMENT '规则分组,同一组内满足任一规则即可',
`priority` INT DEFAULT 0 COMMENT '规则优先级',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_activity_id` (`activity_id`),
KEY `idx_rule_type` (`rule_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动规则表';玩家参与表 (activity_player)
sql
CREATE TABLE `activity_player` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`activity_id` BIGINT NOT NULL COMMENT '活动ID',
`player_id` BIGINT NOT NULL COMMENT '玩家ID',
`server_id` INT NOT NULL COMMENT '服务器ID',
`progress` JSON DEFAULT NULL COMMENT '活动进度JSON,如签到天数、充值金额等',
`status` TINYINT DEFAULT 0 COMMENT '参与状态 0-参与中 1-已完成 2-已领奖',
`ext_data` JSON DEFAULT NULL COMMENT '扩展数据',
`first_join_at` DATETIME DEFAULT NULL COMMENT '首次参与时间',
`last_join_at` DATETIME DEFAULT NULL COMMENT '最后参与时间',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_activity_player` (`activity_id`, `player_id`),
KEY `idx_player_id` (`player_id`),
KEY `idx_server_id` (`server_id`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='玩家活动参与表';奖励领取记录表 (activity_reward_log)
sql
CREATE TABLE `activity_reward_log` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`activity_id` BIGINT NOT NULL COMMENT '活动ID',
`player_id` BIGINT NOT NULL COMMENT '玩家ID',
`reward_id` VARCHAR(64) NOT NULL COMMENT '奖励ID,对应规则中的奖励标识',
`reward_type` TINYINT NOT NULL COMMENT '奖励类型 1-单次 2-每日 3-每周 4-累计',
`reward_json` JSON NOT NULL COMMENT '奖励内容JSON',
`status` TINYINT DEFAULT 1 COMMENT '发放状态 1-已发放 2-已领取 3-发放失败',
`source` VARCHAR(32) DEFAULT NULL COMMENT '来源,auto/manual',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_activity_player` (`activity_id`, `player_id`),
UNIQUE KEY `uk_reward_id` (`activity_id`, `player_id`, `reward_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动奖励领取记录表';活动日志表 (activity_log)
sql
CREATE TABLE `activity_log` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`activity_id` BIGINT NOT NULL COMMENT '活动ID',
`player_id` BIGINT DEFAULT NULL COMMENT '玩家ID(非必须)',
`action` VARCHAR(64) NOT NULL COMMENT '操作类型 join/leave/claim/refresh/auto_award',
`detail` JSON DEFAULT NULL COMMENT '操作详情',
`result` TINYINT DEFAULT 1 COMMENT '结果 1-成功 0-失败',
`ip` VARCHAR(64) DEFAULT NULL COMMENT '操作IP',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_activity_id` (`activity_id`),
KEY `idx_player_id` (`player_id`),
KEY `idx_action_time` (`action`, `created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动日志表';1.2.2 字段设计说明
| 字段 | 说明 |
|---|---|
| activity_id | 活动全局唯一ID,建议使用分布式ID生成器,避免冲突 |
| name | 活动名称,支持多语言时使用key引用i18n配置 |
| type | 活动大类,用于分类查询和运营统计 |
| template_type | 活动模板标识,用于匹配对应的活动处理器 |
| config_json | 完整的活动配置,包括规则、奖励、UI配置等 |
| status | 活动状态,结合状态机管理活动生命周期 |
| priority | 多个活动同时生效时的优先级,优先级高的活动奖励优先计算 |
1.3 活动引擎
活动引擎负责活动条件判断、奖励计算和发放的核心逻辑。
1.3.1 条件判断
java
// 条件判断接口
public interface ConditionEvaluator {
boolean evaluate(PlayerContext context, ConditionConfig config);
}
// 等级判断实现
public class LevelConditionEvaluator implements ConditionEvaluator {
@Override
public boolean evaluate(PlayerContext context, ConditionConfig config) {
int playerLevel = context.getPlayerLevel();
int targetValue = Integer.parseInt(config.getValue());
return switch (config.getOperator()) {
case "GTE" -> playerLevel >= targetValue;
case "LTE" -> playerLevel <= targetValue;
case "EQ" -> playerLevel == targetValue;
case "RANGE" -> {
String[] parts = config.getValue().split("-");
int min = Integer.parseInt(parts[0]);
int max = Integer.parseInt(parts[1]);
yield playerLevel >= min && playerLevel <= max;
}
default -> false;
};
}
}
// VIP等级判断实现
public class VipConditionEvaluator implements ConditionEvaluator {
@Override
public boolean evaluate(PlayerContext context, ConditionConfig config) {
return context.getVipLevel() >= Integer.parseInt(config.getValue());
}
}
// 充值金额判断
public class RechargeConditionEvaluator implements ConditionEvaluator {
@Override
public boolean evaluate(PlayerContext context, ConditionConfig config) {
long totalRecharge = context.getTotalRecharge();
return totalRecharge >= Long.parseLong(config.getValue());
}
}支持的判断条件类型:
| 条件类型 | 说明 | 配置示例 |
|---|---|---|
| 时间范围 | 判断是否在活动时间段内 | {type:"time_range",value:"2026-07-15/2026-07-22"} |
| 玩家等级 | 判断玩家等级是否达到要求 | {type:"player_level",operator:"GTE",value:"30"} |
| VIP等级 | 判断VIP等级是否达标 | {type:"vip_level",operator:"GTE",value:"5"} |
| 充值金额 | 累计充值金额判断 | {type:"total_recharge",operator:"GTE",value:"1000"} |
| 登录天数 | 累计登录天数判断 | {type:"login_days",operator:"GTE",value:"7"} |
| 签到天数 | 累计签到天数判断 | {type:"signin_days",operator:"GTE",value:"3"} |
| 完成任务数 | 完成特定任务数量判断 | {type:"completed_tasks",operator:"GTE",value:"20"} |
1.3.2 奖励发放
奖励配置化
yaml
# 奖励规则配置
rewards:
- reward_id: "daily_signin_1"
type: "daily" # 发放周期: once/daily/weekly/accumulate
condition:
type: "signin_day"
value: 1
items:
- { id: "gold", amount: 1000, bind: true }
- { id: "item_3001", amount: 1, bind: true }
- reward_id: "accumulate_7"
type: "accumulate" # 累计奖励(只能领一次)
condition:
type: "signin_days"
operator: "GTE"
value: 7
items:
- { id: "diamond", amount: 50, bind: true }
- { id: "card_001", amount: 1, bind: false } # 非绑定
- reward_id: "topup_100"
type: "single"
condition:
type: "recharge_amount"
operator: "GTE"
value: 100
items:
- { id: "gold", amount: 1100, bind: true }
- { id: "crystal", amount: 10, bind: true }奖励发放实现
java
// 奖励发放服务
@Service
public class RewardService {
@Autowired
private RewardTemplateLoader templateLoader;
@Autowired
private RewardDedupService dedupService;
@Autowired
private MailService mailService;
@Autowired
private BackpackService backpackService;
/**
* 发放奖励
*/
public RewardResult deliverReward(PlayerContext context, RewardConfig rewardConfig) {
// 1. 奖励去重检查
if (!dedupService.checkDedup(context, rewardConfig)) {
return RewardResult.duplicate("奖励已领取");
}
// 2. 加载奖励模板
RewardTemplate template = templateLoader.load(rewardConfig.getTemplateId());
// 3. 概率计算(如果是概率奖励)
RewardItem finalItem = applyProbability(template.getItems());
// 4. 根据发放渠道发放
switch (template.getDeliveryChannel()) {
case "mail":
mailService.sendMail(context.getPlayerId(), template.getMailTitle(),
template.getMailContent(), finalItem);
break;
case "backpack":
backpackService.addItems(context.getPlayerId(), finalItem);
break;
case "direct":
// 直接修改玩家属性(金币、钻石等)
context.addResource(finalItem.getId(), finalItem.getAmount());
break;
}
// 5. 记录发放日志
saveRewardLog(context, rewardConfig, finalItem);
return RewardResult.success(finalItem);
}
/**
* 概率奖励计算
*/
private RewardItem applyProbability(List<RewardItem> items) {
double roll = ThreadLocalRandom.current().nextDouble(0, 100);
double cumulative = 0;
for (RewardItem item : items) {
cumulative += item.getProbability();
if (roll < cumulative) {
return item;
}
}
// 保底:返回第一个
return items.get(0);
}
/**
* 发放周期检查
*/
public boolean checkCycleLimit(PlayerContext context, RewardConfig config) {
return switch (config.getCycle()) {
case "once" -> !dedupService.hasClaimed(context, config); // 单次
case "daily" -> !dedupService.hasClaimedToday(context, config); // 每日
case "weekly" -> !dedupService.hasClaimedThisWeek(context, config); // 每周
case "accumulate" -> !dedupService.hasClaimed(context, config); // 累计(一次)
default -> true;
};
}
}奖励模板结构
java
@Data
public class RewardTemplate {
private String templateId; // 奖励模板ID
private List<RewardItem> items; // 奖励物品列表
private String deliveryChannel; // 发放渠道 mail/backpack/direct
private String mailTitle; // 邮件标题(邮件渠道时使用)
private String mailContent; // 邮件内容(邮件渠道时使用)
}
@Data
public class RewardItem {
private String id; // 物品ID
private int amount; // 数量
private boolean bind; // 是否绑定
private double probability; // 概率(0-100),总和为100则为确定奖励
private boolean isCustom; // 是否为自定义奖励(由运营配置具体内容)
}1.3.3 奖励去重
java
// 奖励去重服务
@Service
public class RewardDedupService {
@Autowired
private StringRedisTemplate redisTemplate;
private static final String DEDUP_KEY_PREFIX = "activity:dedup:";
/**
* 检查是否已领取(幂等性检查)
*/
public boolean checkDedup(PlayerContext context, RewardConfig config) {
String key = buildDedupKey(context, config);
Boolean existed = redisTemplate.opsForValue().setIfAbsent(key, "1",
Duration.ofDays(config.getCycleDays()));
return Boolean.TRUE.equals(existed);
}
/**
* 每日领取检查
*/
public boolean hasClaimedToday(PlayerContext context, RewardConfig config) {
String key = buildDedupKey(context, config) + ":" + LocalDate.now();
return Boolean.TRUE.equals(redisTemplate.hasKey(key));
}
private String buildDedupKey(PlayerContext context, RewardConfig config) {
return DEDUP_KEY_PREFIX + config.getRewardId() + ":" + context.getPlayerId();
}
}2. 排行榜系统
排行榜是游戏运营中用于激励玩家竞争、提升留存的核心模块。
2.1 排行榜类型
| 排行榜类型 | 实现方式 | 更新频率 | 典型场景 |
|---|---|---|---|
| 实时排行 | Redis Zset | 实时更新 | 战力排行、等级排行 |
| 定时排行 | 离线结算 T+1 | 每日/每周结算 | 充值排行、消费排行 |
| 分桶排行 | Legacy 分桶统计 | 定时更新 | 历史排行、全服累计排行 |
| ELO 天梯 | 分段排行算法 | 实时更新 | 竞技场、天梯赛 |
| 分类排行 | 按范围筛选 | 混合更新 | 好友排行、公会排行、全服排行、跨服排行 |
常见的排行榜维度:
- 战力排行:按玩家总战力排序
- 等级排行:按玩家等级+经验排序
- 充值排行:按累计充值金额排序
- 竞技场排行:按竞技场积分排序
- 关卡排行:按通关关卡进度排序
- 公会排行:按公会总战力或活跃度排序
- 跨服排行:跨服范围内的综合排名
2.2 Redis Zset 实现
Redis 的有序集合(Zset)是实现实时排行榜的核心数据结构,提供 O(log N) 的插入和查询性能。
2.2.1 核心命令
java
// Redis Zset 排行榜操作
@Service
public class LeaderboardService {
@Autowired
private StringRedisTemplate redisTemplate;
private static final String LB_KEY_PREFIX = "lb:";
/**
* 更新玩家分数
* ZADD: 添加或更新成员分数
*/
public void updateScore(String leaderboardId, long playerId, double score) {
String key = buildKey(leaderboardId);
redisTemplate.opsForZSet().add(key, String.valueOf(playerId), score);
}
/**
* 增加玩家分数(增量更新)
* ZINCRBY: 原子增加分数
*/
public void incrementScore(String leaderboardId, long playerId, double increment) {
String key = buildKey(leaderboardId);
redisTemplate.opsForZSet().incrementScore(key, String.valueOf(playerId), increment);
}
/**
* 获取排行榜(带分页)
* ZREVRANGE: 按分数从高到低获取排名
*/
public List<LeaderboardEntry> getTopN(String leaderboardId, int page, int pageSize) {
String key = buildKey(leaderboardId);
int start = (page - 1) * pageSize;
int end = start + pageSize - 1;
Set<ZSetOperations.TypedTuple<String>> tuples =
redisTemplate.opsForZSet().reverseRangeWithScores(key, start, end);
List<LeaderboardEntry> entries = new ArrayList<>();
if (tuples == null) return entries;
long rank = (long) start + 1;
for (ZSetOperations.TypedTuple<String> tuple : tuples) {
if (tuple.getValue() == null) continue;
entries.add(LeaderboardEntry.builder()
.rank(rank++)
.playerId(Long.parseLong(tuple.getValue()))
.score(tuple.getScore().longValue())
.build());
}
return entries;
}
/**
* 获取玩家排名
* ZREVRANK: 获取指定成员排名(从0开始,需要+1)
*/
public long getPlayerRank(String leaderboardId, long playerId) {
String key = buildKey(leaderboardId);
Long rank = redisTemplate.opsForZSet().reverseRank(key, String.valueOf(playerId));
return rank != null ? rank + 1 : -1; // -1表示未上榜
}
/**
* 获取玩家分数
* ZSCORE: 获取指定成员分数
*/
public double getPlayerScore(String leaderboardId, long playerId) {
String key = buildKey(leaderboardId);
Double score = redisTemplate.opsForZSet().score(key, String.valueOf(playerId));
return score != null ? score : 0.0;
}
/**
* 获取排行榜总人数
* ZCARD: 获取有序集合基数
*/
public long getTotalCount(String leaderboardId) {
String key = buildKey(leaderboardId);
Long count = redisTemplate.opsForZSet().zCard(key);
return count != null ? count : 0;
}
}2.2.2 玩家信息 Hash 存储
为减少查询排行榜时的多次缓存命中,将玩家基本信息存储在 Hash 结构中:
java
/**
* 玩家信息缓存存储
* 使用 Hash 结构存储,方便批量获取
*/
@Service
public class PlayerInfoCacheService {
@Autowired
private StringRedisTemplate redisTemplate;
private static final String PLAYER_INFO_KEY_PREFIX = "player:info:";
/**
* 批量获取玩家信息
*/
public Map<Long, PlayerInfo> batchGetPlayerInfo(List<Long> playerIds) {
List<String> keys = playerIds.stream()
.map(id -> PLAYER_INFO_KEY_PREFIX + id)
.collect(Collectors.toList());
List<Object> rawData = redisTemplate.opsForValue().multiGet(keys);
Map<Long, PlayerInfo> result = new LinkedHashMap<>();
for (int i = 0; i < playerIds.size(); i++) {
Object data = rawData.get(i);
if (data != null) {
result.put(playerIds.get(i), JSON.parseObject((String) data, PlayerInfo.class));
}
}
return result;
}
/**
* 获取排行榜条目(含玩家信息)
*/
public List<LeaderboardEntry> getLeaderboardWithPlayerInfo(
String leaderboardId, int page, int pageSize) {
List<LeaderboardEntry> entries = leaderboardService.getTopN(leaderboardId, page, pageSize);
// 批量查询玩家信息
List<Long> playerIds = entries.stream()
.map(LeaderboardEntry::getPlayerId)
.collect(Collectors.toList());
Map<Long, PlayerInfo> playerInfoMap = batchGetPlayerInfo(playerIds);
// 填充玩家信息
entries.forEach(entry -> {
PlayerInfo info = playerInfoMap.get(entry.getPlayerId());
if (info != null) {
entry.setPlayerName(info.getPlayerName());
entry.setAvatar(info.getAvatar());
entry.setLevel(info.getLevel());
entry.setVipLevel(info.getVipLevel());
entry.setGuildName(info.getGuildName());
}
});
return entries;
}
}2.2.3 定时更新
java
/**
* 排行榜定时更新任务
*/
@Component
public class LeaderboardScheduledTask {
@Autowired
private LeaderboardService leaderboardService;
/**
* 全量重建排行榜(每日凌晨执行)
*/
@Scheduled(cron = "0 0 3 * * ?") // 凌晨3点
public void rebuildDailyLeaderboard() {
String leaderboardId = "power_daily";
// 1. 从数据库全量加载玩家数据
List<PlayerPower> playerPowers = playerPowerRepository.findAll();
// 2. 清空旧排行榜
redisTemplate.delete("lb:" + leaderboardId);
// 3. 批量写入新数据(使用 pipeline 提升性能)
redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
String key = "lb:" + leaderboardId;
for (PlayerPower p : playerPowers) {
connection.zAdd(key.getBytes(),
p.getPower(),
String.valueOf(p.getPlayerId()).getBytes());
}
return null;
});
// 4. 设置过期时间
redisTemplate.expire("lb:" + leaderboardId, Duration.ofDays(1));
}
/**
* 定时更新增量数据(每5分钟)
*/
@Scheduled(fixedRate = 300000)
public void incrementalUpdate() {
// 读取增量变更队列
List<ScoreChangeEvent> changes = scoreChangeQueue.drainAll();
for (ScoreChangeEvent event : changes) {
leaderboardService.incrementScore(
event.getLeaderboardId(),
event.getPlayerId(),
event.getDelta()
);
}
}
}2.2.4 过期策略与持久化
java
/**
* 排行榜过期策略
*/
@Configuration
public class LeaderboardExpireConfig {
/**
* 为不同排行榜设置不同的过期时间
*/
@PostConstruct
public void initExpirePolicies() {
// 实时排行榜:写入时自动刷新TTL,不主动过期
// 每日排行榜:24小时后过期
// 每周排行榜:7天后过期
// 赛季排行榜:赛季结束后30天过期
}
/**
* Redis Key 过期事件监听,用于持久化排行榜数据
*/
@Component
public class LeaderboardExpiredListener extends KeyExpirationEventMessageListener {
public LeaderboardExpiredListener(RedisMessageListenerContainer listenerContainer) {
super(listenerContainer);
}
@Override
public void onMessage(Message message, byte[] pattern) {
String expiredKey = message.toString();
if (expiredKey.startsWith("lb:")) {
// 排行榜过期前,将数据持久化到数据库
String leaderboardId = expiredKey.substring(3);
persistLeaderboard(leaderboardId);
}
}
private void persistLeaderboard(String leaderboardId) {
List<LeaderboardEntry> entries = leaderboardService.getTopN(leaderboardId, 1, 10000);
leaderboardSnapshotRepository.saveSnapshot(leaderboardId, entries);
}
}
}2.3 排行榜优化
2.3.1 跳表 vs Zset 性能
Redis Zset 底层使用跳表(Skip List)和哈希表两种数据结构:
- 跳表:提供有序的成员遍历和范围查询,平均 O(log N) 复杂度
- 哈希表:提供 O(1) 的单成员分数查询(ZSCORE)
| 维度 | Redis Zset | 数据库跳表 | 内存跳表 |
|---|---|---|---|
| 插入性能 | O(log N) | O(log N) | O(log N) |
| 范围查询 | O(log N + M) | O(log N + M) | O(log N + M) |
| 分布式支持 | 原生支持 | 需自实现 | 不支持 |
| 持久化 | RDB/AOF | 自带 | 需自行实现 |
| 原子操作 | ZINCRBY 原子操作 | 需事务 | 需锁 |
| 数据量限制 | 内存限制 | 磁盘 | 内存限制 |
2.3.2 冷热数据与分段排行榜
对于大规模玩家(百万级),单个 Zset 可能导致性能瓶颈,采用分段排行榜策略:
java
/**
* 分段排行榜实现
* 将全服玩家按分数段划分为多个桶,减少单 Zset 大小
*/
@Component
public class SegmentedLeaderboardService {
@Autowired
private StringRedisTemplate redisTemplate;
// 分段时间,例如每1000分为一段
private static final long SEGMENT_SIZE = 1000;
private static final String SEGMENT_KEY_PREFIX = "lb:seg:";
/**
* 计算玩家所在分段
*/
private String getSegmentKey(String leaderboardId, double score) {
long segment = (long) (score / SEGMENT_SIZE);
return SEGMENT_KEY_PREFIX + leaderboardId + ":" + segment;
}
/**
* 更新分数,同时维护全局排行索引
*/
public void updateScore(String leaderboardId, long playerId, double score) {
// 1. 获取旧的分数和分段
String globalKey = "lb:" + leaderboardId;
Double oldScore = redisTemplate.opsForZSet().score(globalKey, String.valueOf(playerId));
// 2. 如果有旧分数,从旧分段中移除
if (oldScore != null) {
String oldSegmentKey = getSegmentKey(leaderboardId, oldScore);
redisTemplate.opsForZSet().remove(oldSegmentKey, String.valueOf(playerId));
}
// 3. 更新全局排行(用于快速查排名)
redisTemplate.opsForZSet().add(globalKey, String.valueOf(playerId), score);
// 4. 加入新分段
String newSegmentKey = getSegmentKey(leaderboardId, score);
redisTemplate.opsForZSet().add(newSegmentKey, String.valueOf(playerId), score);
// 5. 设置分段TTL(热数据保留,冷数据自动淘汰)
redisTemplate.expire(newSegmentKey, Duration.ofHours(1));
}
/**
* 获取全局排行(跨分段查询)
*/
public List<LeaderboardEntry> getGlobalTopN(String leaderboardId, int page, int pageSize) {
String globalKey = "lb:" + leaderboardId;
int start = (page - 1) * pageSize;
int end = start + pageSize - 1;
Set<ZSetOperations.TypedTuple<String>> tuples =
redisTemplate.opsForZSet().reverseRangeWithScores(globalKey, start, end);
List<LeaderboardEntry> result = new ArrayList<>();
if (tuples == null) return result;
long rank = start + 1;
for (ZSetOperations.TypedTuple<String> tuple : tuples) {
if (tuple.getValue() == null) continue;
result.add(LeaderboardEntry.builder()
.rank(rank++)
.playerId(Long.parseLong(tuple.getValue()))
.score(tuple.getScore().longValue())
.build());
}
return result;
}
/**
* 查找玩家附近的玩家("我附近"功能)
*/
public List<LeaderboardEntry> getNeighbors(String leaderboardId, long playerId, int range) {
String globalKey = "lb:" + leaderboardId;
Long rank = redisTemplate.opsForZSet().reverseRank(globalKey, String.valueOf(playerId));
if (rank == null) return Collections.emptyList();
int start = Math.max(0, (int) (rank - range));
int end = (int) (rank + range);
Set<ZSetOperations.TypedTuple<String>> tuples =
redisTemplate.opsForZSet().reverseRangeWithScores(globalKey, start, end);
List<LeaderboardEntry> result = new ArrayList<>();
if (tuples == null) return result;
long currentRank = start + 1;
for (ZSetOperations.TypedTuple<String> tuple : tuples) {
if (tuple.getValue() == null) continue;
result.add(LeaderboardEntry.builder()
.rank(currentRank++)
.playerId(Long.parseLong(tuple.getValue()))
.score(tuple.getScore().longValue())
.build());
}
return result;
}
}2.3.3 千分位与万分段
对于超大排行榜,使用千分位/万分段进一步优化:
java
/**
* 千分位分段排行榜
* 适用于百万级玩家的全服排行榜
*/
@Component
public class ThousandSegLeaderboard {
// 每个桶1000人
private static final int BUCKET_SIZE = 1000;
private static final String BUCKET_KEY = "lb:bucket:";
/**
* 获取玩家所属桶
*/
public int getBucketIndex(long rank) {
return (int) (rank / BUCKET_SIZE);
}
/**
* 按桶加载排行榜
*/
public List<LeaderboardEntry> getBucket(String leaderboardId, int bucketIndex) {
String key = BUCKET_KEY + leaderboardId + ":" + bucketIndex;
Set<ZSetOperations.TypedTuple<String>> tuples =
redisTemplate.opsForZSet().reverseRangeWithScores(key, 0, BUCKET_SIZE - 1);
// 解析并返回
return parseEntries(tuples, bucketIndex * BUCKET_SIZE + 1);
}
}2.3.4 缓存预热
java
/**
* 排行榜缓存预热
* 服务器启动或新排行榜创建时预热
*/
@Component
public class LeaderboardCacheWarmer {
@PostConstruct
public void warmUp() {
// 预热热门排行榜
List<String> hotLeaderboards = List.of("power", "level", "arena");
for (String lb : hotLeaderboards) {
warmUpLeaderboard(lb);
}
}
private void warmUpLeaderboard(String leaderboardId) {
// 加载TOP 1000到缓存
List<LeaderboardEntry> topEntries =
loadFromDatabase(leaderboardId, 0, 1000);
String key = "lb:" + leaderboardId;
redisTemplate.opsForZSet().add(key, topEntries);
redisTemplate.expire(key, Duration.ofHours(1));
}
}2.3.5 增量更新 vs 全量重建
| 策略 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 增量更新 | 实时排行(战力、等级) | 实时性高,延迟低 | 需要可靠的变更事件流 |
| 全量重建 | 定时排行(充值、消费) | 数据一致性高 | 耗时较长,高峰期不宜执行 |
混合策略:实时排行使用增量更新 + 每日定时全量重建做数据校正。
2.3.6 榜单分桶
java
/**
* 榜单分桶策略
* 将大榜拆分为多个小榜,分别排序后合并
*/
@Service
public class BucketLeaderboardService {
// Level 1-50: 新手榜, 51-100: 高手榜, 100+: 传说榜
private static final Map<String, Range<Integer>> LEVEL_BUCKETS = Map.of(
"newbie", Range.closed(1, 50),
"advanced", Range.closed(51, 100),
"legend", Range.atLeast(101)
);
public List<LeaderboardEntry> getLevelBucketTopN(String bucketName, int n) {
String leaderboardId = "level_" + bucketName;
return leaderboardService.getTopN(leaderboardId, 1, n);
}
}2.3.7 排名变化通知
java
/**
* 排名变化通知
* 当玩家排名变化超过阈值时推送通知
*/
@Service
public class RankChangeNotifier {
private static final long NOTIFY_THRESHOLD = 10; // 变化超过10名才通知
public void onScoreChanged(String leaderboardId, long playerId, double newScore) {
long newRank = leaderboardService.getPlayerRank(leaderboardId, playerId);
String cacheKey = "rank:prev:" + leaderboardId + ":" + playerId;
String prevRankStr = redisTemplate.opsForValue().get(cacheKey);
if (prevRankStr != null) {
long prevRank = Long.parseLong(prevRankStr);
long change = prevRank - newRank; // 正数表示上升
if (Math.abs(change) >= NOTIFY_THRESHOLD) {
// 推送排名变化通知
notifyService.pushRankChange(playerId, leaderboardId, prevRank, newRank);
}
}
// 缓存当前排名
redisTemplate.opsForValue().set(cacheKey, String.valueOf(newRank), Duration.ofMinutes(10));
}
}2.3.8 排行奖励与结算
java
/**
* 排行奖励结算系统
*/
@Component
public class LeaderboardRewardScheduler {
/**
* 每日结算
*/
@Scheduled(cron = "0 0 0 * * ?") // 每天午夜
public void dailySettlement() {
settleLeaderboard("power_daily");
settleLeaderboard("level_daily");
}
/**
* 每周结算
*/
@Scheduled(cron = "0 0 0 * * MON") // 每周一
public void weeklySettlement() {
settleLeaderboard("arena_weekly");
settleLeaderboard("guild_weekly");
}
/**
* 赛季结算
*/
@Scheduled(cron = "0 0 0 1 * ?") // 每月1号
public void seasonSettlement() {
settleLeaderboard("pvp_season");
}
private void settleLeaderboard(String leaderboardId) {
// 1. 获取最终排行
List<LeaderboardEntry> finalRankings =
leaderboardService.getTopN(leaderboardId, 1, 10000);
// 2. 加载奖励配置
RewardTierConfig rewardConfig = rewardConfigLoader.load(leaderboardId);
// 3. 发放奖励
for (LeaderboardEntry entry : finalRankings) {
RewardTier tier = rewardConfig.findTier(entry.getRank());
if (tier != null) {
rewardService.deliverReward(
PlayerContext.of(entry.getPlayerId()),
tier.getRewardConfig()
);
}
}
// 4. 保存结算快照
leaderboardSnapshotRepository.saveSnapshot(leaderboardId, finalRankings);
}
}3. GM 工具系统
GM 工具是游戏运营人员进行日常管理、问题排查和紧急操作的核心平台。
3.1 GM 后台架构
3.1.1 整体架构
┌─────────────────────────────────────────────────────────────┐
│ GM 管理后台 (Web Admin) │
│ Vue/React + Ant Design / Element UI │
├─────────────────────────────────────────────────────────────┤
│ BFF 层 (Gateway) │
│ 认证鉴权 / 参数校验 / 频率限制 / 操作审计 │
├─────────────────────────────────────────────────────────────┤
│ 游戏服务器集群 │
│ Game Server 1 Game Server 2 Game Server N │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ GM Module │ │ GM Module │ │ GM Module │ │
│ └────────────┘ └────────────┘ └────────────┘ │
└─────────────────────────────────────────────────────────────┘