刚开始做后端项目那几年,我一直抱着一个想法:Redis 是高并发系统的专属武器,日均请求量只有几万的小项目,直接查数据库就够了,引入 Redis 反而增加维护成本。直到后来在真实业务里被缓存穿透、接口重复提交、会话共享、排行榜实时统计这些问题反复折腾,我才意识到,这个想法错得很离谱。
Redis 真正的价值不是“解决高并发”,而是“让数据访问更合理、让业务逻辑更可靠”。高并发只是它发挥价值最显眼的一个场景,绝不是唯一场景。哪怕你的系统每天只有几百个请求,Redis 也可能因为分布式锁、幂等控制、热点数据缓存、轻量级消息队列等需求而成为必需品。
这篇文章我会从认知误区讲起,把 Redis 的核心数据类型、典型应用场景、实战落地案例、常见踩坑点一次讲清楚。不管你现在做的是企业管理系统、电商后台,还是小型创业项目,都会找到可以直接复用的思路。
1. 为什么“没有高并发就不用 Redis”是初学者的常见误区
1.1 这个误区是怎么来的
网上大量 Redis 教程的开篇都喜欢用“Redis 是高并发架构的核心组件”“秒杀系统、抢红包系统必备”来吸引眼球,看多了之后,很容易形成一种思维定式:高并发项目才需要 Redis,普通项目直接操作数据库就行。
这种说法不能说完全错误,但它把 Redis 的功能窄化了。Redis 确实在缓存、流量削峰、数据热点隔离方面表现突出,可它同时也是一套内存数据结构服务器,提供了丰富的数据类型、原子操作、过期策略、发布订阅、Lua 脚本能力。这些能力与“高并发”并没有必然的绑定关系。
还有一个原因在于,很多人接触 Redis 是从八股文开始的。面试题里总在问 Redis 为什么快、Redis 和 MySQL 怎么保证一致性、Redis 持久化怎么选,这些问题全部默认你已经处在高并发场景中。一旦业务真正落地,面对的是登录会话、数据字典、临时标记、异步任务这些琐碎需求,反而不知道 Redis 该往哪里放。
1.2 Redis 不是缓存中间件,而是数据结构服务器
要真正理解 Redis 的适用范围,需要先纠正一个概念。
我们平时说“Redis 做缓存”,是因为它最常见的用法是充当 key-value 缓存层。但 Redis 官方对自己的定位不是 cache,而是 data structure server,也就是数据结构服务器。
这意味着你面对的不是一个只能存字符串的 Map,而是一整套可以独立完成业务逻辑的数据结构:
- String 可以完成计数、限流、分布式 ID。
- Hash 可以完成对象属性的读写。
- List 可以完成消息队列、时间轴、最新列表。
- Set 可以完成去重、点赞关系、共同好友。
- ZSet 可以完成排行榜、延迟队列、优先级任务。
- Stream 可以完成消费者组模式的消息队列。
- HyperLogLog 可以在极小内存下完成 UV 统计。
- Geo 可以完成附近的人、门店距离计算。
- Bitmap 可以完成用户签到、布隆过滤器的底层实现。
这些能力决定了 Redis 的核心价值在于“以极低延迟完成频繁读写的业务逻辑”,并不仅仅是把数据放内存里。很多没有高并发的系统,同样会遇到频繁读写的状态数据,比如在线用户标记、任务执行进度、验证码存储、接口防重标记。这类数据如果用数据库实现,不仅 SQL 写起来麻烦,而且会给数据库带来大量无价值的读写压力。
1.3 低并发项目的痛点同样需要 Redis 解决
举一个最简单的例子。
一个内部管理系统,每天只有几百个用户访问,量级确实不大。但这个系统大概率会遇到:
- 多个实例部署时,同一个订单被不同接口重复提交。
- 用户上传文件后,前端轮询处理状态,后端需要一个临时状态存储。
- 手机验证码需要 5 分钟有效且只能验证一次。
- 站点公告需要全量拉取,但每秒被访问几十次。
- 搜索关键词需要最近 100 条记录。
这些需求如果全部落到 MySQL,要么建一堆临时表,要么用内存 Map 自己管理过期时间。临时表的问题是要处理过期数据的清理,内存 Map 的问题是集群多实例之间无法共享。Redis 的出现恰好用一套标准且简单的 API 解决了这些问题。
所以,判断“要不要用 Redis”,不应该看并发量,而应该看三点:数据是否被高频读取、是否需要跨实例共享、是否需要快速完成逻辑判断。只要命中其中一点,Redis 就有价值。
2. 环境准备与 Redis 安装
在开始写代码之前,先把 Redis 环境准备好。这一部分会根据不同操作系统给出安装方案,建议选择自己最熟悉的一种,不要每个环境都折腾一遍。
2.1 Windows 环境安装 Redis
Redis 官方并不原生支持 Windows,但微软和第三方社区维护了 Windows 移植版本,适合本地开发调试。
比较常见的做法是直接下载 Redis 官方 Windows 版本压缩包(一般是 Redis-x64-xxx.zip),解压后进入目录执行:
redis-server.exe redis.windows.conf将 Redis 作为服务安装可以执行:
redis-server.exe --service-install redis.windows.conf --loglevel verbose redis-server.exe --service-start连接测试使用命令行客户端:
redis-cli.exe -h 127.0.0.1 -p 6379 pingWindows 移植版本一般功能完整,适合学习,但生产环境不建议使用 Windows 部署 Redis。Linux 才是 Redis 官方推荐的生产环境。
2.2 Linux 环境安装 Redis
以 CentOS / Ubuntu 为例,推荐使用源码编译安装或 Docker 安装。
源码安装步骤:
wget https://download.redis.io/releases/redis-7.0.12.tar.gz tar xzf redis-7.0.12.tar.gz cd redis-7.0.12 make make install PREFIX=/usr/local/redis编译完成后,Redis 服务端程序会被安装到/usr/local/redis/bin目录下,可以通过 redis-server 启动。
启动方式支持前端启动和后台启动两种。开发环境图方便,可以直接用默认配置:
redis-server生产环境建议使用配置文件后台运行:
redis-server /usr/local/redis/conf/redis.conf --daemonize yes验证是否启动成功:
redis-cli -h 127.0.0.1 -p 6379 ping如果返回 PONG,说明服务正常。
这里需要提醒一句,实际安装时版本号请以官网最新稳定版为准,不要照抄文章里的具体版本号。不同版本的配置项会有差异,比如 Redis 6 开始默认启用 ACL 用户体系和 Redis 7 对部分命令权限做了调整。
2.3 Docker 安装 Redis 与主从搭建
如果你本机装了 Docker,安装 Redis 的成本会低很多。
单机启动:
docker run -d \ --name redis \ -p 6379:6379 \ -v /data/redis/data:/data \ -v /data/redis/conf/redis.conf:/etc/redis/redis.conf \ redis:7.0 \ redis-server /etc/redis/redis.conf本地快速体验也可以不挂载配置:
docker run -d --name redis -p 6379:6379 redis:7.0如果你想同时体验主从复制,可以用 docker-compose 一次性拉起三个容器,三个容器分别对应一主两从:
version: '3' services: redis-master: image: redis:7.0 container_name: redis-master ports: - "6379:6379" command: redis-server --appendonly yes redis-slave1: image: redis:7.0 container_name: redis-slave1 ports: - "6380:6379" command: redis-server --slaveof redis-master 6379 redis-slave2: image: redis:7.0 container_name: redis-slave2 ports: - "6381:6379" command: redis-server --slaveof redis-master 6379启动:
docker-compose up -d这是一个最基础的主从架构,从节点会实时同步主节点的数据。主从架构的价值在于读写分离和数据冗余,但注意它不具备自动故障转移能力,生产环境需要引入哨兵或直接使用 Redis Cluster。
2.4 可视化客户端推荐
命令行操作 Redis 虽然高效,但查看 key、分析大 key、查看内存占用时,使用可视化工具有时会方便很多。
目前比较常用的客户端有:
- Redis Desktop Manager:老牌客户端,界面直观。
- Another Redis Desktop Manager:国产开源客户端,跨平台,功能比 RDM 更活跃。
- Redis Insight:Redis 官方推出的可视化工具,支持数据浏览、命令行、内存分析、慢日志分析,功能比较完整。
工具的选择属于个人习惯,不影响核心逻辑。建议至少熟悉 redis-cli 的命令行操作方式,因为生产环境排查问题时,最容易接触到的还是命令行。
3. Redis 核心数据类型,先建立正确的存储思维
Redis 学习的地基不是缓存,而是数据类型。不同数据类型对应不同的底层实现和适用场景,选对类型,往往比调优更有效。下面按开发中最常遇到的场景来介绍。
3.1 String:计数器、缓存、限流
String 是最基础的类型,本质上是一个二进制安全字符串,最大 512MB。
常用命令:
SET user:1:name "张三" GET user:1:name INCR page:view:20250101 EXPIRE user:1:name 3600 SETNX lock:order:1001 1INCR 和 DECR 是原子操作,即使在多线程或分布式环境下,也能保证计数不出现错乱。基于这个特性,String 经常用于实现:
- 接口访问次数统计。
- 分布式 ID 的初始值生成。
- 短信发送频率限制。
- 秒杀库存的预扣减。
SETNX 是 SET if Not Exists 的缩写,只有在 key 不存在时才能设置成功,这是实现分布式锁和接口幂等的重要基础。
3.2 Hash:对象属性的最佳存储
Hash 更适合存储一个“对象”。
如果使用 String 存储用户对象,通常只能整体序列化成一个 JSON 字符串,修改其中一个字段时,需要先把整个对象取出来,反序列化,修改字段,再整体写回。而 Hash 可以直接针对单个字段进行操作。
常用命令:
HSET user:1001 name "张三" age 25 city "杭州" HGET user:1001 name HGETALL user:1001 HINCRBY user:1001 age 1这套结构特别适合存储用户资料、商品信息、订单快照等字段相对固定的数据。
3.3 List:消息队列与最新列表
List 是一个双向链表,支持从头部或尾部插入弹出元素。
常用命令:
LPUSH task:queue "task-1" RPUSH task:queue "task-2" LPOP task:queue BRPOP task:queue 0BRPOP 是阻塞弹出命令,如果列表为空,会一直等待直到有新元素到达。这个特性让小规模项目可以在不引入 Kafka 等重量级消息中间件的情况下,实现一个简单的任务队列。
List 还适合做最新列表,比如用户操作日志,每次产生新日志就往左边插入,再配合 LTRIM 只保留最近 100 条:
LPUSH user:1001:logs "login" LTRIM user:1001:logs 0 993.4 Set:去重与关系运算
Set 是字符串的无序集合,自动去重,并且支持集合运算。
常用命令:
SADD article:100:likes "u1" "u2" "u3" SCARD article:100:likes SISMEMBER article:100:likes "u1" SINTER user:1:follows user:2:followsSet 特别适合处理“关注关系”“好友关系”“标签关系”。例如两个用户共同关注了哪些人,一行 SINTER 就能得到结果,数据库可能要写很长的联表查询。
3.5 ZSet:排行榜与延迟队列
ZSet 是带权重的有序集合,每个元素都有一个 score,Redis 按照 score 对元素排序。
常用命令:
ZADD leaderboard 100 "user:1" ZADD leaderboard 98 "user:2" ZINCRBY leaderboard 1 "user:1" ZREVRANGE leaderboard 0 9 WITHSCORES排行榜是最典型的场景。游戏积分排行、文章热度排行、商品销量排行,都可以用 ZSet 实现。
延迟队列是基于 score 设计的一种技巧,score 存任务执行时间戳,定时任务通过 ZRANGEBYSCORE 取出到期任务执行。
3.6 进阶类型
除了五种基础类型,Redis 还提供了几个有特定用途的类型:
- HyperLogLog:固定 12KB 内存完成大量数据的去重统计。例如统计一个页面的 UV,几千万条访问记录占用内存也很小。
- Bitmap:位操作。适合用户签到、在线状态、布隆过滤器实现,底层基于 String 类型。
- Geo:地理坐标存储。可以计算两点距离、查找附近的人。
- Stream:Redis 5.0 引入的消息队列类型,支持消费者组、消息确认、消息追溯,比 List 方案更可靠。
3.7 数据类型选择对照表
| 业务场景 | 推荐数据类型 | 核心命令 |
|---|---|---|
| 缓存单个对象 | String / Hash | SET、GET、HMSET、HGETALL |
| 计数器 | String | INCR、DECR |
| 接口幂等 | String | SETNX |
| 消息队列 | List / Stream | LPUSH、BRPOP、XADD、XREADGROUP |
| 去重 / 关系运算 | Set | SADD、SINTER、SUNION |
| 排行榜 / 延迟队列 | ZSet | ZADD、ZINCRBY、ZREVRANGE |
| UV 统计 | HyperLogLog | PFADD、PFCOUNT |
| 用户签到 | Bitmap | SETBIT、BITCOUNT |
| 附近的人 | Geo | GEOADD、GEOSEARCH |
4. Redis 不止缓存:能落地的业务场景逐个击破
这一节我会结合真实业务中常见的痛点,展示 Redis 在“非高并发”需求下的应用思路。每个场景都有对应的代码示例和思考过程。
4.1 场景一:接口幂等,防止重复提交
业务里经常出现这种情况:用户点击“提交订单”按钮后,因为网络波动、前端异常、用户连点,同一个请求被提交了多次。如果不做幂等处理,数据库里会出现多条重复订单。
传统做法是查数据库判断是否已经存在相同订单。但查数据库本身存在并发窗口,两个请求同时查询,发现都不存在,然后同时插入,重复数据依旧会出现。
Redis 的 SETNX 可以很好地解决这个问题。
流程如下:
1. 前端提交请求时,带上业务唯一标识 requestId。 2. 后端使用 SETNX 将 requestId 写入 Redis,并设置过期时间。 3. 如果 SETNX 返回成功,说明是第一次处理该请求,继续业务逻辑。 4. 如果 SETNX 返回失败,说明请求已经处理过,直接返回重复提交提示。核心代码:
// 文件:OrderServiceImpl.java public String createOrder(OrderCreateDTO dto) { // 幂等键:业务维度 + 唯一流水号 String idempotentKey = "idempotent:order:" + dto.getOrderNo(); // setIfAbsent 对应 SETNX,同时设置过期时间 Boolean first = redisTemplate.opsForValue() .setIfAbsent(idempotentKey, "1", Duration.ofMinutes(5)); // 如果 key 已存在,说明请求重复 if (Boolean.FALSE.equals(first)) { throw new BusinessException("订单已提交,请勿重复操作"); } try { // 真正的订单创建逻辑 return doCreateOrder(dto); } catch (Exception e) { // 业务失败时删除幂等标记,允许用户重试 redisTemplate.delete(idempotentKey); throw e; } }这个方案的巧妙之处在于把并发判断变成了一次 O(1) 的 Redis 操作。过期时间起到了“兜底”作用,即使业务执行失败且没有显式删除标记,key 也会在 5 分钟后自动消失。
4.2 场景二:分布式锁,解决多实例下资源互斥
当系统以多实例部署时,JVM 级别的 synchronized 或 Lock 都失去了作用。因为不同实例是不同的 JVM,锁只能锁住自己进程内的线程,锁不住其他实例。
分布式锁的经典实现方式就是用 Redis 的 SETNX + 过期时间。
// 文件:RedisLockUtil.java public boolean tryLock(String lockKey, String requestId, long expireSeconds) { return redisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, Duration.ofSeconds(expireSeconds)); } public boolean releaseLock(String lockKey, String requestId) { // 释放锁时要校验是否是自己的锁,防止误删他人锁 String script = "if redis.call('get', KEYS[1]) == ARGV[1] " + "then return redis.call('del', KEYS[1]) " + "else return 0 end"; Long result = redisTemplate.execute( new DefaultRedisScript<>(script, Long.class), Collections.singletonList(lockKey), requestId ); return result != null && result > 0; }使用示例:
String lockKey = "lock:stock:1001"; String requestId = UUID.randomUUID().toString(); boolean locked = redisLockUtil.tryLock(lockKey, requestId, 10); if (!locked) { throw new BusinessException("系统繁忙,请稍后重试"); } try { // 扣减库存、发放优惠券等临界区业务 } finally { redisLockUtil.releaseLock(lockKey, requestId); }这里有几个值得注意的点。requestId 用来标识锁的持有者,释放锁时先判断 value 是否一致,避免因为锁过期导致误删。锁不能设置永久有效,必须给过期时间,防止持有锁的实例宕机后产生死锁。过期时间又不能太短,否则业务还没执行完,锁已经释放,其他线程就能同时进入临界区。生产环境常用 Redisson 封装好的分布式锁,它内置了看门狗机制,可以自动续期。
4.3 场景三:轻量级消息队列,实现任务异步化
一个低并发系统,可能只是希望把耗时操作异步化。比如用户导出 Excel 报表,数据量几十万时,同步执行可能需要几十秒,用户只能一直等待。更合理的方案是先把任务丢进队列,立即返回“导出中”的提示,后台线程再慢慢处理。
Redis 的 List 队列可以实现这个功能。
生产者:
LPUSH export:task "{userId: 1001, type: 'order', date: '2024-01-01'}"消费者:
BRPOP export:task 0Java 侧可以使用 RedisTemplate 完成消息的写入和消费。对于小规模项目,这个方案完全够用。如果业务量继续增长,再考虑 RocketMQ、RabbitMQ、Kafka。
不过用 List 做消息队列有天然缺陷:消息没有确认机制,消费者拉取消息后如果处理异常,消息会丢失。Redis Stream 解决了这个问题,提供了消费者组、消息确认、Pending 列表。如果你是在新项目里落地消息队列,建议优先使用 Stream。
XADD order:stream * orderId 1001 status created XREADGROUP GROUP exporter consumer-1 COUNT 10 BLOCK 0 STREAMS order:stream >4.4 场景四:缓存穿透、击穿、雪崩的处理
不管并发量高不高,只要用 Redis 做缓存,就必须考虑缓存失效带来的三类问题。很多初学者以为只有大促场景才需要担心,实际上小系统也会因为一个热点数据失效而被数据库压垮。
缓存穿透指查询一个一定不存在的数据。请求到达 Redis,发现没有缓存,于是查数据库,数据库也没有,不写回缓存。如果这个 key 被恶意构造,大量请求绕过 Redis,直接打到数据库。
解决思路是空值缓存和布隆过滤器。
public Object getData(String key) { Object value = redisTemplate.opsForValue().get(key); if (value != null) { return value; } // 数据库没有数据时,写一个短时空值 Object dbValue = queryFromDb(key); if (dbValue == null) { redisTemplate.opsForValue().set(key, "", Duration.ofMinutes(3)); return null; } redisTemplate.opsForValue().set(key, dbValue, Duration.ofMinutes(30)); return dbValue; }缓存击穿指一个热点 key 在失效瞬间,大量请求同时查询数据库。解决思路是使用互斥锁或逻辑过期。
缓存雪崩指大量 key 在同一时间段集中失效,导致数据库在短时间收到大量请求。解决思路是随机化过期时间。
// 随机化过期时间,避免大量 key 同时过期 long timeout = baseTimeout + ThreadLocalRandom.current().nextLong(300); redisTemplate.opsForValue().set(key, dbValue, Duration.ofSeconds(timeout));这三个问题是 Redis 缓存治理的核心内容,建议每个使用 Redis 的开发者都理解背后的原因和解决思路。
4.5 场景五:分页查询慢怎么用 Redis 优化
分页查询慢是关系型数据库的典型痛点。深分页场景下,LIMIT 100000, 20 需要扫描 100020 行数据再丢弃前 100000 行,性能会随页码增大而快速恶化。
优化手段有很多,比如使用覆盖索引、延迟关联、游标分页。Redis 的 ZSet 也可以用在某些特定场景中。
例如文章列表按发布时间倒序排列,可以维护一个 ZSet,score 存发布时间,value 存文章 ID:
ZADD article:list 170000000001 article:1001 ZADD article:list 170000000002 article:1002分页查询时按 score 倒序取出 ID 列表:
ZREVRANGE article:list 100 119然后根据 ID 批量查询文章详情,这样即使翻到很深的页,也只是从 ZSet 中取区间数据,再由数据库回表。需要注意的是,这个方案适合列表顺序与 score 语义一致的场景,全表记录数量极大且频繁插入时,ZSet 的内存占用和排序成本也不容忽视。
5. 完整项目实战:用 Spring Boot 集成 Redis 实现缓存、幂等、排行榜
我们把前面提到的场景整合成一个完整项目。项目目标是实现一个简易文章系统,包含:
- 根据文章 ID 查询文章详情,使用 Redis 缓存。
- 发布文章时带幂等控制。
- 文章被点赞后更新热门文章排行榜。
5.1 项目结构
redis-tutorial/ ├── pom.xml └── src/main/ ├── java/com/example/redistutorial/ │ ├── RedisTutorialApplication.java │ ├── controller/ArticleController.java │ ├── service/ArticleService.java │ ├── vo/ArticleVO.java │ └── config/RedisConfig.java └── resources/ └── application.yml5.2 添加依赖与配置
pom.xml 核心依赖:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-pool2</artifactId> </dependency> </dependencies>Spring Boot 2.x 默认使用的是 Lettuce 客户端连接池,需要引入 commons-pool2 才能配置连接池。版本号建议根据自己项目使用的 Spring Boot 版本调整,不要照搬。
application.yml 配置:
spring: redis: host: 127.0.0.1 port: 6379 password: database: 0 timeout: 3000ms lettuce: pool: max-active: 8 max-idle: 8 min-idle: 0开发环境 password 留空即可,生产环境必须设置强密码并建议启用 ACL。
5.3 编写核心代码
创建一个缓存配置类,为 RedisTemplate 指定序列化方式。不配置序列化时,默认使用 JdkSerializationRedisSerializer,会带来可读性差、占用空间大、跨语言困难的问题。
// 文件:config/RedisConfig.java @Configuration public class RedisConfig { @Bean public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, Object> template = new RedisTemplate<>(); template.setConnectionFactory(factory); // key 使用 String 序列化 StringRedisSerializer stringRedisSerializer = new StringRedisSerializer(); // value 使用 Jackson 序列化,便于阅读 Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class); ObjectMapper objectMapper = new ObjectMapper(); objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); objectMapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY); jackson2JsonRedisSerializer.setObjectMapper(objectMapper); template.setKeySerializer(stringRedisSerializer); template.setHashKeySerializer(stringRedisSerializer); template.setValueSerializer(jackson2JsonRedisSerializer); template.setHashValueSerializer(jackson2JsonRedisSerializer); template.afterPropertiesSet(); return template; } }再写 ArticleVO 和 ArticleService。
// 文件:vo/ArticleVO.java public class ArticleVO { private Long id; private String title; private String author; private Integer likeCount; // 省略 getter/setter }// 文件:service/ArticleService.java @Service public class ArticleService { @Autowired private RedisTemplate<String, Object> redisTemplate; private static final String ARTICLE_CACHE_KEY = "article:detail:"; private static final String ARTICLE_PUBLISH_IDEMPOTENT_KEY = "idempotent:article:publish:"; private static final String ARTICLE_HOT_RANK_KEY = "rank:article:hot"; /** * 查询文章详情,优先读缓存 */ public ArticleVO getArticleById(Long id) { String key = ARTICLE_CACHE_KEY + id; Object cacheValue = redisTemplate.opsForValue().get(key); if (cacheValue != null) { return (ArticleVO) cacheValue; } ArticleVO dbResult = queryFromDb(id); if (dbResult == null) { return null; } // 设置 30 分钟过期,并添加随机偏移 long expireTime = 30 * 60 + ThreadLocalRandom.current().nextLong(60); redisTemplate.opsForValue().set(key, dbResult, Duration.ofSeconds(expireTime)); return dbResult; } /** * 发布文章,附带幂等控制 */ public Long publishArticle(String requestId, ArticleVO article) { String idempotentKey = ARTICLE_PUBLISH_IDEMPOTENT_KEY + requestId; Boolean first = redisTemplate.opsForValue() .setIfAbsent(idempotentKey, "1", Duration.ofMinutes(5)); if (!Boolean.TRUE.equals(first)) { throw new RuntimeException("重复提交"); } try { Long articleId = insertDb(article); redisTemplate.opsForValue().set(ARTICLE_CACHE_KEY + articleId, article, Duration.ofSeconds(30 * 60)); return articleId; } catch (Exception e) { redisTemplate.delete(idempotentKey); throw e; } } /** * 文章点赞,并更新热门排行榜 */ public void likeArticle(Long articleId) { String cacheKey = ARTICLE_CACHE_KEY + articleId; ArticleVO article = getArticleById(articleId); if (article == null) { throw new RuntimeException("文章不存在"); } article.setLikeCount(article.getLikeCount() + 1); redisTemplate.opsForValue().set(cacheKey, article, Duration.ofSeconds(30 * 60)); // 排行榜 score 表示点赞总数 redisTemplate.opsForZSet().incrementScore(ARTICLE_HOT_RANK_KEY, String.valueOf(articleId), 1); // 异步或同步更新数据库 updateDbLikeCount(articleId); } }Controller 层:
// 文件:controller/ArticleController.java @RestController @RequestMapping("/article") public class ArticleController { @Autowired private ArticleService articleService; @GetMapping("/{id}") public ArticleVO getById(@PathVariable Long id) { return articleService.getArticleById(id); } @PostMapping("/publish") public Long publish(@RequestHeader("requestId") String requestId, @RequestBody ArticleVO article) { return articleService.publishArticle(requestId, article); } @PostMapping("/{id}/like") public void like(@PathVariable Long id) { articleService.likeArticle(id); } }5.4 运行与验证
启动 Redis 服务后,启动 Spring Boot 项目,通过 curl 验证接口:
curl http://localhost:8080/article/1001第一次访问时,缓存未命中,服务会查数据库并回填缓存。第二次访问时,直接返回缓存数据,响应速度会明显提升。
此时在 redis-cli 中执行:
keys article:*可以看到缓存 key 已经生成。再执行:
ZREVRANGE rank:article:hot 0 9 WITHSCORES可以看到热门文章排行榜数据。
通过这个项目,你可以完整理解 Redis 在一个普通 Spring Boot 系统中的实际位置。它不需要高并发,但每一步都在优化系统性能和用户体验。
6. 生产环境必踩的坑:Redis 实践避坑指南
6.1 keys 命令导致 Redis 阻塞
Redis 是单线程处理命令的,keys 命令会遍历所有 key,在 key 数量大的情况下会阻塞 Redis 服务,导致所有请求排队等待。这个坑在一些新手项目中很容易踩到。
比如你想删除所有以ekyc_pic_开头的缓存 key,随手执行:
keys ekyc_pic_*如果匹配到的 key 只有几十个,可能感觉不到问题。一旦 key 数量达到百万级别,这个命令会让 Redis 卡顿数秒甚至更久。
生产环境应该使用 scan 命令分批遍历:
scan 0 MATCH ekyc_pic_* COUNT 100scan 命令是游标式遍历,不会阻塞整个 Redis。
6.2 持久化策略不能盲目关闭
Redis 默认开启了 RDB 持久化,但很多初学者为了追求性能,直接把持久化关闭。这种做法在缓存场景下勉强可以接受,但如果 Redis 里存了分布式锁、幂等标记、任务队列等有状态数据,关掉持久化意味着 Redis 重启后数据全部丢失,可能引发业务异常。
常见的持久化方案:
- RDB:快照式持久化,文件紧凑,恢复快,但可能丢失最后一次快照之后的数据。
- AOF:日志式持久化,数据可靠性高,但文件体积大,恢复速度相对慢。
- 混合持久化:Redis 4.0 开始支持,兼顾 RDB 和 AOF 的优点。
生产环境建议开启 AOF,并根据业务容忍度选择 everysec 或 always 写入策略。
6.3 Big Key 和 Hot Key
Big Key 指 value 特别大的 key,比如一个 String 存了 10MB 数据,或一个 Set 里有百万个元素。操作这种 key 时,Redis 需要花费较长时间处理,容易阻塞其他命令。
Hot Key 指访问频率特别高的 key,比如某个明星的粉丝量统计 key。瞬间大量请求访问同一个 key,会让单个 Redis 节点成为瓶颈。
应对思路:
- 大 key 拆分为多个小 key,比如 Hash 按字段拆分,或 value 压缩后存储。
- 热 key 做本地缓存,将一部分读请求拦截在应用内存中。
- 热 key 做副本复制,多个从节点分担读压力。
6.4 安全边界:端口暴露和弱密码
Redis 默认端口 6379 如果没有设置密码,并且暴露到了公网,很容易被恶意攻击。攻击者可以通过 Redis 写入定时任务、公钥文件等方式获取服务器权限。这在安全圈已经有很多真实案例,必须重视。
安全基线建议:
- 设置强密码或使用 Redis ACL 按用户分配权限。
- 禁止将 Redis 端口直接暴露到公网,使用内网访问或通过安全组限制来源 IP。
- 不使用默认端口或至少修改绑定地址 127.0.0.1。
- 生产环境禁用危险命令,如 FLUSHALL、FLUSHDB、KEYS。
- 部署前先做漏洞扫描,配合防火墙策略。
6.5 集群不是银弹
Redis 主从复制能解决单点故障,但主从切换需要哨兵或手动干预。Redis Cluster 能自动分片,但引入了多 key 操作限制、重定向机制、客户端适配等复杂度。
如果业务量不大,优先考虑单机加持久化,再通过主从复制保证高可用。不要一开始就贪图集群架构,过度设计同样会带来运维负担。
7. 常见问题排查与解决方案
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 启动报“Can't open the log file” | 日志目录不存在或无权限 | 创建日志目录,检查运行用户权限 |
| 连接超时 | 防火墙未放行端口、bind 配置限制、连接数耗尽 | 检查安全组、bind 配置、maxclients 设置 |
| Redis 连接数突然飙升 | 客户端连接池配置过大或未释放 | 调整连接池 max-active 参数,排查代码泄漏 |
| 缓存中的数据与数据库不一致 | 更新数据库后未删除缓存 | 使用 Cache Aside Pattern 标准策略 |
| 缓存雪崩 | 大量 key 设置了相同过期时间 | 过期时间添加随机偏移 |
| 缓存穿透 | 查询不存在的数据无缓存兜底 | 空值缓存、布隆过滤器 |
| 缓存击穿 | 热点 key 过期瞬间并发请求 | 互斥锁、逻辑过期 |
| 数据丢失 | 持久化策略关闭或配置不当 | 开启 AOF,配置合理的刷盘策略 |
| 主从数据不一致 | 网络分区、从节点同步延迟 | 监控同步延迟,必要时强制重新同步 |
| 内存持续增长 | 设置了过长的过期时间或没有过期时间 | 梳理 key 生命周期,设置合理 TTL,定期扫描清理 |
排查 Redis 问题时,首先通过 info 查看整体运行状态,再通过 slowlog get 获取慢查询日志,最后用 monitor 命令监控实时命令流(生产环境慎用,会拖慢性能)。
8. 最佳实践与工程建议
8.1 缓存设计规范
推荐使用 Cache Aside Pattern,也就是旁路缓存模式:
- 读:先读缓存,缓存未命中再读数据库,回填缓存并设置过期时间。
- 写:先更新数据库,再删除缓存。
为什么不选择“先删缓存,再更新数据库”?因为删缓存和更新数据库之间存在时间窗口,另一个线程可能读取旧数据并回填缓存,导致缓存长期为脏数据。
为什么不选择“先更新缓存,再更新数据库”?因为一旦数据库更新失败,缓存里的新数据和数据库不一致。
Cache Aside 模式是社区验证过的最稳健方案,缺点是存在短暂不一致,但对大多数业务可接受。如果对一致性要求极高,可以引入消息队列或 Canal 监听 binlog 来异步删除缓存。
8.2 Key 设计规范
合理的 key 设计应当遵循三个原则:
- 可分类:业务模块前缀,如
order:status:1001。 - 可辨识:看到 key 能知道存储内容。
- 有边界:区分不同的业务线、环境、租户。
推荐格式:
业务模块:实体:ID:字段例如:
user:info:1001 order:detail:20240101001 idempotent:pay:requestId同时要注意 key 不要太长,避免浪费内存;不要使用中文,避免编码问题;不要在 key 中拼接不确定的输入,防止 key 设计注入风险。
8.3 线上操作流程建议
修改 Redis 配置、主从切换、清理大 key、上线新缓存逻辑,这些操作都有风险,建议遵循以下流程:
- 先在测试环境验证完整流程。
- 操作前备份 Redis 数据和配置文件。
- 涉及删除数据时,先确认 key 的归属业务和数据价值。
- 变更后观察监控指标,包括命中率、内存、连接数、慢日志。
- 保留回滚方案,必要时快速回切。
8.4 哪些项目真正不需要 Redis
任何系统都有成本。额外引入 Redis 意味着多一个中间件需要维护,需要处理缓存一致性,需要关注内存和集群状态。
如果项目满足以下条件,确实可以先不引入 Redis:
- 请求量极低,数据库压力可以忽略。
- 单机部署,不存在多实例共享状态问题。
- 没有分布式锁、幂等、消息队列等需求。
- 团队对 Redis 运维不熟悉,且没有精力承担额外运维成本。
但即便项目当前不引入 Redis,也建议掌握核心数据类型和常见场景的应用方式。因为业务增长是动态的,第一次引入 Redis 的决策,往往就发生在业务出现痛点的转折点。
9. 从“要不要用”到“怎么用好”:把 Redis 的价值发挥出来
回到文章标题:认为系统没有高并发就不用 Redis,为什么说这是初学者思维?因为初学者往往通过“并发量”这个单一指标来判断技术选型,而成熟开发者会从数据访问特征、业务逻辑复杂度、系统扩展性多个维度来评估。
Redis 是高并发场景的利器,但它同时也是普通业务系统里解决状态管理、幂等、去重、统计、队列等问题的通用工具。学习 Redis,不应该只背几条命令,而是要理解在什么场景选择什么数据类型,为什么这样做,以及生产环境会遇到哪些风险。
如果你目前还停留在“会 SET 和 GET”的阶段,建议下一步深入学习这几个方向:
- Redis 持久化原理与数据恢复演练。
- 分布式锁的底层实现与 Redisson 源码分析。
- Redis Cluster 分片原理与集群运维。
- 缓存一致性问题的系统性治理思路。
- 通过实战项目把所有知识点串联起来。
学完这些,你不仅能在面试中讲清楚 Redis 高并发场景的答案,更重要的是,你在日常项目里能真正判断出什么时候该用 Redis,以及怎么用得更好。等到入手项目足够多,你会慢慢发现,技术选型不变量化的那一刻,往往是业务真正读懂的那一刻。