ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Redisson操作Redis实战:分布式锁、延迟队列与限流

Redisson操作Redis实战:分布式锁、延迟队列与限流 提到用Java操作Redis很多人的第一反应是Jedis或者Spring Data Redis。但一旦你的业务里出现“分布式锁”“分布式限流”“可靠延迟队列”这类字眼光靠这些底层客户端就有点吃力了——你得自己拼Lua脚本、自己处理续期、自己封装重试逻辑踩坑踩到怀疑人生。Redisson就是为这个场景而生的它以Redis为基础把锁、集合、队列、信号量、发布订阅、限流器这些分布式场景里的常见组件全部封装成了Java API让你感觉像是在操作一个本地对象底层其实全部跑在Redis上。这篇是“Redis入门到精通”系列的第十一篇专门聊Redisson操作Redis的实战玩法内容包括环境搭建、分布式锁的完整原理与手写对比、常用高级组件、Spring Cache整合、序列化问题以及我实际项目里踩过的坑适合有一定Redis基础、又想把Redisson用进真实项目的同学。1. 为什么是Redisson解决Jedis不完全够用的那部分需求1.1 三代客户端演进Redisson为什么是最终选择Redis官方最早推荐的Java客户端是Jedis特点是轻、直连、API简单很多人的第一段Redis代码都是拿Jedis写的。Jedis的问题在于它只是一个“连接器”你发命令、收结果剩下的并发控制、分布式协调逻辑都要自己写。比如一个最简单的分布式锁用Jedis至少得写SET NX、EXPIRE、Lua释放脚本三套逻辑还要考虑原子性、误删锁、主从同步延迟这些边界问题。后来Spring Boot默认集成了Lettuce性能好、支持异步但它也还停留在“命令客户端”这个层级。真正的业务痛点不是“怎么发送一个Redis命令”而是“怎么用Redis解决分布式场景下的业务问题”。Redis官方后来也意识到这一点从3.0版本开始重点扶持Redisson这个项目。Redisson的定位明显更高一层它提供的是RLock、RMap、RQueue、RTopic、RRateLimiter这样的分布式组件开箱即用内部自己处理线程安全、连接管理、重试机制和底层数据结构映射。实际用下来我的感受是Jedis像螺丝刀Lettuce像电动螺丝刀Redisson则像一套已经组装好的工具箱里面连钻孔定位器都给你配好了。你自己拧螺丝固然可以但工程量大了以后效率差距非常明显。1.2 Redisson的核心理念把分布式能力“本地化”Redisson的设计哲学很有意思它把Redis的数据结构尽量往Java集合框架上靠。你会发现Redisson里有RMap对应Java的Map、RSet对应Set、RScoredSortedSet对应带排序的Set、RBlockingQueue对应BlockingQueue。这带来的直接收益是你原来写单机并发代码用的API几乎可以平移到分布式环境里。举个例子单机环境下你写一个ConcurrentHashMap就能完成多线程数据共享分布式环境下就崩溃了。换成Redisson的RMap代码长得很像但数据实际存到了Redis里多个应用节点共享的是同一份数据。这种“低成本迁移”是Redisson能火起来的根本原因——它不逼你改变编程习惯而是把分布式复杂度藏在框架内部。这种设计也决定了Redisson适合什么人用不想关心底层Redis命令细节、希望把精力放在业务逻辑上的Java开发。反过来如果你对分布式锁原理一点都不懂就上Redisson也很容易踩坑因为框架能帮你干活但不会帮你理解“这个锁为什么不能解决所有一致性难题”。所以这篇文章我会把原理部分也展开讲。1.3 Redisson组件全景图在进入实操之前先看一遍Redisson的组件家族对后面阅读很有帮助。Redisson官方文档把能力分成了几大类分布式锁类RLock、公平锁FairLock、读写锁ReadWriteLock、联锁MultiLock、红锁RedLock。分布式集合类RMap、RSet、RList、RQueue、RBlockingQueue、RDelayedQueue、RLexSortedSet等。分布式同步器类RSemaphore、RCountDownLatch、RPermitExpirableSemaphore。分布式发布订阅RTopic、RPatternTopic。分布式限流RRateLimiter。分布式原子类RAtomicLong、RAtomicDouble。分布式服务RExecutorService、RScheduledExecutorService可以在Redis集群上编排远程执行任务。这些组件不是每个都会用到但锁、队列、并发控制、缓存这四处是高频场景。下面的章节我会按“能直接抄走”的标准来写保证你读完能上手。2. 快速接入依赖、配置文件与连接模式2.1 依赖引入与版本选择最省事的做法是引入Redisson的Spring Boot Starter。这里有一个非常关键的版本选择问题Redisson 3.x的官方Starter内部依赖了Spring Boot版本。如果你的项目是Spring Boot 2.x建议用redisson-spring-boot-starter的3.16.x~3.19.x线如果是Spring Boot 3.x建议用3.23.x及以上版本或者直接切到Redisson 4.x线。我去年把一个老项目从Spring Boot 2.5升级到2.7Redisson还锁在3.16.0跑得没问题另一个新项目用的Spring Boot 3.2Redisson直接上3.27.2也没有兼容性问题。总之别随手下最新版先去Maven仓库看一眼release notes再定。Maven依赖写法dependency groupIdorg.redisson/groupId artifactIdredisson-spring-boot-starter/artifactId version3.27.2/version /dependency如果你不用Spring Boot只依赖核心包也行dependency groupIdorg.redisson/groupId artifactIdredisson/artifactId version3.27.2/version /dependency这里有个小经验如果项目里同时存在Spring Data Redis和Redisson两者可以共存不会冲突。Redisson的Starter只负责创建RedissonClient不会动Spring Data Redis的自动配置。2.2 单机、主从、哨兵、集群的连接配置差异Redisson支持Redis全拓扑模式这大大减少了运维层的割裂感。以最常用的YAML配置为例单机模式直接指向地址和端口singleServerConfig: address: redis://127.0.0.1:6379 password: null database: 0 connectionPoolSize: 64 connectionMinimumIdleSize: 16 idleConnectionTimeout: 10000 connectTimeout: 10000 timeout: 3000注意password不能写成空字符串如果Redis没有密码直接写null否则Redisson会尝试AUTH导致连接失败。这个坑我踩过一次当时配置了“password: ”结果启动就报ERR Client sent AUTH, but no password is set。主从配置则要指定master与slave同时可以设置readMode走从库读masterSlaveServersConfig: masterAddress: redis://127.0.0.1:6379 slaveAddresses: - redis://127.0.0.1:6380 - redis://127.0.0.1:6381 readMode: SLAVE loadBalancer: !org.redisson.connection.balancer.RoundRobinLoadBalancer {}哨兵模式则需要指向sentinel地址sentinelServersConfig: sentinelAddresses: - redis://127.0.0.1:26379 masterName: mymaster readMode: MASTER集群模式更简单只要列出所有节点地址clusterServersConfig: nodeAddresses: - redis://127.0.0.1:7001 - redis://127.0.0.1:7002 - redis://127.0.0.1:7003 scanInterval: 1000生产环境我一般建议把timeout和connectTimeout调低到3000~5000毫秒避免Redis故障时业务线程大面积卡死。connectionPoolSize则以业务并发数为参考通常64够用没必要开很大。2.3 Spring Boot集成与自定义Config Bean使用Starter后默认会读取spring.redis配置。如果你不想用YAML描述Redisson专属配置也可以直接注册一个RedissonClient的Bean。这样便于在代码里动态拼接节点地址适合配置中心下发场景。Configuration public class RedissonConfig { Value(${redis.address:redis://127.0.0.1:6379}) private String address; Bean(destroyMethod shutdown) public RedissonClient redissonClient() { Config config new Config(); config.useSingleServer() .setAddress(address) .setConnectionPoolSize(64) .setConnectionMinimumIdleSize(16); return Redisson.create(config); } }注意destroyMethod必须写成shutdown否则Spring容器关闭时不会释放Redisson内部线程池和连接池容易造成长时间运行的进程释放不了句柄。实际项目中我还见过有人把RedissonClient注入到Service里项目停机时Tomcat先关了然后Redisson的watch dog线程还在跑日志一直刷异常。自定义Bean并指定shutdown后这种情况基本能避免。3. 分布式锁实战加锁、续期、释放与锁类型选择3.1 手写分布式锁的经典缺陷别自己造轮子很多“Redis分布式锁”教程会教你先用SETNX实现加锁再用EXPIRE设置过期时间。这种写法最早期版本有很大问题SETNX和EXPIRE不是原子操作如果设置完SETNX、进程刚好挂掉锁永远不释放。后来演进成一条原子命令SET lockKey lockValue NX PX 30000这条命令可以解决原子性问题但又带来一个问题“锁的value到底存什么”如果所有线程都用同一个value线程A释放锁时可能把线程B的锁给释放掉。所以value需要存一个全局唯一标识释放锁的时候先比较value是否匹配只有匹配才删除。这个比较和删除又必须是原子的只能用Lua脚本。到了这一步你已经发现手写分布式锁要考虑的点实在太多加锁原子性、释放原子性、锁续期、重入、公平性、防误删、主从切换时的锁丢失。如果这是面试题你能把上述逻辑说清楚已经不错了如果是生产代码我更建议直接用Redisson的RLock因为这些问题框架全都内置了。3.2 watch dog自动续期原理30秒的锁为什么不会提前失效Redisson最惊艳的设计就是watch dog看门狗机制。默认情况下RLock加锁成功后如果没有指定leaseTime锁租赁时间Redisson会给这个锁一个默认30秒的过期时间然后启动一个后台定时任务每10秒执行一次续期把锁的过期时间重新拉回30秒。这个续期过程是通过Lua脚本完成的先判断锁是否还是当前线程持有是就重新设置expire。RLock lock redissonClient.getLock(order:pay:1001); try { boolean locked lock.tryLock(3, TimeUnit.SECONDS); if (locked) { // 业务逻辑执行时间可以超过30秒 } } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } }上面的tryLock只传了一个waitTime没有传leaseTime所以watch dog才会生效。它的存在意义是当你的业务方法因为慢SQL、第三方接口超时等原因执行得比预期久时锁不会因为到期而自动释放从而避免并发线程同时进入临界区。注意一个小细节lock.unlock()必须在finally里调用并且最好先判断isHeldByCurrentThread()。Redisson的锁是可重入的同一个线程多次加锁后必须对应调用相同次数的unlock否则锁不会被真正释放。这是个特别容易忽略的坑。3.3 tryLock与lock的选择公平锁、读写锁怎么用Redisson的RLock继承自JUC的Lock接口基本用法和ReentrantLock几乎一致。lock()是阻塞加锁拿不到锁就一直阻塞一般不推荐在分布式环境下使用因为会长时间占用线程tryLock(waitTime, leaseTime, TimeUnit)限制等待时间拿不到就快速失败更适合大多数业务场景。如果你需要锁竞争按照申请顺序公平分配可以用FairLockRLock fairLock redissonClient.getFairLock(anyFairLock);公平锁底层通过Redis的ZSet记录请求顺序性能比普通锁差。实测在高并发抢锁场景下公平锁的吞吐量大概是普通锁的60%~70%所以除非业务真的有顺序要求否则不建议开。读写锁也很常用适合读多写少的业务例如商品详情配置RReadWriteLock rwLock redissonClient.getReadWriteLock(product:info:1001); RLock readLock rwLock.readLock(); RLock writeLock rwLock.writeLock();读锁和读锁之间不互斥多个线程可以同时读写锁与读锁、写锁与写锁之间互斥。这样在设计缓存更新策略时读接口加读锁、写接口加写锁能显著降低无谓的互斥等待。3.4 Redis集群下锁可靠吗MultiLock的现实处境这里不得不提一个被面试官反复追问的话题在主从复制架构下如果master节点突然宕机锁数据还没来得及同步到slave节点新master上锁就丢了两个线程可能同时拿到同一把锁分布式锁直接失效。Redisson官方为了解决这个问题提供了MultiLock也就是把锁同时加在多个独立Redis实例上只有全部加锁成功才算拿到锁。RLock lock1 redissonClient1.getLock(lockKey); RLock lock2 redissonClient2.getLock(lockKey); RLock lock3 redissonClient3.getLock(lockKey); RLock multiLock redissonClient1.getMultiLock(lock1, lock2, lock3);但说实话在我的实际项目接触来看绝大多数公司不会为了几把分布式锁去部署5个相互独立的Redis实例。这个成本太高了。更常见的做法是直接用主从或哨兵架构下的RLock接受极端情况下的锁丢失风险同时在业务层做幂等兜底。这本质上是一种工程权衡面试时能讲清楚RedLock的原理就够了生产选择上绝大多数还是单点RLock业务幂等。4. 不只是锁分布式集合、队列、发布订阅与限流4.1 RMap/RSet/RList把Redis当Java内存集合用RLock是Redisson的明星功能但Redisson并不是只有锁。RMap是很实用的分布式Map它比直接用Hash操作更顺手因为方法名和Java的Map完全一致RMapString, UserInfo userMap redissonClient.getMap(cache:user); userMap.put(1001, user); UserInfo user userMap.get(1001); userMap.remove(1001);默认情况下RMap不会自动过期如果你希望每个key单独过期应该使用RMapCacheRMapCacheString, UserInfo userCache redissonClient.getMapCache(cache:user); userCache.put(1001, user, 30, TimeUnit.MINUTES, 10, TimeUnit.MINUTES);第四个参数是TTL整体30分钟过期第五个参数是maxIdleTime也就是10分钟内没有被读取就会删除。这两个过期策略在“登录态”这类场景很实用用户连续操作一直保活停止操作10分钟后失效。RSet和RList使用方式类似底层分别对应Redis的Set和List天然支持分布式去重。RScoredSortedSet则对应ZSet适合做排行榜但要注意的是Redisson的ZSet元素的score是Double类型大金额场景下精度有限需要提前转换单位。4.2 RBlockingQueue与RDelayedQueue可靠延迟队列分布式队列是订单超时未支付、任务延迟处理等场景的基础设施。Redisson的RBlockingQueue实现了JUC的BlockingQueue接口消费者可以阻塞式获取消息RBlockingQueueString queue redissonClient.getBlockingQueue(queue:order); // 生产者 queue.offer(orderId); // 消费者 String orderId queue.take();RDelayedQueue则更高级它给队列里的元素设置延迟时间到期后元素才进入真正可消费的队列。实现一个订单超时关闭功能可以这么写RBlockingQueueString blockingQueue redissonClient.getBlockingQueue(order:timeout); RDelayedQueueString delayedQueue redissonClient.getDelayedQueue(blockingQueue); delayedQueue.offer(orderId, 30, TimeUnit.MINUTES);消费者通过blockingQueue.take()拿到元素说明订单已经超时。这里最需要注意的一点是RDelayedQueue和RBlockingQueue是同一个Redis key关联的生产者在向delayedQueueoffer数据后消费者应当从blockingQueue里take。如果搞混了消息就取不出来。4.3 RTopic与RPatternTopic发布订阅的注意事项Redisson的RTopic是对Redis Pub/Sub的封装。Redis的Pub/Sub结构是“即发即失”如果消费者不在线消息直接丢失而且消息不会持久化。所以RTopic只适用于通知类、实时类消息例如配置变更、缓存刷新提醒不适合作为业务消息队列。基本用法RTopic topic redissonClient.getTopic(channel:config:change); topic.addListener(String.class, (channel, msg) - { // 收到频道消息 }); topic.publish(reload);这里序列化坑比较多。addListener里的Class参数决定了Redisson用什么解码器反序列化消息体。如果发布端使用JSON序列化监听端就必须指定对应的Class类型并使用相同的Codec否则会抛ClassCastException或反序列化异常。后面第6章我会专门讲Codec问题。RPatternTopic支持通配符订阅比如订阅“order:*”开头的所有频道适合业务频道按订单号拆分的小型通知场景。4.4 RRateLimiter基于令牌桶的分布式限流限流是一个老话题。单机限流可以用Guava的RateLimiter分布式限流就必须把计量状态放到Redis里。Redisson提供的RRateLimiter底层使用令牌桶算法API比手动写Lua脚本友好太多RRateLimiter limiter redissonClient.getRateLimiter(rate:api:query); // 每秒放10个令牌最多缓存10个 limiter.trySetRate(RateType.OVERALL, 10, 1, RateIntervalUnit.SECONDS); if (limiter.tryAcquire()) { // 通过 } else { // 拒绝 }RateType.PER_CLIENT表示每个客户端单独计数OVERALL表示所有客户端共享同一个令牌桶。日常接口限流建议用OVERALL防止某个并发调用方把整个服务的配额全吃光。还有一点需要提醒trySetRate并不是每次调用都要执行它是幂等配置的意思实际调用一次后后续再调用不会重置已消耗的令牌。但对限流器变化敏感的场景建议在启动时预置好RateLimit配置而不是运行中频繁修改。5. Spring Cache与Redisson整合注解式缓存落地5.1 用RedissonSpringCacheManager管理缓存TTLRedisson官方提供了Spring Cache的集成方案RedissonSpringCacheManager。配置好缓存管理器后你可以直接使用Spring的Cacheable、CacheEvict注解而Cache底层则落到Redis上。这个组合比Spring Data Redis默认的缓存方案更好的一点是Redisson天然支持value过期和LRU淘汰等策略。基础配置如下Bean public CacheManager cacheManager(RedissonClient redissonClient) { MapString, CacheConfig config new HashMap(); // userCache缓存TTL 30分钟 config.put(userCache, new CacheConfig(30 * 60 * 1000, 10 * 60 * 1000)); return new RedissonSpringCacheManager(redissonClient, config); }CacheConfig构造函数里的两个参数第一个是TTL单位毫秒第二个是maxIdleTime。如果只传一个参数另一个可以给0表示不限制。这里有个经验Redis缓存最容易出现的问题不是TTL太长而是缓存Key异常膨胀。所以命名上必须带业务前缀比如“userCache::1001”不然排查问题时你会面对一片不可读的Key列表。5.2 缓存与DB一致性的实战配置Spring Cache注解本身解决不了缓存与数据库的一致性只解决了“读写缓存”的代码复杂度。最常见的问题就是更新了数据库缓存还是旧值导致用户看到脏数据。我建议在写接口里做“先更新DB再删除缓存”删除缓存用CacheEvict而不是更新缓存。为什么因为并发场景下先更新DB再更新缓存如果两个请求并发执行可能出现“后更新的DB配了早更新的缓存”这种错位而删除缓存则简单粗暴下次读取自然回源数据库。CacheEvict(value userCache, key #userId) public void updateUser(Long userId, UserUpdateDTO dto) { // 先更新数据库 userMapper.update(userId, dto); }另外如果服务是集群部署还要注意一个“缓存击穿”的问题热点缓存失效的一瞬间所有请求同时打到数据库。Redisson官方提供了分布式锁可以和Spring Cache配合但更简单的做法是给热点数据缓存加上逻辑过期时间或者对空结果做短暂缓存。这部分不是Redisson的核心功能但和“Redisson操作Redis”一起用的时候缓存的一致性体验会好很多。6. 序列化、性能调优与那些年踩过的坑6.1 Codec选型StringCodec、JacksonCodec、Kryo5CodecRedisson的Codec负责Java对象和Redis存储数据之间的互相转换这个选择决定了数据的可读性、兼容性和性能。Redisson默认使用Kryo5Codec序列化体积小、速度快但缺点很明显数据是二进制格式在Redis客户端里看到一堆乱码而且对类结构变更很敏感字段增删以后老数据可能反序列化失败。如果你只需要用Redis存普通字符串直接指定StringCodecConfig config new Config(); config.setCodec(new StringCodec()); config.useSingleServer().setAddress(redis://127.0.0.1:6379);用StringCodec时RMap的key和value都必须可能是String类型适合做纯KV缓存、计数器、分布式锁key这类场景。如果你需要把对象存进去并且希望Redis里的人眼可读推荐JacksonCodecconfig.setCodec(new JsonJacksonCodec());JacksonCodec会把对象序列化成JSON字符串排查数据、对接别的系统时都非常方便。它的缺点是体积比Kryo大性能略低。我的一般建议是面向纯缓存且对象结构简单的场景用JsonJacksonCodec面向高频访问、追求极致内存占用的场景保留默认Kryo5Codec面向分布式锁Key或简单字符串直接StringCodec。三种Codec不能混用否则会出现“写入时用的是Kryo读取时用Jackson直接反序列化失败”这种事。6.2 常见异常与排查速查表我整理了一份自己在生产环境排查过程中遇到的Redisson异常速查表可以直接收藏异常现象原因分析解决方案ClassCastException读写两边用了不同的Codec统一配置Codec并在配置中心下发时保持一致RLock is not owned by current thread锁超时或线程不同导致unlock失败检查是否显式传了leaseTime且业务时间过长确保unlock和lock在同一个线程Failed to submit a listener notification taskRedisson事件线程池被占满调大threads或nettyThreads检查是否有长时间阻塞的回调Connect to redis server failed网络不通或连接池耗尽检查Redis地址、防火墙、timeout配置适当调大connectionPoolSizeWrongType同一Redis key被不同类型命令写入通常是同一个key既被当String又被当List或Hash使用规范Key命名java.lang.ClassNotFoundException反序列化时加载不到业务类用StringCodec或JsonJacksonCodec避免跨服务直接传Class对象Moved异常使用了单机连接访问集群Redis改为clusterServersConfig连接集群6.3 独家避坑心得我个人操作Redisson过程中最深的几个体会这里一次性分享出来。第一不要在锁内做远程IO或者睡眠。分布式锁的目的是保护短临界区如果一个线程在临界区内调第三方接口等了5秒其他所有线程都在等锁系统吞吐量会断崖式下跌。锁的粒度能细就细把不必要共享的操作移到锁外面。第二RedissonClient的初始化要发生在Spring容器启动早期。如果在PostConstruct里就尝试注入RedissonClient做缓存预热而Bean还没有实例化完成容易拿到空指针。我习惯把缓存预热逻辑放在ApplicationReadyEvent事件里确保容器完全就绪后再执行。第三连接池不是越大越好。我见过有人把connectionPoolSize写到512的结果是Redis连接数瞬间打满直接拖垮了Redis。实际经验里业务QPS在1000附近connectionPoolSize64已经足够因为Redisson是异步非阻塞连接64条连接能撑住的并发比你想象的高得多。第四不要指望Redisson解决所有问题。比如RLock在主从故障切换时可能失效、RDelayedQueue在网络抖动时可能出现消息延迟、RRateLimiter的令牌桶在时钟跳跃时不受影响但Redis重启后限流状态会重置。这些都不是Bug而是分布式环境的物理边界。理解这些边界才能正确地设计业务兜底逻辑。比如订单超时关闭这种场景即使延迟队列偶尔晚执行几分钟只要后续有对账任务做补偿业务上就是可以接受的。第五尽量用独立的RedissonClient管理“内部组件数据”和“业务缓存数据”。如果项目规模不大可以共用一个Client但如果其中一个业务模块疯狂写入大对象造成连接池占用会影响分布式锁的获取速度。我当时在网关层和业务服务里各建了一个RedissonClient通过配置项区分连接池大小后期排查问题会清爽很多。7. 从入门到精通的最后一段路Redis系列写到这里其实你已经有能力在真实项目里把Redisson用起来了。从分布式锁的watch dog续期到RMapCache的精准过期再到RRateLimiter的令牌桶限流这些能力组合起来基本覆盖了Redis在Java服务端90%的常见需求。我的建议是先在一两个非核心模块里引入Redisson把锁、缓存、限流各跑一遍感受一下数据结构从本地到分布式的迁移过程再逐步扩大使用规模。最后再分享一个小技巧排查Redisson问题不要太依赖看日志先在Redis客户端里观察对应Key的TTL变化。比如你可以用Redis Desktop Manager监控一个分布式锁Key的TTL如果TTL一直稳定在30秒左右跳动说明watch dog在正常工作如果TTL出现了归零那基本是业务线程已经走了unlock或者传了leaseTime导致看门狗没启动。这种“从数据反推代码行为”的方式在分布式环境里定位问题会比翻代码高效得多。
返回列表