L0.参数校验:幂等键必填
@LiteflowComponent("validateCmp") class ValidateCmp extends NodeComponent { @Override public void process() { RechargeContext ctx = this.getContextBean(RechargeContext.class); if (ctx.getUserId() == null || ctx.getAmount() == null || ctx.getAmount().compareTo(BigDecimal.ZERO) <= 0) throw new IllegalArgumentException("userId/amount 非法"); if (ctx.getIdempotentKey() == null || ctx.getIdempotentKey().isBlank()) throw new IllegalArgumentException("idempotentKey 必填"); } }幂等键是用来唯一标识一次业务请求的令牌,保证同一个业务请求,重复提交多次,只会真正执行一次业务逻辑,结果和执行一次完全一样
如何判定“每次点击是同一个业务操作,只生成相同的幂等键”
这部分不由服务端判定点击,而是客户端/调用方的约定:
- 幂等键的生成时机:一次用户业务操作(一次充值意图)生成一个 key
- 复用时机:同一笔操作的重试/网络抖动/连点必须复用同一个 key;服务端靠 key 相等来识别“是同一个操作”。
- 新建时机:用户明确发起新的充值(金额/意图变化)才生成新 key。
前端通过“请求期间禁用按钮”避免并发重复提交,并在输入框里保留 key 供重试
服务端并不知道“点击”语义,它只看:
key 相同 → 返回已有订单
key 不同 → 当作新操作创建新订单
因此如果同一操作每次点击都生成新 key,服务端会创建多笔订单,幂等就失效。要保证“相同操作相同 key”,需要客户端把 key 绑定到操作实例(如页面状态/请求上下文),重试时复用,成功后再换新 key。
L1.前置查重
@LiteflowComponent("idempotencyCheckCmp") class IdempotencyCheckCmp extends NodeComponent { @Autowired RechargeOrderMapper orderMapper; @Override public void process() { //`ctx`:LiteFlow 链路全局上下文,整条链共享的数据容器 RechargeContext ctx = this.getContextBean(RechargeContext.class); //根据幂等键查询充值订单 RechargeOrder o = orderMapper .selectOne(new LambdaQueryWrapper<RechargeOrder>() .eq(RechargeOrder::getIdempotentKey, ctx.getIdempotentKey())); // 查到旧订单:把旧订单信息塞进上下文 if (o != null) { ctx.setOrder(o); ctx.setOrderNo(o.getOrderNo()); ctx.setBonusToken(o.getBonusToken()); // 核心标记:幂等命中 ctx.setIdempotentHit(true); } } }上下文存在哪里?
上下文(Context)不是 JVM 里某个固定区域的对象,要看是什么上下文:
- 方法调用上下文(调用栈、局部变量)→虚拟机栈(栈帧)
- 对象实例上下文(实例字段)→堆
- 类 / 静态上下文(static 变量、Class 元信息)→元空间(Metaspace,JDK8+)
- 框架里的业务上下文(Spring Context、RAG 上下文、链路 Trace 上下文)大多是堆上普通对象
可以先学学JVM的构成精讲-CSDN博客
// 命中幂等 @Override public boolean isAccess() { return !this.getContextBean(RechargeContext.class).isIdempotentHit(); } //未命中 @Override public boolean isEnd() { return this.getContextBean(RechargeContext.class).isIdempotentHit(); }若命中后-> 跳过后续全部节点,由Service直接返回已有订单
isEnd()返回 true 让 LiteFlow 直接中断整条链,RechargeService.sync随后重新查出订单返回给前端(idempotentHit=true)。这就是"重试拿旧结果、不重复发放"的主路径。
L2.DB唯一索引 + 冲突兜底
(唯一性裁判在数据库,不在应用层)
// 前置查询没找到,准备插入新订单 ... try { orderMapper.insert(o); } catch (DuplicateKeyException e) { //唯一索引冲突引起异常 // 【并发冲突兜底】 // 多个线程同时通过上面的select(都查到null),然后同时进入insert // 只有1个线程插入成功,其他线程插入会报唯一键冲突 DuplicateKeyException RechargeOrder back = orderMapper.selectOne( new LambdaQueryWrapper<RechargeOrder>() .eq(RechargeOrder::getIdempotentKey, ctx.getIdempotentKey()) ); ctx.setOrder(back); ctx.setOrderNo(back.getOrderNo()); ctx.setIdempotentHit(true); return; }两个线程同时插入时,
一个成功、
另一个撞
uk_idem_key→DuplicateKeyException→ 查回对方那单,idempotentHit=true
关键前提:
DuplicateKeyException是在 LiteFlow 组件内部被 catch 的,没有逃出
@Transactional方法边界,所以 Spring 不会把事务标记为 rollback-only;
MySQL 的唯一键冲突也不会像 PG 那样 abort 整个事务。
事务照常提交,设计才成立。(看不懂建议学学Spring 事务规则+MySQL vs PG 事务行为)
L3.发放侧唯一键兜底
即使前两层都被绕过,钱也只会加一次
裁判同样在唯一索引上:
@LiteflowComponent("grantTokenCmp") class GrantTokenCmp extends NodeComponent { @Autowired TokenAccountMapper accountMapper; @Autowired TokenLedgerMapper ledgerMapper; @Override public void process() { RechargeContext ctx = this.getContextBean(RechargeContext.class); long bonus = ctx.getBonusToken() == null ? 0L : ctx.getBonusToken(); // 1) 确保账户行存在 TokenAccount acc = accountMapper.selectById(ctx.getUserId()); if (acc == null) { acc = new TokenAccount(); acc.setUserId(ctx.getUserId()); acc.setBalance(0L); acc.setVersion(0); accountMapper.insert(acc); } // 2) 先写流水(唯一索引 UK(biz_id,change_type) 兜底): 重复则视为已发放,直接结束 TokenLedger l = new TokenLedger(); l.setUserId(ctx.getUserId()); l.setChangeType("RECHARGE_BONUS"); l.setBizId(ctx.getOrderNo()); l.setChangeAmount(bonus); l.setBalanceAfter(acc.getBalance() + bonus); l.setCreateTime(LocalDateTime.now()); try { ledgerMapper.insert(l); } catch (DuplicateKeyException e) { ctx.setConsumeDone(true); return; } // 3) 账户加钱(SQL 原子自增,绝不重复加) accountMapper.addBalance(ctx.getUserId(), bonus); } }顺序很讲究:先写流水(幂等凭证),再改余额。 流水插不进去就说明这单已经发过款,直接短路,不再加钱。
L4.状态机CAS
订单状态机0(PENDING) → 1(PAID) → 2(COMPLETED),每次推进都带"期望旧状态"作为WHERE条件,天然幂等推进。
// 模拟支付成功: PENDING(0) -> PAID(1), CAS 保证只推进一次 orderMapper.update(null, new LambdaUpdateWrapper<RechargeOrder>() .set(RechargeOrder::getStatus, 1) .eq(RechargeOrder::getOrderNo, ctx.getOrderNo()) .eq(RechargeOrder::getStatus, 0));//充值完成 orderMapper.update(null, new LambdaUpdateWrapper<RechargeOrder>() .set(RechargeOrder::getStatus, 2) .set(RechargeOrder::getBonusToken, ctx.getBonusToken()) .eq(RechargeOrder::getOrderNo, ctx.getOrderNo()) .in(RechargeOrder::getStatus, 0, 1));以上代码SQL版本:
UPDATE recharge_order SET status = 1 WHERE order_no = #{ctx.orderNo} AND status = 0; UPDATE recharge_order SET status = 2, bonus_token = #{ctx.bonusToken} WHERE order_no = #{ctx.orderNo} AND status IN (0,1);L5.事务边界
下单→支付→发放→完成全在一个本地事务里,所以方案 A (即没有使用MQ的方案)是强一致,且不存在"消息重复消费"这类问题——幂等只需要防"外部重复请求"和"并发重复请求",上面四层已经覆盖。
@Transactional(rollbackFor = Exception.class) public Map<String,Object> sync(Long userId, BigDecimal amount, String key) { //构建充值上下文,存放本次充值所有入参、中间结果 RechargeContext ctx = new RechargeContext(); ctx.setUserId(userId); ctx.setAmount(amount); ctx.setIdempotentKey(key); ctx.setChannel(0); //执行非异步方案(调用LiteFlow) LiteflowResponse resp = flowExecutor.execute2Resp("syncChain", null, ctx); //失败,事务回滚(LiteFlow) if (!resp.isSuccess()) throw new RuntimeException(resp.getMessage()); //根据幂等键查询充值订单 RechargeOrder o = orderMapper .selectOne(new LambdaQueryWrapper<RechargeOrder>() .eq(RechargeOrder::getIdempotentKey, key)); //返回订单信息 + 是否命中 return Map.of( "orderNo", o.getOrderNo(), "status", o.getStatus(), "bonusToken", String.valueOf(o.getBonusToken()), "idempotentHit", ctx.isIdempotentHit()); }全链路调用完整过一遍
POST /api/recharge/sync { userId, amount, idempotentKey } │ ├─ RechargeController.sync() │ ↓ ├─ RechargeService.sync() ← 【事务在这里开始】@Transactional │ │ 构造 RechargeContext │ ↓ │ LiteFlow 执行引擎按 flow.el.xml 驱动 │ ↓ │ ┌─────────────────────────────────────────────────────────────┐ │ │ syncChain (flow.el.xml) │ │ │ THEN( │ │ │ validateCmp, ① 校验参数 │ │ │ idempotencyCheckCmp, ② 前置查重 ← 命中就短路整条链 │ │ │ SWITCH(bonusTierSwitchCmp).TO(tierLow, tierMid, │ │ │ tierHigh, tierSuper), ③ 算赠送额度 │ │ │ createOrderCmp, ④ 建单 (INSERT, 唯一键兜底) │ │ │ mockPayCmp, ⑤ 模拟支付成功 (CAS) │ │ │ grantTokenCmp, ⑥ 发放 token (先写流水, 再加余额) │ │ │ completeOrderCmp ⑦ 订单置完成 (CAS) │ │ │ ) │ │ └─────────────────────────────────────────────────────────────┘ │ ↓ │ Service 再查一次订单, 返回 { orderNo, status, bonusToken, idempotentHit } │ ↓ │ 【事务在这里提交】 │ ↓ └─ 返回 JSON 给前端