cms项目细节-幂等与分布式锁

共 2,210 字 约 7 分钟

幂等

为什么需要幂等?

用户点“提交留言”,网络卡了一下,他又点了一次;或者浏览器自动重试。如果不做保护,数据库就会插入两条一模一样的数据:

Text
UTF-8|2 Lines|
第一次 POST /api/message  → 插入留言 A
第二次 POST /api/message  → 插入留言 A(重复)

这就是“不幂等”的后果:同一个动作,重复触发产生了重复结果。

更危险的场景是支付、下单:重复提交会重复扣款、重复下单。

所以“幂等”要解决的问题是:同一个业务请求发 N 次,业务结果和发 1 次一样。

什么是幂等

如果一个操作执行一次和执行多次,结果一致,它就是幂等的。

例子:

  • 查询:查询 id=1 的内容,查几次都一样 → 天然幂等。
  • 删除:delete by id,删一次和删多次效果相同 → 幂等。
  • 插入:insert 每次插入一条,重复执行会产生多条 → 不幂等,最需要保护。
  • 扣款/下单:重复执行会产生重复业务 → 不幂等,必须加保护。

所以幂等主要针对“写操作”,尤其是插入和变更型接口。

实现层次

第一层:前端防呆(治标)

  • 提交后立即禁用按钮。
  • 防抖/节流。

作用:减少用户点击。但网络重试、恶意请求防不住,只能算“体验层”。

第二层:服务端唯一业务键(核心)

前端提交时生成一个唯一键(UUID),后端第一次见到它执行,第二次见到直接拒绝或返回第一次结果。

第三层:数据库唯一约束(兜底)

即使前端没传 token,数据库唯一索引也能拦住重复数据,是最后一道防线。

第四层:乐观锁(version)

更新时带版本号,update ... set x=?, version=version+1 where id=? and version=?,version 不匹配就失败,防并发覆盖。

唯一业务键(幂等键)方案详解

前端生成:

JavaScript
UTF-8|1 Line|
const idempotentKey = crypto.randomUUID();   // 每次提交生成一个

请求带上:

http
UTF-8|2 Lines|
POST /api/message
X-Idempotency-Key: 9f2a...(UUID)

后端流程:

Text
UTF-8|3 Lines|
1. 用 X-Idempotency-Key 作为 Redis key,缓存业务结果
2. 第一次:SETNX 成功 → 执行业务 → 把结果写进 Redis → 返回结果
3. 第二次同 key:SETNX 失败 → 说明已经处理过 → 读取 Redis 里的结果直接返回(或拒绝)

核心代码(Redis 版):

Java
UTF-8|22 Lines|
String key = "idempotent:message:" + idempotentKey;

// SETNX:key 不存在才写入,返回 true 表示第一个拿到
Boolean first = redisTemplate.opsForValue()
        .setIfAbsent(key, "doing", Duration.ofMinutes(10));

if (!Boolean.TRUE.equals(first)) {
    // 已被处理:直接返回一个明确的“重复提交”提示
    throw new BizException("请勿重复提交");
}

try {
    // 执行真正的业务:插入留言
    Object result = messageService.add(...);
    // 业务成功后,可以把结果存进 Redis,供后续重试查询
    redisTemplate.opsForValue().set(key, JSON.toJSONString(result));
    return result;
} catch (Exception e) {
    // 业务失败要删除幂等 key,否则这个请求永远被当成“已处理”
    redisTemplate.delete(key);
    throw e;
}

为什么 SETNX 能满足幂等:SETNX(set if not exists)是 Redis 的原子操作,多个请求同时提交同一个 key,只会有一个返回 true,其余都是 false。天然解决了“并发两个请求都以为自己是第一次”的竞态。

分布式锁

多实例部署时

Text
UTF-8|2 Lines|
实例 A 执行备份
实例 B 也在执行备份   ← 两个实例同时跑,冲突、重复

Java 里的 synchronized、ReentrantLock 只能锁住当前 JVM,管不到另一台机器。所以要一个“所有实例都能访问”的锁中心,最常用的是 Redis。

分布式锁要满足的要求:

  1. 互斥:同一时刻只有一个实例能拿到锁。
  2. 防死锁:拿到锁的实例挂了,锁要能自动释放(靠过期时间)。
  3. 只能释放自己的锁:A 的锁不能让 B 删掉。
  4. 可重入(可选):同一线程可多次加锁(Redisson 支持)。
  5. 性能:加锁/解锁要快、要原子。

value 用唯一标识 + 删除前校验(Lua 保证原子)

加锁:

Java
UTF-8|6 Lines|
String owner = UUID.randomUUID().toString();  // 唯一标识,代表“这把锁是我加的”
Boolean locked = redisTemplate.opsForValue()
        .setIfAbsent("lock:backup", owner, Duration.ofMinutes(5));
if (!Boolean.TRUE.equals(locked)) {
    return;
}

释放锁必须用 Lua 脚本,保证“判断是不是我的锁 + 删除”是原子的:

Java
UTF-8|11 Lines|
String script =
        "if redis.call('get', KEYS[1]) == ARGV[1] then " +
        "  return redis.call('del', KEYS[1]) " +
        "else " +
        "  return 0 " +
        "end";

