充电订单防重复:原理拆解 + 完整可运行代码 - 慧知开源充电桩管理平台

网络抖动重复充电订单解决方案


做充电业务的普遍会遇到“重复订单”问题:用户在充电站网络卡顿,点击“启动充电”后无响应,多次重复点击,导致后台生成多笔待支付订单,引发用户投诉、客服退款、技术应急处理等一系列麻烦。


本方案聚焦这一核心痛点,提供可直接落地的解决方案,代码完整可复制,无需额外调试,适配各类 新能源充电业务场景,技术人员可直接复用,高效解决重复订单问题。

一、问题说明

结合实际业务场景,具体问题如下,方便大家快速判断自身是否存在同类情况:

  1. 场景就是这么个场景:用户去充电站充电,打开APP点“启动充电”,结果网络卡了——APP既不显示“启动成功”,也不显示“启动失败”,就卡在那。用户急着充电,下意识就会连续点好几次(换谁都会这么做),相当于短时间内,给后台发了好几条“启动充电”的请求。
  2. 问题根源就在这:后台没做防护,是个“傻大个”——收到一条请求,就生成一条待支付订单,不管是不是同一个用户、同一个充电枪,也不管是不是重复点的。最后就导致:用户就充一次电,手机上收到好几个支付通知,后台好几笔待支付订单,用户以为被扣了好几次钱,直接投诉,客服忙得脚不沾地,技术还得手动删订单、处理退款,纯纯内耗!

核心原因:网络波动导致用户重复触发请求,后端未做有效防护,最终生成重复订单,增加运营及技术处理成本。

二、解决方案及技术原理

核心解决思路:前端限制重复点击,后端拦截重复及并发请求,确保同一充电枪、同一用户在同一时间仅能处理一次充电请求。方案采用3项核心技术,以下详细说明技术作用及原理,便于理解和落地。

核心技术及原理

采用通俗化解读结合生活化比喻,清晰说明各技术的作用及原理,帮助非技术人员理解,技术人员可直接参考落地。

1、全局请求幂等(最基础的防护,拦不住它,后面全白搭)
作用:给每一次“启动充电”请求,发一个“独一无二的身份证”(就叫请求ID),后台收到请求后,先查这个“身份证”有没有来过——来过,就直接拒绝,不处理;没来过,才正常往下走。相当于:同一个请求,不管发多少次,后台只认第一次,后面的全拦在门外。

原理:就像你去超市结账,拿了一个结账号,不管你排多少次队,同一个号,收银员只给你结一次账,不会因为你多排几次,就给你结好几次。这里的“结账号”,就是请求ID;“收银员”,就是后台程序。


2、枪维度分布式锁(核心中的核心,从根源防重复)

作用:给“充电枪+用户”这个组合,加一把“专属锁”。比如:用户A用充电枪001,点击启动充电后,这把锁就锁上了,在锁没解开之前,用户A再点多少次、再发多少请求,都进不来;只有等第一次请求处理完(启动充电成功/失败),锁解开了,才能处理下一次请求(正常情况下,第一次处理完就启动充电了,下一次请求会被拦住)。

原理:就像你去公共厕所,每间厕所都有锁,一个人进去锁上门,其他人就只能等,不能同时进去。这里的“厕所”,就是“充电枪+用户”;“锁”,就是分布式锁;“人”,就是用户的请求。这样就能确保,同一时间,同一个用户用同一把枪,只能有一个请求在处理,不会出现并发请求导致的重复订单。


3、状态机互斥(最后一道防线,兜底用的)

作用:给每一把充电枪,设置一个“状态”——空闲、充电中、故障、已预约。后台处理请求前,先查一下充电枪的状态:只有状态是“空闲”,才能启动充电、生成订单;如果是“充电中”,直接拒绝,不让生成新订单。哪怕前面两道防线漏了,这一道也能拦住。

原理:就像你去餐厅吃饭,桌子上放着“空闲”“用餐中”的牌子,服务员看到“空闲”,才会安排你坐下;看到“用餐中”,就会让你等。这里的“桌子”,就是充电枪;“牌子”,就是充电枪的状态;“服务员”,就是后台的校验程序。


总结:3个技术配合,相当于“三重防护”

第一道:幂等校验,拦重复请求;
第二道:分布式 锁,拦并发请求;
第三道:状态机,拦无效请求。
三道防线一起上,不管网络怎么抖、用户怎么点,都不会出重复订单。

