如何保证写入Redis的数据不重复

一、为什么考察这个问题?

  1. 你是否能识别重复写入的常见场景,理解 "重复写入" 的核心诱因(并发、重试、分布式部署)?
  2. 能否掌握 2-3 种主流解决方案(尤其是 Redis 原子命令,高频考点),知道其原理与适用场景?
  3. 能否结合业务设计幂等性标识,从源头规避重复写入,体现实战思维?
  4. 能否考虑异常场景(如 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命令实现分布式锁,流程如下:

  1. 写入前,线程 / 节点先尝试获取分布式锁(SET lockKey lockValue NX PX 30000);
  2. 若获取锁成功,执行复杂业务逻辑,再写入 Redis,最后释放锁(删除lockKey);
  3. 若获取锁失败,说明已有线程 / 节点正在执行写入操作,当前线程 / 节点直接返回(或重试),避免重复写入。

关键保障

  • 锁唯一性: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)核心原理
  1. 生成唯一业务标识:针对每一次业务操作,生成唯一的幂等号(如订单号、用户 ID + 操作类型 + 时间戳、雪花算法 ID);
  2. 绑定 Redis 写入键:将幂等号作为 Redis 的keyfield,结合 Redis 原子命令(如SET NXHSETNX)实现唯一写入;
  3. 重复操作判断:当同一业务操作再次执行时,通过幂等号判断 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 宕机导致数据未持久化,客户端重试写入
  • 解决方案:
    1. 开启 Redis 持久化(AOF 持久化 +fsync everysec,兼顾性能与数据安全性),确保写入命令被持久化到磁盘;
    2. 业务层增加幂等性判断,即使 Redis 宕机重启,也能通过幂等号避免重复写入;
    3. 避免客户端无限制重试,设置重试次数上限(如 3 次),重试失败后告警人工处理。
异常 2:分布式锁超时,业务未执行完,其他线程获取锁重复写入
  • 解决方案:
    1. 使用 Redisson 的 "自动续期" 功能(看门狗机制),当业务未执行完时,自动延长锁的过期时间;
    2. 合理设置锁超时时间(基于业务平均执行时间,设置为 3-5 倍);
    3. 业务层增加最终一致性校验(如写入 Redis 后,定时任务对比数据库与 Redis 数据,修正不一致)。
异常 3:幂等号生成重复,导致 Redis 重复写入
  • 解决方案:
    1. 优先使用业务唯一单号(如订单号)作为幂等号,避免依赖算法生成;
    2. 雪花算法增加时钟回拨防护,UUID 使用带时间戳的 UUID(如 UUID.randomUUID () + System.currentTimeMillis ());
    3. 结合 Redis 原子命令,即使幂等号重复,也能通过SET NX避免重复写入。

加分项

  1. 从源头到存储层的多层防护:"我会先在业务层设计幂等号,再结合 Redis 的 SET NX 原子命令,复杂场景下使用 Redisson 分布式锁,多层防护确保不重复写入";
  2. 结合业务举例:"在订单支付场景中,我用订单号作为幂等号,通过 SET NX PX 命令缓存支付状态,同时设置 7 天过期时间,既避免重复写入,又防止数据堆积";
  3. 考虑异常场景与兜底:"使用分布式锁时,我会用 Redisson 的自动续期功能,避免锁超时;开启 Redis AOF 持久化,避免宕机导致数据丢失后重试写入";
  4. 关注性能与可扩展性:"高并发简单场景用原子命令,复杂场景用分布式锁,批量写入用事务 / 管道,根据场景选型提升性能"。

踩坑点

  1. 仅依赖 "先查后写",无原子性保障:直接先查 Redis 是否存在,再写入,高并发场景下会出现 "查时不存在,写时已存在" 的重复写入;
  2. 分布式锁不设置过期时间:导致线程获取锁后宕机,锁永久存在,后续无法写入;
  3. 幂等号设计不合理:使用单纯的时间戳或自增 ID,高并发场景下重复,导致重复写入;
  4. 忽略 Redis 持久化:认为写入 Redis 就一定成功,宕机后数据丢失,客户端重试导致重复写入;
  5. 批量写入不使用事务 / 管道:导致部分写入成功,部分失败,出现数据不一致与重复。

