☰
充电宝共享项目核心交易链路:状态机、并发锁与幂等支付实战
2026/10/22 4:37:57 网站建设 项目流程

最近这段时间在跟一个Java实战教程做充电宝共享项目,前面的章节把工程初始化、数据库建模、用户登录这些基础环节都过了一遍,项目跑起来是没问题,但业务上其实是空的。真正让我觉得“这项目才开始有灵魂”的,是第4部分把借用、归还、计费、支付这条核心交易链路完整打通的时候。这篇文章就针对第4部分的学习内容做一个整体复盘,重点聊聊订单状态机怎么设计、借还流程里的并发问题怎么防、支付回调怎么做到幂等、超时订单又该怎么兜底,顺带把我在写代码和压测过程中踩过的几个坑一并记录下来,给同样在学这套项目或者做类似业务的朋友一点参考。

1. 这一阶段的定位:从“业务演示”到“核心链路闭环”

1.1 前面几阶段完成了什么

在进入第4部分之前,整套项目已经搭好了基本骨架。工程层面是标准的Spring Boot分层结构,控制层、服务层、数据访问层分得清清楚楚,MyBatis-Plus集成好了,基础的增删改查也没有问题。数据模型这边,用户表、充电宝表、充电桩表、槽位表都已经建好,用户注册登录也通了,JWT这块基本能跑。

但如果你仔细去看,这个阶段的项目还只是一个“空壳”。充电宝表里有数据,可它们永远处于“可用”状态;用户能登录,但登录之后不知道能干什么;接口调来调去,无非是查列表、查详情。就像一家超市装修完了、货架也摆好了,但收银系统还没装,顾客进来只能干瞪眼。

1.2 第4部分要解决的问题

第4部分的核心任务,就是把共享充电宝的完整业务流程闭环做出来。所谓闭环,我把它梳理成下面这条链路:

用户扫码充电宝 → 系统校验并借出 → 用户带走使用 → 归还可用的桩位 → 系统结算计费 → 用户支付费用 → 订单完成

这里面每一步都不像看上去那么简单。借出时怎么保证同一台充电宝不会被两个人同时借走?归还时费用怎么算,遇到免费时段怎么处理?支付回调来了怎么避免重复更新订单状态?用户下单后一直不支付,订单又该怎么处理?这些问题全部堆在“业务逻辑”这一层,也是第4部分最有含金量的地方。

我自己的体感是,写到这一阶段才真正把之前学的Spring Boot、MyBatis-Plus、Redis这些技术串起来,不是一个个孤立的demo,而是为了同一个业务目标互相配合。

1.3 我会用到的技术栈说明

在具体展开之前,先交代一下第4部分涉及到的技术组件以及它们各自承担的角色,这样后面看代码不会懵。

技术组件解决的问题在本项目中的角色
Spring Boot提供Web容器和依赖管理所有接口的宿主
MyBatis-Plus数据持久化和条件构造订单、充电宝等表的读写
MySQL数据最终存储订单记录、充电宝状态
Redis缓存、分布式锁防止并发借出、缓存热数据
支付SDK模拟第三方支付回调支付状态通知

这套组合也是目前中小型Java后端项目很常见的技术栈,学习价值不只是为了应付教程,而是能直接迁移到同类业务里。下面各节我会按“设计 → 实现 → 踩坑”的顺序来梳理。

2. 订单核心:状态机设计是这块业务的地基

2.1 订单状态怎么定

借还、计费、支付这些业务动作,最后都要落到订单上。订单状态设计得好不好,直接决定后面所有逻辑写起来顺不顺手。这也是为什么我在第4部分最先死磕的就是状态机。

我把订单状态拆成了下面这几个:

状态含义触发动作
CREATED订单已创建用户扫码确认借出
IN_USE使用中充电宝成功借出,开始计时
PENDING_PAYMENT待支付用户归还,系统算完费用
COMPLETED已完成支付成功或者免费订单直接完结
CLOSED已关闭超时未支付或异常取消