DefaultRedisScript<Long> releaseScript = new DefaultRedisScript<>(script, Long.class);

Long result = redisTemplate.execute(releaseScript,
        Collections.singletonList("lock:backup"), owner);

执行流程:

Text
UTF-8|3 Lines|
1. 加锁:SET lock:backup <owner>  EX 300 NX  (NX=不存在才设置,原子)
2. 业务:databaseService.backup()
3. 释放:Lua 脚本里先 GET 判断 value == owner,是才 DEL

这样即使 A 的锁过期、B 拿走了锁,A 释放时发现 value 不是自己的 owner,不会误删 B 的锁。

锁过期时间

如果业务执行时间超过锁的过期时间,锁在业务运行中就被自动释放了,别的线程就能拿到锁,导致“锁失效”。

  • 方案一:过期时间设得比业务最长执行时间还长(简单,但可能太长)。
  • 方案二:看门狗自动续期:Redisson 的 RLock 默认会启动一个后台任务,在锁快过期时自动续期,直到业务结束或线程被取消

Redisson 方案

Java
UTF-8|14 Lines|
RLock lock = redissonClient.getLock("lock:backup");

try {
    // 尝试加锁,最多等 10 秒,锁自动过期 30 秒
    boolean locked = lock.tryLock(10, 30, TimeUnit.SECONDS);
    if (!locked) {
        return;
    }
    databaseService.backup();
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();
} finally {
    lock.unlock();   // 只有持有者能释放
}

Redisson 好处:

  • 自动续期(看门狗)。
  • 可重入。
  • 公平锁、读写锁等更丰富的锁。
  • 释放锁安全(内部判断持有者 + Lua)。
对比接口幂等分布式锁
目标防止“同一个业务请求”被重复处理保证“同一个资源/任务”同一时刻只有一个实例访问
典型场景下单、提交、支付防重复定时任务、并发写同一资源、扣库存
手段唯一业务键 + SETNX锁 + value 判主 + Lua 释放
粒度一次业务请求一个 key一个资源一个锁
结果重复请求被拒绝/返回原结果并发访问串行化

实例

表单提交/留言/评论防重复

项目里 DynamicFormService.submit()、PublicInteractionService.addMessage()、addComment() 都是纯 insert,重复提交会插多条。可以加幂等键:

Java
UTF-8|22 Lines|
public void addMessage(String idempotencyKey, String acode, String contacts,
                       String mobile, String content, String ip) {
    String key = "idempotent:message:" + idempotencyKey;

    // 第一次请求才会成功拿到 true
    Boolean first = redisTemplate.opsForValue()
            .setIfAbsent(key, "doing", Duration.ofMinutes(10));

    if (!Boolean.TRUE.equals(first)) {
        throw new BizException("请勿重复提交");   // 第二个同 key 请求被拦
    }

    try {
        // 真正插入
        messageMapper.insert(message);
        // 成功:不删 key,靠 TTL(10分钟) 自然过期,让“这 10 分钟内同 key 都不能再提交”
        // 如果你想“重复请求返回第一次结果”,可以在这里把结果写进 key
    } catch (Exception e) {
        redisTemplate.delete(key);   // 失败:删掉 key,允许用户重试
        throw e;
    }
}

idempotencyKey 是前端为“这一次提交”生成的唯一标识(UUID)

相同 idempotencyKey → 同一个 key → 被当成同一次请求;不同 idempotencyKey → 不同 key → 新请求

登录失败锁定从单机升级为 Redis

项目里 LoginAttemptService 用 ConcurrentHashMap 记失败次数,多实例下各记各的,攻击者可以每个实例试几次。升级后:

Java
UTF-8|8 Lines|
String key = "login:lock:" + ip;
Long count = redisTemplate.opsForValue().increment(key);
if (count != null && count == 1) {
    redisTemplate.expire(key, Duration.ofMinutes(15)); // 15 分钟窗口
}
if (count != null && count > 5) {
    throw new BizException("Too many attempts, retry later");
}
问题答案
什么接口需要幂等插入/更新型、尤其是下单、扣款、提交,重复执行会产生重复业务
怎么防重复提交前端防呆 + 唯一业务键 + 数据库唯一约束,三层
SETNX 能保证并发唯一吗能,SETNX 是原子操作,多请求只有一个返回 true
业务失败要不要删幂等 key要,否则失败请求被当成已处理,用户再试被拒
幂等 key 怎么生成前端 UUID 或后端根据业务参数生成
为什么需要分布式锁synchronized 只锁当前 JVM,多实例管不到
分布式锁要满足什么互斥、防死锁、只释放自己的锁、可重入、高效
怎么防误删别人的锁加锁 value 存唯一 owner,释放用 Lua 判断再删
锁过期业务没做完怎么办Redisson 看门狗自动续期,或把过期时间设得足够长
为什么用 Lua 释放锁避免“判断是不是自己的锁 + 删除”两步之间的竞态
幂等和分布式锁区别幂等防重复处理,分布式锁保证资源同一时刻单访问