装备合成系统技术拆解:事务、一致性、幂等与并发处理
在不少网页游戏项目的开发群里装备合成是一个讨论度很高、但容易被做“翻车”的功能。很多开发者的第一反应是这不就是一个随机数加一次数据库更新吗真正接到需求、看到“Divinity Edge”这种玩家盯着不放的终极武器之后才会发现问题一点都不简单——材料校验、概率计算、并发扣减、失败补偿、日志追溯任何一个环节漏掉都会变成线上事故。本文以 swordmasters.io 这类网页游戏项目中的“Divinity Edge”合成为实际场景讲解装备合成系统的完整设计与落地思路。你可以把这套方案直接套到自己项目的锻造、炼金、合成模块里。读完你会得到一个可以照着实现的架构方案数据库怎么建表、后端接口怎么设计、前端合成面板怎么对接、线上并发和扣材料的一致性怎么处理。先给出一个明确判断装备合成系统真正难的不是“概率”而是数据一致性。做好这个模块需要的是事务边界、幂等设计、配置化和日志埋点这几件事比随机数算法本身重要得多。如果你正准备在项目里加入合成功能或者想重构现有那套“能跑但不敢动”的合成代码这篇文章正好合适。1. 这篇文章真正要解决的问题为什么单独聊装备合成因为它的业务特征非常特殊一次操作涉及多张表的变更、一次用户点击可能产生一次掉落或一次消耗、失败时还要考虑保底和补偿而玩家对合成失败的容忍度极低。任何一个表结构设计不合理都可能导致玩家材料凭空消失或者同一份材料被重复使用。这个功能在业务上的位置也很典型。它既不像登录注册那样有现成框架也不像商城购买那样刷一下就能完成。它需要一套独立的规则引擎至少包含以下能力定义“用什么材料 多少数量 多少钱”可以合成什么装备合成有成功和失败两种结果失败后材料消耗方式可配置支持保底次数合成失败 N 次之后必定成功每次合成都要有完整记录方便客服查单和后台审计同一个玩家同一时间不能发起多笔合成防止材料被超扣。如果你只盯着“点击合成按钮 → 判断成功率 → 发装备”那实现出来的一定是玩具代码。真正生产级的设计必须是前端发起请求后端在一个事务里完成“查配方、校验背包、扣材料、算结果、发奖励、记日志”每一步失败都不能留下中间状态。还有一类问题容易被忽略玩家在合成面板点击多次、网络重试、页面刷新后再次提交这些情况怎么保证不会重复扣材料、重复发装备这不是测试人员想出来的边界情况而是线上玩家一定会遇到的操作。后面我会专门展开幂等处理的写法。2. 装备合成系统的核心概念进入代码之前先把概念对齐。不同项目叫法可能不同但底层逻辑基本一致。2.1 合成配方合成配方描述的是“哪些材料、各多少份、合成出什么”。用数据库表来表达配方是一个主表加一个从表的结构。主表存目标装备、成功率、保底次数从表存这个配方需要哪些材料每个材料需要多少数量。2.2 材料与背包材料是玩家的财产存放在背包表里。合成时系统需要在事务里锁定背包记录并扣减数量。注意这里的“锁定”指的是数据库行锁或者分布式锁不是 Java 代码里的 synchronized。玩家可能从多个设备同时操作必须在数据层保证并发安全。2.3 成功率与保底成功率通常是一个 0 到 1 之间的浮点数。保底机制用于保证玩家的“最坏预期”比如连续失败 5 次第 6 次必定成功。实现时可以在合成记录表里统计该玩家对某个配方的连续失败次数达到阈值后直接把本次成功率设为 1。2.4 合成记录合成记录是整个系统的账本。谁、在什么时间、用哪个配方、扣了哪些材料、成功还是失败、拿到了什么装备都要完整落库。线上客服处理玩家投诉时第一件事就是查合成记录。2.5 与强化、镶嵌的区别合成是“材料换新装备”强化是在现有装备上增加属性镶嵌是在装备槽上放入宝石。三者的区别决定了实现方式不同合成会删除或消耗材料并产生一个新物品强化和镶嵌则是修改一个已有物品的属性。很多项目把这三类逻辑混在一个接口里导致代码越来越难维护。实际开发中更推荐把合成作为一个独立模块后面的代码也按照这个思路来做。3. 环境准备与技术选型本文的示例基于 Java 技术栈采用 Spring Boot MySQL MyBatis 这类常见组合。如果你用的是 Go、Node.js 或其他技术栈也可以参考同样的表结构和业务分层核心思路是通用的。3.1 后端环境开发环境并不复杂只要满足以下条件即可版本不必完全一致JDK 8 或更高版本Spring Boot 2.x 或 3.x具体以项目实际为准MySQL 5.7 或 8.x如果使用其他数据库注意把 SQL 方言做对应调整一个标准 Maven 工程引入 spring-boot-starter-web、spring-boot-starter-data-jdbc 或 MyBatis 相关依赖。需要提醒的是实际项目的 Spring Boot、JDK 版本由团队统一管理。本文的示例代码是工程的核心业务写法不是完整可部署项目重点是展示设计思路。如果你想快速跑通直接看第 7 章的纯 Java 验证类更省事不依赖任何框架。3.2 前端环境前端示例我使用 Vue 3 的组合式 API 来写。实际项目中合成面板大概率会做成一个弹窗组件里面展示配方列表、材料数量和合成按钮。如果你的项目是 React 或者其他框架逻辑一样只是语法不同。3.3 中间件说明Redis 在这种场景里是可选项。如果项目刚起步、用户量不大可以用数据库行锁解决并发问题如果玩家量大、合成接口频繁推荐引入 Redis 做分布式锁或者用 Lua 脚本做原子扣减。第 8 章我会讲这两种方式的取舍这里先不展开。4. 数据库表结构设计表结构是合成系统的地基。地基要是歪了后面写多少代码都救不回来。下面这张图就是完整的数据模型包含六张核心表装备表存放所有可合成或可获得的装备基础信息材料表存放所有材料基础信息配方表存放合成配方主信息配方材料表存放每个配方需要的材料清单背包表存放玩家拥有的材料数量合成记录表存放每一次合成操作的完整日志。4.1 装备表与材料表这两张表的结构类似核心字段是 ID、名称和图标地址。实际项目可能还会加品质、属性、描述等字段但为了聚焦合成逻辑示例只保留最核心的字段。装备表和材料表也可以合并成一张物品表再用类型字段区分。我更建议分开因为后续扩展强化、镶嵌时装备和材料上的字段差异会越来越大拆开更清晰。CREATE TABLE equipment ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL, icon_url VARCHAR(255) DEFAULT , quality TINYINT NOT NULL DEFAULT 1, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) COMMENT 装备表; CREATE TABLE material ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL, icon_url VARCHAR(255) DEFAULT , stack_limit INT NOT NULL DEFAULT 999, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ) COMMENT 材料表;4.2 配方表与配方材料表配方主表记录目标装备、基础成功率、保底次数。成功率用 DECIMAL 存储避免浮点误差。保底次数为 0 表示不启用保底。配方材料表是配方主表的子表一个配方对应多行材料。这里故意不用 JSON 字段存储材料列表是为了让后台可以按材料维度查询、统计也方便后续做“一键合成”的批量校验。CREATE TABLE recipe ( id BIGINT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL, target_equipment_id BIGINT NOT NULL, base_rate DECIMAL(5, 4) NOT NULL DEFAULT 0.5000, pity_count INT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_target_equipment (target_equipment_id) ) COMMENT 合成配方表; CREATE TABLE recipe_material ( id BIGINT PRIMARY KEY AUTO_INCREMENT, recipe_id BIGINT NOT NULL, material_id BIGINT NOT NULL, quantity INT NOT NULL DEFAULT 1, KEY idx_recipe_id (recipe_id) ) COMMENT 配方材料关系表;4.3 背包表背包表的设计要重点说。这里维护的是玩家和物品的数量关系玩家只会操作自己拥有的物品所以一定加唯一索引。唯一索引同时是并发控制的关键后面会细讲。CREATE TABLE user_bag ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id BIGINT NOT NULL, item_type TINYINT NOT NULL COMMENT 1装备 2材料, item_id BIGINT NOT NULL, quantity INT NOT NULL DEFAULT 0, version INT NOT NULL DEFAULT 0, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_user_item (user_id, item_type, item_id) ) COMMENT 玩家背包表;这里加了一个version字段是乐观锁的标准做法。合成扣材料时通过UPDATE ... WHERE version ?来做条件更新影响行数为 0 就说明背包被其他人改过需要重试或者直接失败。这个字段在并发场景下是第二道防线。4.4 合成记录表合成记录表要尽量设计得“大而全”宁可多存冗余字段也不要事后查不到数据。除了玩家 ID、配方 ID、成功标识还应该把当时扣了哪些材料、最终产出什么装备都记录下来。为了方便客服查单通常会冗余一份完整的请求参数 JSON。CREATE TABLE combine_log ( id BIGINT PRIMARY KEY AUTO_INCREMENT, request_id VARCHAR(64) NOT NULL, user_id BIGINT NOT NULL, recipe_id BIGINT NOT NULL, success TINYINT NOT NULL DEFAULT 0, cost_detail JSON NULL COMMENT 扣材料明细, reward_detail JSON NULL COMMENT 产出明细, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_user_time (user_id, created_at), UNIQUE KEY uk_request_id (request_id) ) COMMENT 合成记录表;这里的request_id是幂等键。前端每次发起合成请求时生成一个全局唯一的请求 ID后端在事务里先尝试插入合成记录如果唯一键冲突说明这个请求已经处理过了直接返回上一次的结果。这是“重复点击不重复扣材料”的关键设计。5. 装备合成核心接口设计数据库建好之后核心就是 Service 层。先看合成接口的整体流程再逐段解释代码。5.1 合成接口的整体流程前端点击“合成”之后后端需要按顺序处理以下步骤拿到请求里的recipeId和requestId先做幂等检查查询配方主表和配方材料表确认配方存在且启用检查玩家背包中的材料数量是否足够按配方扣减材料计算本次合成是否成功成功则给玩家背包添加装备失败则按配置决定是否增加保底计数写入合成记录表标记整个事务成功。上面的每一步都必须在一个数据库事务里完成。也就是说扣材料成功、发装备失败这种状态绝对不能出现。下面给出ForgeService的核心代码。这里用 Mapper 接口抽象数据库操作你可以根据项目使用的 ORM 框架做对应实现。重点看事务和判断逻辑不要纠结具体的方法调用。Service public class ForgeService { private final RecipeMapper recipeMapper; private final UserBagMapper userBagMapper; private final CombineLogMapper combineLogMapper; public ForgeService(RecipeMapper recipeMapper, UserBagMapper userBagMapper, CombineLogMapper combineLogMapper) { this.recipeMapper recipeMapper; this.userBagMapper userBagMapper; this.combineLogMapper combineLogMapper; } Transactional(rollbackFor Exception.class) public CombineResult synthesize(CombineRequest request) { // 1. 幂等检查如果这条 requestId 已经处理过直接返回历史结果 CombineLog existing combineLogMapper.findByRequestId(request.getRequestId()); if (existing ! null) { return CombineResult.fromLog(existing); } // 2. 查询配方 Recipe recipe recipeMapper.findById(request.getRecipeId()); if (recipe null) { throw new BizException(配方不存在); } // 3. 查询配方材料并要求行锁防止并发修改 ListRecipeMaterial recipeMaterials recipeMaterialMapper.findByRecipeId(recipe.getId()); for (RecipeMaterial rm : recipeMaterials) { UserBag bag userBagMapper.findForUpdate(request.getUserId(), 2, rm.getMaterialId()); if (bag null || bag.getQuantity() rm.getQuantity()) { throw new BizException(材料不足 materialName(rm.getMaterialId())); } } // 4. 扣材料 for (RecipeMaterial rm : recipeMaterials) { int rows userBagMapper.deduct(request.getUserId(), 2, rm.getMaterialId(), rm.getQuantity()); if (rows 0) { throw new BizException(材料扣减失败请重试); } } // 5. 计算成功率带保底逻辑 int failStreak combineLogMapper.countFailStreak(request.getUserId(), recipe.getId()); boolean success calcSuccess(recipe.getBaseRate(), failStreak, recipe.getPityCount()); boolean pityTriggered failStreak 1 recipe.getPityCount() recipe.getPityCount() 0; Long rewardEquipmentId null; if (success) { // 6. 成功发放装备 rewardEquipmentId recipe.getTargetEquipmentId(); userBagMapper.increase(request.getUserId(), 1, rewardEquipmentId, 1); // 成功之后清空保底计数 combineLogMapper.clearFailStreak(request.getUserId(), recipe.getId()); } else { // 失败保底计数由查询结果里的 failStreak 推导下一次会自然加一 } // 7. 写日志 CombineLog log new CombineLog(); log.setRequestId(request.getRequestId()); log.setUserId(request.getUserId()); log.setRecipeId(recipe.getId()); log.setSuccess(success); log.setCostDetail(buildCostDetail(recipeMaterials)); log.setRewardDetail(rewardEquipmentId null ? null : {\equipmentId\: rewardEquipmentId }); combineLogMapper.insert(log); return CombineResult.of(success, rewardEquipmentId); } }这段代码里有几个关键点需要讲透。第一个关键点是幂等。第 1 步用requestId查已有记录但这一步只是快速路径真正的幂等保障来自数据库唯一键uk_request_id。即使有两个请求同时进来、同时通过第 1 步检查数据库也只会允许一个插入成功另一个会抛唯一键冲突事务回滚后由全局异常处理器统一转为“已处理过”的提示。第二个关键点是扣材料的两种并发控制。第 3 步使用SELECT ... FOR UPDATE对玩家背包行加锁这能保证同一时间只有一个线程可以修改这行背包数据。第 4 步的deduct方法除了扣数量还会带quantity 扣减数的条件形成双重校验。这两层措施配合在一起能覆盖绝大多数并发场景。第三个关键点是保底计数。这里用一个countFailStreak方法查询失败连击次数再在后续插入日志时把结果通过clearFailStreak或自然累加完成状态变更。实际项目里可以简化成把失败次数冗余在combine_user_stat表里每次合成时更新。核心思路是保底不一定要在合成时实时统计也可以用玩家维度维护一个连续失败字段最终效果一致。5.2 成功率计算函数成功率计算是合成系统里最容易被“随手写错”的部分。我把这个逻辑单独抽出来方便你做单元测试。private boolean calcSuccess(BigDecimal baseRate, int failStreak, int pityCount) { if (pityCount 0 failStreak 1 pityCount) { return true; } double rate baseRate.doubleValue(); return ThreadLocalRandom.current().nextDouble() rate; }这个函数先判断保底再走随机数。注意 Random 的用法不要在循环里不断new Random()而是用ThreadLocalRandom.current()。生产环境中随机数种子、线程安全都需要考虑虽然这是细节但线上出问题时往往就是这些细节。6. 前端合成面板交互实现后端接口就绪之后前端负责把配方展示清楚把用户的合成意愿翻译成一次安全的请求。前端代码不算特别复杂但有三个点要处理好合成按钮点击后要立即进入“合成中”状态防止用户连续点击请求体里要带上每次请求唯一的requestId失败后展示原因但不要直接关闭弹窗方便用户调整材料后再次合成。下面是一个 Vue 3 组合式 API 的合成面板示例。实际项目中样式可以自由调整关键逻辑在这段代码里已经包含。template div classforge-panel h3锻造合成/h3 div v-forrecipe in recipes :keyrecipe.id classforge-card div classrecipe-name{{ recipe.name }}/div div classrecipe-materials span v-form in recipe.materials :keym.materialId {{ m.materialName }} × {{ m.quantity }} /span /div div classrecipe-rate 成功率{{ (recipe.baseRate * 100).toFixed(1) }}% span v-ifrecipe.pityCount 0 保底 {{ recipe.pityCount }} 次 /span /div button :disabledcombining || !canAfford(recipe) clickhandleCombine(recipe) {{ combining ? 合成中... : 开始合成 }} /button /div /div /template script setup import { ref, onMounted } from vue; const recipes ref([]); const combining ref(false); const userBag ref({}); function loadRecipes() { // 读取 /api/recipe/list接口返回配方信息 } function canAfford(recipe) { return recipe.materials.every((m) (userBag.value[m.materialId] || 0) m.quantity); } function generateRequestId() { const prefix Date.now().toString(36); const suffix Math.random().toString(36).slice(2, 10); return combine_${prefix}_${suffix}; } async function handleCombine(recipe) { if (combining.value) return; combining.value true; try { const resp await fetch(/api/forge/combine, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ recipeId: recipe.id, requestId: generateRequestId(), }), }); const data await resp.json(); if (data.code 0) { alert(data.success ? 合成成功 : 合成失败别灰心); await loadRecipes(); } else { alert(data.message); } } finally { combining.value false; } } onMounted(loadRecipes); /script前端这里有一个很容易犯的错generateRequestId里的随机数只在点击瞬间生成一次如果用户网络卡顿、按钮没变成禁用状态第二次点击会生成一个不同的requestId这就绕过了幂等。所以真正的防重点不只是按钮禁用后端幂等才是兜底。按钮禁用只是改善体验不能作为唯一防线。7. 运行结果与效果验证为了让你能快速验证这套逻辑我提供一个不依赖 Spring 容器的纯 Java 演示类。它用 Map 模拟内存背包和数据库记录跑一次完整的“Divinity Edge”合成流程。你可以直接把这个类复制到 IDE 里运行观察输出结果。import java.math.BigDecimal; import java.util.HashMap; import java.util.Map; import java.util.concurrent.ThreadLocalRandom; public class ForgeDemo { static final long USER_ID 1001L; static final long DIVINITY_EDGE_ID 50001L; static final long SOUL_STONE_ID 20001L; static final long DRAGON_CRYSTAL_ID 20002L; public static void main(String[] args) { ForgeDemo demo new ForgeDemo(); // 玩家背包拥有 10 个灵魂石、5 个龙晶 demo.bag.put(SOUL_STONE_ID, 10); demo.bag.put(DRAGON_CRYSTAL_ID, 5); // 配方Divinity Edge 需要 5 个灵魂石 2 个龙晶基础成功率 30%保底 4 次 demo.print(初始材料 demo.bag); boolean success false; for (int i 1; i 5; i) { boolean result demo.combine(); System.out.println(第 i 次合成结果 (result ? 成功 : 失败) 剩余材料 demo.bag); if (result) { success true; break; } } demo.print(最终结果 (success ? 获得 Divinity Edge : 材料不足未获得)); } MapLong, Integer bag new HashMap(); MapString, Integer failStreak new HashMap(); BigDecimal baseRate new BigDecimal(0.3000); int pityCount 4; boolean combine() { // 材料校验 if (bag.getOrDefault(SOUL_STONE_ID, 0) 5) { return false; } if (bag.getOrDefault(DRAGON_CRYSTAL_ID, 0) 2) { return false; } // 扣材料 bag.put(SOUL_STONE_ID, bag.get(SOUL_STONE_ID) - 5); bag.put(DRAGON_CRYSTAL_ID, bag.get(DRAGON_CRYSTAL_ID) - 2); // 概率计算 int streak failStreak.getOrDefault(USER_ID, 0); boolean success (streak 1 pityCount) || ThreadLocalRandom.current().nextDouble() baseRate.doubleValue(); // 更新保底计数 failStreak.put(USER_ID, success ? 0 : streak 1); return success; } void print(String message) { System.out.println(message); } }运行这个类你会看到连续合成的日志。由于运气因素成功可能出现在第 1 次也可能出现在第 4 次保底。可以把pityCount改成 2 再跑一次会明显看到“连续失败 N 次后必定成功”的保底效果。在真实项目中验证方式要分三部分缺一不可接口联调验证用 Postman 或 curl 调用合成接口连续提交同一个requestId观察第二次请求返回的结果是否与第一次一致且背包材料只扣一次。数据库验证执行完成后查询combine_log表检查请求 ID 是否有重复记录查询user_bag确认材料数量和装备数量符合预期。并发验证用压测工具或写一段并发测试代码让同一个玩家的同一材料并发提交合成请求确认不会出现超扣或超发。8. 常见问题与排查方法合成系统上线后大部分问题集中在数据不一致和概率异常这两类。下面列出几个高频问题以及对应的排查路径。问题现象可能原因排查方式解决方案玩家材料被扣了但没收到装备事务边界错误发装备的步骤在事务外执行或者事务未覆盖完整查看combine_log确认该请求是否标记成功对比开始和结束时的背包流水把整个合成流程放到同一个Transactional方法中确保扣材料和发奖励在同一事务玩家重复点击材料被扣多次前端按钮未禁用只是体验问题后端缺少幂等控制检查combine_log中是否存在多个相同requestId的记录给combine_log.request_id加唯一索引重复插入时返回历史结果并发请求下材料数量变成负数扣材料 SQL 没有带数量条件或者缺少行锁查看数据库日志中的 SQL检查 UPDATE 语句是否带quantity 扣减数条件使用UPDATE ... SET quantity quantity - 扣减数 WHERE user_id ? AND item_id ? AND quantity 扣减数合成成功概率明显偏低随机数用法错误或者保底计数在失败后没有累加临时打印每次合成的随机数种子检查失败连击计数是否正常递增统一使用ThreadLocalRandom写单元测试覆盖保底阈值后台调整成功率不生效配方表字段被硬编码在代码里没有从数据库读取检查 Service 代码中baseRate的来源将成功率、保底次数全部配置化禁止硬编码服务器返回 500但玩家实际已收到装备事务回滚时未正确补偿或者请求超时后前端重试查看异常日志比对combine_log与背包表引入全局异常处理确保事务方法内任何异常都回滚并在日志中记录完整请求参数这里的排查核心是“先看账本”。combine_log就是整个合成系统的账本任何数据不一致问题先查账本再查代码。如果账本里没有记录但材料扣了说明事务边界有问题如果账本里有记录但背包没变化说明发送奖励逻辑有缺陷。9. 最佳实践与工程建议模块能跑起来只是第一步。放进生产环境时下面这些工程细节会让你的系统更稳。9.1 概率配置化禁止硬编码成功率、保底次数、材料数量都应该放在数据库或配置中心。运营调整活动时改配置就能生效不用发版。硬编码的结局通常是上线第二天运营要求改概率开发被迫紧急发版线上还容易改错。9.2 合成记录要全链路埋点除了数据库落库还建议把合成请求的关键事件发送到消息队列或日志平台。格式可以是 JSON包含玩家 ID、时间戳、配方 ID、结果、耗时。这部分数据既用于监控告警也用于后续分析玩家合成行为的漏斗。不是所有项目都需要大数据分析但至少要在日志里保留足够的信息否则线上问题回溯时无从下手。9.3 锁粒度要控制好行锁比表锁好分布式锁比行锁更复杂。当玩家量不大时直接用SELECT ... FOR UPDATE加条件更新就能解决绝大多数并发问题。当系统演进到需要 Redis 分布式锁时要重点考虑锁的失效时间和重入问题。这里最怕的不是锁冲突而是“以为锁住了实际没锁住”。9.4 合成结果由服务端判定任何合成结果都不能由前端传入包括成功率。前端只负责展示和发请求后端根据数据库配置计算。这是最基本的安全边界否则玩家改一下请求参数就能 100% 合出 Divinity Edge。9.5 合规与未成年人保护合成系统如果包含随机玩法需要注意合法合规运营避免被认定为诱导性抽奖。常见的做法是明确公示概率、设置保底次数、控制自动连续合成的频率、对低龄玩家设置消费限制。合成本身是普通游戏功能但涉及随机奖励时运营侧必须做好规则公示。9.6 做好失败补偿设计失败不能只给一句“合成失败”。优秀的合成系统会让失败也有反馈比如返还一部分材料碎片、增加保底次数、或者提供失败道具用于下一次合成加成。这个设计既影响玩家体验也影响运营节奏最好一开始就留好扩展字段。10. 总结与后续学习方向从数据库建模到后端事务处理再到前端交互和线上排查本文用“Divinity Edge”这个具体目标把装备合成系统的完整实现拆了一遍。核心结论可以归纳成三条合成系统的难点不在随机概率而在多表变更的数据一致性必须用事务、幂等键和条件更新来兜底合成记录是系统的账本表结构设计时要考虑全链路追溯宁可多存冗余也不要在出问题时查不到数据配置化、日志埋点、并发控制这些工程能力决定了这套系统能不能长期稳定运行。如果你想继续深入建议从三个方向入手第一研究 Redis Lua 脚本如何实现原子性的背包扣减对比数据库行锁在高并发下的表现第二把合成模块抽象成规则引擎让运营配置更灵活第三为合成系统补充完整的监控大盘成功量、失败量、材料消耗量、接口耗时都应该有实时指标。最后提醒一句如果你准备在公司项目里落地这套方案先把第 4 章的表结构和第 5 章的事务边界给团队讲一遍。这两个地方想清楚了代码层面的实现只是时间问题。