网络抖动重复充电订单解决方案
做充电业务的普遍会遇到“重复订单”问题:用户在充电站网络卡顿,点击“启动充电”后无响应,多次重复点击,导致后台生成多笔待支付订单,引发用户投诉、客服退款、技术应急处理等一系列麻烦。
本方案聚焦这一核心痛点,提供可直接落地的解决方案,代码完整可复制,无需额外调试,适配各类 新能源充电业务场景,技术人员可直接复用,高效解决重复订单问题。

一、问题说明
结合实际业务场景,具体问题如下,方便大家快速判断自身是否存在同类情况:
- 场景就是这么个场景:用户去充电站充电,打开APP点“启动充电”,结果网络卡了——APP既不显示“启动成功”,也不显示“启动失败”,就卡在那。用户急着充电,下意识就会连续点好几次(换谁都会这么做),相当于短时间内,给后台发了好几条“启动充电”的请求。
- 问题根源就在这:后台没做防护,是个“傻大个”——收到一条请求,就生成一条待支付订单,不管是不是同一个用户、同一个充电枪,也不管是不是重复点的。最后就导致:用户就充一次电,手机上收到好几个支付通知,后台好几笔待支付订单,用户以为被扣了好几次钱,直接投诉,客服忙得脚不沾地,技术还得手动删订单、处理退款,纯纯内耗!
核心原因:网络波动导致用户重复触发请求,后端未做有效防护,最终生成重复订单,增加运营及技术处理成本。
二、解决方案及技术原理
核心解决思路:前端限制重复点击,后端拦截重复及并发请求,确保同一充电枪、同一用户在同一时间仅能处理一次充电请求。方案采用3项核心技术,以下详细说明技术作用及原理,便于理解和落地。

