1. 充电订单防重复的核心挑战
在充电桩运营场景中,订单防重复是一个看似简单实则暗藏玄机的技术点。去年我们团队在开发慧知开源充电桩管理平台时,就曾因为一个小数点后三位的金额差异,导致同一用户在同一充电桩上生成了两条几乎相同的订单记录。这种看似微小的漏洞,在财务对账时会造成巨大困扰。
充电订单的重复问题主要来自三个维度:
- 网络抖动导致的重复请求:用户在信号不佳的地下车库点击"开始充电"按钮时,移动端可能因超时重试机制发送多次请求
- 客户端防重失效:某些低版本APP禁用JavaScript后,前端防重点击逻辑可能失效
- 并发场景下的竞争条件:当多个充电桩终端同时向服务器发送状态变更请求时
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 防重复技术方案选型
2.1 业界常见方案对比
我们在技术选型时对比了四种主流方案:
| 方案类型 | 实现复杂度 | 可靠性 | 适用场景 | 缺点 |
|---|---|---|---|---|
| 前端防重 | 低 | 中 | 简单业务 | 易被绕过 |
| 数据库唯一索引 | 中 | 高 | 数据强一致 | 影响写入性能 |
| 分布式锁 | 高 | 高 | 高并发场景 | 实现复杂 |
| 幂等令牌 | 中 | 高 | 支付类业务 | 需额外存储 |
2.2 最终采用的混合方案
结合充电桩业务特点,我们设计了三级防御体系:
- 前端层面:采用Vue指令实现的防重点击(300ms冷却)
- 网关层面:基于Redis的简易幂等校验(5秒有效期)
- 业务层面:组合唯一索引(用户ID+桩号+开始时间戳)
java复制// 组合唯一索引示例
@Table(uniqueConstraints = {
@UniqueConstraint(columnNames = {"userId", "pileId", "startTime"})
})
public class ChargingOrder {
// 实体类字段
}
3. 核心实现细节解析
3.1 订单号生成策略
我们放弃了传统的UUID方案,采用更具业务意义的编码规则:
code复制CP-{地区码}-{桩类型}-{年月日}-{Redis自增序列}
这种设计带来三个好处:
- 人工识别时能快速定位问题桩
- 避免完全随机字符串导致的索引碎片
- 自增部分使用Redis集群保证全局唯一
python复制# Python版订单号生成
def generate_order_id(region_code, pile_type):
date_part = datetime.now().strftime("%Y%m%d")
seq = redis_client.incr(f"order_seq:{date_part}")
return f"CP-{region_code}-{pile_type}-{date_part}-{seq:06d}"
3.2 状态机设计要点
充电订单有严格的状变迁逻辑,我们采用状态模式实现:
mermaid复制stateDiagram
[*] --> UNPAID
UNPAID --> CHARGING : 扫码成功
CHARGING --> PAUSED : 手动暂停
PAUSED --> CHARGING : 继续充电
CHARGING --> COMPLETED : 自动结束
COMPLETED --> [*]
关键约束条件:
- 从UNPAID到CHARGING需要校验桩的可用状态
- 暂停操作需要记录中断时的电量值
- 结束状态必须包含最终结算信息
4. 分布式环境下的特殊处理
4.1 时钟同步问题
我们在测试环境发现过因服务器时间不同步导致的诡异问题:
- 服务器A生成订单时间戳:2023-01-01 10:00:00
- 服务器B生成订单时间戳:2023-01-01 09:59:59
- 导致唯一索引失效
解决方案:
- 强制所有服务器启用NTP同步
- 业务代码中统一使用数据库时间:
java复制// 避免直接使用System.currentTimeMillis()
@Query("SELECT NOW()")
LocalDateTime getDbTime();
4.2 缓存与数据库一致性
采用"先更新数据库再删除缓存"的策略,并添加补偿机制:
python复制def update_order_status(order_id, new_status):
# 先更新数据库
db.execute("UPDATE orders SET status=? WHERE id=?", [new_status, order_id])
# 再删除缓存
try:
cache.delete(f"order:{order_id}")
except Exception as e:
# 失败时加入补偿队列
mq.send_compensation_task({
"type": "CACHE_DELETE",
"key": f"order:{order_id}"
})
5. 性能优化实践
5.1 索引优化方案
经过慢查询分析,我们在orders表上建立了复合索引:
sql复制-- 原始表结构
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
user_id BIGINT,
pile_id VARCHAR(32),
start_time DATETIME,
-- 其他字段...
);
-- 优化后的索引
CREATE INDEX idx_order_query ON orders(user_id, status, start_time DESC);
CREATE UNIQUE INDEX uk_order_unique ON orders(user_id, pile_id, start_time);
5.2 批量处理技巧
对于日终结算任务,我们采用分批处理方式:
java复制public void batchSettleOrders(LocalDate settleDate) {
int batchSize = 500;
long maxId = 0;
do {
List<Order> orders = orderRepository.findUnsettledOrders(
settleDate, maxId, batchSize);
if (orders.isEmpty()) {
break;
}
orders.forEach(this::settleSingleOrder);
maxId = orders.get(orders.size()-1).getId();
} while (true);
}
6. 异常处理实录
6.1 典型问题排查表
| 异常现象 | 可能原因 | 解决方案 |
|---|---|---|
| 重复订单状态不一致 | 并发更新冲突 | 添加乐观锁版本号 |
| 结算金额差0.01元 | 浮点数精度问题 | 改用Decimal类型 |
| 历史订单查询慢 | 缺少合适索引 | 添加组合索引 |
| 缓存显示旧状态 | 缓存未及时清除 | 双重删除策略 |
6.2 重试机制设计
对于可能失败的第三方调用,我们实现了指数退避重试:
python复制def call_with_retry(func, max_retries=3):
base_delay = 0.1
for attempt in range(max_retries):
try:
return func()
except Exception as e:
if attempt == max_retries - 1:
raise
time.sleep(base_delay * (2 ** attempt))
7. 监控与告警体系
7.1 关键指标监控
我们在Grafana中配置了以下仪表盘:
- 订单创建成功率(<99.9%触发告警)
- 重复订单比率(>0.1%触发调查)
- 状态变更平均耗时(>500ms需要优化)
7.2 日志追踪方案
使用ELK栈实现全链路追踪,关键字段包括:
- trace_id:贯穿整个请求生命周期
- order_id:业务实体标识
- user_id:关联用户行为
java复制// 日志打点示例
MDC.put("traceId", UUID.randomUUID().toString());
logger.info("Begin charging process, pileId={}", pileId);
8. 完整代码实现
项目开源地址已包含完整实现,这里展示核心防重逻辑:
java复制@RestController
@RequestMapping("/api/orders")
public class OrderController {
@Autowired
private RedisTemplate<String, String> redisTemplate;
@PostMapping
public ResponseEntity<?> createOrder(@RequestBody OrderRequest request) {
// 1. 校验幂等令牌
String idempotentKey = "idempotent:" + request.getUserId()
+ ":" + request.getPileId();
if (!redisTemplate.opsForValue().setIfAbsent(idempotentKey, "1", 5, TimeUnit.SECONDS)) {
throw new BusinessException("操作正在处理中,请勿重复提交");
}
try {
// 2. 创建订单
Order order = orderService.createOrder(request);
return ResponseEntity.ok(order);
} finally {
// 3. 清理令牌(实际业务中可能延迟删除)
redisTemplate.delete(idempotentKey);
}
}
}
9. 实战经验总结
在半年多的生产环境运行中,我们收获了这些宝贵经验:
-
防重时间窗口:充电业务设置5-10秒的防重窗口最合适,太短无法覆盖网络抖动,太长影响用户体验
-
唯一索引选择:user_id + pile_id + 精确到分钟的时间戳(非秒级),既能防重又不会因时间同步问题失效
-
补偿机制:对于极可能出现的重复订单,要有完善的人工处理流程和补偿接口
-
压力测试:模拟200个充电桩同时启动的场景,验证防重系统可靠性
这个方案目前每天处理超过10万笔充电订单,重复订单率控制在0.02%以下。对于想要自建充电管理系统的团队,建议重点关注状态机设计和分布式时钟问题,这两个点最容易埋下隐患。