注意我在这里把“待支付”和“使用中”明确分开了。有的同学可能会觉得,借出之后一直到支付完成才算是结束,中间搞那么多状态是不是多余?其实不是。归还动作和支付动作在时间上是分离的,用户还了充电宝不代表他马上付钱,他可能拖着,也可能不付。如果没有独立的待支付状态,后续的超时提醒、订单关闭、财务对账就都没有抓手。

2.2 状态流转规则与防呆设计

状态定义了,流转规则也得跟着定下来,不能让订单状态随便跳。我整理出的正常流转关系是这样的:

  • CREATED → IN_USE:借出成功立即进入使用中
  • CREATED → CLOSED:用户下单但未借出成功,或系统取消
  • IN_USE → PENDING_PAYMENT:用户归还,费用计算完成
  • IN_USE → CLOSED:异常关闭,比如充电宝丢失上报
  • PENDING_PAYMENT → COMPLETED:支付回调成功
  • PENDING_PAYMENT → CLOSED:超时未支付,系统关闭

为了避免逻辑散落在各个Service方法里,我单独封装了一个状态流转校验方法。任何更新订单状态的地方,都必须先做一次合法性检查,非法跳转直接抛异常。

private static final Map<OrderStatus, Set<OrderStatus>> ALLOWED_TRANSITIONS = new EnumMap<>(OrderStatus.class); static { ALLOWED_TRANSITIONS.put(OrderStatus.CREATED, EnumSet.of(OrderStatus.IN_USE, OrderStatus.CLOSED)); ALLOWED_TRANSITIONS.put(OrderStatus.IN_USE, EnumSet.of(OrderStatus.PENDING_PAYMENT, OrderStatus.CLOSED)); ALLOWED_TRANSITIONS.put(OrderStatus.PENDING_PAYMENT, EnumSet.of(OrderStatus.COMPLETED, OrderStatus.CLOSED)); } public static void checkTransition(OrderStatus from, OrderStatus to) { if (!ALLOWED_TRANSITIONS.getOrDefault(from, Collections.emptySet()).contains(to)) { throw new BizException("非法的订单状态流转: " + from + " -> " + to); } }

这种集中式校验有个明显好处:后续加新状态时,只需要改这一处,不容易漏。我在实际编码中因为图省事跳过这个校验,结果有一次归还流程里更新状态顺序写错,把待支付直接覆盖成已完成,金额都没付,还好是在测试环境发现的。

2.3 订单表字段设计的细节取舍

状态机之外,订单表的字段设计也值得好好说。我最初建表偷懒,想着能用就行,字段能少就少,后来发现根本不够用,反复改了两次表结构。下面是经过第4部分业务验证后比较稳定的表结构。

字段名类型说明
idbigint主键
order_novarchar(32)订单号,唯一索引
user_idbigint用户ID
power_bank_idbigint充电宝ID
cabinet_idbigint借出充电桩ID
slot_noint借出槽位号
return_cabinet_idbigint归还充电桩ID,可空
return_slot_noint归还槽位号,可空
borrow_timedatetime借出时间
return_timedatetime归还时间,可空
amountdecimal(10,2)应付金额
statustinyint订单状态
create_timedatetime创建时间
update_timedatetime更新时间

两个细节提醒一下。金额字段建议用decimal而不是float/double,这个做支付相关业务的人都懂,浮点数在金额计算里会产生精度问题,第4部分的计费逻辑我一开始用double写过,结果算出来的费用偶尔带出一串诡异的小数。时间字段建议统一用datetime,应用层用LocalDateTime与它对应,别混用Java的Date和数据库函数,后面我会在踩坑章节展开讲。

3. 借还流程的实现细节:把并发和边界条件提前排掉

3.1 扫码借出的完整链路

借出接口是整个借还流程的入口。用户在前端点击“借用”,后端接收充电宝ID、充电桩ID、槽位号这些参数后,按顺序执行下面这几步:

  1. 校验充电宝是否存在且状态可用
  2. 对充电宝ID加分布式锁
  3. 在锁内再次校验充电宝状态
  4. 将充电宝状态从“可用”更新为“借用中”
  5. 将槽位从“空闲”更新为“占用”
  6. 创建订单,状态设为IN_USE
  7. 返回订单号给前端

