ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java实战:订单状态机与支付幂等处理,破解充电宝计费难点

Java实战:订单状态机与支付幂等处理,破解充电宝计费难点 把一套充电宝后台项目跟到第3部分我才真正感受到Java实战项目和练手Demo之间的巨大差别。前面几章还在搭建框架、写CRUD到这一部分突然就进入了共享充电宝业务最敏感的环节订单、计费和支付。用户扫码借出充电宝到归还时该扣多少钱支付平台的异步回调和订单状态怎么保证不乱——这些问题每一个都牵扯到真金白银出错了就不是改个Bug那么简单。今天这篇笔记就是第3部分的完整复盘我会把订单状态机设计、计费引擎、支付幂等处理、超时关单这几个核心模块的代码思路和踩坑经历全部摊开来讲。适合已经学完Spring Boot基础、想通过一个完整项目把微服务、Redis、消息队列串起来的小伙伴。1. 为什么第3部分先啃计费与支付这块硬骨头1.1 充电宝项目的核心业务闭环到底是什么学习这个项目之前我以为共享充电宝最复杂的是扫码借出的硬件交互真正把代码写完才发现硬件端只负责开锁、闭锁平台上最核心的是一笔租借订单从生到死的完整业务闭环用户扫码 → 下单 → 支付押金/费用 → 充电桩弹出一个充电宝 → 用户归还 → 按使用时长计费 → 扣款 → 结算给商家。这个闭环里每一步都在改订单状态每一步都可能被用户、定时任务、支付回调并发触发。第1部分和第2部分通常还在搞注册登录、商家管理、充电宝点位管理这些模块做好了运营才能看到有多少宝、在哪个点位。但真正让系统跑起来赚钱的是从订单和计费开始的。所以第3部分一进入订单服务我就明显感觉到代码量和复杂度同时上来了。1.2 第3部分新增的技术栈和难点这一阶段项目里出现了几个之前没怎么见过的组合Spring Cloud Alibaba 的 Nacos 负责服务注册和配置中心RabbitMQ 负责异步解耦和延迟消息Redis 配合 Redisson 做分布式锁MySQL 里开始出现带唯一索引和状态机字段的业务表。我不是第一次听说这些组件但在这个项目里它们全部围绕一个业务目标服务保证一笔订单在并发、重复回调、服务重启的情况下最终也只会被正确结算一次。这个目标听起来简单实际落地时牵扯到的边界条件非常多也是我写这篇笔记最想讲清楚的部分。2. 订单状态机从扫码借出到归还结算的全过程建模2.1 为什么订单状态不能靠散落的 if-else刚开始写订单模块我本能地想在每次操作里加点状态判断支付回调里写如果是待支付就改成已支付归还时写如果是使用中改成待结算。写着写着发现不对劲订单状态一旦多起来每个操作都要判断当前状态允不允许被改到新状态同样的判断散落在好几个接口里。如果哪天下线一个功能漏改一处线上就会出状态被非法覆盖的事故。所以这个项目里采用了状态机建模的方式把所有允许的状态迁移集中定义。我用枚举把订单状态和它们之间的流转关系固定下来后面所有接口都不再各自判断而是走同一个状态迁移入口。public enum OrderStatus { CREATED(0, 待支付), PAID(1, 已支付待借出), IN_USE(2, 使用中), SETTLING(3, 归还待结算), SETTLED(4, 已结算), CLOSED(5, 已关闭); private final Integer code; private final String desc; public static boolean canTransfer(Integer from, Integer to) { // 状态机允许的迁移关系集中在这里 return (from 0 to 1) || (from 0 to 5) || (from 1 to 2) || (from 1 to 5) || (from 2 to 3) || (from 3 to 4); } }核心迁移关系我整理成了下面这张表写代码的时候我基本是照着它来核对每个接口的当前状态允许迁移到触发动作CREATED 待支付PAID 已支付支付成功回调CREATED 待支付CLOSED 已关闭超时未支付自动关单PAID 已支付IN_USE 使用中用户扫码借出成功PAID 已支付CLOSED 已关闭用户主动取消或退款IN_USE 使用中SETTLING 待结算用户归还充电宝SETTLING 待结算SETTLED 已结算结算扣款成功2.2 订单表设计的关键字段有了状态机订单表的设计也随之清晰。我用了业务订单号 状态 金额 时间 版本号这套基础结构其中几个字段是实战中反复踩坑才意识到必须加的。CREATE TABLE t_order ( id bigint(20) NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 业务订单号, user_id bigint(20) NOT NULL, merchant_id bigint(20) DEFAULT NULL, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2使用中 3待结算 4已结算 5已关闭, total_amount int(11) NOT NULL DEFAULT 0 COMMENT 总金额单位分, paid_amount int(11) NOT NULL DEFAULT 0 COMMENT 实付金额单位分, start_time datetime DEFAULT NULL COMMENT 借出时间, end_time datetime DEFAULT NULL COMMENT 归还时间, version int(11) NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_status_create_time (status, create_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT充电宝租借订单表;有几个设计点需要特别注意。第一order_no必须加唯一索引所有对外操作都用业务订单号而不是自增主键防止订单号重复导致状态串单。第二status和create_time要建联合索引后面对超时订单做定时扫描时能不能快速查到待支付且创建时间小于当前时间15分钟的订单全靠这个索引。第三total_amount和paid_amount都按分存储这一点在计费章节我会专门展开。2.3 状态迁移要防并发不能先查再改状态机定义了允许怎么变但在实际并发场景下允许还不够。支付回调可能同时来两次两个请求都查到了订单状态是待支付都去更新后更新的一次就可能覆盖掉前一次的正确状态。这个项目里解决并发覆盖的办法是条件更新更新状态时带上旧状态条件而不是只按订单号更新。例如把状态从待支付改成已支付要执行update ... set status 1 where order_no ? and status 0。如果更新的影响行数是0说明当前状态不是预期的待支付说明存在并发冲突直接丢弃本次变更。这个方案不需要额外引入分布式锁在订单状态流转这种低频但关键的操作上非常实用。我还额外建了一张订单状态流转日志表每次状态变更都记录从哪个状态到哪个状态、谁触发的、什么时间。平时不觉得这张表有什么用真正做对账或者处理用户投诉时它能还原订单的完整生命周期省下大量扯皮时间。3. 按时长计费的金额计算看似简单、细节里全是坑3.1 计费规则先拆清楚再写代码充电宝计费看起来就是用多久收多少钱真落地时规则复杂得多。这个项目里我实现了一套典型的计费规则新用户首单前30分钟免费超过免费时长后按小时计费单价1.5元/小时不足1小时按1小时计算单日封顶20元同一个自然日内无论用多久最多收取封顶金额跨天使用需要分段计算第二天重新累计封顶额度免费时长、向上取整、跨天封顶这三个条件组合在一起金额计算就不能再用一个简单的乘法完事。我最后实现的金额计算分成了几个独立步骤每个步骤都有单独的测试用例。Transactional public SettleResult computeSettleAmount(Order order, LocalDateTime endTime) { long totalMinutes Duration.between(order.getStartTime(), endTime).toMinutes(); if (totalMinutes FREE_MINUTES) { return SettleResult.free(0); } // 按自然天拆时间段分别计算每天的金额 ListTimeSegment segments splitByDay(order.getStartTime(), endTime); BigDecimal totalAmount BigDecimal.ZERO; for (TimeSegment segment : segments) { long billableMinutes segment.getMinutes() - FREE_MINUTES_PER_DAY; if (billableMinutes 0) { continue; } long billableHours (billableMinutes 59) / 60; // 向上取整 BigDecimal dayAmount HOURLY_RATE .multiply(BigDecimal.valueOf(billableHours)) .min(DAILY_CAP); totalAmount totalAmount.add(dayAmount); } return SettleResult.success(totalAmount); }这里向上取整用了(billableMinutes 59) / 60而不是Math.ceil原因是整数运算永远比浮点数可靠也更快。用Math.ceil会先把分钟数转成 double再除以60浮点精度在分钟数很大的时候可能出现 1.499999 这种让人摸不着头脑的结果。3.2 金额单位必须统一用分展示层再转元这个项目里我犯过最典型的金额错误是用 double 类型保存金额中间结果。有一次测试跨天计费打印出来的金额是 41.999999999页面却显示42.00元排查半天才发现是浮点精度问题。后来的规范是数据库金额字段用 int 存分Java 里金额计算全部用 BigDecimal对外 API 返回的金额统一用分到了前端的展示层才除以100转成元。包括支付平台的回调报文、退款单金额、对账单全部以分为最小单位。这样虽然写代码时数字有点大但彻底杜绝了浮点数带来的脏数据。3.3 计算和结算是两件事要拆成两张表刚开始我图省事把计算金额这个动作直接放在主订单上归还时一算金额就更新订单的total_amount。后来发现这么设计有个隐患计算归还是算账扣款和支付回调是收钱两边都可能失败。如果算完账直接改主订单金额支付回调失败时订单金额已经变了对账时候完全说不清楚。所以我把计算和结算拆开。用户归还充电宝时系统先生成一个独立的结算单结算单记录的是当时的计费快照用了多久、单价、免费时长、封顶金额、最终应付。支付回调只负责改结算单的状态结算单状态变为已结算后再去更新主订单状态。这样主订单管生命周期结算单管金额流水两者互不干扰后续加优惠券、退款、部分退款都有地方扩展。这个思路和电商系统把订单和支付单拆开是同一个道理。一单可能包含多次支付行为拆开后每次支付都能独立追踪。4. 支付回调的幂等处理验签之后真正要做的三件事4.1 回调处理的四步流程支付平台的异步通知是共享充电宝系统里最容易被低估的接口。很多初学者以为回调就是把订单状态改成已支付真正实现时会发现通知可能重复、可能乱序、可能延迟甚至可能在你接口挂掉的时候重发。所以我在回调处理上做了一套非常保守的流程。PostMapping(/notify/settle) public String handlePayNotify(RequestBody String payload) { // 第一步验签 PayNotify notify payService.verifyAndParse(payload); if (notify null) { return FAIL; } // 第二步查结算单是否存在 SettleBill bill settleBillMapper.selectByOrderNo(notify.getOrderNo()); if (bill null) { return FAIL; } // 第三步幂等判断已经终态的直接返回成功 if (bill.getStatus() SettleStatus.SETTLED.getCode()) { return SUCCESS; } // 第四步本地事务更新结算单和订单 settleBillService.markSuccess(bill.getId(), notify.getTransactionId()); orderService.markSettled(notify.getOrderNo()); return SUCCESS; }验签是第一步也是很多人容易跳过的一步。支付平台回调报文一旦被伪造攻击者可以直接把未支付订单标记成已支付这种漏洞在真实项目里是致命的。验签的逻辑是用平台公钥对回调签名做校验校验通过才继续处理否则直接返回失败。4.2 幂等三件套唯一索引、状态判断、分布式锁支付平台不保证只通知一次同一个支付结果可能因为网络原因重发三五次。接口必须做到无论回调来几次业务只生效一次。第一道防线是数据库唯一索引。我在支付流水表上对transaction_id建了唯一约束同一个支付平台的流水号只能插入一次。第二道防线是状态判断也就是上面代码里已经结算的直接返回成功。第三道防线是分布式锁防止并发情况下两个回调同时查询、同时更新。如果项目部署了多个实例本地锁不管用必须用 Redis 分布式锁锁的 key 用订单号加锁后重新查询状态再决定是否更新。RLock lock redissonClient.getLock(lock:settle: orderNo); try { lock.lock(3, TimeUnit.SECONDS); // 查单、幂等判断、状态流转都放在锁内执行 } finally { lock.unlock(); }这个查了再改的设计本质上是在用乐观锁和悲观锁双重保护订单状态。具体到数据库操作层面我还会用条件更新来兜底比如update t_settle_bill set status 1 where id ? and status 0即使前面两道防线漏掉了条件更新也能保证状态不会翻转两次。4.3 回调丢了怎么办定时对账补单回调确实会丢这是支付平台的正常行为。网络分区、应用重启、消息积压任何一个环节出问题都可能导致回调没到达服务器。这时候不能只依赖回调还要有主动查询机制。我实现了一个定时任务每5分钟扫描处于待结算状态的结算单向支付平台发起订单查询。查询结果有三种情况平台返回已支付说明本地漏掉了回调直接执行补单平台返回支付中说明用户还没完成支付保持现状等待下一轮平台返回未找到说明支付其实没发起把结算单标记为异常并释放充电宝占用。配合这个定时查询每天凌晨还有一个对账任务把支付平台的对账单和本地支付流水做一次全量比对金额不一致就触发告警。这套异步回调 主动查询 日终对账的组合才是支付系统真正可靠的保障。5. 超时未支付订单的自动关闭延迟队列和定时扫描谁才是主力5.1 15分钟未支付自动关单充电宝项目里有两类订单会出现超时未支付一类是用户下单后没支付押金/预付租金另一类是计费完成后用户迟迟不付款。这些订单如果一直悬挂在待支付状态不仅占用充电宝资源还会让对账越来越混乱。所以产品要求15分钟内未支付就自动关闭。关闭动作本身很简单难点在于怎么准时触发。我在这个项目里试过两种方案各有优缺点。5.2 方案一RabbitMQ 的延迟队列延迟队列的思路是下单成功后往消息队列发一条延迟消息消息在队列里等待15分钟到期后进入死信队列由消费者执行关单操作。这个项目用的是 Spring Boot 加 RabbitMQ通过 TTL 和死信交换机实现延迟效果不需要额外依赖插件部署成本低。Component public class OrderDelayListener { RabbitListener(queues ORDER_CLOSE_QUEUE) public void onCloseMessage(OrderCloseMessage message) { Order order orderMapper.selectByOrderNo(message.getOrderNo()); // 只关闭仍处于待支付状态的订单 if (order ! null OrderStatus.CREATED.getCode().equals(order.getStatus())) { orderService.closeOrder(order.getOrderNo()); } } }这种方案的优点是时间控制精准而且完全不占数据库连接。缺点也明显消息中间件不保证100%不丢消息万一消息丢了订单就会一直挂着。所以它只能作为正常路径不能作为唯一保障。5.3 方案二定时任务扫描兜底为了防消息丢失我加了一个定时任务每1分钟扫描一次订单表把待支付且创建时间超过15分钟的订单批量关掉。Scheduled(fixedDelay 60_000L) public void closeTimeoutOrders() { LocalDateTime deadline LocalDateTime.now().minusMinutes(15); ListOrder timeoutOrders orderMapper.selectTimeoutOrders( OrderStatus.CREATED.getCode(), deadline, 200); for (Order order : timeoutOrders) { try { orderService.closeOrder(order.getOrderNo()); } catch (Exception e) { // 单条失败不影响其他订单 log.error(自动关单失败, orderNo{}, order.getOrderNo(), e); } } }定时扫描的优点是可靠、简单、可控缺点是无法做到秒级精确最多有1分钟误差。但放在15分钟关单这个业务场景里1分钟的误差用户基本感知不到。关键问题反而是扫描SQL的性能我在前面的订单表设计里已经加了(status, create_time)联合索引每次扫描只会扫到少量符合条件的订单不会因为全表扫描把数据库打垮。5.4 我为什么不只用一种方案如果你要问我延迟队列和定时扫描哪个更好我的回答是两个都要。延迟队列负责正常情况下的准时关单定时任务负责兜底处理延迟队列漏掉的部分每天凌晨再跑一次全量对账把极端情况下遗留的悬挂单找出来关闭。这种双保险思路在涉及钱的系统里特别重要单一机制再可靠也经不起中间件故障、网络抖动的考验。6. 实盘踩坑记录并发回调、跨天封顶和锁的边界我一个个踩过来的6.1 double 计算金额的教训差点把订单金额算成小数有一次我图方便在计算金额时用 double 保存小时数结果打印出来的结算单金额总是出现 1.499999、40.999999 这种数字。查了半天才发现 BigDecimal 之间做乘法没问题但中途一旦经过 double精度就丢了。后来我规定项目里所有涉及金额的字段、方法参数、返回值一律是 BigDecimal 或 int 分代码审查时看到 double 直接打回。另一件相关的事是金额入库前要做一次显式的setScale(0, RoundingMode.HALF_UP)把分单位的浮点结果四舍五入成整数避免 MySQL 的 int 字段从数据库层面截断小数。6.2 并发回调把状态打翻三件套缺一不可我为了测试回调接口用脚本同时发了两个相同的支付回调请求。第一次执行完后订单状态从待结算变成了已结算这是对的。但第二个请求也进来了因为当时我只做了状态判断没有加锁第二个请求查到的状态实际上是在第一个请求已经更新之后看起来是已结算逻辑上不会重复处理。但真正的坑出在我把状态判断和状态更新之间隔了一段耗时操作时。两个线程同时都查询到待结算然后先后执行更新第二个更新覆盖了第一个更新造成的事务效果。后来我把判断和更新放在同一个分布式锁内才彻底解决这个问题。经过这次踩坑我总结的支付幂等三件套是唯一索引兜住重复流水状态判断挡住无序通知分布式锁挡住并发更新。三件套少一个都可能出事故。6.3 跨天封顶的反直觉坑封顶是按天算不是按订单算最初实现封顶时我把整个订单从开始到结束的总时长做了一次封顶判断。看起来没问题但实际场景里用户可能连续租借30个小时跨越两天。如果按整个订单算只收到20元封顶金额而按规则应该收第一天封顶20元 第二天超出的费用。这个坑花了我大半个晚上才测出来。修复后我增加了按自然天切分时间段的逻辑把一次长租借拆成多个日段每段独立计算再汇总总金额。切分逻辑要特别小心跨月、跨年以及夏令时之类的边角场景。测试时我专门构造了23:59到次日00:01的用例确保分钟落在正确的日段里。6.4 分布式锁的边界锁的 key 和解锁时机都有讲究Redis 分布式锁用起来简单但边界条件多。我第一次实现时把锁的 key 设成了用户ID结果用户同时租了两个充电宝两个订单共用一把锁一个订单的操作把另一个订单也阻塞了还差点把不相关的状态更新覆盖掉。后来锁的 key 一律用业务订单号一个订单一把锁。解锁时机也出过问题。我习惯在 finally 里无条件解锁但有一次在持有锁的代码块里还调用了远程接口耗时超过锁自动过期时间锁提前释放了后面的线程拿到锁进入代码块前一个线程 finally 解锁时释放的其实是后一个线程的锁。这个问题的解法和 Redisson 的看门狗机制有关我在自己的代码里最终选择把锁的过期时间调大并避免在锁内调用不可控的远程接口保证锁内代码执行时间远小于锁过期时间。6.5 配置中心和数据库连接池的连带问题第3部分开始用 Nacos 做配置中心后我还踩过一个不太起眼的坑。当时把所有配置都放在共享配置文件里包括数据源配置。每次在配置中心修改并发布共享配置所有服务都会刷新配置数据源配置的刷新会导致连接池重建数据库连接数瞬时飙升出现一堆 Connection timeout。后来我把配置按敏感程度拆分数据源这类核心且敏感的配置放在服务自身的配置文件里不放到配置中心动态刷新非敏感的配置比如开关、超时时间、业务参数才放共享配置。这个调整之后配置刷新引发的连接池抖动再也没出现过。最后的实操心得如果你也在跟这套充电宝项目第3部分是最值得放慢速度的地方。计费引擎的边界条件、支付回调的幂等处理、超时关单的兜底机制这三块建议翻来覆去多看几遍最好能自己动手改规则重新跑一遍用例。我自己的习惯是先把订单状态流转和回调时序画在纸上理清楚谁触发、状态怎么变、重复触发会怎样再开始写代码。后面真正上线维护涉及钱的模块时这套思维方式比具体的代码更值钱。下次我还会整理一下这套项目里优惠券和会员权益叠加计费的设计思路那个模块比纯按时长计费又要复杂一档。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进