把一套充电宝后台项目跟到第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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 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); } // 按自然天拆时间段,分别计算每天的金额 List<TimeSegment> 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); List<Order> 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部分是最值得放慢速度的地方。计费引擎的边界条件、支付回调的幂等处理、超时关单的兜底机制,这三块建议翻来覆去多看几遍,最好能自己动手改规则重新跑一遍用例。我自己的习惯是先把订单状态流转和回调时序画在纸上,理清楚"谁触发、状态怎么变、重复触发会怎样",再开始写代码。后面真正上线维护涉及钱的模块时,这套思维方式比具体的代码更值钱。下次我还会整理一下这套项目里优惠券和会员权益叠加计费的设计思路,那个模块比纯按时长计费又要复杂一档。