一、为什么考察这个问题?
- 你是否能识别重复写入的常见场景,理解 "重复写入" 的核心诱因(并发、重试、分布式部署)?
- 能否掌握 2-3 种主流解决方案(尤其是 Redis 原子命令,高频考点),知道其原理与适用场景?
- 能否结合业务设计幂等性标识,从源头规避重复写入,体现实战思维?
- 能否考虑异常场景(如 Redis 宕机、锁超时),体现方案的健壮性?
二、通俗认知
用 "给自助储物柜存物品" 类比,快速理解避免重复写入的核心思路:
- 「Redis」= 自助储物柜,「数据写入」= 往柜子里放物品,「重复写入」= 同一物品多次放入同一柜子(或不同柜子导致混乱);
- 方案 1(原子存件):柜子自带 "仅空柜可存" 功能,放入前无需检查,直接操作,已有物品则存失败(对应 Redis 原子命令);
- 方案 2(先拿钥匙再存):存件前先到前台拿唯一钥匙(对应分布式锁),只有拿到钥匙才能存,避免多人同时存;
- 方案 3(贴唯一标签):给物品贴唯一二维码标签(对应业务唯一标识),柜子识别标签,重复标签拒绝存入。
简单说:确保 Redis 数据不重复写入,核心是要么让写入操作本身具有 "唯一性判断" 的原子性,要么通过外部控制保证 "同一数据仅能被写入一次",要么从源头生成唯一标识规避重复。
三、先明确 Redis 重复写入的常见场景
在拆解方案前,先明确重复写入的诱因(不同场景对应不同最优方案):
| 重复写入场景 | 具体描述 | 示例场景 |
|---|---|---|
| 并发请求重复提交 | 用户多次点击按钮(如下单、提交表单),或系统多线程同时写入同一数据 | 用户连续点击 "缓存商品信息" 按钮、多线程同步同一用户的积分数据 |
| 网络超时 / 重试导致重复 | 客户端发送 Redis 写入请求后,未收到响应(网络抖动 / Redis 超时),客户端触发重试,导致多次写入 | 订单支付成功后,缓存订单状态时 Redis 响应超时,客户端重试写入 |
| 分布式系统多节点重复执行 | 分布式服务 / 定时任务集群部署,多个节点同时执行同一写入逻辑 | 定时任务集群多节点同时缓存每日报表数据、多服务节点同步同一配置到 Redis |
| 业务逻辑无幂等性设计 | 写入前未判断数据是否已存在,直接执行覆盖 / 新增操作,导致重复存储 | 未判断用户是否已缓存过,重复将用户信息写入 Redis |
四、确保 Redis 数据不重复写入
方案 1:使用 Redis 原子命令(最推荐,无锁竞争,性能最高)
Redis 的单线程执行模型保证了单个命令的原子性(执行过程中不会被其他命令打断),利用这一特性,可直接通过命令实现 "存在则不写入,不存在才写入",从根本上避免重复。
(1)核心命令与适用场景
| 原子命令 | 命令格式 | 核心逻辑 | 适用场景 |
|---|---|---|---|
| SETNX(Set If Not Exists) | SETNX key value |
仅当key不存在时,才设置key的值,返回 1;存在则返回 0,不执行写入 |
单键值对的唯一写入(如缓存唯一标识、锁标记) |
| SET 扩展命令(XX/NX) | SET key value NX PX 30000(NX = 不存在才设,PX = 过期时间) |
结合NX实现唯一写入,同时设置过期时间(避免死键),比 SETNX 更灵活 |
单键值对唯一写入 + 自动过期(如缓存临时数据、分布式锁) |
| HSETNX(Hash Set If Not Exists) | HSETNX hashKey field value |
仅当hashKey中的field不存在时,才设置field的值,返回 1;存在则返回 0 |
哈希结构的字段唯一写入(如存储用户多维度信息,避免字段重复) |
关键补充:
SETNX无过期时间,若写入后服务宕机,key会永久存在,建议优先使用SET key value NX PX(既保证唯一写入,又避免死键);- 原子命令的返回值是判断是否写入成功的关键(返回 1/OK 表示写入成功,返回 0/nil 表示已存在,写入失败)。
(2)代码示例(基于 Spring Data Redis 实现)
示例 1:SET NX PX 实现商品信息唯一缓存(避免并发重复缓存)
@Service
public class GoodsCacheService {
@Autowired
private StringRedisTemplate redisTemplate;
// 缓存商品信息,确保同一商品仅被缓存一次(避免并发重复写入)
public boolean cacheGoodsInfo(Long goodsId, GoodsInfo goodsInfo) {
// 1. 定义Redis key(以商品ID作为唯一key,从源头保证键的唯一性)
String redisKey = "goods:info:" + goodsId;
// 2. 序列化工商品信息
String goodsJson = JSON.toJSONString(goodsInfo);
// 3. 执行Redis原子命令:NX=不存在才写入,PX=30分钟过期(1800000毫秒)
Boolean result = redisTemplate.opsForValue()
.setIfAbsent(redisKey, goodsJson, 1800, TimeUnit.SECONDS);
// 4. 解析结果:result=true表示写入成功,false表示已存在,重复写入失败
if (Boolean.TRUE.equals(result)) {
log.info("商品{}信息缓存成功", goodsId);
return true;
} else {
log.warn("商品{}信息已缓存,无需重复写入", goodsId);
return false;
}
}
}
示例 2:HSETNX 实现用户积分记录唯一写入(避免字段重复)
@Service
public class UserScoreService {
@Autowired
private StringRedisTemplate redisTemplate;
// 记录用户单日积分获取,同一用户同一任务仅能记录一次(避免重复加分)
public boolean recordUserScore(Long userId, String taskId, Integer score) {
// 1. 定义Hash key(用户积分记录表)和field(用户ID+任务ID作为唯一字段)
String hashKey = "user:score:record:" + LocalDate.now();
String field = userId + ":" + taskId;
// 2. 执行HSETNX原子命令:仅当field不存在时,才写入积分
Boolean result = redisTemplate.opsForHash()
.putIfAbsent(hashKey, field, score.toString());
// 3. 解析结果
if (Boolean.TRUE.equals(result)) {
log.info("用户{}任务{}积分记录成功", userId, taskId);
// 可选:设置Hash过期时间(避免数据堆积)
redisTemplate.expire(hashKey, 7, TimeUnit.DAYS);
return true;
} else {
log.warn("用户{}任务{}积分已记录,无需重复写入", userId, taskId);
return false;
}
}
}
(3)优缺点与适用场景
| 特性 | 详情描述 |
|---|---|
| 优点 | 1. 高性能:单命令原子执行,无锁竞争,支持高并发(Redis 单线程处理,QPS 可达 10 万 +);2. 实现简单:无需额外封装,直接调用 Redis 命令即可;3. 无死锁风险:设置过期时间后,即使服务宕机,key也会自动过期 |
| 缺点 | 1. 仅支持简单场景:无法处理 "写入前需执行复杂业务逻辑" 的场景(如先查数据库再写入 Redis);2. 键唯一性依赖业务设计:若key/field设计不合理,仍可能出现重复(如商品 ID 错误) |
| 适用场景 | 高并发、简单唯一写入场景(如缓存唯一数据、记录唯一操作、临时标记存储) |
方案 2:使用 Redis 分布式锁(解决复杂业务场景,保证写入唯一性)
当写入 Redis 前需要执行复杂业务逻辑(如查询数据库、计算数据)时,原子命令无法满足 "业务逻辑 + 写入 Redis" 的整体原子性,此时需要通过分布式锁保证 "同一时间仅一个线程 / 节点能执行写入操作",避免重复。
(1)核心原理
利用 Redis 的SET NX PX命令实现分布式锁,流程如下:
- 写入前,线程 / 节点先尝试获取分布式锁(
SET lockKey lockValue NX PX 30000); - 若获取锁成功,执行复杂业务逻辑,再写入 Redis,最后释放锁(删除
lockKey); - 若获取锁失败,说明已有线程 / 节点正在执行写入操作,当前线程 / 节点直接返回(或重试),避免重复写入。
关键保障:
- 锁唯一性:
NX保证同一锁仅能被一个线程获取; - 锁自动释放:
PX设置过期时间,避免线程获取锁后宕机导致死锁; - 锁释放安全性:通过
lockValue(如 UUID)保证仅持有锁的线程能释放锁(避免误删其他线程的锁)。
(2)代码示例(基于 Redisson 实现,避免手动实现锁的坑)
Redisson 是 Redis 的 Java 客户端,已封装完善的分布式锁(支持自动续期、重入锁等),无需手动处理锁的释放与超时。
@Service
public class OrderCacheService {
@Autowired
private RedissonClient redissonClient;
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private OrderMapper orderMapper;
// 缓存订单信息(先查数据库,再写入Redis,避免并发重复缓存)
public boolean cacheOrderInfo(Long orderId) {
// 1. 定义分布式锁key(以订单ID作为锁key,保证同一订单仅一个线程处理)
String lockKey = "lock:order:cache:" + orderId;
RLock lock = redissonClient.getLock(lockKey);
try {
// 2. 尝试获取锁:等待10秒,锁自动过期30秒(避免死锁)
boolean lockAcquired = lock.tryLock(10, 30, TimeUnit.SECONDS);
if (!lockAcquired) {
log.warn("订单{}缓存操作正在执行中,无需重复写入", orderId);
return false;
}
// 3. 执行复杂业务逻辑:先查Redis是否已缓存,再查数据库
String redisKey = "order:info:" + orderId;
String orderJson = redisTemplate.opsForValue().get(redisKey);
if (orderJson != null) {
log.warn("订单{}已缓存,无需重复写入", orderId);
return false;
}
// 4. 查数据库获取订单信息
Order order = orderMapper.selectById(orderId);
if (order == null) {
log.error("订单{}不存在,无法缓存", orderId);
return false;
}
// 5. 写入Redis(此时无并发竞争,避免重复写入)
orderJson = JSON.toJSONString(order);
redisTemplate.opsForValue().set(redisKey, orderJson, 24, TimeUnit.HOURS);
log.info("订单{}信息缓存成功", orderId);
return true;
} catch (InterruptedException e) {
log.error("获取订单{}缓存锁失败", orderId, e);
Thread.currentThread().interrupt();
return false;
} finally {
// 6. 释放锁(仅当前线程持有锁时才释放)
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
(3)优缺点与适用场景
| 特性 | 详情描述 |
|---|---|
| 优点 | 1. 支持复杂场景:可在写入前执行任意业务逻辑,保证 "业务逻辑 + 写入 Redis" 的整体唯一性;2. 高可用:Redisson 支持 Redis 集群 / 哨兵,避免单点故障;3. 功能完善:支持自动续期、重入锁、公平锁,无需手动处理细节 |
| 缺点 | 1. 性能损耗:存在锁竞争,高并发场景下性能低于原子命令;2. 实现复杂:手动实现锁易踩坑(如误删锁、死锁),需依赖 Redisson 等成熟客户端;3. 锁超时风险:若业务执行时间超过锁过期时间,仍可能出现重复写入 |
| 适用场景 | 复杂业务场景(写入前需查库、计算等)、分布式系统多节点写入、定时任务集群执行 |
方案 3:业务层幂等性设计(从源头规避重复写入,根本解决方案)
无论原子命令还是分布式锁,都是 "存储层" 的防护,而幂等性设计是 "业务层" 的源头防护,通过生成唯一业务标识,保证同一业务操作仅能产生一次有效数据,从根本上避免 Redis 重复写入。
(1)核心原理
- 生成唯一业务标识:针对每一次业务操作,生成唯一的幂等号(如订单号、用户 ID + 操作类型 + 时间戳、雪花算法 ID);
- 绑定 Redis 写入键:将幂等号作为 Redis 的
key或field,结合 Redis 原子命令(如SET NX、HSETNX)实现唯一写入; - 重复操作判断:当同一业务操作再次执行时,通过幂等号判断 Redis 中已存在对应数据,直接返回成功(或失败),避免重复写入。
(2)代码示例(基于雪花算法生成幂等号,实现订单支付状态唯一缓存)
@Service
public class OrderPayStatusService {
@Autowired
private StringRedisTemplate redisTemplate;
// 雪花算法生成器(生成唯一幂等号)
private final SnowflakeGenerator snowflakeGenerator = new SnowflakeGenerator(0, 0);
// 订单支付成功后,缓存支付状态(避免重复缓存)
public boolean cachePayStatus(OrderPayDTO payDTO) {
// 1. 生成唯一幂等号(可选:直接用订单号作为幂等号,更简洁)
String requestId = String.valueOf(snowflakeGenerator.nextId());
// 或:用订单号作为幂等号(更贴合业务)
String businessNo = payDTO.getOrderNo();
// 2. 定义Redis key(绑定幂等号/业务号)
String redisKey = "order:pay:status:" + businessNo;
// 3. 结合Redis原子命令,实现唯一写入
Boolean result = redisTemplate.opsForValue()
.setIfAbsent(redisKey, "PAID", 7, TimeUnit.DAYS);
if (Boolean.TRUE.equals(result)) {
log.info("订单{}支付状态缓存成功,幂等号:{}", businessNo, requestId);
return true;
} else {
log.warn("订单{}支付状态已缓存,无需重复写入,幂等号:{}", businessNo, requestId);
// 幂等处理:直接返回成功,避免上游重试
return true;
}
}
}
(3)常用幂等号生成方式
| 幂等号生成方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 订单号 / 业务单号(唯一) | 贴合业务,无需额外生成,唯一性有保障 | 依赖业务系统生成唯一单号,无业务单号时无法使用 | 订单、支付、交易等有唯一业务单号的场景 |
| 雪花算法 / UUID | 生成简单,全局唯一,不依赖业务 | UUID 无序,不利于 Redis 性能优化;雪花算法依赖时钟 | 无唯一业务单号的场景、临时请求标识 |
| 用户 ID + 操作类型 + 时间戳 | 可读性强,便于排查问题 | 时间戳精度不足(如 1 秒内多次操作)可能重复 | 用户操作(如点赞、收藏、签到) |
(4)优缺点与适用场景
| 特性 | 详情描述 |
|---|---|
| 优点 | 1. 从源头规避:无需依赖存储层防护,从根本上解决重复写入问题;2. 高可用:不受 Redis 宕机、锁竞争等影响;3. 通用性强:适用于所有存储介质(Redis、数据库等) |
| 缺点 | 1. 侵入业务:需要在业务层生成和传递幂等号,增加开发成本;2. 唯一性依赖生成算法:若幂等号生成重复,仍会出现重复写入;3. 数据堆积:幂等号对应的 Redis 数据需要手动清理或设置过期时间 |
| 适用场景 | 所有需要保证唯一写入的业务场景(尤其是支付、订单、交易等核心场景) |
方案 4:Redis 事务 / 管道(批量写入场景,保证原子性避免部分重复)
当需要批量写入多个 Redis 键值对时,为避免 "部分写入成功,部分失败" 导致的重复 / 不一致,可使用 Redis 事务(MULTI/EXEC)或管道(Pipeline)保证批量操作的原子性。
(1)核心原理
- Redis 事务:将多个命令放入事务队列,通过
MULTI开启事务,EXEC执行事务,要么所有命令都执行成功,要么都失败(不支持回滚,仅保证批量执行); - Redis 管道:将多个命令打包发送给 Redis,减少网络往返次数,同时保证命令按顺序执行,避免中间被其他命令插入导致的重复 / 不一致。
(2)代码示例(Redis 事务实现批量商品缓存,避免部分重复)
@Service
public class GoodsBatchCacheService {
@Autowired
private StringRedisTemplate redisTemplate;
// 批量缓存商品信息,保证要么全部缓存成功,要么全部失败
public boolean batchCacheGoods(List<GoodsInfo> goodsList) {
// 1. 开启Redis事务
SessionCallback<Boolean> sessionCallback = new SessionCallback<Boolean>() {
@Override
public Boolean execute(RedisOperations operations) throws DataAccessException {
operations.multi(); // 开启事务
// 2. 批量写入Redis(结合SET NX避免重复写入单个商品)
for (GoodsInfo goods : goodsList) {
String redisKey = "goods:info:" + goods.getGoodsId();
String goodsJson = JSON.toJSONString(goods);
// 执行SET NX PX命令
operations.opsForValue().setIfAbsent(redisKey, goodsJson, 1800, TimeUnit.SECONDS);
}
// 3. 执行事务
List<Object> results = operations.exec();
// 4. 判断是否全部写入成功(无重复商品时,所有结果都为true)
for (Object result : results) {
if (Boolean.FALSE.equals(result)) {
log.warn("批量缓存商品存在重复,部分商品已缓存");
return false;
}
}
return true;
}
};
// 5. 执行回调,获取事务结果
Boolean result = redisTemplate.execute(sessionCallback);
return Boolean.TRUE.equals(result);
}
}
(3)优缺点与适用场景
| 特性 | 详情描述 |
|---|---|
| 优点 | 1. 保证批量操作原子性:避免部分写入导致的重复 / 不一致;2. 提升批量写入性能:管道减少网络往返次数,事务保证顺序执行;3. 实现简单:可结合原子命令使用 |
| 缺点 | 1. 不支持回滚:Redis 事务仅保证批量执行,若中间命令执行失败,已执行的命令不会回滚;2. 性能低于单个原子命令:批量操作耗时较长,高并发场景下不适用;3. 无法解决并发批量写入的重复问题 |
| 适用场景 | 批量写入场景(如批量缓存、批量记录)、需要保证命令顺序执行的场景 |
五、异常场景与兜底方案
异常 1:Redis 原子命令写入后,Redis 宕机导致数据未持久化,客户端重试写入
- 解决方案:
- 开启 Redis 持久化(AOF 持久化 +
fsync everysec,兼顾性能与数据安全性),确保写入命令被持久化到磁盘; - 业务层增加幂等性判断,即使 Redis 宕机重启,也能通过幂等号避免重复写入;
- 避免客户端无限制重试,设置重试次数上限(如 3 次),重试失败后告警人工处理。
- 开启 Redis 持久化(AOF 持久化 +
异常 2:分布式锁超时,业务未执行完,其他线程获取锁重复写入
- 解决方案:
- 使用 Redisson 的 "自动续期" 功能(看门狗机制),当业务未执行完时,自动延长锁的过期时间;
- 合理设置锁超时时间(基于业务平均执行时间,设置为 3-5 倍);
- 业务层增加最终一致性校验(如写入 Redis 后,定时任务对比数据库与 Redis 数据,修正不一致)。
异常 3:幂等号生成重复,导致 Redis 重复写入
- 解决方案:
- 优先使用业务唯一单号(如订单号)作为幂等号,避免依赖算法生成;
- 雪花算法增加时钟回拨防护,UUID 使用带时间戳的 UUID(如 UUID.randomUUID () + System.currentTimeMillis ());
- 结合 Redis 原子命令,即使幂等号重复,也能通过
SET NX避免重复写入。
加分项
- 从源头到存储层的多层防护:"我会先在业务层设计幂等号,再结合 Redis 的 SET NX 原子命令,复杂场景下使用 Redisson 分布式锁,多层防护确保不重复写入";
- 结合业务举例:"在订单支付场景中,我用订单号作为幂等号,通过 SET NX PX 命令缓存支付状态,同时设置 7 天过期时间,既避免重复写入,又防止数据堆积";
- 考虑异常场景与兜底:"使用分布式锁时,我会用 Redisson 的自动续期功能,避免锁超时;开启 Redis AOF 持久化,避免宕机导致数据丢失后重试写入";
- 关注性能与可扩展性:"高并发简单场景用原子命令,复杂场景用分布式锁,批量写入用事务 / 管道,根据场景选型提升性能"。
踩坑点
- 仅依赖 "先查后写",无原子性保障:直接先查 Redis 是否存在,再写入,高并发场景下会出现 "查时不存在,写时已存在" 的重复写入;
- 分布式锁不设置过期时间:导致线程获取锁后宕机,锁永久存在,后续无法写入;
- 幂等号设计不合理:使用单纯的时间戳或自增 ID,高并发场景下重复,导致重复写入;
- 忽略 Redis 持久化:认为写入 Redis 就一定成功,宕机后数据丢失,客户端重试导致重复写入;
- 批量写入不使用事务 / 管道:导致部分写入成功,部分失败,出现数据不一致与重复。
举一反三
- "SETNX 和 SET key value NX PX 的区别是什么?为什么推荐使用后者?"(答案:① SETNX 无过期时间,易产生死键;② SET NX PX 可同时设置过期时间,避免死键,且支持更多参数(如 EX),更灵活;③ 核心区别:是否支持自动过期,后者更健壮);
- "Redisson 分布式锁的'看门狗'机制是什么?如何实现自动续期?"(答案:① 看门狗机制:当线程获取锁后,业务未执行完,Redisson 会每隔 10 秒(默认)自动延长锁的过期时间(延长至 30 秒);② 实现原理:获取锁成功后,启动一个后台定时线程,每隔锁过期时间的 1/3,执行
PEXPIRE命令延长锁的过期时间,直到线程释放锁或业务执行完成); - "业务层幂等性设计和 Redis 原子命令的关系是什么?为什么要结合使用?"(答案:① 幂等性设计是源头防护,保证业务操作的唯一性;Redis 原子命令是存储层防护,保证写入操作的唯一性;② 结合使用:幂等性设计解决 "同一业务操作多次执行" 的问题,Redis 原子命令解决 "同一幂等号多次写入" 的问题,形成双层防护,更健壮);
- "Redis 事务为什么不支持回滚?它和 MySQL 事务的区别是什么?"(答案:① Redis 事务不支持回滚的原因:Redis 是单线程执行,命令执行失败通常是语法错误或类型错误,这类错误在入队时即可发现,无需回滚;且回滚会增加 Redis 复杂度,降低性能;② 区别:MySQL 事务支持 ACID(原子性、一致性、隔离性、持久性),Redis 事务仅保证 "批量执行" 和 "顺序执行",不支持隔离性和回滚,仅满足部分原子性);
- "如何避免 Redis 分布式锁的'误删锁'问题?"(答案:① 锁释放时,先判断锁的 value(如 UUID)是否与当前线程持有的 value 一致,一致才删除;② 使用 Redisson 的内置锁释放逻辑,已封装误删防护;③ 避免在锁内执行耗时操作,减少锁超时的概率)。
总结
- 确保 Redis 数据不重复写入,优先使用 Redis 原子命令(SET NX、HSETNX) (高性能、实现简单),复杂场景使用 Redisson 分布式锁 (支持复杂业务逻辑),核心业务必须做业务层幂等性设计(从源头规避);
- 核心思路:要么保证写入操作的原子性(存在则不写),要么保证写入操作的唯一性(分布式锁),要么保证业务操作的唯一性(幂等性);
- 异常兜底:开启 Redis 持久化、设置合理过期时间、使用成熟客户端、限制重试次数,确保方案的健壮性。