实战篇:玩家系统完整实现
概述
本篇把玩家系统从登录到落库的完整链路实现出来:注册/登录(游客 + 微信渠道)→ 玩家数据模型(Player 对象)→ MySQL 存储(player 表)→ Redis 缓存(在线状态与热数据)→ 双写与持久化(定时落盘 + 事件触发 + 下线保存)。最终产物是一个"登录即建号、数据可靠落库、重启可恢复"的玩家模块,可直接跑在骨架工程之上。
一、工程目标与结构
1.1 目标
本篇实现:
登录链路:游客登录 + 渠道登录(微信)
Player 数据模型:核心字段 + 背包/任务子模块
MySQL 存储:player 表 + DAO(MyBatis)
Redis 缓存:在线状态 + 热数据
双写策略:事件触发(货币)+ 定时落盘 + 下线保存
重启恢复:登录时从库加载,数据不丢1.2 代码结构
com.game.player
├── model
│ ├── Player.java // 玩家实体(含子模块)
│ └── PlayerBag.java // 背包子模块
├── dao
│ └── PlayerMapper.java // MySQL DAO
├── service
│ ├── AuthService.java // 登录认证
│ ├── PlayerService.java // 玩家数据服务
│ └── PlayerSaveService.java // 持久化服务
├── cache
│ └── PlayerCache.java // Redis 缓存
└── handler
└── LoginHandler.java // 登录消息处理器二、Player 数据模型
2.1 Player 实体
java
package com.game.player.model;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class Player {
private long playerId;
private String nickname;
private int level = 1;
private long exp;
private long coin; // 金币
private long diamond; // 钻石
private long createTime;
private long lastLoginTime;
private long lastLogoutTime;
private String channel;
// 子模块(简化)
private final Map<Integer, Integer> bag = new ConcurrentHashMap<>(); // itemId -> count
private final Map<Integer, Integer> questProgress = new ConcurrentHashMap<>(); // questId -> progress
private volatile boolean dirty; // 脏标记
private volatile long version; // 版本号
// getter / setter 略
public void changeCoin(long delta) {
this.coin += delta;
this.dirty = true; // 货币变更置脏,事件触发落库
}
public boolean isDirty() { return dirty; }
public void clearDirty() { this.dirty = false; }
}要点:
内存态为业务权威
dirty 标记驱动定时落盘
背包用 Map 内存持有,序列化后落库2.2 背包序列化
背包在 MySQL 中的存储方式:
方案一:独立表 player_bag(推荐,量级大时)
方案二:JSON 列存整包(简单,量级小)
本篇用 JSON 列演示(简单场景):
bag_json: {"1001":5,"2002":1}
反序列化到 Map 后操作,操作完置脏三、MySQL 存储
3.1 建表
sql
CREATE TABLE player (
player_id BIGINT PRIMARY KEY,
nickname VARCHAR(32) NOT NULL DEFAULT '',
level INT NOT NULL DEFAULT 1,
exp BIGINT NOT NULL DEFAULT 0,
coin BIGINT NOT NULL DEFAULT 0,
diamond BIGINT NOT NULL DEFAULT 0,
bag_json TEXT,
create_time BIGINT NOT NULL,
last_login_time BIGINT NOT NULL,
last_logout_time BIGINT NOT NULL,
channel VARCHAR(16) NOT NULL DEFAULT '',
version BIGINT NOT NULL DEFAULT 0
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;3.2 DAO(MyBatis)
java
package com.game.player.dao;
import com.game.player.model.Player;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.apache.ibatis.annotations.Update;
public interface PlayerMapper {
@Select("SELECT * FROM player WHERE player_id = #{playerId}")
Player findById(@Param("playerId") long playerId);
@Insert("INSERT INTO player (player_id, nickname, level, exp, coin, diamond, bag_json, "
+ "create_time, last_login_time, last_logout_time, channel, version) "
+ "VALUES (#{playerId}, #{nickname}, #{level}, #{exp}, #{coin}, #{diamond}, #{bagJson}, "
+ "#{createTime}, #{lastLoginTime}, #{lastLogoutTime}, #{channel}, 0)")
int insert(Player player);
// 乐观锁更新:version 不一致返回 0
@Update("UPDATE player SET level=#{level}, exp=#{exp}, coin=#{coin}, diamond=#{diamond}, "
+ "bag_json=#{bagJson}, last_logout_time=#{lastLogoutTime}, version=version+1 "
+ "WHERE player_id=#{playerId} AND version=#{version}")
int updateWithVersion(Player player);
}说明:
实际项目中分表由 ShardingSphere 配置完成,DAO 不变
version 乐观锁防并发覆盖四、Redis 缓存
4.1 缓存设计
java
package com.game.player.cache;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
@Component
public class PlayerCache {
private static final String ONLINE_KEY = "player:online:";
private static final String HOT_KEY = "player:hot:";
private static final String TOKEN_KEY = "player:token:";
private final StringRedisTemplate redis;
public PlayerCache(StringRedisTemplate redis) {
this.redis = redis;
}
// 上线:标记在线 + 写 Token 映射
public void markOnline(long playerId, String token) {
redis.opsForValue().set(ONLINE_KEY + playerId, "1");
redis.opsForValue().set(TOKEN_KEY + token, String.valueOf(playerId),
java.time.Duration.ofDays(7));
}
// 下线:清除在线标记
public void markOffline(long playerId) {
redis.delete(ONLINE_KEY + playerId);
}
// 热数据:简单资料(供排行榜等跨节点读取)
public void cacheProfile(long playerId, String json) {
redis.opsForValue().set(HOT_KEY + playerId, json,
java.time.Duration.ofHours(1));
}
public boolean isOnline(long playerId) {
return Boolean.TRUE.equals(redis.hasKey(ONLINE_KEY + playerId));
}
}缓存职责:
在线状态(跨节点共享)
Token 认证(顶号/下线即失效)
热数据快照(简单资料)
玩家完整数据仍在内存 Player 中五、登录认证服务
5.1 AuthService
java
package com.game.player.service;
import com.game.player.cache.PlayerCache;
import com.game.player.dao.PlayerMapper;
import com.game.player.model.Player;
import org.springframework.stereotype.Service;
import java.util.UUID;
@Service
public class AuthService {
private final PlayerMapper playerMapper;
private final PlayerCache playerCache;
public AuthService(PlayerMapper playerMapper, PlayerCache playerCache) {
this.playerMapper = playerMapper;
this.playerCache = playerCache;
}
public LoginResult login(String channel, String channelUserId, String nickname) {
// 1. 渠道校验(此处简化:真实环境调微信接口)
verifyChannel(channel, channelUserId);
// 2. 查账号:channel_openid 表 → playerId(简化:直接按 openid 查)
Player player = playerMapper.findByOpenId(channel, channelUserId);
boolean newPlayer = false;
if (player == null) {
// 3. 首次登录:创建账号
player = new Player();
player.setPlayerId(nextPlayerId());
player.setNickname(nickname == null ? "玩家" + (player.getPlayerId() % 10000) : nickname);
player.setCreateTime(System.currentTimeMillis());
player.setChannel(channel);
playerMapper.insert(player);
newPlayer = true;
}
// 4. 刷新登录时间
player.setLastLoginTime(System.currentTimeMillis());
player.setLastLogoutTime(0);
playerMapper.updateLogin(player);
// 5. 签发 Token + 标记在线
String token = UUID.randomUUID().toString().replace("-", "");
playerCache.markOnline(player.getPlayerId(), token);
return new LoginResult(player.getPlayerId(), token, newPlayer);
}
private void verifyChannel(String channel, String code) {
// 真实实现:调微信 jscode2session 换 openid
}
private long nextPlayerId() {
// 雪花算法 / Redis INCR,此处用时间戳占位
return System.currentTimeMillis() % 100000000L + ThreadLocalRandom.current().nextLong(1000);
}
}说明:
实际项目会有 channel_openid 绑定表(账号体系篇已讲)
这里简化以演示链路
nextPlayerId 生产用雪花算法5.2 LoginHandler
java
package com.game.player.handler;
import com.game.gateway.dispatch.MessageHandler;
import com.game.gateway.dispatch.MessageMapping;
import com.game.gateway.session.GameSession;
import com.game.player.service.AuthService;
import io.netty.channel.ChannelHandlerContext;
import org.springframework.stereotype.Component;
@MessageMapping(opcode = 1001)
@Component
public class LoginHandler implements MessageHandler {
private final AuthService authService;
public LoginHandler(AuthService authService) {
this.authService = authService;
}
@Override
public void handle(ChannelHandlerContext ctx, GameSession session, byte[] body) {
// body 为 Protobuf 的 LoginReq,此处简化解析
String channel = "WECHAT";
String openId = "mock_openid_" + System.currentTimeMillis() % 1000;
AuthService.LoginResult result = authService.login(channel, openId, "小明");
session.setPlayerId(result.playerId());
session.setState(GameSession.STATE_AUTHED);
// 回包:登录成功
session.send(new com.game.gateway.codec.GameMessage(1002,
("{\"playerId\":" + result.playerId() + ",\"new\":" + result.isNew() + "}").getBytes()));
}
}六、双写与持久化
6.1 事件触发(货币变更即时落库)
java
@Service
public class PlayerService {
private final PlayerMapper playerMapper;
// 充值/消费等重要变更:即时落库
public void changeDiamond(Player player, long delta) {
player.changeCoin(delta);
// 关键变更:立即保存(含流水记录)
playerMapper.updateWithVersion(player);
player.clearDirty();
// 同时写货币流水(currency_log),供对账
}
}6.2 定时落盘
java
@Service
public class PlayerSaveService {
private final PlayerMapper playerMapper;
private final SessionManager sessionManager;
@Scheduled(fixedDelay = 30_000L) // 30s 一次
public void saveDirtyPlayers() {
for (GameSession session : sessionManager.onlineSessions()) {
Player player = session.getPlayer();
if (player != null && player.isDirty()) {
playerMapper.updateWithVersion(player);
player.clearDirty();
}
}
}
}6.3 下线强制保存
java
// 在 Session 断开/掉线时调用
public void onLogout(GameSession session) {
Player player = session.getPlayer();
if (player != null) {
player.setLastLogoutTime(System.currentTimeMillis());
playerMapper.updateWithVersion(player); // 强制落库
player.clearDirty();
}
playerCache.markOffline(session.getPlayerId());
sessionManager.remove(session.getChannel());
}持久化三层配合:
货币等关键数据:变更即存
常规数据:30s 定时批量存
下线/掉线:强制保存
配合 version 乐观锁防覆盖七、重启恢复验证
7.1 验证清单
1. 首次登录:新账号创建成功(insert)
2. 再次登录:加载已有账号(findById)
3. 修改货币:立即落库(进程杀掉也不丢)
4. 普通操作:30s 定时落库
5. 下线:强制保存 last_logout_time
6. 重启服务器:重新登录数据完整
7. 顶号:同账号二次登录旧连接被顶7.2 压测要点
关注指标:
登录 QPS(鉴权 + 建号/加载 + Redis 标记)
定时落库批量耗时
版本冲突率(并发落库)
数据库连接池水位优化方向:
批量落库用 SQL 批量 update
加载玩家数据用分片批量查询
热数据可加本地缓存八、小结
本篇实现了玩家系统的全链路:AuthService 完成渠道校验、建号、Token 签发与在线标记;Player 以内存态持有核心字段与背包子模块,通过 PlayerMapper(乐观锁)与 MySQL 交互;PlayerCache 用 Redis 承载在线状态、Token 与热数据;持久化采用"货币即时存 + 常规 30s 定时存 + 下线强制存"三层策略,配合版本号防覆盖。整个模块跑通后,"登录即建号、数据不丢、重启可恢复"的目标达成。玩家系统是后续所有玩法的数据基座——房间、社交、经济系统都将围绕 Player 对象生长。