ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

删除还是下线?从郿坞夺宝看限时任务安全下线的工程实践

删除还是下线?从郿坞夺宝看限时任务安全下线的工程实践 “郿坞夺宝的任务真应该删除”如果这句话出现在需求池里严格来说它不是一张能直接执行的任务单。在游戏研发流程中“删除任务”是一个比“新增任务”更需要谨慎对待的系统变更因为任务背后关联着玩家进度、奖励库存、邮件、活动弹窗、客户端入口和客服文档。以三国题材里常见的“夺宝”类限时活动为例任务名可以叫“郿坞夺宝”也可以换成其他城市名但底层都依赖同一套任务系统。把这套系统设计好删除一个任务才是几分钟的事设计不好一次误下线就可能引发大量玩家投诉和不可逆的数据问题。这篇文章适合游戏后端开发、系统策划、版本运营以及对游戏任务系统设计感兴趣的产品经理。会围绕“当一个限时夺宝任务被认为应该删除时研发应该怎样评估、怎样执行、怎样验证”这条主线提供一个可复用的工程方案。重点不是替你说“删”或“不删”而是把判断依据和操作流程补齐。1. 先说清楚当玩家说“任务该删”在技术上意味着什么玩家说“郿坞夺宝的任务真应该删除”体验层面指的是“这个活动在我眼前很烦、很亏、很浪费时间”。研发接到这条反馈后如果只看到“删除”两个字很容易直接执行成“把配置表里这一行删掉”。要避免这个误区先要理解一次任务上线后会在系统里留下哪些数据。1.1 一次任务上线后会留下三段数据以“郿坞夺宝”为例玩家进入活动、消耗体力挑战守军、收集宝箱并兑换奖励这个过程至少会产生三类数据一是任务定义数据。它描述任务叫什么、属于什么类型、什么时间开放、前置条件是什么、奖励模板是什么。这类数据通常对应一张task_define表一个任务在配置里只有一条记录。二是玩家的任务进度数据。每个玩家接取任务后服务端要记录他当前做到哪一步打了多少次守军、收集了多少个宝箱、有没有达到领奖条件。这类数据通常落在player_task_status表里一个热门任务的记录数会接近活跃玩家数。三是玩家的奖励领取与流水数据。任务完成后发出去的奖励包括道具、货币、积分、邮件附件都要留下流水方便后续审计、补发和排查问题。这类数据往往写在task_reward_log或玩家背包流水表里。一个任务上线后并不是一张静态配置而是一张任务定义配合几万甚至几十万条玩家进度数据同时存在。直接执行DELETE FROM task_define WHERE task_no mwdb_20240601看起来任务入口没有了但玩家进度表里的数据还在奖励流水也还在任务系统、活动系统、客服系统之间会失去关联。等后续需要查“哪些玩家还没领奖”“哪些玩家兑换了某个物品”时就会发现源头配置没了只能靠备份去恢复。1.2 “删除任务”通常应该翻译成“下线任务”游戏后台领域里更稳妥的动词不是“删除”而是“下线”。两者最大的区别在于下线是让任务从玩家可见范围内消失但保留任务定义、玩家进度、奖励流水和操作日志。即使后续不再开放这些数据仍然是可查、可审计、可追溯的。在实际工程中任务下线有三种实现层次处理方式玩家可见性数据保留回滚能力适用场景物理删除立即不可见不保留或只保留备份很难需要恢复备份本地联调、测试环境清理生产不推荐配置下架立即不可见全部保留可以改回状态即可常规活动结束、临时紧急关闭定时过期在配置时间点自动失效全部保留可以调整失效时间即可限时活动、赛季任务、节日任务灰度下线按比例或白名单分批次不可见全部保留可以调整灰度比例即可对删除结果不确定或者担心误伤玩家“郿坞夺宝”这类需求真正应该进入的状态不是DELETE而是把task_define.status从“已发布”改成“已下线”并且触发必要的补偿处理。这样即使产品判断反了想在下周恢复活动也只需要重新发布配置而不是从备份里捞数据。注意不要在生产环境执行物理删除任务配置。任务表是强审计数据删除动作一旦执行后期排查玩家投诉、奖励异常和活动效果时都会失去锚点。2. 判断该不该下线先把“感觉”变成指标“郿坞夺宝的任务真应该删除”可能来自一名玩家也可能来自客服群里的十几条负反馈。无论声音多大它都是单点反馈不能直接代表全部玩家的体验。一个成熟的后台系统应该提供足够的数据让研发判断这个任务是真的应该下线还是只需要优化入口、奖励或文案。2.1 负面反馈来自哪个环节决定了处理方式玩家抱怨“这个任务真应该删除”可能反映的是不同层面的问题任务本身有 Bug导致无法完成或者奖励发错。任务节奏不合理重复操作过多奖励却很低。任务入口太深玩家找不到误以为活动消失了。任务引导文案有歧义玩家不理解规则。任务产出影响了市场物价导致玩家不满。任务过期后没有清理入口长期挂在前端造成干扰。这些原因里只有第一类和部分第二类才需要考虑下线。入口太深是优化任务列表排序文案有歧义是改写描述产出影响经济是调整奖励模板任务过期残留是处理客户端配置。如果一看到负面反馈就下线整个活动相当于用“最重的手段”处理所有问题损失参与度也浪费已经完成的开发资源。2.2 用一张任务漏斗表评估“郿坞夺宝”的真实价值判断任务是否保留先看它在玩家链路中的表现。任务的标准漏斗是看到任务、接取任务、完成任务、领取奖励。以player_task_status表为例可以按下表统计指标含义曝光玩家数服务端下发了任务列表玩家能看到该任务接取玩家数玩家点过接取或任务自动进入进行中状态到达完成线玩家数玩家完成了指定次数或积分目标领取奖励玩家数玩家最终领取了任务奖励对应的查询可以参考下面的 SQL。这里假设玩家状态约定为0 表示未接取1 表示进行中2 表示完成未领取3 表示已领取。SELECT task_no, COUNT(DISTINCT player_id) AS expose_uv, COUNT(DISTINCT CASE WHEN task_status 1 THEN player_id END) AS accept_uv, COUNT(DISTINCT CASE WHEN task_status 2 OR task_status 3 THEN player_id END) AS finish_uv, COUNT(DISTINCT CASE WHEN task_status 3 THEN player_id END) AS claim_uv, ROUND( COUNT(DISTINCT CASE WHEN task_status 1 THEN player_id END) * 100.0 / NULLIF(COUNT(DISTINCT player_id), 0), 2 ) AS accept_rate FROM player_task_status WHERE task_no mwdb_20240601 GROUP BY task_no;这个 SQL 只是示例实际项目中状态枚举可能更长也可能把任务流水单独放一张表。关键是评估步骤要先拆分漏斗而不是只看最终领取人数。如果“郿坞夺宝”的曝光人数很高接取率也有 80%但完成率只有 10%那问题大概率出在任务难度、时间限制或奖励预期上直接删除会让已经接取的玩家失去参与机会。如果接取率本身只有 5%并且负反馈大多集中在“找不到入口”“不知道在哪打开”那要考虑的是任务入口和前置引导而不是任务内容本身。2.3 用决策矩阵决定优化、保留还是下线漏斗指标只能反映量不能直接反映玩家的态度。比较理想的做法是把参与情况和负反馈情况组合起来看参与情况负反馈情况推荐动作高参与、低负反馈玩家愿意做但对部分细节不满意保留优化文案或奖励展示高参与、高负反馈大量玩家参与但体验受损不轻易全量下线先小流量修正必要时灰度下线低参与、低负反馈任务存在感弱但不惹人反感保留但降低资源位或压缩活动周期低参与、高负反馈参与的人少且都在骂重点考虑下线或重做“郿坞夺宝”如果属于“低参与、高负反馈”删除的优先级确实高。但即便要下线也要先回答三个问题任务产出的资源有没有其他获取途径正在进行的玩家按什么规则结算下线后如果被冲能不能快速回到可玩状态这三个问题没搞清楚前直接动配置是危险的。3. 任务系统要支持“安全下线”状态机必须提前设计很多任务系统在设计时只考虑了“上线”链路策划配任务服务端发布玩家做任务时间到了自动结束。等到真的需要提前下线时才发现没有下线状态、没有操作日志、没有补偿入口。要让“郿坞夺宝”这类任务可以被安全下线关键是把任务状态机设计完整。3.1 配置态、运行态和玩家态要分开任务定义状态与玩家的个人任务状态是两个维度不能混在一个字段里。任务定义状态描述的是“这个活动任务整体处于什么阶段”DRAFT(草稿) - PUBLISHED(已发布) - OFFLINE(已下线) - ARCHIVED(已归档) | ^ |------------------| | 可回滚 |草稿表示配置还在制作中玩家不可见。已发布表示玩家可以看到并参与。已下线表示任务入口关闭但数据仍然保留。已归档表示下线周期已经很长通常不再回滚只留作审计或数据仓库分析。玩家任务状态描述的是“单个玩家在这个任务里做到了哪一步”玩家状态说明0 未接取玩家看到任务但没有开始或系统未自动接入1 进行中玩家已经开始但未达到完成条件2 完成未领取玩家已经达到条件只差点击领取3 已领取奖励已经发放任务结束4 已过期任务下线后没有结算或未领取的任务被标记为过期两个维度必须同时存在。任务定义状态只负责服务端是否把任务下发到玩家可见列表玩家状态负责单个玩家的进度和领奖流程。这样设计的好处是哪怕任务定义已经变成OFFLINE玩家表里的完成记录仍然存在补偿任务可以按玩家状态精确处理。3.2 任务引擎读取时做过滤不要把下线判断交给前端实际操作中容易踩的坑是前端把任务列表写死在客户端资源里后端只在活动结束时间上加判断。这种方式很难支持“灰度下线”也不利于应急关闭。正确做法是任务可见性由服务端统一判断客户端只负责展示服务端返回的任务。任务查询服务的核心逻辑可以概括为三件事取回当前正在运行的任务集、过滤掉已下线或玩家不可见的任务、再按排序规则返回给前端。下面给一个最小示例代码用来说明服务端过滤的思路。示例代码不是可以直接复制的完整项目实际项目需要结合自己的任务框架调整。Service public class TaskQueryService { private final TaskDefineRepository taskDefineRepository; private final OfflineRuleLoader offlineRuleLoader; public TaskQueryService(TaskDefineRepository taskDefineRepository, OfflineRuleLoader offlineRuleLoader) { this.taskDefineRepository taskDefineRepository; this.offlineRuleLoader offlineRuleLoader; } public ListTaskView listVisibleTasks(PlayerContext player, String scene) { ListTaskDefine allTasks taskDefineRepository.findByScene(scene); OfflineRule offlineRule offlineRuleLoader.load(player.getServerId()); return allTasks.stream() .filter(task - isPublished(task)) .filter(task - !offlineRule.isOfflineForTask(player, task)) .map(task - toView(player, task)) .collect(Collectors.toList()); } private boolean isPublished(TaskDefine task) { return task.getStatus() TaskStatus.PUBLISHED; } }OfflineRule是重点。它不只能做全量下线判断还能做“按玩家比例下线”让单个任务在某些玩家面前隐藏在另一些玩家面前仍然可见。这个能力在灰度下线阶段会非常有用。3.3 任务操作记录和版本号是回滚的基础改一个状态字段很容易难的是几天后想回滚时没有人知道当时的配置是什么、谁改的、改之前是什么状态。因此任务表建议增加version字段和独立操作日志表。每次下线、上线、编辑配置都记录一位操作人、一个原因、一套前后快照。操作日志表可以参考下面的结构CREATE TABLE task_operate_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, task_no VARCHAR(64) NOT NULL, operate_type VARCHAR(32) NOT NULL COMMENT PUBLISH/OFFLINE/ROLLBACK/UPDATE_CONFIG, before_status TINYINT NOT NULL, after_status TINYINT NOT NULL, operator VARCHAR(64) NOT NULL, reason VARCHAR(512) DEFAULT NULL, created_at DATETIME NOT NULL, INDEX idx_task_no(task_no), INDEX idx_operate_type(operate_type) );有了version字段和操作日志回滚就变成一件可审计的事。例如某次“郿坞夺宝”下线后策划发现补偿规则算错了需要先回滚到已发布状态停止补偿流程修正后再重新下线。这时只要把版本号和状态恢复到上一版即可玩家数据不会丢失。4. 用“灰度下线”代替一刀切降低误操作风险“郿坞夺宝”如果只是被吐槽并没有出现刷产出、服务器故障等严重问题最合理的下线方式不是全量硬关闭而是灰度下线。灰度下线让少数玩家先看不到这个任务通过线上数据确认没有引发大量客诉、没有造成资源产出异常、没有影响其他玩法之后再慢慢扩大到全量。4.1 为什么下线也要灰度很多团队认为“下线就是恢复原状最多再加一条公告”没有把下线当成一次发布处理。结果就是配置改完直接生效一旦补偿方案有问题、经济系统出现缺口、玩家找不到活动入口所有人都没有反应时间。如果采用灰度下线就可以把风险控制在一个很小的范围内。同样是处理“玩家吐槽很多”的任务全量下线可能让所有正在参与的人瞬间失去目标灰度下线则只让 5% 或 10% 的玩家先进入下线态后台观察客服工单数量、货币产出曲线、登录留存变化。如果这批玩家没有明显异常再逐步把比例提升到 20%、50%、100%。对比维度全量下线下线灰下线上线生效速度立即生效按比例逐步生效风险范围所有玩家只影响部分玩家回滚效果重新发布但反馈可能已扩散把比例调回 0 就能快速恢复数据对比不便于对比实验可以对比灰度组与对照组适用场景紧急 Bug、严重刷产出常规体验优化、活动提前关闭4.2 灰度下线配置项设计灰度下线配置建议在配置中心或活动后台保存用 JSON 描述一份“下线批次”。每个批次都应该有独立批次号同一个任务可以先后创建多个批次方便追溯历史上什么时候执行过下线。示例配置如下{ offlineBatchId: mwdb_20240601_offline_01, taskNo: mwdb_20240601, mode: GRAY, grayPercent: 10, channelList: [android, ios], exceptPlayerIds: [1001, 1002], hardOfflineAt: 2024-06-03 10:00:00, graceEndAt: 2024-06-05 23:59:59, rewardMailTemplate: MAIL_MWDB_OFFLINE_COMPENSATE }参数含义注意事项offlineBatchId下线批次号同一任务多次下线要使用不同批次号taskNo任务编号指向任务定义表mode下线模式GRAY 表示按比例灰度HARD 表示全量PREVIEW 表示只对白名单可见grayPercent灰度比例取值范围 1 到 100代表有多少百分比的玩家被下线channelList渠道范围可以只针对 iOS 或 Android 灰度便于根据反馈定向调整exceptPlayerIds白名单内部测试号、客服账号、关键大客户通常不放量到不可见hardOfflineAt强制全量下线时间超过这个时间不再灰度所有玩家不可见graceEndAt宽限期结束时间已接取的玩家可以继续完成任务直到该时间点rewardMailTemplate补偿邮件模板用于给进行中任务结算的玩家补发奖励4.3 按玩家粒度稳定灰度的实现思路灰度比例不是按请求随机抽样而是按玩家粒度做稳定哈希保证同一个玩家在某段时间内看到的状态一致。否则玩家可能这分钟还看到任务下分钟刷新后任务消失再过几分钟又出现这种状态跳变比任务下线本身更伤体验。稳定哈希的示例实现如下。代码使用playerId和taskNo拼接后计算哈希再取百分位public class OfflineRule { private final int grayPercent; public OfflineRule(int grayPercent) { this.grayPercent grayPercent; } public boolean isOfflineForTask(long playerId, String taskNo) { if (grayPercent 0) { return false; } if (grayPercent 100) { return true; } int percent stablePercent(playerId, taskNo); return percent grayPercent; } private int stablePercent(long playerId, String taskNo) { String text playerId _ taskNo; long hash 0; for (int i 0; i text.length(); i) { hash hash * 31 text.charAt(i); } int percent (int) (Math.floorMod(hash, 100L)) 1; return percent; } }这段代码的价值在于确定性只要playerId和taskNo不变每次计算结果都一样。没有随机数就不会出现同一个玩家反复横跳。4.4 下线后的入口隐藏与缓存更新任务列表服务端判断隐藏后客户端需要及时感知。常见做法是客户端每次进入任务页时请求服务端接口服务端返回最新的可见任务列表。为了减少数据库压力可以在任务列表外加一层缓存。这里需要特别注意的是缓存失效。如果将“郿坞夺宝”下线配置写入 Redis但任务查询服务读取的是本地缓存就会出现在后台已经下线、线上还在展示的问题。发布下线配置时要主动通知任务服务刷新缓存或者在配置中心里推送一个版本变更事件。# 示例查看任务下线相关的缓存键 redis-cli SCAN 0 MATCH task:offline:* COUNT 1000生产环境不建议在 Redis 里直接执行KEYS命令使用SCAN更安全。实际项目中缓存键命名要遵循团队规范下线后按业务键精确清理避免把所有任务缓存一次性清掉。5. 存量玩家处理宽限期、补偿和幂等设计灰度下线只解决“什么时候让玩家看不到任务”的问题真正决定玩家满意度的是“我已经做到一半的任务怎么算”。如果任务直接消失不说明原因不给任何补偿那原本只是少数人吐槽的问题会被放大成全量玩家的客诉。5.1 按玩家任务状态分场景处理任务下线时玩家可能处于不同进度。处理办法不能一概而论建议按下表的逻辑处理玩家当前状态下线后的处理策略从未接取任务直接隐藏不需要补偿因为玩家没有投入已接取但进度很低标记为过期按“已参与”档小额补偿避免感觉被吞进度已接取且进度较高允许在宽限期内继续完成超过宽限期仍未完成则按比例折算补偿已完成但未领取不要直接清掉优先自动发放奖励到邮件并保留领取记录已领取奖励但奖励未到账通过补偿队列补发保证流水一致“郿坞夺宝”这类任务通常不是一次性任务而是持续多天的活动。下线时如果直接清掉“进行中”玩家那些已经花费大量时间和体力的玩家是最容易流失的一批。给宽限期是更平滑的处理方式。宽限期策略在配置里体现为graceEndAt。任务定义状态变成OFFLINE后新接取入口关闭但player_task_status中处于“进行中”的玩家在宽限期结束前仍然可以在客户端继续看到并完成任务。宽限期结束后再由定时任务统一处理仍然没有完成的玩家。5.2 补偿消息要携带上下文消费端要保证幂等补偿不是简单发一封邮件邮件附件、邮件标题、邮件过期时间都要按任务模板生成。更关键的是补偿投递过程中可能发生重复消费。比如消息队列重试、定时任务重复扫描、运维手动补跑都会造成同一个玩家收到多封补偿邮件。避免重复补偿的核心是幂等设计。每个补偿动作都要生成一个唯一键这个唯一键通常由玩家 ID、任务编号、下线批次号组成。补偿之前先记录一条“补偿日志”日志表中的唯一键加唯一索引。CREATE TABLE task_compensation_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, unique_key VARCHAR(128) NOT NULL, player_id BIGINT NOT NULL, task_no VARCHAR(64) NOT NULL, batch_id VARCHAR(64) NOT NULL, status TINYINT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, UNIQUE KEY uk_unique_key(unique_key), INDEX idx_player_time(player_id, created_at) );补偿消息可以封装成下面的结构。里面带上玩家当前进度、当前状态、补偿方案编号消费端依据这些上下文决定发什么奖励。public class TaskOfflineCompensationMessage { private String batchId; private long playerId; private String taskNo; private int playerTaskStatus; private int progress; private String rewardTemplate; private String uniqueKey; }消费端处理逻辑要先把uniqueKey写入补偿日志再调用发邮件接口。如果补偿记录已经存在直接返回不重复发送。示例代码如下Transactional public void handle(TaskOfflineCompensationMessage message) { boolean inserted compensationLogRepository.tryInsert( message.getUniqueKey(), message.getPlayerId(), message.getTaskNo(), message.getBatchId()); if (!inserted) { return; } try { mailService.sendRewardMail( message.getPlayerId(), message.getRewardTemplate(), message.getUniqueKey()); compensationLogRepository.markSuccess(message.getUniqueKey()); } catch (Exception e) { compensationLogRepository.markFailed(message.getUniqueKey(), e.getMessage()); throw e; } }这里有一个细节不要在发送邮件成功前就把日志标记为成功。如果发送成功、但数据库更新失败下一次重试会走“已存在”逻辑直接返回这会导致玩家收到了邮件但日志状态不对。更稳妥的做法是把发送动作和日志写入放在同一个事务里或者依靠邮件流水中的外部单号做最终一致校验。5.3 不要物理删除活动数据保留归档与审计任务下线后配置数据会继续保留一段时间。常规做法是等宽限期结束、补偿发送完毕、数据报表完成归档后再把任务定义从运营配置区挪到历史归档区。归档不代表删除归档后仍然可以通过管理后台查询任务详情、参与人数、奖励流水。直接删除数据对研发的长期维护成本非常不划算。比如三个月后有人问“为什么当月产出的某个道具数量波动很大”需要回溯时发现历史任务配置已经被物理删除只能依赖备份或日志查询成本会高很多。保留归档虽然没有立刻产生收益但在审计、对账、经济系统复盘时会节省大量时间。6. 验证下线结果检查配置、缓存、日志和补偿链路任务下线是系统变更不是配置台上点一下“保存”就结束。每次下线必须有一套验证动作覆盖配置生效、玩家可见性、任务状态、补偿链路和运营报表五个层面。6.1 下线后要验证的四个层首先是配置层。确认task_define.status已经变成OFFLINE配置版本号已经递增。只看后台管理界面不够最好直接查库或查配置中心版本SELECT task_no, status, version, updated_at FROM task_define WHERE task_no mwdb_20240601;预期结果中status应该是下线状态对应的枚举值version要比发布前高。其次是缓存层。确认下线配置已经同步到本地缓存或 Redis。如果在灰度下线场景下还要验证不同玩家 ID 的可见性是否正确。第三是接口层。使用测试玩家 ID 拉起任务列表接口检查返回结果里是否还能看到“郿坞夺宝”。如果已经不可见说明服务端过滤逻辑生效如果仍然可见大概率是缓存未刷新或前端走了静态配置。第四是数据层。检查已经完成未领取的玩家是否收到补偿邮件检查补偿日志有没有重复记录。可以使用下面的 SQL 查看补偿进度SELECT batch_id, status, COUNT(*) AS cnt FROM task_compensation_log WHERE task_no mwdb_20240601 GROUP BY batch_id, status;如果大批量补偿记录一直停留在“处理中”要去看消息队列消费速度以及发邮件服务有没有报错。6.2 高频问题排查路径问题现象常见原因检查方式处理建议后台已下线玩家仍能接取任务本地缓存未刷新或任务查询没有走服务端过滤查看任务查询接口返回数据检查缓存版本号清理本地缓存把任务可见性改为服务端统一过滤同一个玩家偶尔能看到、偶尔看不到任务灰度判断使用了随机数或哈希不稳定检查下线规则是否按 playerId taskNo 稳定取模改用稳定哈希灰度时先跑一批固定玩家 ID 验证部分玩家的补偿邮件重复发送消费端未做幂等消息重试后重复发查补偿日志是否存在多个相同 uniqueKey给 unique_key 加唯一索引消费前先插入补偿日志已经下线但邮件里仍然有活动入口公告和任务入口共用一套静态配置检查公告有效期、Banner 配置公告配置增加过期时间入口展示前请求服务端校验下线后某个定时任务还在刷新玩家任务进度定时任务没有判断任务定义状态查任务引擎 Job 日志查看在线任务列表在任务结算前增加状态和下线时间判断以上每条都是生产环境里真实出现过的现象。任何一条没有被验证都不能说“任务已经下线完成”。尤其是补偿链路宁可多观察一轮也不要急于清理队列或删除日志。7.
返回列表