核心技术及原理
采用通俗化解读结合生活化比喻,清晰说明各技术的作用及原理,帮助非技术人员理解,技术人员可直接参考落地。
1、全局请求幂等(最基础的防护,拦不住它,后面全白搭)
作用:给每一次“启动充电”请求,发一个“独一无二的身份证”(就叫请求ID),后台收到请求后,先查这个“身份证”有没有来过——来过,就直接拒绝,不处理;没来过,才正常往下走。相当于:同一个请求,不管发多少次,后台只认第一次,后面的全拦在门外。
原理:就像你去超市结账,拿了一个结账号,不管你排多少次队,同一个号,收银员只给你结一次账,不会因为你多排几次,就给你结好几次。这里的“结账号”,就是请求ID;“收银员”,就是后台程序。
2、枪维度分布式锁(核心中的核心,从根源防重复)
作用:给“充电枪+用户”这个组合,加一把“专属锁”。比如:用户A用充电枪001,点击启动充电后,这把锁就锁上了,在锁没解开之前,用户A再点多少次、再发多少请求,都进不来;只有等第一次请求处理完(启动充电成功/失败),锁解开了,才能处理下一次请求(正常情况下,第一次处理完就启动充电了,下一次请求会被拦住)。
原理:就像你去公共厕所,每间厕所都有锁,一个人进去锁上门,其他人就只能等,不能同时进去。这里的“厕所”,就是“充电枪+用户”;“锁”,就是分布式锁;“人”,就是用户的请求。这样就能确保,同一时间,同一个用户用同一把枪,只能有一个请求在处理,不会出现并发请求导致的重复订单。
3、状态机互斥(最后一道防线,兜底用的)
作用:给每一把充电枪,设置一个“状态”——空闲、充电中、故障、已预约。后台处理请求前,先查一下充电枪的状态:只有状态是“空闲”,才能启动充电、生成订单;如果是“充电中”,直接拒绝,不让生成新订单。哪怕前面两道防线漏了,这一道也能拦住。
原理:就像你去餐厅吃饭,桌子上放着“空闲”“用餐中”的牌子,服务员看到“空闲”,才会安排你坐下;看到“用餐中”,就会让你等。这里的“桌子”,就是充电枪;“牌子”,就是充电枪的状态;“服务员”,就是后台的校验程序。
总结:3个技术配合,相当于“三重防护”
第一道:幂等校验,拦重复请求;
第二道:分布式 锁,拦并发请求;
第三道:状态机,拦无效请求。
三道防线一起上,不管网络怎么抖、用户怎么点,都不会出重复订单。
三、业务流转:从用户点击到充电启动,整个流程是怎样的?
按实际发生的顺序,说明每一步流程,清晰呈现用户点击后后台的处理逻辑及防重复请求的机制:
- 用户操作:用户打开充电APP,选择充电枪,点击“启动充电”;
- 前端防护:用户点击后,APP按钮立马变灰,不能再点击(防止用户重复点),同时生成一个“独一无二的请求ID”;
- 请求发送:APP把“请求ID、用户ID、充电枪ID”这三个关键信息,发给后台;
- 第一道校验(幂等):后台收到请求,先查“请求ID”有没有来过,来过就直接返回“操作太频繁”,拒绝处理;没来过,就继续下一步;
- 第二道校验(分布式锁):后台给“当前充电枪+当前用户”加一把锁,加锁成功,就继续下一步;加锁失败(说明有其他请求在处理),就返回“操作正在处理中”;
- 第三道校验(状态机):后台查当前充电枪的状态,是“空闲”就继续;不是空闲(充电中/故障),就返回“充电枪不可用”;
- 生成订单:三道校验都通过,后台生成1笔待支付订单,同时把充电枪状态改成“充电中”;
- 反馈结果:后台把“启动成功+订单信息”返回给APP,APP按钮恢复可点击,用户跳转支付,充电启动;
- 释放锁:不管启动成功还是失败,后台都会解开“充电枪+用户”的锁,避免后续请求无法处理。
补充:整个流程,从用户点击到反馈结果,也就几百毫秒,用户完全感觉不到延迟,同时还能彻底杜绝重复订单。
四、实践流程:从新建工程到运行成功
所有代码均已完整编写,可直接复制粘贴使用,仅需根据自身环境修改Redis地址,按流程操作即可完成部署。
说明:用的是行业最常用的「Spring Boot 2.7.x + Redis」,这两个工具,做技术的基本都在用,不用额外装其他复杂工具,常规环境就能运行。
第一步:准备环境
项目运行需提前安装两个基础工具,无需特殊配置:
- JDK:装JDK8(推荐,兼容性最好),装完后打开cmd,输入“java -version”,能显示版本号(比如1.8.0_301),就说明装好了;
- Redis:装Redis 6.0+,本地部署的话,装完直接启动(双击redis-server.exe就行);远程部署的话,记住Redis的地址、端口、密码(后续改配置用)。
两个工具均为常规安装,可参考网上常规教程完成,无需关注细节。
第二步:新建工程
- 打开IDEA(或Eclipse),新建一个Spring Boot项目,项目名称随便起(比如charging-project);
- 项目版本选择Spring Boot 2.7.x(别选太高,避免兼容性问题);
- 新建完成后,删除默认的DemoApplication之外的所有文件,保持项目整洁。
- 新建完成后,删除默认的DemoApplication之外的所有文件(没用,干净省事)。
第三步:复制代码
按以下顺序,将代码复制到对应文件夹,所有代码完整可复用,仅需修改Redis配置。
1. 导入依赖(pom.xml ,复制到项目的pom.xml文件中,覆盖原有内容)
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.10</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.example</groupId>
<artifactId>charging</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>charging</name>
<description>充电订单防重复解决方案</description>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<!-- Spring Boot 核心依赖,不用改 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Redis 依赖(分布式锁、幂等校验用),不用改 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- Lombok(简化代码,不用手动写get/set),不用改 -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- 工具类依赖(分布式锁实现用),不用改 -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.20</version>
</dependency>
<!-- 测试依赖,不用改 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
2. 配置Redis(application.yml,新建文件,复制下面内容,仅改Redis地址)
spring:
redis:
# 本地Redis无需修改,远程Redis需修改host和password
host: 127.0.0.1 # 本地Redis默认地址,远程部署替换为Redis服务器IP
port: 6379 # Redis默认端口,无需修改
password: # 无密码留空,有密码填写对应Redis密码
database: 0 # 默认数据库,无需修改
timeout: 10000ms # 超时时间,无需修改
lettuce:
pool:
max-active: 100
max-idle: 10
min-idle: 5
# 自定义配置,无需修改
charging:
lock:
expire-seconds: 30 # 分布式锁过期时间30秒,防止死锁
idempotent:
expire-seconds: 60 # 幂等请求过期时间60秒,避免短时间重复请求
仅需修改“host”和“password”,其余内容保持不变,修改后保存即可。
3. 分布式锁工具类(RedisDistributedLockUtil.java,复制到com.example.charging.util包下)
在com.example.charging包下,新建util包,然后新建RedisDistributedLockUtil.java文件,复制以下代码:
package com.example.charging.util;
import cn.hutool.core.util.StrUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.concurrent.TimeUnit;
/**
* 分布式锁工具类,可直接复制使用
*/
@Component
@Slf4j
public class RedisDistributedLockUtil {
@Resource
private StringRedisTemplate stringRedisTemplate;
/**
* 获取分布式锁
* @param lockKey 锁的key(格式:charging:lock:gunId:userId)
* @param requestId 请求ID(全局唯一)
* @param expireSeconds 锁过期时间(秒)
* @return true:获取锁成功;false:获取锁失败
*/
public boolean tryLock(String lockKey, String requestId, long expireSeconds) {
try {
// Redis原子操作,不存在则设置,避免并发问题
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(lockKey, requestId, expireSeconds, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
} catch (Exception e) {
log.error("获取分布式锁失败,lockKey:{},requestId:{}", lockKey, requestId, e);
return false;
}
}
/**
* 释放分布式锁,防止误释放
* @param lockKey 锁的key
* @param requestId 请求ID
*/
public void releaseLock(String lockKey, String requestId) {
try {
String value = stringRedisTemplate.opsForValue().get(lockKey);
// 只有当前锁的value和requestId一致,才释放锁
if (StrUtil.isNotBlank(value) && value.equals(requestId)) {
stringRedisTemplate.delete(lockKey);
log.info("释放分布式锁成功,lockKey:{},requestId:{}", lockKey, requestId);
}
} catch (Exception e) {
log.error("释放分布式锁失败,lockKey:{},requestId:{}", lockKey, requestId, e);
}
}
}
4. 核心业务接口(ChargingStartController.java,复制到com.example.charging.controller包下)
在com.example.charging包下,新建controller包,然后新建ChargingStartController.java文件,复制以下完整代码:
package com.example.charging.controller;
import com.example.charging.util.RedisDistributedLockUtil;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 充电启动核心接口,含幂等、分布式锁、状态机校验,可直接复制使用
*/
@RestController
@RequestMapping("/api/charging")
@Slf4j
public class ChargingStartController {
@Resource
private RedisDistributedLockUtil distributedLockUtil;
@Resource
private StringRedisTemplate stringRedisTemplate;
// 从配置文件读取参数,无需修改
@Value("${charging.lock.expire-seconds}")
private long lockExpireSeconds;
@Value("${charging.idempotent.expire-seconds}")
private long idempotentExpireSeconds;
// 模拟充电枪状态缓存(实际可存Redis/数据库,这里简化,不影响运行)
private final Map<String, GunStatusEnum> gunStatusCache = new ConcurrentHashMap<>();
// 模拟订单存储(实际可存数据库,这里简化)
private final Map<String, Order> orderCache = new ConcurrentHashMap<>();
/**
* 启动充电接口(前端调用此接口)
* @param request 前端传入参数(requestId、gunId、userId)
* @return 响应结果
*/
@PostMapping("/start")
public Map<String, Object> startCharging(@RequestBody ChargingStartRequest request) {
Map<String, Object> result = new HashMap<>();
String requestId = request.getRequestId();
String gunId = request.getGunId();
String userId = request.getUserId();
// 1. 幂等校验:拦截重复请求
String idempotentKey = "charging:idempotent:" + requestId;
Boolean isIdempotent = stringRedisTemplate.hasKey(idempotentKey);
if (Boolean.TRUE.equals(isIdempotent)) {
result.put("code", 400);
result.put("msg", "操作过于频繁,请稍后再试");
return result;
}
// 2. 获取分布式锁:同一枪+用户,同一时间只能一个请求
String lockKey = "charging:lock:" + gunId + ":" + userId;
boolean lockSuccess = distributedLockUtil.tryLock(lockKey, requestId, lockExpireSeconds);
if (!lockSuccess) {
result.put("code", 400);
result.put("msg", "操作正在处理中,请稍后再试");
return result;
}
try {
// 3. 状态机校验:充电枪必须空闲
GunStatusEnum gunStatus = gunStatusCache.getOrDefault(gunId, GunStatusEnum.IDLE);
if (!GunStatusEnum.IDLE.equals(gunStatus)) {
result.put("code", 400);
result.put("msg", "充电枪当前状态:" + gunStatus.getDesc() + ",无法启动充电");
return result;
}
// 4. 生成订单(仅生成1笔)
stringRedisTemplate.opsForValue().set(idempotentKey, "1", idempotentExpireSeconds);
String orderId = "ORDER_" + new Date().getTime() + "_" + gunId;
Order order = new Order(orderId, userId, gunId, OrderStatusEnum.PENDING_PAY);
orderCache.put(orderId, order);
gunStatusCache.put(gunId, GunStatusEnum.CHARGING);
log.info("启动充电成功,生成订单:{},requestId:{}", orderId, requestId);
result.put("code", 200);
result.put("msg", "启动充电成功");
result.put("data", order);
return result;
} catch (Exception e) {
log.error("启动充电异常,requestId:{},gunId:{}", requestId, gunId, e);
result.put("code", 500);
result.put("msg", "系统异常,请稍后再试");
return result;
} finally {
// 释放分布式锁,必须执行
distributedLockUtil.releaseLock(lockKey, requestId);
}
}
// 前端请求参数实体,无需修改
@Data
public static class ChargingStartRequest {
private String requestId; // 全局唯一请求ID
private String gunId; // 充电枪ID
private String userId; // 用户ID
}
// 订单实体,无需修改
@Data
@AllArgsConstructor
public static class Order {
private String orderId; // 订单ID
private String userId; // 用户ID
private String gunId; // 充电枪ID
private OrderStatusEnum status; // 订单状态
}
// 充电枪状态机(空闲/充电中/故障/已预约),无需修改
public enum GunStatusEnum {
IDLE("IDLE", "空闲"),
CHARGING("CHARGING", "充电中"),
FAULT("FAULT", "故障"),
RESERVED("RESERVED", "已预约");
private final String code;
private final String desc;
GunStatusEnum(String code, String desc) {
this.code = code;
this.desc = desc;
}
public String getDesc() {
return desc;
}
}
// 订单状态枚举,无需修改
public enum OrderStatusEnum {
PENDING_PAY("PENDING_PAY", "待支付"),
PAY_SUCCESS("PAY_SUCCESS", "支付成功"),
PAY_FAILED("PAY_FAILED", "支付失败"),
CHARGING_COMPLETE("CHARGING_COMPLETE", "充电完成");
private final String code;
private final String desc;
OrderStatusEnum(String code, String desc) {
this.code = code;
this.desc = desc;
}
}
}
5. 前端防重复点击代码(Vue3,可直接复制到前端页面使用)
若前端采用Vue3框架 ,可直接复制以下代码替换现有启动充电按钮;其他框架可参考此逻辑调整,核心实现点击后按钮置灰、请求完成后恢复的功能:
<template>
<!-- 启动充电按钮,点击后置灰,防止重复点击 -->
<button @click="startCharging" :disabled="isBtnDisabled" class="charging-btn">
启动充电
</button>
</template>
<script setup>
import { ref } from 'vue';
import axios from 'axios';
// 按钮是否可点击,默认可点击
const isBtnDisabled = ref(false);
// 充电枪ID(实际从页面参数获取,这里模拟)
const gunId = ref('gun_001');
// 用户ID(实际从登录态获取,这里模拟)
const userId = ref('user_123');
// 启动充电方法,直接复制用
const startCharging = async () => {
// 点击后立即禁用按钮,防止重复点击
isBtnDisabled.value = true;
try {
// 生成全局唯一请求ID(前端生成,简单有效)
const requestId = 'REQ_' + new Date().getTime() + '_' + Math.random().toString(36).substr(2, 9);
// 发送请求到后端接口(替换成你的后端地址)
const res = await axios.post('http://localhost:8080/api/charging/start', {
requestId: requestId,
gunId: gunId.value,
userId: userId.value
});
// 处理响应结果
if (res.data.code === 200) {
alert('启动充电成功,跳转支付页面~');
// 这里可添加跳转支付页面的逻辑
} else {
alert('启动失败:' + res.data.msg);
}
} catch (error) {
alert('网络异常,请稍后再试');
console.error('启动充电异常:', error);
} finally {
// 无论成功失败,都恢复按钮可点击,避免卡死
isBtnDisabled.value = false;
}
};
</script>
<style scoped>
.charging-btn {
padding: 10px 20px;
background: #1890ff;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
}
.charging-btn:disabled {
background: #ccc;
cursor: not-allowed;
}
</style>
6. 启动类(ChargingApplication.java,复制到com.example.charging包下,覆盖默认文件)
第四步:启动项目,测试效果
1、启动Redis:本地部署的Redis直接启动,启动成功后后台会显示默认端口6379;
package com.example.charging;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* 项目启动类,可直接复制使用,启动后即可调用接口
*/
@SpringBootApplication
public class ChargingApplication {
public static void main(String[] args) {
SpringApplication.run(ChargingApplication.class, args);
System.out.println("充电防重复订单系统启动成功!接口地址:http://localhost:8080/api/charging/start");
}
}
2、启动Spring Boot项目:在IDEA中,找到ChargingApplication.java,右键→Run,启动成功后,控制台会显示“充电防重复订单系统启动成功”;
3、测试效果:通过Postman或前端页面,多次发送同一请求(相同requestId、gunId、userId),仅第一次请求可成功,后续请求会被拦截,确保仅生成1笔订单,验证防重复效果。
补充说明:
- 代码全部复制,仅需修改application.yml中的Redis地址和密码,其他不用改;
- 若启动失败,大概率是Redis没启动,或Redis地址配置错误,检查Redis是否正常运行、配置是否正确;
- 实际部署时,把充电枪状态、订单信息存到数据库/Redis,避免项目重启后数据丢失(当前代码用内存模拟,不影响测试和使用);
- 多服务部署时,Redis用集群模式,确保分布式锁生效,代码不用改,仅改Redis配置即可。