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> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>需要连接池的话,再加一个Apache Commons Pool的依赖:
<dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-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 RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, 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.002=1个,所以默认16个连接其实绰绰有余。但如果并发集中在秒杀场景,峰值QPS上到几千甚至几万,连接池就得往上调,同时也要给Redis实例本身留足内存和CPU余量。
3. RedisTemplate核心API与数据类型:String、Hash、Set这些别记混
3.1 五种基本类型对应五种ops
Redis类型和RedisTemplate的API是一一对应的,常见的五种到六种都要知道:
| Redis类型 | RedisTemplate API | 典型场景 |
|---|---|---|
| String | opsForValue() | 缓存对象JSON、计数器、分布式锁 |
| Hash | opsForHash() | 缓存结构化对象字段、购物车 |
| List | opsForList() | 简单消息队列、最近列表 |
| Set | opsForSet() | 去重统计、共同好友 |
| ZSet | opsForZSet() | 排行榜、延时队列 |
| Stream | opsForStream() | 专业消息队列(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里有个经常被忽略的API:opsForValue().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的统一前缀,比如value="user"加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再DEL:
if (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> <groupId>org.redisson</groupId> <artifactId>redisson-spring-boot-starter</artifactId> <version>3.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是获取锁的最大等待时间,超过后返回false;leaseTime是锁的自动释放时间,自首次拿到锁起开始计算,到期后就算业务没执行完也会被释放。特别注意,传了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,从缓存注解到分布式锁,都是一以贯之的:先想清楚机制,再写代码,远比装一堆依赖然后踩坑强。