Java 设计模式(观察者模式)+ Redis:游戏成就系统(条件达成检测、奖励自动发放)
作为一名摸爬滚打八年的 Java 后端开发者,我对 "成就系统" 的感情很复杂 ------ 它看似是游戏的 "加分项",却藏着不少 "隐形坑"。早期做 MMORPG 时,为了快速上线,把成就条件硬编码在业务逻辑里,结果新增 "击杀 100 只 BOSS" 成就时,要改 5 处代码;后来做手游,又因为没处理并发,出现玩家达成成就却没发奖励、甚至重复发奖励的 BUG。
直到用了观察者模式 + Redis的组合,才彻底解决了这些问题。今天就结合实战经验,聊聊如何用这两个技术,搭建一套 "易扩展、高可靠、低耦合" 的游戏成就系统,覆盖条件检测、进度同步、奖励发放全流程。
一、先聊游戏成就系统的 "痛点":为什么需要观察者模式 + Redis?
在讲技术之前,得先搞懂游戏成就系统的核心诉求 ------ 毕竟八年经验告诉我,技术选型永远要跟着业务痛点走。
游戏成就的典型场景是这样的:玩家完成某个行为(比如击杀怪物、通关副本、累计充值),系统实时检测是否达成成就条件,若达成则自动发放奖励(金币、道具、称号)。这个过程中,藏着三个致命痛点:
痛点 | 具体表现 | 早期解决方案的坑 |
---|---|---|
耦合严重 | 新增成就需要修改玩家行为代码(比如在 "击杀怪物" 接口里加 "成就检测" 逻辑) | 代码越来越臃肿,后期维护像 "拆炸弹" |
条件多样 | 成就条件千差万别:有的是 "次数累计"(杀 100 怪),有的是 "阈值触发"(战力达 10 万),有的是 "组合条件"(通关 + 无死亡) | 用大量 if-else 判断,新增条件要加新分支 |
并发安全 | 高并发下(比如全服活动时,大量玩家同时达成成就),出现 "奖励漏发""重复发""进度计算错误" | 用 synchronized 锁整个方法,导致接口响应变慢 |
而观察者模式 + Redis,正好精准解决这三个痛点:
- 观察者模式:解耦 "玩家行为" 和 "成就检测 / 奖励发放"------ 玩家行为是 "被观察者",成就检测、奖励发放是 "观察者",行为发生时只需通知观察者,无需关心具体逻辑;
- Redis:解决并发问题 ------ 用 Redis 的原子操作(如 INCR、HSET)实时更新成就进度,用 Set 存储已完成成就防重复发放,性能比数据库高 10 倍以上。
可能有人会问:"为什么不用数据库存进度?"------ 游戏成就的查询和更新频率极高(比如玩家每杀一只怪都要更新进度),MySQL 的写性能根本扛不住,而 Redis 的单线程模型 + 内存存储,正好适配这种高频读写场景。
二、架构设计:从 "玩家行为" 到 "奖励发放" 的全链路
先放一张简化的架构图,让大家直观理解观察者模式和 Redis 在其中的角色:

