如何保证写入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 持久化、设置合理过期时间、使用成熟客户端、限制重试次数,确保方案的健壮性。
相关推荐
代码栈上的思考1 小时前
SpringBoot 拦截器
java·spring boot·spring
消失的旧时光-19431 小时前
C++ 拷贝构造、拷贝赋值、移动构造、移动赋值 —— 四大对象语义完全梳理
开发语言·c++
AI_56781 小时前
阿里云OSS成本优化:生命周期规则+分层存储省70%
运维·数据库·人工智能·ai
执着2591 小时前
力扣hot100 - 199、二叉树的右视图
数据结构·算法·leetcode
送秋三十五1 小时前
一次大文件处理性能优化实录————Java 优化过程
java·开发语言·性能优化
choke2331 小时前
软件测试任务测试
服务器·数据库·sqlserver
龙山云仓1 小时前
MES系统超融合架构
大数据·数据库·人工智能·sql·机器学习·架构·全文检索
雨中飘荡的记忆1 小时前
千万级数据秒级对账!银行日终批处理对账系统从理论到实战
java
IT邦德1 小时前
OEL9.7 安装 Oracle 26ai RAC
数据库·oracle
jbtianci1 小时前
Spring Boot管理用户数据
java·spring boot·后端