举一反三

  1. "SETNX 和 SET key value NX PX 的区别是什么?为什么推荐使用后者?"(答案:① SETNX 无过期时间,易产生死键;② SET NX PX 可同时设置过期时间,避免死键,且支持更多参数(如 EX),更灵活;③ 核心区别:是否支持自动过期,后者更健壮);
  2. "Redisson 分布式锁的'看门狗'机制是什么?如何实现自动续期?"(答案:① 看门狗机制:当线程获取锁后,业务未执行完,Redisson 会每隔 10 秒(默认)自动延长锁的过期时间(延长至 30 秒);② 实现原理:获取锁成功后,启动一个后台定时线程,每隔锁过期时间的 1/3,执行PEXPIRE命令延长锁的过期时间,直到线程释放锁或业务执行完成);
  3. "业务层幂等性设计和 Redis 原子命令的关系是什么?为什么要结合使用?"(答案:① 幂等性设计是源头防护,保证业务操作的唯一性;Redis 原子命令是存储层防护,保证写入操作的唯一性;② 结合使用:幂等性设计解决 "同一业务操作多次执行" 的问题,Redis 原子命令解决 "同一幂等号多次写入" 的问题,形成双层防护,更健壮);
  4. "Redis 事务为什么不支持回滚?它和 MySQL 事务的区别是什么?"(答案:① Redis 事务不支持回滚的原因:Redis 是单线程执行,命令执行失败通常是语法错误或类型错误,这类错误在入队时即可发现,无需回滚;且回滚会增加 Redis 复杂度,降低性能;② 区别:MySQL 事务支持 ACID(原子性、一致性、隔离性、持久性),Redis 事务仅保证 "批量执行" 和 "顺序执行",不支持隔离性和回滚,仅满足部分原子性);
  5. "如何避免 Redis 分布式锁的'误删锁'问题?"(答案:① 锁释放时,先判断锁的 value(如 UUID)是否与当前线程持有的 value 一致,一致才删除;② 使用 Redisson 的内置锁释放逻辑,已封装误删防护;③ 避免在锁内执行耗时操作,减少锁超时的概率)。

总结

  1. 确保 Redis 数据不重复写入,优先使用 Redis 原子命令(SET NX、HSETNX) (高性能、实现简单),复杂场景使用 Redisson 分布式锁 (支持复杂业务逻辑),核心业务必须做业务层幂等性设计(从源头规避);
  2. 核心思路:要么保证写入操作的原子性(存在则不写),要么保证写入操作的唯一性(分布式锁),要么保证业务操作的唯一性(幂等性);
  3. 异常兜底:开启 Redis 持久化、设置合理过期时间、使用成熟客户端、限制重试次数,确保方案的健壮性。
相关推荐
uup2 小时前
Future.get () 的潜在陷阱
java
JAY_LIN——82 小时前
字符串函数(strncpy/cat/cmp、strstr、strtok、strerror)
c语言·开发语言
狂奔小菜鸡2 小时前
Day36 | Java中的线程池技术
java·后端·java ee
廋到被风吹走2 小时前
【数据库】【Oracle】事务与约束详解
数据库·oracle
sheji34162 小时前
【开题答辩全过程】以大学校园点餐系统为例,包含答辩的问题和答案
java
lly2024062 小时前
C# 数据类型
开发语言
天然玩家2 小时前
【数据库知识】聚簇索引&二级索引
数据库·聚簇索引·回表·二级索引
斯普信专业组2 小时前
Redis Cluster 集群化部署全流程指南:从源码编译到容器化
数据库·redis·缓存
苏婳6662 小时前
Java---SSH(MVC)面试题
java·ssh·mvc