cms项目细节-幂等与分布式锁
幂等
为什么需要幂等?
用户点“提交留言”,网络卡了一下,他又点了一次;或者浏览器自动重试。如果不做保护,数据库就会插入两条一模一样的数据:
第一次 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 不匹配就失败,防并发覆盖。
唯一业务键(幂等键)方案详解
前端生成:
const idempotentKey = crypto.randomUUID(); // 每次提交生成一个请求带上:
POST /api/message
X-Idempotency-Key: 9f2a...(UUID)后端流程:
1. 用 X-Idempotency-Key 作为 Redis key,缓存业务结果
2. 第一次:SETNX 成功 → 执行业务 → 把结果写进 Redis → 返回结果
3. 第二次同 key:SETNX 失败 → 说明已经处理过 → 读取 Redis 里的结果直接返回(或拒绝)核心代码(Redis 版):
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。天然解决了“并发两个请求都以为自己是第一次”的竞态。
分布式锁
多实例部署时
实例 A 执行备份
实例 B 也在执行备份 ← 两个实例同时跑,冲突、重复Java 里的 synchronized、ReentrantLock 只能锁住当前 JVM,管不到另一台机器。所以要一个“所有实例都能访问”的锁中心,最常用的是 Redis。
分布式锁要满足的要求:
- 互斥:同一时刻只有一个实例能拿到锁。
- 防死锁:拿到锁的实例挂了,锁要能自动释放(靠过期时间)。
- 只能释放自己的锁:A 的锁不能让 B 删掉。
- 可重入(可选):同一线程可多次加锁(Redisson 支持)。
- 性能:加锁/解锁要快、要原子。
value 用唯一标识 + 删除前校验(Lua 保证原子)
加锁:
String owner = UUID.randomUUID().toString(); // 唯一标识,代表“这把锁是我加的”
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent("lock:backup", owner, Duration.ofMinutes(5));
if (!Boolean.TRUE.equals(locked)) {
return;
}释放锁必须用 Lua 脚本,保证“判断是不是我的锁 + 删除”是原子的:
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);执行流程:
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 方案
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,重复提交会插多条。可以加幂等键:
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 记失败次数,多实例下各记各的,攻击者可以每个实例试几次。升级后:
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 释放锁 | 避免“判断是不是自己的锁 + 删除”两步之间的竞态 |
| 幂等和分布式锁区别 | 幂等防重复处理,分布式锁保证资源同一时刻单访问 |