ARTICLE DETAIL

资讯详情

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

SpringBoot整合Redis:缓存序列化、三大缓存问题与分布式锁设计

SpringBoot整合Redis:缓存序列化、三大缓存问题与分布式锁设计 SpringBoot项目接入Redis缓存算不上一件多复杂的事但我在排查线上问题的时候发现真正把Redis用明白的项目少之又少。缓存动不动全量失效、分布式锁没锁住、缓存和数据库对不上这些问题的根子大多不在框架而在接入前没把序列化方案、缓存Key、锁的原子操作这些基本功想清楚。这篇我把SpringBoot项目引用Redis缓存、落地分布式锁的完整思路拆开讲一遍从依赖配置到缓存注解从三大缓存经典问题到锁的可靠性设计都是实际跑过的方案也包含我踩过的坑。1. 为什么业务要上Redis缓存扛住高频读顺便把分布式锁也做了1.1 数据库扛不住的不是并发数而是热点集中度很多刚开始做服务的同学会有一个错觉数据库挺快的MySQL本地查询也就几毫秒为什么还要接Redis等你真正遇到线上问题就明白了。MySQL的瓶颈不在单次查询而在连接数和事务开销。一个接口如果QPS上了两千每次查询都要拿连接、解析SQL、走存储引擎数据库连接池很容易被打满后续请求直接排队等连接。而Redis把数据直接放在内存里请求走的是网络IO加内存操作单机轻松跑几万QPS这就是它作为缓存层最大的价值。但是缓存不能乱用。我见过一个团队把所有表的数据全塞进Redis结果冷数据占满内存热数据还被LRU挤掉了收益非常差。缓存应该只承接“读多写少、热点集中”的数据。比如商品详情、用户信息、配置项这类数据绝大多数是读请求写请求很少。把数据库的读压力降下去连接池自然就喘过气来了。1.2 一个Redis实例同时承担缓存与锁再说分布式锁。项目上了多实例后你会遇到一个很尴尬的问题JVM自带的synchronized和Lock只对当前进程里的多个线程有效但请求被负载均衡分发到不同实例同一个用户的操作可能落在不同机器上单机锁等于没锁。这时候需要一个所有实例都能访问的公共组件去协调“能不能执行”这个判断必须发生在这个公共组件上这就是分布式锁。Redis能做分布式锁一是因为它真快操作是原子的二是因为SET key value NX EX这种命令本身就是原子操作天然适合做互斥判断。所以很多项目里Redis一份部署既当缓存用又当分布式锁的后端。省钱省事架构上也清晰。2. SpringBoot接Redis的完整配置依赖、连接池、序列化一个都不能少2.1 引入依赖与基础连接配置SpringBoot接Redis非常简单Maven里加一个依赖就够了dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency需要连接池的话再加一个Apache Commons Pool的依赖dependency groupIdorg.apache.commons/groupId artifactIdcommons-pool2/artifactId /dependency然后配置application.yml。这里有个版本差异要提醒Spring Boot 2.x用的是spring.redis前缀Spring Boot 3.x改成了spring.data.redis。有人问“springboot版本太高”导致配置不生效很多时候就是老配置写法在新版本里没对上spring: data: redis: host: 127.0.0.1 port: 6379 password: 123456 database: 0 timeout: 3s lettuce: pool: max-active: 16 max-idle: 8 min-idle: 2 max-wait: 3sdatabase这个参数建议固定下来别在代码里切换。我看到有人为了多业务隔离动态选0到15号库最后运维排查问题的时候根本分不清key在哪个库调一次还得写一堆脚本。如果业务确实需要隔离更推荐用不同的前缀比如user:、order:或者干脆单独部署Redis实例。2.2 RedisTemplate序列化方案必须自己接管这是SpringBoot引用Redis最容易被坑的地方。直接注入RedisTemplate用不做任何配置的话key和value都会被JdkSerializationRedisSerializer处理存到Redis里的结果是\xAC\xED\x00\x05t...这种二进制乱码。用RedisDesktopManager看一眼全是乱码TAB键根本没法排查。更麻烦的是这种序列化结果只有Java能读如果以后有Python脚本或者Go服务要复用这些缓存数据直接傻眼。我建议在项目里放一个配置类专门接管RedisTemplate的序列化方案Configuration public class RedisConfig { Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(factory); StringRedisSerializer stringSerializer new StringRedisSerializer(); GenericJackson2JsonRedisSerializer jsonSerializer new GenericJackson2JsonRedisSerializer(); template.setKeySerializer(stringSerializer); template.setHashKeySerializer(stringSerializer); template.setValueSerializer(jsonSerializer); template.setHashValueSerializer(jsonSerializer); template.afterPropertiesSet(); return template; } }key、hash key必须用StringRedisSerializer方便肉眼识别和按前缀批量管理value用GenericJackson2JsonRedisSerializer序列化结果能存完整对象还会带上class类型信息反序列化时才不会丢失类型。如果你确定所有value都是简单的String直接用StringRedisTemplate更省心它是Spring内置好的纯字符串操作模板。2.3 连接池参数别照抄结合QPS去估连接池参数是很多人会忽略的一环。Lettuce本身很轻但Spring Data Redis默认不启用连接池如果你不引入commons-pool2所有的连接都是每次操作临时建立的。低并发下没关系压力一起来就出现连接创建风暴报Unable to connect to Redis。所以生产环境一定要开连接池。参数也别直接照抄网上的。max-active建议粗略按接口QPS和Redis操作耗时的乘积来估算。比如接口QPS是500每次Redis操作平均2ms任意时刻在途的Redis操作大约是500×0.0021个所以默认16个连接其实绰绰有余。但如果并发集中在秒杀场景峰值QPS上到几千甚至几万连接池就得往上调同时也要给Redis实例本身留足内存和CPU余量。3. RedisTemplate核心API与数据类型String、Hash、Set这些别记混3.1 五种基本类型对应五种opsRedis类型和RedisTemplate的API是一一对应的常见的五种到六种都要知道Redis类型RedisTemplate API典型场景StringopsForValue()缓存对象JSON、计数器、分布式锁HashopsForHash()缓存结构化对象字段、购物车ListopsForList()简单消息队列、最近列表SetopsForSet()去重统计、共同好友ZSetopsForZSet()排行榜、延时队列StreamopsForStream()专业消息队列5.0我实际项目里最常用的是String和Hash。缓存一条用户记录直接用opsForValue().set(user:1, jsonString)最简单但如果要更新用户某一个字段全量反序列化再写回有点浪费用Hash可以只hash.put(user:1, name, 张三)对字段级操作更友好。3.2 设置过期时间是缓存操作的基本功往Redis里写数据不加过期时间是我见过最多的新手失误。尤其是用缓存保存验证码、临时Token这种数据必须有过期时间。RedisTemplate写过期时间的标准写法是stringRedisTemplate.opsForValue().set(cacheKey, value, 10, TimeUnit.MINUTES);有些同学喜欢分两步先set再expire结果中途抛异常验证码就永不过期了。虽然RedisTemplate的set和expire看起来都有调用但它们是两次独立操作没有原子性保证所以能用带超时参数的单个命令就别拆开。过期时间怎么设计也有讲究。缓存热点数据我一般会给一个合理业务时间比如用户详情的TTL设成10分钟然后在数据变更时主动删缓存而不是傻等过期。如果担心大量key同时失效TTL可以加上几秒到几十秒的随机偏移这个细节在后面第5章会展开讲。3.3 setIfAbsent分布式锁的地基RedisTemplate里有个经常被忽略的APIopsForValue().setIfAbsent(key, value, timeout, TimeUnit)对应原生命令SET key value NX EX。它的含义是“只有key不存在时才写入”写成功后返回true否则返回false。这个原子操作就是Redis分布式锁的地基后面第6章实现锁的时候会反复用到。类似的不等式判断操作还有decrement、increment可以做计数器。比如限流场景把窗口期作为key每来一个请求就increment一次超过阈值就拒绝成本很低。这些操作之所以能放心用靠的都是Redis单线程模型下命令的原子性。4. 缓存注解实战Cacheable、CacheEvict和Key设计4.1 开启缓存注解与基础用法不想在业务代码里手写RedisTemplate可以走Spring的缓存注解连序列化模板都不用自己组装。先在一个配置类上加EnableCaching开启缓存能力然后在方法上标记Cacheable(value user, key #id) public User getUserById(Long id) { return userMapper.selectById(id); }Cacheable先查缓存缓存有就直接返回没有才执行方法体方法返回值会被自动写进Redis。配合CachePut做更新缓存CacheEvict做删除缓存CacheEvict(value user, key #user.id) public void updateUser(User user) { userMapper.updateById(user); }三个注解组合起来基本上能覆盖业务里90%的缓存操作需求。注意一点缓存注解是通过AOP代理实现的同一个类内部方法调用不会走代理注解会失效。以前我踩过这个坑一个Service里有个private方法调自己类的Cacheable方法死活不生效排查半天才发现是AOP代理没进去后来都习惯把缓存方法和业务方法拆到不同Bean里。4.2 Key设计默认生成策略不背锅可读性才重要用了注解不指定key的话Spring会依照方法参数生成一个SimpleKey。如果方法里有参数就用参数值但你看不到具体长啥样多个方法同用一个value的时候也不好区分。实际排查问题时你在Redis里看到一串编译生成的key没法一眼判断对应哪个业务。所以我的习惯是所有Cacheable都显式指定key用SpEL表达式Cacheable(value user, key #id) // 取参数id Cacheable(value user, key #user.id) // 取对象属性 Cacheable(value user, key all) // 固定值value就是Redis key的统一前缀比如valueuser加key#id在Redis里存出来是user::123一看就知道是缓存用户ID为123的对象。另一个要注意的点是Spring Cache默认把整体对象塞进一个缓存遇到对象字段频繁变动反而不如Hash灵活。如果字段级更新需求很明显我建议放弃注解直接用RedisTemplate做缓存层。4.3 自定义RedisCacheManager统一TTL只加Cacheable不配置CacheManager缓存默认永不过期。这在数据不变的前提下没问题但业务数据迟早要变缓存一旦没有合理的过期策略脏数据就会一直留在Redis里。生产环境我一般这样配置Configuration public class CacheConfig { Bean public RedisCacheManager cacheManager(RedisConnectionFactory factory) { RedisCacheConfiguration config RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(30)) .serializeKeysWith(SerializationPair.fromSerializer(new StringRedisSerializer())) .serializeValuesWith(SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())) .disableCachingNullValues(); return RedisCacheManager.builder(factory) .cacheDefaults(config) .transactionAware() .build(); } }entryTtl(Duration.ofMinutes(30))配的是默认TTL个别缓存想要不同过期时间可以用RedisCacheManager的withCacheConfiguration方法对指定缓存名单独设置。这里我建议把空值缓存关闭即disableCachingNullValues()避免所有不存在的查询结果都被缓存成一个Null把Redis搞成垃圾场。5. 缓存三大经典问题穿透、击穿、雪崩的应对5.1 缓存穿透空值缓存和布隆过滤器怎么选穿透是指请求一个完全不存在的数据缓存里没有数据库里也没有偏偏这种请求可以被恶意构造出来比如遍历ID去查不存在的用户。每查一次都直接打到数据库Redis挡住了正常流量却挡不住这种“全部落空”的流量。最简单的应对是缓存空值。数据库查出来是null就写一个空串到缓存TTL设短一点比如30秒下次相同请求直接返回空Product product productMapper.selectById(id); String cacheKey product: id; if (product null) { stringRedisTemplate.opsForValue().set(cacheKey, , 30, TimeUnit.SECONDS); return null; }布隆过滤器更专业它可以把所有可能存在的ID提前hash到一个bitmap里查询前先做一次exists判断能过滤掉绝大多数不存在的key。Redis里可以用Redisson提供的RBloomFilter省去自己实现位数组的麻烦。这两种方案我的取舍是业务简单、空查询占比不高用空值缓存数据量很大且恶意遍历风险高上布隆过滤器。5.2 缓存击穿互斥重建与逻辑过期击穿和穿透名字像场景完全不同。击穿指的是一个高热度的key恰好到了过期时间在它失效的瞬间大量请求同时打到数据库数据库瞬间被打爆。最典型的例子是热点新闻的详情页平时缓存扛着一到缓存过期的那个毫秒级窗口所有用户都成了“真实流量”。我用的方案是互斥锁重建。当缓存没命中时先尝试拿分布式锁拿到锁的线程重新查数据库并写缓存没拿到锁的线程短暂自旋后重新查缓存。因为只有一个线程去查库数据库压力就控制住了public String getData(String key) { String value stringRedisTemplate.opsForValue().get(key); if (value ! null) { return value; } String lockKey lock: key; String requestId UUID.randomUUID().toString(); boolean locked Boolean.TRUE.equals( stringRedisTemplate.opsForValue().setIfAbsent(lockKey, requestId, 10, TimeUnit.SECONDS)); if (!locked) { try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return getData(key); } try { String again stringRedisTemplate.opsForValue().get(key); if (again ! null) { return again; } String dbValue loadFromDb(key); stringRedisTemplate.opsForValue().set(key, dbValue, 300, TimeUnit.SECONDS); return dbValue; } finally { releaseLock(lockKey, requestId); } }编码时注意两点锁的value必须带唯一标识释放锁时必须走Lua脚本校验防止误删别的线程刚拿到的锁抢锁失败后的自旋间隔别太长也别太短50到100毫秒比较合理。逻辑过期是另一种思路不给真实key设置物理过期时间而是把过期时间写进value里后台轮询发现逻辑过期后重建缓存。这样请求永远能命中缓存不会出现瞬间打到数据库的窗口但实现复杂度高很多还要处理并发重建新手不建议一上来就用。5.3 缓存雪崩不要让“同一秒”成为事故现场雪崩是击穿的放大版大量key在同一时间段集体失效或者Redis实例本身挂掉所有请求瞬间全量打到数据库。最常见的诱因是全量缓存做定时刷新时把所有key的TTL都设成了同一个时间点。对应的方案很简单过期时间加随机偏移。比如TTL设计成5分钟实际写缓存的时候在5分钟基础上加上0到60秒的随机值。这样即使一次批量加载上千个key它们的过期时间也会被摊开不会在同一个秒级窗口集体失效。这个策略我用了很久线上从未因为批量缓存刷新引发过雪崩。至于Redis挂掉的情况光靠Redis自己救不了需要做多级缓存降级本地Caffeine缓存兜底或者熔断部分依赖Redis的功能返回限流提示等Redis恢复再继续。多级缓存的复杂度明显上升但核心链路的稳定性也明显提升看业务对可用性的要求再决定上不上。6. 分布式锁从零到能用SETNX的原子操作、Lua脚本与误删锁问题6.1 单机锁为什么在多实例下失效分布式锁的需求很直白多个服务实例可能会同时操作同一个共享资源比如同一件商品的库存。如果每个实例都用synchronized锁自己进程内的代码A实例在扣减库存B实例并不知道照样扣一份库存就超卖了。分布式锁要让“当前是否允许我执行”这个判断在一个所有实例都信任的地方完成一般是Redis、ZooKeeper或者Etcd。用Redis实现分布式锁要满足几个基本条件互斥性同一时刻只有一个实例持有锁原子性加锁和释放必须整体原子容错性持有锁的实例挂了锁也能超时释放不会变成死锁识别性释放锁时只能释放自己持有的锁。前两个是硬要求后两个是防事故的安全底线。6.2 加锁要一个命令完成SET NX EX很多人一开始会这么写先用setIfAbsent设置锁再单独调用expire设置过期时间。这两步分开就会有一个时间窗口key刚写入expire还没执行持有锁的实例突然宕机这把锁永不过期所有实例永远拿不到锁系统直接死锁。正确的做法是让“加锁”和“设置过期时间”合成一个原子操作Redis原生命令就是SET key value NX EX timeout在SpringBoot里对应String lockKey lock:order:123; String requestId UUID.randomUUID().toString(); Boolean locked stringRedisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, 10, TimeUnit.SECONDS); if (Boolean.TRUE.equals(locked)) { // 拿到锁执行临界区业务 }这里的value为什么要带随机UUID因为释放锁的时候需要确认“这把锁确实是我拿的”。如果只用固定的“lock”作为value任何线程都能直接删锁锁就失去了意义。6.3 释放锁必须走Lua脚本否则就会误删别人的锁释放锁常见的错误写法是先GET再DELif (lockKey的value等于自己的requestId) { delete lockKey; }这看起来没问题但GET和DEL是两步操作。考虑这个场景线程A拿锁执行任务比较久锁在10秒后超时自动释放了线程B马上拿到同一把锁这时A终于执行完了回去做GET发现key还在以为锁是自己的直接DEL就把B的锁删掉了。B还没干完活锁没了另一个线程C也能拿锁进入临界区两个线程同时操作共享资源事故就来了。要避免误删必须把“比较value”和“删除key”放在一个原子操作里用Lua脚本if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end对应Java代码private static final String RELEASE_LOCK_SCRIPT if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; public void releaseLock(String lockKey, String requestId) { stringRedisTemplate.execute(new DefaultRedisScript(RELEASE_LOCK_SCRIPT, Long.class), Collections.singletonList(lockKey), requestId); }Redis单线程执行Lua脚本所以这段判断加删除天然原子安全。自己手写这套逻辑锁的基本可靠性有了但可重入和自动续期还是没有。下一章讲的Redisson就是把这些问题全部解决的成熟方案。7. Redisson分布式锁实战可重入、看门狗和集群下的取舍7.1 为什么最终用了Redisson手写SETNX加Lua脚本能应付简单的互斥场景但有几个问题绕不开锁没有可重入性同一个线程tryLock两次就死锁锁过期时间设短了业务还没跑完锁就释放设长了万一宕机其他线程要等很久拿锁后如果任务执行时间不可控没有续期机制。这些问题Redisson早就解决了。Redisson是Redis官方推荐的Java客户端之一它在Redis之上封装了一套完整的分布式锁实现我最终选择它不是因为它花哨而是因为稳定性经过大量生产验证。引入依赖dependency groupIdorg.redisson/groupId artifactIdredisson-spring-boot-starter/artifactId version3.27.1/version /dependency如果不想用starter传统方式也是在classpath里放入Redisson依赖然后手动创建RedissonClient两种方式都不复杂。RedissonClient本身线程安全建议只创建一次作为Spring Bean管理。7.2 RLock的基本用法与看门狗机制Redisson的锁使用起来和JUC的Lock很像基本用法Autowired private RedissonClient redissonClient; public void deductStock(Long skuId, int count) { RLock lock redissonClient.getLock(stock: skuId); lock.lock(); try { // 扣减库存的核心业务逻辑 } finally { lock.unlock(); } }这段代码背后有个重要机制叫看门狗。默认情况下lock()不传leaseTime时Redisson会启动一个后台定时任务锁的初始过期时间是30秒每过10秒就自动续期到30秒只要线程还没执行完锁就不会因为超时被Redis删除。这个机制解决了我前面说的“业务执行时间不可控、锁提前失效”的痛点。但有得必有失看门狗只能保证锁不提前释放如果持有锁的实例真的挂掉网络断开看门狗也发不出续期指令了30秒后锁自动释放不会死锁。这是分布式锁最理想的状态业务正常时锁一直有效异常时锁也能兜底释放。7.3 tryLock的等待时间和自动释放时间要分清lock.lock()是“拿不到锁就一直阻塞等待”在极端竞争场景下线程会一直挂在那里用户请求超时也不知道。更推荐的做法是用tryLock设置最大等待时间boolean locked lock.tryLock(3, 10, TimeUnit.SECONDS); if (locked) { try { // 业务逻辑执行时间不要超过10秒 } finally { lock.unlock(); } } else { // 3秒内没抢到锁放弃或提示稍后重试 throw new BizException(系统繁忙请稍后再试); }tryLock的三个参数含义分别是waitTime是获取锁的最大等待时间超过后返回falseleaseTime是锁的自动释放时间自首次拿到锁起开始计算到期后就算业务没执行完也会被释放。特别注意传了leaseTime之后看门狗不工作因为Redisson认为你明确指定了业务最大执行时长。所以我把两个数分开记waitTime是“我等锁等多久”leaseTime是“锁最多给我用多久”。7.4 集群环境下Redis锁的局限Redisson虽然成熟但它底层依赖的仍是Redis的原子命令。在主从架构下有个隐患主节点上成功写入锁还没来得及同步到从节点主节点宕机从节点升级为主节点锁信息丢了。其他线程再来获取同一把锁就会成功互斥被打破。这种问题在要求强一致性的场景下是致命的。所以如果你的系统部署集群规模大且分布式锁保护的资源是资金、订单这类强一致数据可以考虑RedLock算法它需要在多个独立Redis节点上依次获取锁超过半数成功才算拿到。RedLock的思路没问题但多个节点部署成本和运维复杂度都不低业界也有不少争议。我的建议是先分清楚自己的业务等级。普通业务锁单节点加主从加哨兵足够锁丢失的概率很低换来的是极低的延迟和简单的运维核心资金链路与其纠结Redis的RedLock不如直接用ZooKeeper或Etcd的分布式锁它们的模型本身就是CP架构一致性比Redis强。这个取舍没有绝对对错只有适合不适合。8. 缓存与数据库一致性先删缓存还是先更新库我的取舍8.1 Cache Aside模式下的两种顺序缓存和数据库的一致性是Redis缓存绕不过去的话题。很多项目用Cache Aside模式读的时候先读缓存没有就查库然后回填写的时候更新数据库然后把缓存删掉。问题来了更新数据库和操作缓存先做哪个先删缓存再更新库会有这样一个窗口线程A删掉缓存还没更新数据库线程B来读缓存没命中查到旧数据并回填缓存随后A才更新数据库。结果缓存里存的还是旧数据后续所有读都会读到旧值缓存一致性完全被破坏。先更新库再删缓存窗口小得多但也存在更新成功、删除缓存失败的情况缓存里旧数据就一直留着。8.2 延迟双删与最终一致做法针对先删缓存后更新库的脏数据窗口业界有延迟双删方案先删一次缓存更新数据库再隔几百毫秒删一次缓存。第二次删除的目的是把线程B在那几百毫秒内回填的旧值清掉。这个方案简单直接但延迟时间是拍脑袋定的实际并发模型稍微复杂一点依旧可能漏删。更稳的路径是先更新数据库再删缓存。这条链路下缓存和数据库发生不一致的唯一可能是“删缓存”这一步失败或者耗时过长。所以可靠性重点在于保证删缓存这个动作一定成功可以引入异步补偿把要删除的key放到一个可靠队列里消费端不断重试删除直到成功做得更重一点用Canal监听MySQL的binlog解析出变更的key异步删缓存。这种方案虽然引入额外组件但能保证最终一致。8.3 我实际项目中的方案我在实际项目中选的是比较平衡的组合先更新数据库后删缓存删除动作通过本地重试加一个简单的异步补偿兜底。具体来说更新成功后先直接删一次缓存如果删除返回失败就把key塞进本地内存队列后台线程每5秒重试一次重试超过10次就告警人工介入。同时我不会让所有数据都走这个强一致流程。判断标准很简单这份数据是“强读”还是“弱读”。用户昵称、商品详情这类允许秒级延迟的数据缓存失效了重新回源就行库存、余额这类绝对不能读旧值的数据要么不缓存要么用分布式锁保证同一时刻只有一个线程读写。说到底缓存解决的是性能问题不是正确性问题数据库才是数据的最终真相。我在项目里还养成了一个习惯每次接缓存之前先问自己三个问题。这个数据读的频率真的高吗能容忍多少秒的脏读数据变更时缓存能不能及时清掉三个问题想清楚再接缓存就不容易出乱子。这套思路从SpringBoot到Redis从缓存注解到分布式锁都是一以贯之的先想清楚机制再写代码远比装一堆依赖然后踩坑强。
返回列表