news 2026/10/28 1:51:55

充值送token任务非异步设计下如何实现幂等性

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
充值送token任务非异步设计下如何实现幂等性

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 必填"); } }

幂等键是用来唯一标识一次业务请求的令牌,保证同一个业务请求,重复提交多次,只会真正执行一次业务逻辑,结果和执行一次完全一样

如何判定“每次点击是同一个业务操作,只生成相同的幂等键”

这部分不由服务端判定点击,而是客户端/调用方的约定:

  1. 幂等键的生成时机:一次用户业务操作(一次充值意图)生成一个 key
  2. 复用时机:同一笔操作的重试/网络抖动/连点必须复用同一个 key;服务端靠 key 相等来识别“是同一个操作”。
  3. 新建时机:用户明确发起新的充值(金额/意图变化)才生成新 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 里某个固定区域的对象,要看是什么上下文:

  1. 方法调用上下文(调用栈、局部变量)→虚拟机栈(栈帧)
  2. 对象实例上下文(实例字段)→堆
  3. 类 / 静态上下文(static 变量、Class 元信息)→元空间(Metaspace,JDK8+)
  4. 框架里的业务上下文(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 给前端
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/28 1:51:11

纸箱打样一般有哪几种方式?为什么有的打样免费有的要收刀模费?

纸箱打样一般有哪几种方式&#xff1f;为什么有的打样免费有的要收刀模费&#xff1f;一、纸箱打样的常见方式纸箱打样是批量生产前确认尺寸、结构、印刷效果的关键环节&#xff0c;不同打样方式的精度、成本、周期各不相同&#xff0c;对应不同的确认需求。纸箱打样方式对比二…

作者头像 李华
网站建设 2026/10/28 1:50:27

RFID系统由哪些部分组成?

RFID系统由以下三个部分组成&#xff1a; ● 电子标签&#xff1a;附着在物品上&#xff0c;由芯片和天线组成&#xff0c;存储唯一标识或业务数据 。 ● 读写器&#xff1a;发射射频信号并接收标签返回的信息&#xff0c;分为固定式和手持式 。 ● 后台系统&#xff1a;接收读…

作者头像 李华
网站建设 2026/10/28 1:50:23

命令安全设置

本页位于 设置 -> 主机Agent -> 对话与主机 -> 命令安全&#xff0c;用于控制结构化命令写入终端前的检查。 命令安全适用于命令行、全局执行、快捷命令和 Agent 执行。用户在终端窗格内直接键盘输入&#xff0c;或通过右键、中键、上下文菜单和 CtrlShiftV 粘贴时&am…

作者头像 李华
网站建设 2026/10/28 1:48:10

WeChatMsg 依赖更新完整指南:3 步安全升级微信聊天记录分析工具

WeChatMsg 依赖更新完整指南&#xff1a;3 步安全升级微信聊天记录分析工具 【免费下载链接】WeChatMsg 提取微信聊天记录&#xff0c;将其导出成HTML、Word、CSV文档永久保存&#xff0c;对聊天记录进行分析生成年度聊天报告 项目地址: https://gitcode.com/GitHub_Trending…

作者头像 李华
网站建设 2026/10/28 1:47:02

9 大网盘直链下载助手:从安装到批量出链

9 大网盘直链下载助手&#xff1a;从安装到批量出链 【免费下载链接】Online-disk-direct-link-download-assistant 一个基于 JavaScript 的网盘文件下载地址获取工具。基于【网盘直链下载助手】修改 &#xff0c;支持 百度网盘 / 阿里云盘 / 中国移动云盘 / 天翼云盘 / 迅雷云…

作者头像 李华