这段流程里最核心的是第2步到第4步。如果少了加锁,两个用户同时扫码,完全可能同时读到“充电宝可用”,然后各自创建一张订单,充电宝被借出去两次,这在真实业务里是要出大事故的。

3.2 为什么必须用分布式锁而不是简单的状态判断

有同学会问,我在更新充电宝状态的时候加一个条件不就行了吗?比如UPDATE ... WHERE status = 'AVAILABLE',这样两条并发请求只有一条能成功。方案本身没问题,但它没办法解决另一个问题:如果充电宝是A用户先借的,紧接着B用户也要借,两个请求几乎同时到达,B可能是在A的订单还没创建完的时候就进来了。数据库层面的条件更新确实能拦住B对充电宝状态的修改,但业务流程里可能已经产生了脏数据,或者前端已经收到了“借用成功”的假象。

所以常规做法是双重保险。先上Redis分布式锁,保证同一时刻只有一个线程在处理同一个充电宝的业务;再在数据库层面做条件更新,作为兜底。锁我用的是最简单的SET NX EX方式:

String lockKey = "lock:powerbank:" + powerBankId; Boolean locked = stringRedisTemplate.opsForValue() .setIfAbsent(lockKey, "1", Duration.ofSeconds(10)); if (Boolean.TRUE.equals(locked)) { try { // 执行业务逻辑 } finally { stringRedisTemplate.delete(lockKey); } }

这里有个很容易踩的坑:锁的过期时间不能设置得太短,否则业务还没执行完锁就自动释放了,下一个请求就能进来。但也不能太长,万一线程执行过程中挂了,锁要等很久才能被别的线程获取。课程里给的参考值是10秒,对于这个项目的业务复杂度来说是够用的。生产环境如果单次操作耗时波动大,可以考虑用Redisson的看门狗机制自动续期,不过作为学习项目,掌握基本写法更重要。

3.3 归还流程和计费规则的落地

归还接口的整体链路比借出要稍微复杂一点,因为它涉及到费用计算和状态回写。

我实现的归还流程是这样的:

  1. 根据订单号查询订单
  2. 校验订单状态必须为IN_USE
  3. 按订单号加分布式锁,防止归还请求重复提交
  4. 计算借出时长,算出应收费用
  5. 将充电宝状态更新为“可用”
  6. 将归还桩位的槽位更新为“空闲”
  7. 更新订单:填归还时间、归还桩位、金额、状态
  8. 如果费用为0,订单直接变成COMPLETED;否则进入PENDING_PAYMENT

计费规则我是这样定的:前5分钟免费,超过后按小时计费,每小时2元,不足1小时按1小时算,单日封顶20元。这里最需要注意的是“不足1小时按1小时算”的向上取整逻辑,千万别直接对分钟数取整,会少算钱或者多算钱。

public BigDecimal calculateAmount(LocalDateTime borrowTime, LocalDateTime returnTime) { long minutes = Duration.between(borrowTime, returnTime).toMinutes(); long freeMinutes = 5; long billableMinutes = Math.max(0, minutes - freeMinutes); long hours = (billableMinutes + 59) / 60; // 向上取整 BigDecimal amount = BigDecimal.valueOf(hours).multiply(new BigDecimal("2")); return amount.min(new BigDecimal("20")); }

免费时段的处理逻辑也值得留意。如果用户借出4分钟就归还,费用是0,我直接把订单置为COMPLETED,而不是让它进入待支付状态。否则用户打开订单列表,看到一个0元的待支付订单,还得再去点一次“支付”,体验很怪。这一点是我在自测的时候发现的,改完之后整个流程顺畅很多。

3.4 借用接口核心代码示例

借用接口的核心逻辑,我把精简后的代码贴出来。这版代码没有包含所有细节,但主流程是完整可跑的。

@Transactional public BorrowResult borrow(Long userId, Long powerBankId, Long cabinetId, Integer slotNo) { String lockKey = "lock:powerbank:" + powerBankId; Boolean locked = stringRedisTemplate.opsForValue() .setIfAbsent(lockKey, "1", Duration.ofSeconds(10)); if (!Boolean.TRUE.equals(locked)) { throw new BizException("充电宝使用中,请稍后重试"); } try { // 锁内查询并校验状态 PowerBank powerBank = powerBankMapper.selectById(powerBankId); if (powerBank == null || !"AVAILABLE".equals(powerBank.getStatus())) { throw new BizException("充电宝不可借用"); } // 条件更新状态,返回0说明被并发抢先 int updated = powerBankMapper.updateStatus( powerBankId, "AVAILABLE", "BORROWED"); if (updated == 0) { throw new BizException("手慢了,充电宝已被借走"); } // 槽位占用 slotMapper.updateSlotStatus(cabinetId, slotNo, "FREE", "OCCUPIED"); // 创建订单 Order order = Order.builder() .orderNo(generateOrderNo()) .userId(userId) .powerBankId(powerBankId) .cabinetId(cabinetId) .slotNo(slotNo) .status(OrderStatus.IN_USE.getValue()) .borrowTime(LocalDateTime.now()) .build(); orderMapper.insert(order); return BorrowResult.of(order.getOrderNo()); } finally { stringRedisTemplate.delete(lockKey); } }

这段代码有个细节值得说:锁的释放放在finally里,保证即使业务抛异常,锁也能释放掉。我在刚开始写的时候把释放动作放在了try里方法末尾,结果有一次充电宝状态更新失败抛了异常,锁一直没释放,后续所有对这个充电宝的请求全部被挡在外面,日志里一片“充电宝使用中”。

4. 支付回调:幂等都是底线

4.1 支付交互的主流程

归还完成后,用户会看到一个待支付金额,前端调起支付,用户输密码完成付款。这里有一个非常关键的设计原则:订单状态绝对不能在前端支付成功页面跳转时直接更新,必须等支付平台的异步回调通知后端。

原因是前端跳转是可以伪造的,任何人只要懂一点前端知识,都能模拟一个“支付成功”的页面跳转。真正可信的支付确认只能来自支付平台的后端回调。所以在第4部分的学习里,我着重把回调接口的实现逻辑捋了一遍,核心就三个词:验签、对金额、幂等更新。

4.2 回调幂等实现的两种方式

支付平台为了保证通知送达,会在一段时间内对回调接口进行多次重试。也就是说,同一个支付成功的消息,后端可能会收到好几次。如果每次收到回调都直接更新订单状态,轻则重复更新产生无效SQL,重则把已关闭的订单重新改成已完成,造成资金风险。

我采用的幂等方案是数据库条件更新。更新的时候带上期望的当前状态,只有状态匹配才更新成功:

@PostMapping("/api/pay/notify") public String payNotify(@RequestBody PayNotifyRequest request) { // 1. 验签,失败直接返回FAIL if (!payService.verifySign(request)) { log.error("回调验签失败: {}", request.getOrderNo()); return "FAIL"; } // 2. 查订单并校验金额 Order order = orderMapper.selectByOrderNo(request.getOrderNo()); if (order == null || order.getAmount().compareTo(request.getAmount()) != 0) { log.error("订单不存在或金额不一致: {}", request.getOrderNo()); return "FAIL"; } // 3. 条件更新:只有待支付状态才能更新为已完成 int updated = orderMapper.updateStatusByOrderNo( request.getOrderNo(), OrderStatus.PENDING_PAYMENT.getValue(), OrderStatus.COMPLETED.getValue()); if (updated == 0) { // 可能是重复回调,也可能是状态异常,都返回成功 log.info("重复回调或状态异常: {}", request.getOrderNo()); return "SUCCESS"; } return "SUCCESS"; }

对应的SQL是这样:

UPDATE t_order SET status = #{targetStatus}, update_time = NOW() WHERE order_no = #{orderNo} AND status = #{expectStatus}

第一次回调进来时,订单状态是待支付,条件更新成功,返回SUCCESS。第二次同样的回调进来时,订单已经是已完成,条件更新影响行数为0,这时候直接返回SUCCESS就行,不能返回FAIL,否则支付平台会认为通知失败,继续无限重试。

另一种常见的幂等做法是建一张支付流水表,收到回调先插入流水,靠流水表唯一索引去重,成功插入才继续更新订单。两种方案我都试过,条件更新更简单,流水表更灵活。教学项目里我建议先用条件更新,逻辑直观,也容易讲清楚。

4.3 回调处理时的一个易错点

校验金额这一步很多人会忽略。我一开始也觉得,支付平台都回调了,还能有假吗?后来课程里提醒才知道,回调参数虽然经过平台签名,但业务上仍然要自己拿订单金额和回调金额比对。万一订单金额和实际支付金额不一致,说明有异常,宁可返回FAIL也不要动订单状态。

另外,验签逻辑虽然在前面的代码里只写了方法调用,但这个环节千万不能省。我自己测试时曾经为了省事把验签注释掉,直接用Postman模拟回调,把一笔0元的订单给硬改成已完成,数据库里就出现了脏数据。后来恢复验签,才发现支付平台的签名规则其实还涉及到请求头里的时间戳和随机字符串,不是一个简单的参数拼接。

5. 超时、异常与兜底:产品能跑起来的关键

5.1 下单未支付和未归还的兜底

用户归还充电宝后,订单进入待支付状态,但用户可能一直不付钱。这种情况下订单不能无限期挂着,需要一个超时关闭机制。我在项目里用的是定时任务扫描,每5分钟执行一次,找出创建时间超过30分钟且状态为待支付的订单,把状态更新为CLOSED。

@Scheduled(fixedDelay = 300000) public void closeTimeoutOrders() { LocalDateTime deadline = LocalDateTime.now().minusMinutes(30); List<Order> timeoutOrders = orderMapper.selectTimeoutPendingOrders(deadline); for (Order order : timeoutOrders) { orderMapper.updateStatusByOrderNo( order.getOrderNo(), OrderStatus.PENDING_PAYMENT.getValue(), OrderStatus.CLOSED.getValue()); } }

生产环境里,扫描式定时任务不是最优解,因为存在最多一个扫描周期的时间差,更及时的做法是用Redis延迟队列或者消息队列的延时消息。但作为学习项目,定时任务已经足够,而且能让你把注意力放在业务逻辑本身。

还有一种更隐蔽的异常场景:用户借出充电宝后一直不归还。这时候订单一直停在IN_USE状态,计费在持续累加。课程里没重点讲这个场景,但我自己实现了一个简单的提醒逻辑:订单使用超过24小时,就给用户发一条待归还提醒。如果超过72小时,就把充电宝标记为“丢失”,并在后台提醒运营人工介入。这个扩展做下来,让我对这个业务的整体理解深了不少。

5.2 归还环节的系统异常兜底

归还环节最容易出问题的点是:用户把充电宝插回柜机,柜机上报了归还事件,但后端处理到一半挂了,或者网络超时。这时候数据库里充电宝可能还是“借用中”,槽位还是“占用”,用户却已经完成了物理归还。如果不管,用户会被一直计费。

我的处理思路是加一个对账定时任务。每天凌晨扫描一次充电宝状态为“借用中”但关联订单的更新时间已经超过24小时的记录,把这类异常数据捞出来,推送告警。运营人员在后台看到后,可以手动确认充电宝是否已经在柜机里,确认后就手动把订单完结。

这种“自动处理+人工兜底”的思路,是从真实业务里学来的,也是我认为教学项目里很值得主动扩展的一部分。因为真实世界里的异常永远比正常流程多,能不能兜得住异常,往往决定一个系统上线后会不会翻车。

5.3 简易运营后台的必要性

前面说了人工兜底,那人工在哪里操作?这就需要一个最简单的运营后台。我在第4部分结束之后,顺手做了一个非常简陋的后台页面,功能只有一个:订单列表查询,加上一个“人工完成订单”的按钮。

后台接口看起来不复杂,但有一个安全点必须注意:这类运营接口不能只靠前端隐藏入口来保护,必须做服务端鉴权。我一开始没做权限校验,结果通过浏览器直接调后台接口就能改订单状态,后来给所有运营接口加了一个简单的管理员Token校验。

6. 实操过程中的坑和排查实录

6.1 并发测试时同时借同一个充电宝,订单重复了

这块场景是我在第4部分印象最深的一个坑。有一版代码我为了测试分布式锁到底有没有必要,故意把Redis加锁部分注释掉,只保留了数据库的条件更新。然后我用模拟并发工具开了20个线程,同时请求借用同一个充电宝。

现象很快出来了:日志里出现了两条订单,充电宝ID对应两个订单号,数据库里被插进了两条状态均为IN_USE的订单。按理说条件更新应该能挡住第二条请求,但实际跑下来发现,两条请求几乎在同一时刻通过了状态检查,都走到了插入订单这一步。

排查下来,原因就是“检查状态”和“更新状态”不是原子操作。A线程检查完充电宝可用,还没来得及更新状态,B线程也完成了检查,两边都认为充电宝可用,于是都去更新,最终只有一条更新成功,而另一条已经走到了订单插入。目录里的那条脏订单就是多出来的那个。

修复方式就是我在3.1节里写的:先加分布式锁,让同一个充电宝的请求串行化;再配合条件更新兜底。恢复锁之后重新压测,再也没出现过重复订单。

6.2 计费差了6分钟:时间存储和时区的坑

有一段时间我在测试归还流程时,发现一个问题:借出5分钟内归还没有被测到免费,而是被收了2元。本来以为是业务代码写错了,后来发现是时间来源不一致。

借出时间我用的数据库字段默认值,也就是通过MyBatis-Plus插入时由数据库填的当前时间。归还时间用的是Java的LocalDateTime.now()。问题在于,数据库连接串里配置的时区是格林尼治时间,而应用服务器的时区是东八区,两边差了8小时。虽然显示出来都是“当前时间”,但对同一个订单来说,借出和归还用的不是同一个时钟,计算出来的时长自然就乱了。

修复方式很简单:把整个项目的时间获取统一起来,全部使用应用层的LocalDateTime.now(),并且连接串里显式指定时区。

spring: datasource: url: jdbc:mysql://localhost:3306/chongdianbao?serverTimezone=Asia/Shanghai

这个坑我记得很牢,因为排查了很久。如果你在类似项目里遇到计费时间不准的问题,先别急着怀疑代码,去看看所有时间到底是不是同一个来源。

6.3 支付回调重试把日志和数据库打爆

回调接口刚写好的时候,我犯过一个低级错误:接口处理成功后,返回的字符串少了一个字母,导致支付平台认为处理失败。结果那笔订单的回调被支付平台重试了十几次,每次进来都打一条日志,还多次查询订单表。

后来我把幂等逻辑加上,重复回调的请求会直接返回SUCCESS,不执行业务更新,问题就解决了。这里也提醒一句,凡是回调类接口,一定要设计成“不管重复多少次都不能产生副作用”,这就是幂等的意义所在。

6.4 长事务引起的数据库锁等待

归还接口刚开始实现时,我把太多操作都塞进一个事务里:查订单、算费用、更新充电宝、更新槽位、更新订单,还调了一次支付平台的下单接口,整个过程在慢网络下要好几秒。测试并发归还时,在数据库的锁等待日志里看到了大量超时。

问题本质是事务范围太大,锁持有时间太长。解决方式是把外部调用移出事务,或者用事务事件监听器在事务提交后再去调用外部系统。教学项目里最简单有效的做法是:保证事务里只做数据库操作,对外部系统的调用挪到事务外面。

7. 阶段复盘与我的一点心得

第4部分学下来,最大的感受是:写业务的难度不在某个单一技术点,而在把所有环节拼在一起不出漏洞。订单状态少设计一个,后面就要到处打补丁;锁不加,并发场景就出事;回调不幂等,支付数据就可能对不上。这些经验很难从教科书里直接学到,必须自己在代码里栽过跟头才有体会。

我也建议大家在学习这类实战项目时别只满足于把代码敲通跑起来。把分布式锁去掉跑一次并发,故意把回调重复提交两次,把时区配置改乱再去看计费结果,这些“破坏性实验”才能帮你真正理解每一行代码为什么存在。第4部分的内容承载了太多这类细节,值得反复刷几遍。后续如果继续往深处扩展,我打算再看看运营统计、报表分析、用户积分这些方向,到时候再单独写文章分享。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询