三、业务流转:从用户点击到充电启动,整个流程是怎样的?

按实际发生的顺序,说明每一步流程,清晰呈现用户点击后后台的处理逻辑及防重复请求的机制:

  1. 用户操作:用户打开充电APP,选择充电枪,点击“启动充电”;
  2. 前端防护:用户点击后,APP按钮立马变灰,不能再点击(防止用户重复点),同时生成一个“独一无二的请求ID”;
  3. 请求发送:APP把“请求ID、用户ID、充电枪ID”这三个关键信息,发给后台;
  4. 第一道校验(幂等):后台收到请求,先查“请求ID”有没有来过,来过就直接返回“操作太频繁”,拒绝处理;没来过,就继续下一步;
  5. 第二道校验(分布式锁):后台给“当前充电枪+当前用户”加一把锁,加锁成功,就继续下一步;加锁失败(说明有其他请求在处理),就返回“操作正在处理中”;
  6. 第三道校验(状态机):后台查当前充电枪的状态,是“空闲”就继续;不是空闲(充电中/故障),就返回“充电枪不可用”;
  7. 生成订单:三道校验都通过,后台生成1笔待支付订单,同时把充电枪状态改成“充电中”;
  8. 反馈结果:后台把“启动成功+订单信息”返回给APP,APP按钮恢复可点击,用户跳转支付,充电启动;
  9. 释放锁:不管启动成功还是失败,后台都会解开“充电枪+用户”的锁,避免后续请求无法处理。

补充:整个流程,从用户点击到反馈结果,也就几百毫秒,用户完全感觉不到延迟,同时还能彻底杜绝重复订单。

四、实践流程:从新建工程到运行成功

所有代码均已完整编写,可直接复制粘贴使用,仅需根据自身环境修改Redis地址,按流程操作即可完成部署。

说明:用的是行业最常用的「Spring Boot 2.7.x + Redis」,这两个工具,做技术的基本都在用,不用额外装其他复杂工具,常规环境就能运行。


第一步:准备环境

项目运行需提前安装两个基础工具,无需特殊配置:

  1. JDK:装JDK8(推荐,兼容性最好),装完后打开cmd,输入“java -version”,能显示版本号(比如1.8.0_301),就说明装好了;
  2. Redis:装Redis 6.0+,本地部署的话,装完直接启动(双击redis-server.exe就行);远程部署的话,记住Redis的地址、端口、密码(后续改配置用)。

两个工具均为常规安装,可参考网上常规教程完成,无需关注细节。

第二步:新建工程

  1. 打开IDEA(或Eclipse),新建一个Spring Boot项目,项目名称随便起(比如charging-project);
  2. 项目版本选择Spring Boot 2.7.x(别选太高,避免兼容性问题);
  3. 新建完成后,删除默认的DemoApplication之外的所有文件,保持项目整洁。
  4. 新建完成后,删除默认的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笔订单,验证防重复效果。


补充说明:

  1. 代码全部复制,仅需修改application.yml中的Redis地址和密码,其他不用改;
  2. 若启动失败,大概率是Redis没启动,或Redis地址配置错误,检查Redis是否正常运行、配置是否正确;
  3. 实际部署时,把充电枪状态、订单信息存到数据库/Redis,避免项目重启后数据丢失(当前代码用内存模拟,不影响测试和使用);
  4. 多服务部署时,Redis用集群模式,确保分布式锁生效,代码不用改,仅改Redis配置即可。
Last Updated: 2026/02/28 18:02:50
正月十五 元宵节 - 慧知开源充电桩平台 充电桩管理平台 - 经营管理核心逻辑-慧知开源充电桩平台
OωO 取消
  • |´・ω・)ノ
  • ヾ(≧∇≦*)ゝ
  • (☆ω☆)
  • (╯‵□′)
  •  ̄﹃ ̄
  • (/ω\)
  • →_→
  • (ノ°ο°)ノ
  • ⌇●﹏●⌇
  • (ฅ´ω`ฅ)
  • φ( ̄∇ ̄o)
  • ヾ(´・ ・`。)ノ"
  • (ó﹏ò。)
  • Σ(っ °Д °;)っ
  • ( ,,´・ω・)ノ
  • ╮(╯▽╰)╭
  • (。•ˇ‸ˇ•。)
  • >﹏<
  • ( ๑´•ω•)
  • "(´っω・`。)
  • "(ㆆᴗㆆ)