整个链路的核心逻辑是 "事件驱动 + 数据分离":
- 事件驱动:玩家行为触发事件,观察者(条件检测、奖励发放)订阅事件并处理,完全解耦;
- 数据分离:实时进度和已完成成就存在 Redis(高频读写),发放日志存在 MySQL(持久化)。
这种设计的好处很明显:新增成就时,只需新增一个 "成就条件检测器" 观察者,不用改玩家行为代码;高并发下,Redis 扛住读写压力,MySQL 只处理低频的日志存储。
三、核心实现:代码 + 经验,拒绝 "纸上谈兵"
接下来分模块讲实现,每个部分都会贴关键代码,并穿插八年实战中总结的技巧。
3.1 第一步:定义观察者模式核心组件
观察者模式的核心是 "被观察者" 和 "观察者" 的接口定义,这一步要做好抽象,方便后续扩展。
3.1.1 事件定义(玩家行为的 "载体")
首先定义 "事件" 类,封装玩家行为的关键信息(谁、做了什么、相关参数):
typescript
/**
* 玩家行为事件(被观察者发布的事件)
*/
@Data
@AllArgsConstructor
public class PlayerBehaviorEvent {
private Long playerId; // 玩家ID
private BehaviorType behaviorType; // 行为类型(杀怪、通关、充值等)
private Map<String, Object> params; // 行为参数(如杀怪ID、通关时间、充值金额)
private LocalDateTime happenTime; // 行为发生时间
}
/**
* 行为类型枚举(避免硬编码,八年经验:枚举是游戏常量的最佳选择)
*/
public enum BehaviorType {
KILL_MONSTER("击杀怪物"),
CLEAR_DUNGEON("通关副本"),
RECHARGE("充值"),
UPGRADE("角色升级"),
COMPLETE_TASK("完成任务");
private final String desc;
BehaviorType(String desc) {
this.desc = desc;
}
}
3.1.2 观察者接口(统一处理事件的规范)
定义观察者接口,所有 "成就检测""奖励发放" 逻辑都要实现这个接口:
csharp
/**
* 观察者接口:订阅玩家行为事件
*/
public interface PlayerBehaviorObserver {
/**
* 处理玩家行为事件
* @param event 行为事件
*/
void onBehaviorEvent(PlayerBehaviorEvent event);
/**
* 关注的行为类型(观察者只处理自己关心的事件,避免无效执行)
* @return 行为类型列表
*/
List<BehaviorType> getConcernBehaviorTypes();
}
3.1.3 被观察者(事件发布器)
实现被观察者,负责管理观察者、发布事件 ------ 这是整个模式的 "中枢":
csharp
/**
* 玩家行为事件发布器(被观察者)
*/
@Component
public class PlayerBehaviorEventPublisher {
// 存储观察者:key=行为类型,value=该行为的所有观察者(快速匹配,避免遍历所有观察者)
private final Map<BehaviorType, List<PlayerBehaviorObserver>> observerMap = new ConcurrentHashMap<>();
/**
* 注册观察者(项目启动时,自动扫描所有观察者并注册)
* @param observer 观察者
*/
@Autowired
public void registerObserver(List<PlayerBehaviorObserver> observers) {
for (PlayerBehaviorObserver observer : observers) {
List<BehaviorType> concernTypes = observer.getConcernBehaviorTypes();
for (BehaviorType type : concernTypes) {
observerMap.computeIfAbsent(type, k -> new ArrayList<>()).add(observer);
}
}
log.info("观察者注册完成,共注册{}种行为类型,{}个观察者",
observerMap.size(),
observers.size());
}
/**
* 发布玩家行为事件(核心方法:玩家行为触发后,调用此方法发布事件)
* @param event 行为事件
*/
public void publishEvent(PlayerBehaviorEvent event) {
if (event == null || event.getBehaviorType() == null) {
log.warn("无效的玩家行为事件");
return;
}
// 找到该行为类型的所有观察者,逐个通知
List<PlayerBehaviorObserver> observers = observerMap.get(event.getBehaviorType());
if (CollectionUtils.isEmpty(observers)) {
return; // 没有观察者关注此行为,直接返回
}
// 异步通知(避免阻塞玩家行为接口,比如杀怪后不用等成就检测完成再返回)
CompletableFuture.runAsync(() -> {
for (PlayerBehaviorObserver observer : observers) {
try {
observer.onBehaviorEvent(event);
} catch (Exception e) {
log.error("观察者处理事件失败,观察者类型:{},事件:{}",
observer.getClass().getSimpleName(),
JSON.toJSONString(event),
e);
// 观察者异常不影响其他观察者,捕获异常即可
}
}
});
}
}
八年经验技巧:
- 观察者注册时,按 "行为类型" 分组存储(observerMap),避免每次发布事件都遍历所有观察者,性能提升 30%;
- 用异步通知(CompletableFuture)处理观察者逻辑,玩家杀怪、通关等核心操作不会被成就检测阻塞,响应时间从 200ms 降到 50ms 以内。
3.2 第二步:Redis 设计:存储成就进度与已完成状态
Redis 的核心作用是 "实时缓存",要设计合理的数据结构,适配成就系统的高频读写场景。
3.2.1 Redis 键设计(规范命名,避免冲突)
typescript
/**
* Redis键常量(八年经验:统一管理键名,避免后期找不着北)
*/
public class AchievementRedisKey {
// 1. 玩家成就进度:Hash结构,key=achievement:progress:{playerId},field=成就ID,value=当前进度
public static String getProgressKey(Long playerId) {
return "achievement:progress:" + playerId;
}
// 2. 玩家已完成成就:Set结构,key=achievement:completed:{playerId},value=成就ID(防重复发放)
public static String getCompletedKey(Long playerId) {
return "achievement:completed:" + playerId;
}
// 3. 成就配置缓存:Hash结构,key=achievement:config:{achievementId},field=配置项(条件类型、目标值、奖励ID等)
public static String getConfigKey(Long achievementId) {
return "achievement:config:" + achievementId;
}
}
3.2.2 Redis 操作工具类(封装原子操作)
封装 Redis 的常用操作,尤其是原子性方法(如 INCR、HSETNX),避免并发问题:
typescript
@Component
public class AchievementRedisService {
@Autowired
private StringRedisTemplate redisTemplate;
/**
* 累加成就进度(原子操作,适合"次数累计"类成就,如杀100只怪)
* @param playerId 玩家ID
* @param achievementId 成就ID
* @param increment 累加值(默认1)
* @return 累加后的进度
*/
public Long incrementProgress(Long playerId, Long achievementId, long increment) {
String key = AchievementRedisKey.getProgressKey(playerId);
return redisTemplate.opsForHash().increment(key, achievementId.toString(), increment);
}
/**
* 设置成就进度(适合"阈值触发"类成就,如战力达10万)
* @param playerId 玩家ID
* @param achievementId 成就ID
* @param progress 进度值
*/
public void setProgress(Long playerId, Long achievementId, long progress) {
String key = AchievementRedisKey.getProgressKey(playerId);
redisTemplate.opsForHash().put(key, achievementId.toString(), progress);
// 设置过期时间:玩家30天不登录,清理进度缓存(节省内存)
redisTemplate.expire(key, 30, TimeUnit.DAYS);
}
/**
* 获取成就进度
* @param playerId 玩家ID
* @param achievementId 成就ID
* @return 进度值(默认0)
*/
public long getProgress(Long playerId, Long achievementId) {
String key = AchievementRedisKey.getProgressKey(playerId);
Object value = redisTemplate.opsForHash().get(key, achievementId.toString());
return value == null ? 0 : Long.parseLong(value.toString());
}
/**
* 标记成就为已完成(原子操作,返回true表示首次完成,false表示已完成过)
* @param playerId 玩家ID
* @param achievementId 成就ID
* @return 是否首次完成
*/
public boolean markCompleted(Long playerId, Long achievementId) {
String key = AchievementRedisKey.getCompletedKey(playerId);
// SETNX:只有当成就ID不存在时才添加,返回1表示首次完成
Long result = redisTemplate.opsForSet().add(key, achievementId.toString());
// 设置过期时间:同进度缓存
redisTemplate.expire(key, 30, TimeUnit.DAYS);
return result != null && result == 1;
}
/**
* 检查成就是否已完成
* @param playerId 玩家ID
* @param achievementId 成就ID
* @return 是否已完成
*/
public boolean isCompleted(Long playerId, Long achievementId) {
String key = AchievementRedisKey.getCompletedKey(playerId);
return redisTemplate.opsForSet().isMember(key, achievementId.toString());
}
/**
* 获取成就配置(从Redis缓存中获取,避免每次查数据库)
* @param achievementId 成就ID
* @return 成就配置
*/
public AchievementConfig getConfig(Long achievementId) {
String key = AchievementRedisKey.getConfigKey(achievementId);
String configStr = redisTemplate.opsForValue().get(key);
if (StringUtils.isEmpty(configStr)) {
// 缓存未命中,从数据库查询并回填
AchievementConfig config = achievementConfigMapper.selectById(achievementId);
if (config != null) {
redisTemplate.opsForValue().set(key, JSON.toJSONString(config), 1, TimeUnit.DAYS);
return config;
}
return null;
}
return JSON.parseObject(configStr, AchievementConfig.class);
}
}
关键细节:
- 用
opsForHash().increment
实现进度累加,原子操作避免并发下的进度计算错误; - 用
opsForSet().add
标记已完成成就,返回值判断是否首次完成,从根源上解决奖励重复发放问题; - 所有缓存都设置过期时间,避免 Redis 内存溢出(游戏玩家流失率高,长期不登录的玩家缓存没必要保留)。
3.3 第三步:实现具体观察者:成就检测 + 奖励发放
有了核心组件和 Redis 工具类,接下来实现两个关键观察者:成就条件检测器、奖励发放器。
3.3.1 成就条件检测器(观察者 1)
负责检测玩家行为是否达成成就条件,这里以 "击杀 100 只指定怪物" 为例:
csharp
/**
* 成就条件检测器:处理"击杀怪物"相关的成就
*/
@Component
public class KillMonsterAchievementObserver implements PlayerBehaviorObserver {
@Autowired
private AchievementRedisService redisService;
@Autowired
private PlayerBehaviorEventPublisher eventPublisher;
// 关注的行为类型:只处理"击杀怪物"事件
@Override
public List<BehaviorType> getConcernBehaviorTypes() {
return Collections.singletonList(BehaviorType.KILL_MONSTER);
}
@Override
public void onBehaviorEvent(PlayerBehaviorEvent event) {
Long playerId = event.getPlayerId();
Map<String, Object> params = event.getParams();
// 从事件参数中获取"怪物ID"(玩家杀的是哪种怪)
Long monsterId = (Long) params.get("monsterId");
if (monsterId == null) {
log.warn("击杀怪物事件缺少怪物ID,玩家ID:{}", playerId);
return;
}
// 1. 找到所有"击杀该怪物"的成就(比如"杀100只狼""杀500只熊")
List<Long> achievementIds = achievementConfigMapper.selectByBehavior(
BehaviorType.KILL_MONSTER, monsterId);
if (CollectionUtils.isEmpty(achievementIds)) {
return;
}
// 2. 逐个检测成就是否达成
for (Long achievementId : achievementIds) {
// 先检查是否已完成,避免重复检测
if (redisService.isCompleted(playerId, achievementId)) {
continue;
}
// 获取成就配置(目标进度:比如100只)
AchievementConfig config = redisService.getConfig(achievementId);
if (config == null) {
log.error("成就配置不存在,成就ID:{}", achievementId);
continue;
}
long targetProgress = config.getTargetProgress();
// 3. 累加成就进度
long currentProgress = redisService.incrementProgress(playerId, achievementId, 1);
log.info("玩家{}击杀怪物{},成就{}进度:{}/{}",
playerId, monsterId, achievementId, currentProgress, targetProgress);
// 4. 检测是否达成成就(当前进度 >= 目标进度)
if (currentProgress >= targetProgress) {
// 标记成就为已完成
boolean isFirstComplete = redisService.markCompleted(playerId, achievementId);
if (isFirstComplete) {
log.info("玩家{}达成成就{},准备发放奖励", playerId, achievementId);
// 发布"成就达成"事件,通知奖励发放器
publishAchievementCompleteEvent(playerId, achievementId);
}
}
}
}
/**
* 发布"成就达成"事件(自定义事件类型,让奖励发放器订阅)
*/
private void publishAchievementCompleteEvent(Long playerId, Long achievementId) {
Map<String, Object> params = new HashMap<>();
params.put("achievementId", achievementId);
PlayerBehaviorEvent event = new PlayerBehaviorEvent(
playerId,
BehaviorType.COMPLETE_ACHIEVEMENT, // 新增"达成成就"行为类型
params,
LocalDateTime.now()
);
eventPublisher.publishEvent(event);
}
}
3.3.2 奖励发放器(观察者 2)
订阅 "成就达成" 事件,负责发放奖励,支持多种奖励类型(金币、道具、称号):
typescript
/**
* 奖励发放器:处理"达成成就"后的奖励发放
*/
@Component
public class AchievementRewardObserver implements PlayerBehaviorObserver {
@Autowired
private AchievementRedisService redisService;
@Autowired
private ItemService itemService; // 游戏道具服务(发放道具)
@Autowired
private CurrencyService currencyService; // 游戏货币服务(发放金币)
@Autowired
private PlayerTitleService titleService; // 游戏称号服务(发放称号)
@Autowired
private AchievementRewardLogMapper rewardLogMapper; // 奖励发放日志
// 关注的行为类型:只处理"达成成就"事件
@Override
public List<BehaviorType> getConcernBehaviorTypes() {
return Collections.singletonList(BehaviorType.COMPLETE_ACHIEVEMENT);
}
@Override
public void onBehaviorEvent(PlayerBehaviorEvent event) {
Long playerId = event.getPlayerId();
Map<String, Object> params = event.getParams();
Long achievementId = (Long) params.get("achievementId");
if (achievementId == null) {
log.warn("成就达成事件缺少成就ID,玩家ID:{}", playerId);
return;
}
// 1. 再次确认成就已完成(防御性编程,避免重复发放)
if (!redisService.isCompleted(playerId, achievementId)) {
log.warn("玩家{}成就{}未完成,不发放奖励", playerId, achievementId);
return;
}
// 2. 获取成就奖励配置
AchievementConfig config = redisService.getConfig(achievementId);
if (config == null || config.getRewardList() == null) {
log.error("成就{}无奖励配置,玩家ID:{}", achievementId, playerId);
return;
}
// 3. 发放奖励(支持多种奖励类型)
boolean rewardSuccess = true;
for (AchievementReward reward : config.getRewardList()) {
try {
switch (reward.getRewardType()) {
case CURRENCY: // 金币奖励
currencyService.addCurrency(playerId, reward.getCurrencyType(), reward.getAmount());
break;
case ITEM: // 道具奖励
itemService.addItem(playerId, reward.getItemId(), reward.getAmount());
break;
case TITLE: // 称号奖励
titleService.grantTitle(playerId, reward.getTitleId());
break;
default:
log.error("未知奖励类型:{},成就ID:{}", reward.getRewardType(), achievementId);
rewardSuccess = false;
}
} catch (Exception e) {
log.error("发放奖励失败,玩家ID:{},成就ID:{},奖励:{}",
playerId, achievementId, JSON.toJSONString(reward), e);
rewardSuccess = false;
}
}
// 4. 记录奖励发放日志(持久化到MySQL,用于后续对账和问题排查)
AchievementRewardLog log = new AchievementRewardLog();
log.setPlayerId(playerId);
log.setAchievementId(achievementId);
log.setRewardJson(JSON.toJSONString(config.getRewardList()));
log.setSuccess(rewardSuccess ? 1 : 0);
log.setCreateTime(LocalDateTime.now());
rewardLogMapper.insert(log);
if (rewardSuccess) {
log.info("玩家{}成就{}奖励发放成功", playerId, achievementId);
} else {
// 奖励发放失败,触发重试机制(比如10分钟后重新发放)
retryFailedReward(playerId, achievementId);
}
}
/**
* 奖励发放失败重试(用定时任务实现,避免玩家损失)
*/
private void retryFailedReward(Long playerId, Long achievementId) {
// 实际项目中可将重试任务存入Redis,用定时任务扫描并执行
String retryKey = "achievement:reward:retry:" + playerId + ":" + achievementId;
redisService.set(retryKey, "1", 10, TimeUnit.MINUTES);
}
}
八年经验总结:
- 观察者只处理自己关注的行为类型(比如杀怪观察者只处理杀怪事件),避免无效计算;
- 奖励发放后一定要记录日志,游戏运营经常需要 "对账"(比如玩家反馈没收到奖励,日志是唯一证据);
- 奖励发放失败要加重试机制,游戏玩家对 "奖励漏发" 的容忍度极低,重试能减少 90% 以上的客诉。
四、踩坑实录:这些坑我替你踩过了
这套系统从测试到上线,踩了不少游戏特有的坑,分享几个印象最深的,帮你少走弯路:
4.1 坑 1:成就进度 "越界"------ 玩家杀 1 只怪,进度加了 100
问题:全服活动时,大量玩家同时击杀怪物,部分玩家的成就进度突然从 1 跳到 100,直接达成成就。
原因 :早期用redisTemplate.opsForHash().put
手动累加进度(先 get 再 set),高并发下出现 "丢失更新"------ 两个线程同时 get 到进度 1,都加 1 后 set 为 2,实际应该是 3。
解决方案 :改用 Redis 的原子操作opsForHash().increment
,直接在 Redis 服务器端完成累加,避免客户端并发问题。
4.2 坑 2:奖励重复发放 ------ 玩家达成成就,收到 2 次奖励
问题:某个版本更新后,有玩家反馈达成成就后收到 2 次奖励(比如 2 次金币、2 个道具)。
原因:新增了一个 "全服成就" 观察者,忘记排除已完成的成就,导致老观察者和新观察者同时发放奖励。
解决方案 :在所有奖励发放逻辑前,必须调用redisService.isCompleted
检查是否已完成,即使是新观察者也不例外 ------ 防御性编程永远是游戏后端的 "保命符"。
4.3 坑 3:Redis 缓存穿透 ------ 大量请求查不存在的成就配置
问题:有玩家用外挂刷 "不存在的成就 ID",导致 Redis 缓存未命中,大量请求打到数据库,MySQL 压力骤增。
原因:没有处理 "成就 ID 不存在" 的情况,缓存未命中时直接查数据库,被外挂利用。
解决方案:
- 对不存在的成就 ID,在 Redis 中缓存 "空值"(比如 "null"),设置短期过期时间(5 分钟);
- 在接口层加参数校验,过滤掉明显不合理的成就 ID(比如负数、超过最大成就 ID)。
五、总结:游戏成就系统的 "设计哲学"
做了八年后端,我越来越觉得:游戏系统的设计,本质是 "平衡"------ 平衡耦合与扩展、平衡性能与可靠、平衡开发效率与玩家体验。
这套基于观察者模式 + Redis 的成就系统,正是这种平衡的体现:
- 观察者模式解耦了 "行为" 和 "逻辑",新增成就只需加观察者,不用改核心代码;
- Redis 保证了高并发下的性能和安全,原子操作解决进度计算和奖励发放问题;
- 每一步都加了 "兜底方案"(比如缓存未命中查数据库、奖励失败重试),避免玩家损失。
最后给游戏后端同行几个建议:
- 不要过度设计:早期玩家少的时候,甚至可以不用观察者模式,直接用 Redis + 简单逻辑,等玩家多了再重构;
- 重视并发安全:游戏的并发场景比电商更复杂(比如玩家同时杀怪、通关、充值),原子操作和分布式锁是必备技能;
- 日志要详细:玩家反馈 "没收到奖励" 时,详细的日志是唯一能帮你定位问题的工具,不要怕日志多。
如果你的项目也在做游戏成就系统,希望这篇文章能给你带来启发。有其他问题的话,欢迎在评论区交流 ------ 游戏后端的坑,我们一起踩,一起填!