大概五六年前我刚开始接触Redis的时候,真没觉得它有多少学问。那会儿项目里最常用的就是set、get两个命令,塞在接口里当缓存用,线上数据量也不大,感觉它就是一把"高级点的Map"。真正意识到Redis不是这么用的,是有一次在联调环境排查超时问题,整个服务链路绕来绕去,最后发现所有人都绕不开一个尴尬事实:缓存到底该怎么设计、怎么用,团队里其实没几个人说得清楚。
那次之后,我决定把Redis的使用好好梳理一遍,"Redis使用~第一弹"就是这么来的。这篇我不打算讲冷门高阶特性,先把高频、刚需、能直接落地的东西写透:Redis到底解决什么问题、五种核心数据结构怎么用得明白、如何和后端框架集成、以及几乎每个团队都会踩的缓存穿透、击穿、雪崩这些坑。适合刚用Redis不到半年、还处于"会敲命令但不太敢上生产"阶段的同学,当然,如果你已经写了不少缓存代码,也可以对照着检查一下自己的用法有没有跑偏。
1. 第一弹先聊清楚:Redis到底是来解决什么痛点的
1.1 一次超时排查带来的反思
那次联调时的现象很典型:一个查询商品详情的接口,说慢不慢,平均四五百毫秒,但压测一上来就直接飚到两三秒。看链路,调用关系其实不复杂,每个服务各自查了数据库,每个服务又都做了本地缓存。问题就出在"本地缓存"上——本地缓存是跟着应用进程走的,多个服务实例之间谁也不认识谁的数据。同一个商品ID,A实例缓存了旧价格,B实例缓存了新价格,用户来回刷新看到的价格都不一样。
Redis在当时最直接的吸引力就是:它是独立部署的内存服务,所有应用实例往同一个地方读写,天然解决了多实例数据一致性的问题。加上命令简单、客户端生态成熟,接进来几乎不费劲。
但等到真接进来,才意识到很多问题是"本地缓存时代"根本没有的:key过期了怎么办、热点key被击穿怎么办、Redis重启之后缓存里的数据要不要恢复、内存满了会怎样。这些不是Redis本身坏,而是定位没想清楚。Redis不是MySQL的替代品,它是给数据库挡子弹的。把这句话先放在心里,后面很多纠结都能少一半。
1.2 内存数据库到底快在哪里
很多人把Redis快归结为"它在内存里",这话只对了一半。同一台机器上,用本地HashMap读十万次,大概率比用Redis读十万次更快,因为Redis再快也过了一层网络。Redis的核心优势,是把"计算"搬到了离数据更近的地方,并且用一套很聪明的模型去支撑高并发读。
Redis是单线程模型,这句话经常被误解成"Redis性能不行,一个线程能干多少活"。恰恰相反,单线程让它省掉了锁竞争、省掉了线程切换的开销,所有命令按到达顺序排队执行,天然串行、天然原子。网络IO这块,Redis用IO多路复用让一个线程同时盯住成千上万个连接,哪个连接有请求就处理哪个。
所以Redis适合的场景有非常明显的画像:读多写少、单个操作足够轻量、数据需要被多个服务共享。反过来,如果你有一个重计算任务,或者一个几十MB的JSON要反复读写,那Redis不一定是最优解。
1.3 第一弹的内容边界
我打算把基础使用拆成两到三弹。这一弹只覆盖到"能正常用、用对路"的程度:本地环境怎么搭、五种核心数据类型怎么选、Spring Boot集成时最容易翻车的序列化和失效问题、三大缓存经典坑怎么防、持久化和内存淘汰怎么配。
下一弹大概率讲分布式锁、高可用部署和压测调优。看完这一弹,你至少应该能独立回答这几个问题:这个数据该不该进Redis?应该用哪种数据结构?过期时间设多久?如果Redis崩了,业务会不会跟着崩?
2. 本地环境搭建:启动Redis前最容易忽视的几个细节
2.1 三种安装方式怎么选
开发机装Redis,我最推荐的方式是操作系统的包管理器。比如macOS下直接:
brew install redis装完就能用redis-server启动,简单直接。Linux服务器上如果也想省事,Ubuntu和CentOS也都有官方仓库里的Redis包,但版本通常比较旧,而且不同发行版默认配置差异挺大。
生产服务器上我习惯用源码编译安装。官方源码包下载解压后,依次执行:
make make install PREFIX=/usr/local/redis编译前需要确认机器上有gcc和make工具,Redis是基于C语言的,编译失败十有八九是缺工具链。编译成功后会生成redis-server和redis-cli两个主要二进制,直接放进PATH就能用。
如果实在不想碰环境,Docker其实是最省心的:
docker run -d --name redis-first \ -p 6379:6379 \ redis:7-alpine这样启动的是默认配置,日志直接打到容器里,测试代码连一下就能跑。但要注意,容器部署时挂载数据卷这件事必须提前想好,否则容器一删数据就全没了。
2.2 redis.conf里值得提前改的几个参数
Redis默认配置其实"能用,但不适合直接用",尤其是跑在服务器上的时候。我每次装完新实例,第一时间会检查这几个配置:
- daemonize:改成yes以后Redis会在后台运行,否则终端一关服务就停了。开发时跑前台也没问题,能看到即时日志。
- bind:默认127.0.0.1只允许本机访问。如果有多台机器要连,得改成实际IP或者0.0.0.0,但要配合防火墙一起考虑。
- protected-mode:这是Redis的自我保护机制,没有密码且只绑了本机时,会拒绝远程访问。改bind之后记得确认这个开关的状态。
- requirepass:设置访问密码。生产环境必须设,哪怕只在内网跑也建议设。别嫌麻烦,扫描脚本最喜欢找没密码的6379端口。
- logfile:默认日志打到标准输出,改成文件路径后排查问题会舒服很多。
- maxmemory:不设的话,Redis会一直吃内存直到系统OOM。这个后面专门讲。
配置文件修改后,用指定配置文件启动:
redis-server /etc/redis/redis.conf2.3 用命令行验证安装是否正常
启动完成之后,用一个命令验证:
redis-cli ping返回PONG就说明服务活着。再体验一下最基础的读写:
redis-cli set hello world redis-cli get hello如果设了密码,连接时需要带认证:
redis-cli -a 你的密码命令行会提示密码暴露在进程列表里,仅限开发环境临时用,脚本里推荐用REDISCLI_AUTH环境变量代替。
3. 核心数据类型实操:从命令行到业务代码的思维转换
3.1 String:不只是缓存字符串,还能做计数器和分布式锁
String是Redis里最基础的类型。但很多人用了一两年,还只会set和get,其实String里藏着不少高频好用的命令。
缓存场景下的标准写法是:
SET product:1001 '{"name":"茶杯","price":199}' EX 3600EX 3600表示3600秒后自动过期。这里有个容易忽略的点:set key value和set key value ex 3600是两回事,前者永不过期,后者到期自动删除。用Spring Boot里的opsForValue().set(key, value)时如果不带超时参数,就会写进去一个永不删除的key。
再说两个高频命令。SETNX只有在key不存在时才写入成功,这个特性经常用来做分布式锁的占位操作;INCR是做计数器最顺手的工具:
INCR product:1001:views比如统计某个商品被查看了多少次,直接INCR就行,读出来再显示,走数据库计数还要担心并发扣减问题。
3.2 Hash:一个对象只占一个key的直观优势
大多数新手刚接触Hash时,会觉得它"不就是多了一级吗"。但真正在业务里,Hash能省掉非常多的key混乱。
举个例子,往Redis里存用户信息。用String的话可能会拆成:
SET user:1001:name 张三 SET user:1001:age 25这样一个用户散落成好几个key,删也麻烦,查也麻烦。用Hash就清爽很多:
HSET user:1001 name 张三 age 25 HGET user:1001 name HGETALL user:1001Hash适合存储那些"对象整体读取、偶尔改某个字段"的数据。比如购物车、订单摘要、商品详情。改年龄就HINCRBY user:1001 age 1,完全不碰其他字段。唯一要注意的是HGETALL在field非常多时会有一次性返回大量数据的开销,没必要全取时就别全取,用HMGET指定字段更稳。
3.3 List、Set、ZSet:不同业务场景下怎么选
List可以做简单队列。LPUSH往左边塞,RPOP从右边取,或者反过来,天然先进先出。比如异步任务列表的轻量版,可以用:
LPUSH task:queue task-1001 RPOP task:queueSet适合去重和集合运算。SADD添加成员是自动去重的,SINTER能取两个集合的交集。典型的场景是"我和某位用户有哪些共同关注",或者标签系统里取拥有多个标签的商品。
ZSet这个类型我特别想多说一句,它是在Set的基础上给每个元素加了一个分数(score),按分数排序。排行榜业务几乎绕不开它:
ZADD rank:hot 100 商品A ZADD rank:hot 80 商品B ZINCRBY rank:hot 20 商品A ZREVRANGE rank:hot 0 9 WITHSCORESZREVRANGE ... WITHSCORES取的是按分数从高到低的前十名。ZSet底层用的跳表结构,范围查询性能稳定。代价是内存占用比普通类型大,别什么数据都往ZSet里塞。
| 数据类型 | 典型场景 | 核心命令 |
|---|---|---|
| String | 缓存JSON、token、计数器 | SET、GET、SETNX、INCR |
| Hash | 对象存储,购物车、用户信息 | HSET、HGET、HGETALL、HINCRBY |
| List | 简单队列、时间线 | LPUSH、RPOP、LRANGE |
| Set | 去重、共同好友、抽奖 | SADD、SMEMBERS、SINTER |
| ZSet | 排行榜、热力值排名 | ZADD、ZREVRANGE、ZINCRBY |
4. 与Spring Boot集成:从配置文件到缓存使用的完整链路
4.1 依赖引入与配置里最坑的版本差异
Spring Boot项目接Redis,官方starter就够了:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency>配置文件里,不同Spring Boot版本的写法有变化。2.x版本通常是这样:
spring: redis: host: 127.0.0.1 port: 6379 password: "" timeout: 3s但到了Spring Boot 3.x,配置前缀改成了spring.data.redis:
spring: data: redis: host: 127.0.0.1 port: 6379 password: "" timeout: 3s这个变化很容易被忽略。不少人从网上复制旧配置到新项目里,发现Redis连接参数完全不生效,卡一个小时最后发现只是前缀写错了。
4.2 序列化问题的根因与一次到位配置
新手第一次用RedisTemplate存数据后,打开客户端看到一长串类似\xac\xed\x00\x05t...的二进制乱码,这几乎是必经之路。原因是Spring默认用JDK序列化,把对象序列化成了一长串Java专属字节流,不光看着恶心,其他语言也读不懂。
解决方案是显式指定序列化器。我的固定写法是这样:
@Configuration public class RedisConfig { @Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(connectionFactory); StringRedisSerializer keySerializer = new StringRedisSerializer(); GenericJackson2JsonRedisSerializer valueSerializer = new GenericJackson2JsonRedisSerializer(); template.setKeySerializer(keySerializer); template.setValueSerializer(valueSerializer); template.setHashKeySerializer(keySerializer); template.setHashValueSerializer(valueSerializer); template.afterPropertiesSet(); return template; } }这样key是普通字符串,value是可读的JSON。有一点必须理解:GenericJackson2JsonRedisSerializer会在JSON里带上@class类型信息,否则反序列化时拿不到原始类型。代价是缓存里会多几个字段,但对大多数业务来说完全值得。
4.3 RedisTemplate的常用操作与TTL设计
我习惯把缓存操作包一层自己的Service,不直接在业务代码里到处出现RedisTemplate。简单几个方法就够用:
public void set(String key, Object value, long ttlSeconds) { redisTemplate.opsForValue().set(key, value, Duration.ofSeconds(ttlSeconds)); } public <T> T get(String key, Class<T> clazz) { Object value = redisTemplate.opsForValue().get(key); return value == null ? null : objectMapper.convertValue(value, clazz); } public void delete(String key) { redisTemplate.delete(key); }TTL的设计我认为是缓存设计里最重要的一件事,没有之一。太长,数据陈旧;太短,缓存形同虚设。我一般先看业务对新鲜度的容忍度:商品库存变化频繁,一两分钟就够;用户昵称这种修改不频繁的,半小时甚至一小时都没问题。还有就是,写缓存时一定要顺手写TTL,防止某次异常写入把永远不过期的脏数据带进去。我就见过一次同步任务写坏key导致旧数据展示了快一个月的现场,而那个key根本不需要长时间存活。
4.4 缓存注解与CacheManager的配合
Spring还提供了一套声明式缓存注解,@Cacheable用得最多:
@Cacheable(cacheNames = "product", key = "#id") public Product getProductById(Long id) { return productMapper.selectById(id); }第一次调用会执行方法并把结果放入缓存,后续相同key直接返回缓存。配套的还有@CacheEvict用于删除缓存,@CachePut用于更新缓存。
注解用起来确实方便,但有两个坑要提前知道。一是默认的RedisCacheManager没有设置合理的TTL,所有缓存都不会过期,必须在配置里覆盖默认值:
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(30)) .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())) .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer())); RedisCacheManager cacheManager = RedisCacheManager.builder(redisConnectionFactory) .cacheDefaults(config) .build();二是注解缓存遇到Redis故障时默认会抛异常,接口直接跟着失败。如果你的业务不能接受这种强依赖,可以加sync = true,或者干脆不用注解、走自己封装的双写逻辑。没有银弹,只有适合当前业务的方案。
5. 缓存三大经典坑:穿透、击穿、雪崩的判断与处理
5.1 缓存穿透:查询一个根本不存在的key
形象点说,正常缓存是盾牌,数据库是城。大部分请求被盾牌挡下了。可如果有人故意或者不小心,一直查询一个缓存和数据库里都不存在的ID,那每次请求都会绕过缓存直接砸到数据库上。一次两次无所谓,几千几万次并发压缩,数据库CPU就开始告警了。
高频解法有两种。第一种是缓存空值:
Product product = getFromCache(key); if (product == null) { product = productMapper.selectById(id); if (product == null) { // 空result也缓存,但TTL要短,比如60秒 cacheService.set(key, "", 60); return null; } cacheService.set(key, product, 3600); }这个方案简单有效,但空值key如果太多会占内存,所以TTL必须短。第二种是布隆过滤器,把所有存在的ID先过滤一遍,不在过滤器里的请求直接返回,根本不给数据库压力。布隆过滤器的缺点是有误判率,且ID集合变化频繁时要小心更新。我的建议是:大部分中小业务,缓存空值就够了;只有ID量大、又持续遭遇恶意遍历时才上布隆过滤器。
5.2 缓存击穿:热点key在过期瞬间的高并发压力
击穿和穿透名字像,问题完全不同。击穿打的是"一个热点key刚好过期"的时间窗口:比如一个爆款商品的详情缓存,平时几千QPS全靠缓存扛着,突然缓存过期了,那一瞬间几千个请求同时去查数据库,数据库很容易被打崩。
处理思路也很直接:别让多个请求同时去查数据库。最常用的手段是互斥锁:
String key = "product:detail:" + id; String lockKey = "lock:product:detail:" + id; Object value = cacheService.get(key); if (value != null) { return value; } Boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, "1", Duration.ofSeconds(3)); if (Boolean.TRUE.equals(locked)) { try { value = cacheService.get(key); if (value == null) { value = productMapper.selectById(id); cacheService.set(key, value, 3600); } return value; } finally { redisTemplate.delete(lockKey); } } else { // 拿不到锁的请求,可以短暂休眠后重试,或者返回旧数据 Thread.sleep(100); return cacheService.get(key); }这里有个容易踩的点:锁一定要带超时时间,否则拿到锁的线程还没干完就崩了,锁永远不会释放,后面所有请求全部卡死。如果业务场景对极端情况特别敏感,还可以用逻辑过期方案:缓存里只存旧数据和一个过期时间标记,后台异步去刷新,用户永远拿到旧数据直到新的缓存就绪。这个方案更复杂,但对用户体验最友好。
5.3 缓存雪崩:大面积过期和宕机的区分
雪崩这个词有点被用滥了,我习惯把雪崩拆成两大类。
第一类是数据层面的大面积过期:比如我们把一批商品统一设置成3600秒TTL,那么每天早上整点,这一批key同时过期,数据库瞬间涌入一整片热点。解决方式很朴素,给TTL加上随机偏移:
long ttl = 3600 + new Random().nextInt(300); cacheService.set(key, value, ttl);让过期时间错开,数据库就不会同时收到一大波查询请求。
第二类是Redis节点宕机导致整个缓存层失效。这个比数据过期严重得多,因为不是散开几个请求,而是所有请求全部打到数据库。处理思路不能只靠Redis本身,得在架构层面做准备:多级缓存(本地缓存扛一阵)、熔断降级(限制数据库流量)、快速切换到从节点等。顺便提醒一句,Redis做主从复制时,从节点的数据是异步同步的,故障切换时可能丢少量写入,对缓存来说通常可接受,但如果缓存里存了业务强一致数据,就需要额外考虑了。
5.4 别上来就上复杂方案
我把这三个坑放在一起写,不代表每个都一定要全套方案上齐。现实中见过不少团队,明明只有几十QPS,却为了"防穿透防击穿防雪崩"引入布隆过滤器、多级缓存、逻辑过期,最后困在维护复杂度里动弹不得。
我的经验是先做判断:压测压出真实QPS,看数据库在峰值时能不能接住;再分场景看,是热点key问题还是大批key问题。小体量业务,做好空值缓存、TTL随机化、限流降级,通常已经够了。复杂的方案是给足够大的压力准备的。
6. 持久化与内存淘汰:别把Redis当成永不丢失的内存
6.1 RDB和AOF到底选哪个,实际怎么配
Redis数据默认存在内存里,进程一退出数据就没了,这本身不是问题,问题是有些场景下数据不能丢。如果Redis里只存可重建的热点缓存,不做持久化问题不大;但如果存了计数器、延时任务、业务配置这类数据,就要认真考虑持久化。
RDB是定期生成全量快照,文件紧凑,恢复速度快。缺点是在两次快照之间,数据丢了就丢了。AOF是每一条写命令追加到文件里,数据丢失窗口小,但文件一直涨,比RDB大很多,重启时的恢复速度也慢。
我日常推荐的组合是AOF + 每秒刷盘 + 混合持久化。Redis 4.0以后支持混合持久化,AOF重写时生成一份RDB格式的基础文件,再在上面追加重写之后的增量命令,合并了两者的优点:
appendonly yes appendfsync everysec要说清楚的是,round everysec意味着极端情况下可能丢最近一秒的写入,但对绝大多数业务来说完全可接受。而RDB也有自己的用途:做周期性备份很省空间,比如每天凌晨执行一次BGSAVE,把生成的dump文件拷到备份服务器去。
6.2 maxmemory和淘汰策略:内存满了会发生什么
Redis内存是无上限地用吗?不是,它默认情况下给你一种"能用很多"的感觉,但真用满了,写命令会直接报错:
OOM command not allowed when used memory > 'maxmemory'.所以生产环境一定要给Redis设内存上限:
maxmemory 4gb maxmemory-policy allkeys-lrumaxmemory-policy就是内存满了之后的淘汰策略。几种主要策略全部都列在这里:
| 策略 | 含义 | 适用场景 |
|---|---|---|
| noeviction | 不淘汰,写命令直接报错 | 明确不能丢数据的场景 |
| allkeys-lru | 从所有key里淘汰最久没访问的 | 最常用的通用缓存场景 |
| volatile-lru | 只从设置了过期时间的key里淘汰最久没访问的 | 缓存数据大部分必须有过期时间 |
| allkeys-random | 从所有key里随机淘汰 | 访问非常均匀的场景 |
| volatile-ttl | 从有过期时间的key里淘汰剩余时间最短的 | 优先保新鲜数据 |
| volatile-random | 从有过期时间的key里随机淘汰 | 和volatile-lru类似但更随机 |
大多数业务缓存场景直接选allkeys-lru就够了。但要特别记住:如果Redis里还存了未过期的业务数据,比如临时票据或者分布式锁,那么allkeys-lru可能会把没设置TTL的重要key淘汰掉。这种情况下用volatile-lru更安全,只淘汰那些本来就会过期的缓存key。
我一直强调这一点,因为线上真的出过问题:有人把短信验证码存在Redis里,验证码key没设过期时间(因为代码bug),内存一满,allkeys-lru把验证码key淘汰了,用户收不到验证码、或者收到的验证码永远提示无效,排查了半天才定位到内存淘汰上。
6.3 运维排查时最常用的几条命令
最后再分享几个平时排查问题很有用的命令。
INFO memory能快速看到内存使用情况,重点关注used_memory_human和maxmemory_human:
redis-cli INFO memory如果想知道某个key具体占了多少内存:
redis-cli MEMORY USAGE product:1001如果Redis突然变慢,优先查慢日志:
redis-cli SLOWLOG GET 10慢日志里能看到哪些命令执行时间过长,通常容易出问题的是KEYS全量扫描、HGETALL大hash、SMEMBERS大set这类命令。排查Redis内存分布时也可以跑redis-cli --bigkeys,它会扫描并统计大key分布的概况,但注意这个扫描本身可能造成一定阻塞,最好在低峰期执行。
6.4 第一弹结束前的一点个人体会
Redis用得越久,我越觉得它最大的门槛不在命令,而在"边界感"。哪些数据值得进Redis,哪些数据绝不应该放进去,TTL设置成多久,内存淘汰策略怎么选,每件事说大不大,但合起来就决定了一次线上事故发生还是不发生。第一弹先写到这里,下一篇准备聊聊分布式锁的可靠性设计和主从哨兵架构,等第二弹吧。