Java 设计模式(观察者模式)+ Redis:游戏成就系统(条件达成检测、奖励自动发放)

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 在其中的角色:

整个链路的核心逻辑是 "事件驱动 + 数据分离":

  1. 事件驱动:玩家行为触发事件,观察者(条件检测、奖励发放)订阅事件并处理,完全解耦;
  2. 数据分离:实时进度和已完成成就存在 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 不存在" 的情况,缓存未命中时直接查数据库,被外挂利用。

解决方案

  1. 对不存在的成就 ID,在 Redis 中缓存 "空值"(比如 "null"),设置短期过期时间(5 分钟);
  2. 在接口层加参数校验,过滤掉明显不合理的成就 ID(比如负数、超过最大成就 ID)。

五、总结:游戏成就系统的 "设计哲学"

做了八年后端,我越来越觉得:游戏系统的设计,本质是 "平衡"------ 平衡耦合与扩展、平衡性能与可靠、平衡开发效率与玩家体验

这套基于观察者模式 + Redis 的成就系统,正是这种平衡的体现:

  • 观察者模式解耦了 "行为" 和 "逻辑",新增成就只需加观察者,不用改核心代码;
  • Redis 保证了高并发下的性能和安全,原子操作解决进度计算和奖励发放问题;
  • 每一步都加了 "兜底方案"(比如缓存未命中查数据库、奖励失败重试),避免玩家损失。

最后给游戏后端同行几个建议:

  1. 不要过度设计:早期玩家少的时候,甚至可以不用观察者模式,直接用 Redis + 简单逻辑,等玩家多了再重构;
  2. 重视并发安全:游戏的并发场景比电商更复杂(比如玩家同时杀怪、通关、充值),原子操作和分布式锁是必备技能;
  3. 日志要详细:玩家反馈 "没收到奖励" 时,详细的日志是唯一能帮你定位问题的工具,不要怕日志多。

如果你的项目也在做游戏成就系统,希望这篇文章能给你带来启发。有其他问题的话,欢迎在评论区交流 ------ 游戏后端的坑,我们一起踩,一起填!

相关推荐
转转技术团队3 小时前
AI在前后端联调提效的实践
前端·后端
小码编匠3 小时前
基于 Spring Boot + Vue 的轻量级进销存系统
vue.js·spring boot·后端
忘了ʷºᵇₐ3 小时前
在hadoop中Job提交的流程
java·hadoop
BingoGo3 小时前
PHP 快速集成 ChatGPT 用 AI 让你的应用更聪明
后端·php
aloha_3 小时前
Linux 服务器时区
后端
知其然亦知其所以然3 小时前
MySQL 社招必考题:如何优化WHERE子句?
后端·mysql·面试
一直_在路上3 小时前
Go实战:从零打造百万QPS医疗科技高并发微服务
后端·设计模式
编啊编程啊程3 小时前
Netty从0到1系列之RPC通信
java·spring boot·rpc·kafka·dubbo·nio
莹Innsane3 小时前
将网站展示图片的格式由 JPG 切换到了 WebP
后端