一、Redis 应用全景
Redis 不仅是缓存中间件,更是分布式系统中不可或缺的组件。本文聚焦三大核心应用场景:分布式锁、缓存一致性、延迟队列。
二、分布式锁:从 SETNX 到 Redisson
2.1 为什么需要分布式锁?
在分布式系统中,多个服务实例可能同时操作共享资源(如库存、订单、配置),需要一种跨进程的互斥机制来保证数据一致性。
典型应用场景:
- 库存扣减(防止超卖)
- 分布式任务调度(避免重复执行)
- 配置中心原子更新
- 分布式会话管理
2.2 分布式锁的演进
V1.0:SETNX + EXPIRE(存在死锁风险)
SETNX lock:order:10011# 加锁EXPIRE lock:order:100110# 设置过期# 问题:非原子操作,如果 SETNX 后崩溃,锁永远无法释放V2.0:SET … NX PX(原子加锁 + 过期)
SET lock:order:1001 request_id NX PX10000# 问题:业务执行时间超过锁过期时间,导致锁提前释放V3.0:Redisson 看门狗(原子加锁 + 自动续期)
RLocklock=redisson.getLock("order:1001");try{lock.lock();// 执行业务逻辑}finally{lock.unlock();}2.3 保证加锁和解锁的原子性
加锁原子性:SET key value NX PX ttl是单条命令,Redis 单线程执行天然原子。
解锁原子性:使用 Lua 脚本保证判断 + 删除的原子性。
// Lua 脚本释放锁StringunlockScript="if redis.call('get', KEYS[1]) == ARGV[1] then "+"return redis.call('del', KEYS[1]) "+"else return 0 end";redisTemplate.execute(newDefaultRedisScript<>(unlockScript,Long.class),Collections.singletonList("lock:order:1001"),requestId);2.4 Redisson 看门狗机制
业务线程获取锁 ↓ 看门狗线程启动(delay = lockWatchdogTimeout / 3,默认 10s/3 ≈ 3.3s) ↓ 每 3.3s 检查锁是否仍被持有 ↓ 若是 → 续期至 30s ↓ 业务完成 → unlock() → 看门狗停止 ↓ 异常崩溃 → 锁自动过期释放(避免死锁)2.5 分布式锁的优缺点
| 优点 | 缺点 |
|---|---|
| 性能高效 | 超时时间不好设置 |
| 实现方便 | 主从复制异步导致锁不可靠 |
| 避免单点故障(RedLock) | 需要额外组件(Redisson) |
2.6 合理的超时时间设置
// Redisson 自动处理:默认 30s 过期,看门狗每 10s 续期Configconfig=newConfig();config.useSingleServer().setAddress("redis://127.0.0.1:6379");RedissonClientredisson=Redisson.create(config);RLocklock=redisson.getLock("myLock");lock.lock();// 看门狗自动续期// 业务逻辑lock.unlock();三、缓存一致性策略
3.1 五种策略对比
| 策略 | 读流程 | 写流程 | 一致性 | 适用场景 |
|---|---|---|---|---|
| Cache-Aside | 先读缓存,未命中读 DB | 先更新 DB,再删缓存 | 最终一致 | 读多写少 |
| 延迟双删 | 同 Cache-Aside | 先删缓存→更新 DB→延迟再删 | 最终一致 | 高并发写 |
| Read/Write-Through | 缓存代理查询 | 缓存代理更新,同步写 DB | 强一致 | 金融交易 |
| Write-Behind | 只读缓存 | 只更新缓存,异步批量写 DB | 最终一致 | 秒杀库存 |
| Binlog 同步 | 先读缓存 | 更新 DB,Canal 监听异步删缓存 | 最终一致 | 多级缓存 |
3.2 Cache-Aside 旁路缓存(最常用)
publicStringread(Stringkey){Stringdata=redisTemplate.opsForValue().get(key);if(data==null){data=db.query(key);redisTemplate.opsForValue().set(key,data,Duration.ofHours(1));}returndata;}publicvoidwrite(Stringkey,Stringdata){db.update(key,data);redisTemplate.delete(key);// 删除缓存,非更新}为什么删除缓存而不是更新缓存?
- 更新缓存需要计算新值,可能涉及复杂逻辑
- 删除缓存更简单,下次读取时自动回填最新值
- 避免并发更新导致缓存数据不一致
3.3 延迟双删策略(高并发优化)
publicvoidwriteWithDoubleDelete(Stringkey,Stringdata){redisTemplate.delete(key);// 第一次删除db.update(key,data);// 更新数据库// 延迟第二次删除(通过消息队列或延迟队列)delayedQueue.add(()->redisTemplate.delete(key),500);// 延迟 500ms}原理:第一次删除后,在数据库更新完成前,可能有读请求将旧数据回填到缓存。延迟第二次删除确保这些旧数据被清除。
3.4 如何保证删除缓存操作一定能成功?
方案一:消息队列重试
publicvoiddeleteCacheWithRetry(Stringkey){try{redisTemplate.delete(key);}catch(Exceptione){mqProducer.send(newCacheDeleteMessage(key));}}方案二:订阅 Binlog 补偿
@CanalListener(destination="mydb")publicvoidonBinlog(CanalEntry.Entryentry){if(entry.getHeader().getEventType()==EventType.UPDATE){Stringkey=buildCacheKey(entry);redisTemplate.delete(key);}}3.5 生产推荐组合
Cache-Aside + 延迟双删 + Binlog 补偿:
写操作:先删缓存 → 更新 DB → 延迟双删 ↓ Canal 监听 binlog → 异步补偿删除缓存 ↓ 最终一致性保障四、缓存三大问题:雪崩、击穿、穿透
4.1 缓存雪崩
问题:大量 key 在同一时间过期或 Redis 故障,所有请求同时打到数据库。
解决方案:
// 1. 随机过期时间intbaseTtl=3600;intrandomOffset=ThreadLocalRandom.current().nextInt(0,600);redisTemplate.opsForValue().set(key,data,Duration.ofSeconds(baseTtl+randomOffset));// 2. 多级缓存@Cacheable(value="local",cacheManager="caffeineCacheManager")@Cacheable(value="redis",cacheManager="redisCacheManager")// 3. 熔断降级@SentinelResource(value="getData",fallback="getDataFallback")4.2 缓存击穿
问题:热点 key 恰好过期,高并发请求瞬间穿透到数据库。
解决方案:
// 互斥锁publicStringgetHotData(Stringkey){Stringdata=redisTemplate.opsForValue().get(key);if(data!=null)returndata;StringlockKey="lock:"+key;booleanlocked=redisTemplate.opsForValue().setIfAbsent(lockKey,"1",Duration.ofSeconds(10));if(locked){try{data=redisTemplate.opsForValue().get(key);if(data==null){data=db.query(key);redisTemplate.opsForValue().set(key,data,Duration.ofHours(1));}}finally{redisTemplate.delete(lockKey);}}else{Thread.sleep(100);returngetHotData(key);}returndata;}4.3 缓存穿透
问题:查询不存在的数据,请求直接打到数据库。
解决方案:
// 布隆过滤器BF.ADDusers user:1001BF.EXISTSusers user:1003// 返回 0 → 一定不存在五、Redis 延迟队列实现
延迟队列是一种特殊的消息队列,消息在发送后不会立即被消费,而是延迟指定时间后才能被处理。
5.1 基于 ZSet 的实现
@ServicepublicclassRedisDelayQueue{@AutowiredprivateStringRedisTemplateredisTemplate;// 添加延迟任务publicvoidaddTask(StringtaskId,longdelaySeconds){longexecuteTime=System.currentTimeMillis()+delaySeconds*1000;redisTemplate.opsForZSet().add("delayed_queue",taskId,executeTime);}// 轮询消费到期任务@Scheduled(fixedRate=1000)publicvoidconsume(){longnow=System.currentTimeMillis();Set<String>tasks=redisTemplate.opsForZSet().rangeByScore("delayed_queue",0,now,0,1);for(StringtaskId:tasks){Longremoved=redisTemplate.opsForZSet().remove("delayed_queue",taskId);if(removed!=null&&removed>0){executeTask(taskId);}}}}5.2 适用场景
| 场景 | 说明 |
|---|---|
| 订单超时关闭 | 订单创建后 30 分钟未支付自动关闭 |
| 定时提醒 | 预约成功后 1 小时发送提醒 |
| 任务重试 | 失败任务延迟 5 分钟后重试 |
| 优惠券过期 | 优惠券到期前 1 天发送提醒 |
六、Pipeline 与事务的区别
6.1 Pipeline 的核心特点
- 客户端功能:批量打包命令,减少网络 RTT
- 非原子性:命令可被其他客户端插入
- 单条失败继续:某条命令失败不影响后续执行
- 适用场景:批量数据操作、高吞吐场景
Pipelinepipeline=jedis.pipelined();for(inti=0;i<10000;i++){pipeline.set("key:"+i,"value"+i);}List<Object>results=pipeline.syncAndReturnAll();6.2 事务的核心特点
- 服务端功能:MULTI/EXEC 保证命令原子执行
- 原子性:EXEC 后所有命令按顺序执行,不可插入
- 语法错误全失败:入队时发现语法错误,EXEC 全部放弃
- 运行时错误不回滚:执行时某条失败,后续继续执行
- 适用场景:原子性操作、乐观锁并发控制
MULTI SET balance:10011000DECRBY balance:1001100EXEC6.3 核心差异对比
| 维度 | Pipeline | 事务 |
|---|---|---|
| 原子性 | ✗ 非原子 | ✓ 原子执行 |
| 隔离性 | ✗ 可被插入 | ✓ 天然隔离 |
| 错误处理 | 单条失败继续 | 语法错误全失败 |
| 网络开销 | 批量减少 RTT | 正常 RTT |
| 性能 | 更高 | 较低 |
6.4 为什么优先使用 Lua 脚本?
-- Lua 脚本:原子性 + 灵活性 + 高性能localstock=redis.call('GET',KEYS[1])iftonumber(stock)>=tonumber(ARGV[1])thenredis.call('DECRBY',KEYS[1],ARGV[1])return1elsereturn0endLua 脚本的优势:
- 原子性:整个脚本作为一个命令执行,不会被其他命令插入
- 灵活性:支持复杂逻辑判断和计算
- 高性能:一次网络往返,减少 RTT
七、Redis 事务 vs MySQL 事务
7.1 核心差异
| 维度 | Redis 事务 | MySQL 事务 |
|---|---|---|
| 原子性 | 部分原子:语法错误全失败,运行时错误不回滚 | 完全原子:全部成功或全部回滚 |
| 隔离性 | 天然串行化(单线程) | 多级别隔离(RU/RC/RR/S) |
| 持久性 | 依赖 RDB/AOF 配置 | Redo Log 保证提交不丢失 |
| 回滚机制 | 无回滚 | 支持 Rollback |
| 锁机制 | 乐观锁(WATCH) | 悲观锁(表锁/行锁) |
| 复杂度 | 简单,命令批量执行 | 复杂,支持嵌套/保存点 |
| 适用场景 | 高并发简单操作 | 强一致业务场景 |
7.2 为什么 Redis 事务不支持回滚?
- 错误类型:Redis 事务错误通常是编程错误(命令拼写、参数类型),开发环境可发现
- 设计哲学:追求简单和快速,回滚增加复杂度
- 替代方案:可用 DISCARD 主动放弃,或用 Lua 脚本实现复杂逻辑
八、综合对比表
| 机制 | 核心问题 | 最佳方案 | 关键细节 |
|---|---|---|---|
| 分布式锁 | 跨节点互斥访问 | Redisson 看门狗 | 原子加锁 + 自动续期 |
| 缓存一致 | DB 与缓存不一致 | Cache-Aside + 延迟双删 | 先更新 DB 再删缓存 |
| 缓存雪崩 | 大量 key 同时失效 | 随机 TTL + 多级缓存 | 分散过期时间 |
| 缓存击穿 | 热点 key 过期 | 互斥锁 + 逻辑不过期 | 控制并发重建 |
| 缓存穿透 | 查询不存在数据 | 布隆过滤器 + 空值缓存 | 拦截非法请求 |
| 延迟队列 | 定时触发任务 | ZSet + 轮询消费 | score 存储执行时间 |
| Pipeline | 批量命令优化 | 客户端打包 | 减少网络 RTT |
| 事务 | 原子性操作 | MULTI/EXEC/WATCH | 单线程天然隔离 |
如果本文对你有帮助,欢迎点赞 👍 + 收藏 ⭐ + 关注 🔖,你的支持是我持续创作的动力!