系统幂等性设计:核心解决方案与落地实践

38 阅读6分钟

很高兴你能来阅读,这里我会陆续总结自己的项目经验,编程学习思路即可。首先是对自己编程经验反思,其次希望我的分享对大家有帮助!

在分布式系统消息传递场景中,幂等性是保障业务数据一致性的核心基础。先明确核心概念,再展开具体解决方案:

image.png

什么是幂等性? 指同一操作(或同一条消息)被重复执行多次后,最终得到的业务结果与执行一次完全一致,不会因重复执行导致数据异常(如重复创建订单、库存超额扣减、资金重复划转等)。


方案1:数据库唯一索引

核心原理:利用数据库唯一索引的唯一性约束防止重复插入,结合数据库事务保障幂等标记与业务操作的原子性,避免重复处理。

适配业务场景

  1. 订单创建场景:用户下单时,以订单号作为唯一标识,避免重复创建订单;
  2. 库存扣减场景:扣减库存时,以订单号+商品SKU作为联合唯一标识,防止重复扣减导致负库存。
1. 核心设计:唯一索引表设计(通用去重表,适配多场景)
CREATE TABLE `msg_idempotent` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `biz_no` varchar(64) NOT NULL COMMENT '业务唯一标识(如支付流水号)',
  `biz_type` varchar(32) NOT NULL COMMENT '业务类型(pay_callback/order_create)',
  `status` tinyint NOT NULL COMMENT '处理状态:0-处理中,1-处理成功,2-处理失败',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_biz_no_type` (`biz_no`, `biz_type`) COMMENT '唯一索引:业务号+类型,防止重复'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

幂等性就是 不要消费端重复消费,我们设计一个表,用唯一键,让用户的数据只能进入一次即可


方案2:Redisson分布式锁(高并发/跨服务场景首选)

核心原理:Redisson封装了Redis分布式锁的完整实现,解决了原生RedisTemplate实现的“锁超时、误删锁”等问题,适用于秒杀、高并发下单、跨服务幂等控制。

1. 核心代码实现(秒杀场景为例)
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Service;

import java.util.UUID;
import java.util.concurrent.TimeUnit;

@Slf4j
@Service
public class SeckillService {

    private final RedissonClient redissonClient;
    private final OrderService orderService;

    // 构造器注入
    public SeckillService(RedissonClient redissonClient, OrderService orderService) {
        this.redissonClient = redissonClient;
        this.orderService = orderService;
    }

    /**
     * 处理秒杀消息(Redisson分布式锁实现幂等)
     * @param userId 用户ID
     * @param skuId 商品SKU
     */
    public void handleSeckillMsg(String userId, String skuId) {
        // 1. 生成唯一锁Key(用户ID+商品ID,确保同一用户同一商品只处理一次)
        String lockKey = "idempotent:seckill:" + userId + "_" + skuId;
        RLock lock = redissonClient.getLock(lockKey);

        try {
            // 2. 获取分布式锁:最多等待5秒,锁自动过期时间30秒(Redisson会自动续期)
            boolean lockAcquired = lock.tryLock(5, 30, TimeUnit.SECONDS);
            if (!lockAcquired) {
                // 未获取到锁 → 重复消息/并发处理中,直接返回
                log.info("重复处理秒杀消息,userId:{}, skuId:{}", userId, skuId);
                return;
            }

            // 3. 二次校验(防止锁释放后重复执行)
            if (orderService.checkSeckillOrderExist(userId, skuId)) {
                log.info("用户已秒杀过该商品,userId:{}, skuId:{}", userId, skuId);
                return;
            }

            // 4. 执行业务逻辑(创建秒杀订单)
            orderService.createSeckillOrder(userId, skuId);

        } catch (InterruptedException e) {
            log.error("获取分布式锁中断", e);
            Thread.currentThread().interrupt();
        } catch (Exception e) {
            log.error("处理秒杀消息失败", e);
            throw new RuntimeException("秒杀处理失败", e);
        } finally {
            // 5. 释放锁(仅当前线程持有锁时才释放,避免误删)
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }
}
2. 关键优化点(Redisson核心优势)
  • 自动续期:Redisson的RLock会在业务逻辑执行超时前(默认每10秒)自动续期锁的过期时间,避免“业务没执行完,锁先过期”导致的重复处理;

  • 安全释放:通过isHeldByCurrentThread()校验,确保只有持有锁的线程才能释放,避免误删其他线程的锁;

  • 可配置性:支持公平锁、非公平锁、读写锁等,可根据场景调整(秒杀场景用非公平锁即可)。


方案3:Redis缓存标记(轻量场景)

核心原理:将已处理的消息唯一标识存入Redis,处理前先检查是否存在,适合通知推送、日志记录等非核心场景,基于Redisson简化实现。

import org.redisson.api.RBucket;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Service;

import java.util.concurrent.TimeUnit;

@Service
public class NotifyService {

    private final RedissonClient redissonClient;

    public NotifyService(RedissonClient redissonClient) {
        this.redissonClient = redissonClient;
    }

    /**
     * 处理通知消息(轻量级幂等)
     * @param msgId 消息唯一ID
     */
    public void handleNotifyMsg(String msgId) {
        // 1. 生成缓存Key
        String cacheKey = "idempotent:notify:" + msgId;
        RBucket<String> bucket = redissonClient.getBucket(cacheKey);

        // 2. 检查是否已处理
        if (bucket.isExists()) {
            log.info("通知消息已处理,msgId:{}", msgId);
            return;
        }

        // 3. 执行业务逻辑(发送短信/推送)
        sendSms(msgId);

        // 4. 存入Redis,设置24小时过期(避免缓存膨胀)
        bucket.set("1", 24, TimeUnit.HOURS);
    }

    private void sendSms(String msgId) {
        // 发送短信逻辑
    }
}


方案4:状态机控制(订单/工单场景)

场景:这里适用于状态严格流程的场景,必须A->B->C 每次都会新增校验判断,避免重复更新!

核心原理:严格限定业务状态流转规则,结合数据库唯一索引,杜绝重复更新,适用于订单状态流转(待支付→已支付→已发货)。

// 订单状态枚举
public enum OrderStatus {
    UNPAID(0, "待支付"),
    PAID(1, "已支付"),
    SHIPPED(2, "已发货"),
    FINISHED(3, "已完成");

    private final int code;
    private final String desc;

    OrderStatus(int code, String desc) {
        this.code = code;
        this.desc = desc;
    }

    public int getCode() {
        return code;
    }
}

@Service
@Transactional(rollbackFor = Exception.class)
public class OrderStatusService {

    private final OrderMapper orderMapper;

    public OrderStatusService(OrderMapper orderMapper) {
        this.orderMapper = orderMapper;
    }

    /**
     * 更新订单状态(状态机控制幂等)
     * @param orderId 订单ID
     * @param targetStatus 目标状态
     * @return 是否更新成功
     */
    public boolean updateOrderStatus(Long orderId, OrderStatus targetStatus) {
        // 1. 查询当前订单状态
        OrderDO order = orderMapper.getById(orderId);
        if (order == null) {
            log.warn("订单不存在,orderId:{}", orderId);
            return false;
        }

        // 2. 状态机校验:仅允许待支付→已支付的合法流转
        if (OrderStatus.UNPAID.getCode() != order.getStatus() || OrderStatus.PAID != targetStatus) {
            log.info("订单状态不允许更新,orderId:{}, 当前状态:{}, 目标状态:{}",
                    orderId, order.getStatus(), targetStatus.getCode());
            return false;
        }

        // 3. 执行状态更新(结合订单表唯一索引保障幂等)
        return orderMapper.updateStatus(orderId, targetStatus.getCode()) == 1;
    }
}


二、不同场景的方案选型建议

幂等性问题核心是同一操作重复触发/执行,主要分两类原因:

  • 客观上,网络异常(响应或ACK丢失)、系统组件重试(网关、消息队列等)、分布式一致性保障(事务补偿等)导致重复,难以完全避免;
  • 主观上,用户误操作(如重复提交)、人工运维/运营误操作引发重复,可通过产品设计和权限控制减少。
场景推荐方案核心优势
中低并发写库数据库唯一索引简单、事务一致性保障
高并发/跨服务/秒杀Redisson分布式锁自动续期、安全释放、高可用
轻量通知/日志Redis缓存标记(Redisson)实现简单、性能最优
订单/工单状态流转状态机+数据库唯一索引贴合业务、逻辑严谨

补充说明:

最常见的场景按钮的重复点击导致的幂等性问题,我们可以新增前端限制

这里关于按钮重复点击的问题我补充一点:

  • 前端:这里也可以补充控制,如点击一次后按钮置灰,直到响应返回。

    • 很多新手前端这里容易忘记,比如我们提交订单这里就可以设计,避免用户手抖重复提交!
  • 后端:幂等性校验


📣非常感谢你阅读到这里,如果这篇文章对你有帮助,希望能留下你的点赞👍 关注❤️ 分享👥 留言💬thanks!!!