1

一、起因

我平时写的小 demo,其实很少真正用到 Redis。

原因很简单:

一个人开发设计系统的业务量,和企业级 Web 应用根本不是一个数量级。

日常接口并发量可能连 5 都不到,更别说数据库压力、缓存击穿、连接池耗尽这些问题。

所以我虽然背过很多 Redis 八股文:

Redis 为什么快?
什么是缓存击穿?
为什么要缓存?

但始终缺少真正的实操感。

我发现:

死记硬背的记忆深度其实非常有限。

于是这次我决定:

不再停留在背概念,而是亲手构造一个高并发场景,观察 Redis 到底解决了什么问题。

二、构建实验环境

首先,我利用 springboot 框架快速构建了一个 demo ,只具备数据表User:

image.png

控制器代码:

@RestController
public class UserController {
    private final UserRepository userRepository;

    public UserController(UserRepository userRepository) {
        this.userRepository = userRepository;
    }

    @GetMapping("/user/{id}")
    public User findById(@PathVariable Long id) {
        return userRepository.findById(id).orElseThrow();
    }
}

我使用的是 apache2-utils 轻量化工具,进行并发测试:

➜  ~ ab -n 1000 -c 100 http://localhost:8080/user/1 
This is ApacheBench, Version 2.3 <$Revision: 1903618 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking localhost (be patient)
Completed 100 requests
Completed 200 requests
Completed 300 requests
Completed 400 requests
Completed 500 requests
Completed 600 requests
Completed 700 requests
Completed 800 requests
Completed 900 requests
Completed 1000 requests
Finished 1000 requests


Server Software:        
Server Hostname:        localhost
Server Port:            8080

Document Path:          /user/1
Document Length:        27 bytes

Concurrency Level:      100
Time taken for tests:   0.329 seconds
Complete requests:      1000
Failed requests:        0
Total transferred:      152000 bytes
HTML transferred:       27000 bytes
Requests per second:    3037.27 [#/sec] (mean)
Time per request:       32.924 [ms] (mean)
Time per request:       0.329 [ms] (mean, across all concurrent requests)
Transfer rate:          450.84 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.6      0       3
Processing:     2   21  12.7     20     114
Waiting:        2   21  12.7     20     111
Total:          2   21  12.7     21     114

Percentage of the requests served within a certain time (ms)
  50%     21
  66%     25
  75%     27
  80%     29
  90%     36
  95%     45
  98%     48
  99%     60
 100%    114 (longest request)

总共发送 1000 个请求并同时保持 100 个并发连接。

这里最重要的数据是 QPS:

Requests per second: 3037.27 [#/sec] (mean)

意思是:当前的 SpringBoot 每秒大约能处理 3037 个请求。当然,这是在本机+本地回环+极简接口的基础上有的效果,不是真实的互联网。

互联网包括但不限于:

网络延迟
网关
Redis
RPC(Remote Procedure Call)
微服务
MQ
日志系统
链路追踪
分布式事务
大JSON
大字段
权限校验

其次重要的还有:

Time per request: 32.924 [ms] (mean)
99% 60

分别代表:单个请求平均耗时 32 ms 和 99% 的请求小于等于 60 ms。

三、制造慢查询,观察崩溃

因此,为了对比效果明显,我打算人为制造数据库慢查询(每次查询让数据库暂停 0.2 秒):

public interface UserRepository extends JpaRepository<User, Long> {
    @Query(value = """
            SELECT *
            FROM user u
            WHERE u.id = :id
            AND SLEEP(0.2) = 0
            """, nativeQuery = true)
    User findSlowUserById(@Param("id") Long id);
}

再次并发测试:

➜  ~ ab -n 1000 -c 100 http://localhost:8080/user/1
This is ApacheBench, Version 2.3 <$Revision: 1903618 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking localhost (be patient)
Completed 100 requests
Completed 200 requests
Completed 300 requests
Completed 400 requests
Completed 500 requests
Completed 600 requests
Completed 700 requests
Completed 800 requests
Completed 900 requests
Completed 1000 requests
Finished 1000 requests


Server Software:        
Server Hostname:        localhost
Server Port:            8080

Document Path:          /user/1
Document Length:        27 bytes

Concurrency Level:      100
Time taken for tests:   20.509 seconds
Complete requests:      1000
Failed requests:        0
Total transferred:      152000 bytes
HTML transferred:       27000 bytes
Requests per second:    48.76 [#/sec] (mean)
Time per request:       2050.909 [ms] (mean)
Time per request:       20.509 [ms] (mean, across all concurrent requests)
Transfer rate:          7.24 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.3      0       2
Processing:   201 1922 367.1   2012    3825
Waiting:      201 1922 367.1   2012    3825
Total:        201 1923 366.9   2013    3825

Percentage of the requests served within a certain time (ms)
  50%   2013
  66%   2015
  75%   2017
  80%   2017
  90%   2020
  95%   2021
  98%   2022
  99%   2025
 100%   3825 (longest request)

相同条件下,效果立竿见影:

指标第一次(正常查询)第二次(加 SLEEP 200ms)变化幅度
QPS303748暴跌 63 倍
平均耗时32 ms2050 ms飙升 64 倍
P99 耗时60 ms2025 ms慢了 34 倍
系统状态非常轻松直接窒息完全瘫痪

一个问题:为什么我加的是 200ms 最后变成了增加 2s ?

原因在于:Spring Boot HikariCP 默认最大连接数 maximumPoolSize = 10,100 并发请求,只有 10 个线程能够执行 SQL,剩下 90 个在排队。一批 10 个请求,每批耗时 200 ms ,100 个请求分 10 批,总时间:10 × 200 ms = 2000 ms,也就是:2秒。

四、引入 Redis,系统复活

@Service
public class UserService {
    private final UserRepository userRepository;
    private final RedisTemplate<String, String> redisTemplate;

    private final ObjectMapper objectMapper = new ObjectMapper();

    public UserService(UserRepository userRepository, RedisTemplate<String, String> redisTemplate) {
        this.userRepository = userRepository;
        this.redisTemplate = redisTemplate;
    }

    public User findById(Long id) {
        String key = "user:" + id;
        String cache = redisTemplate.opsForValue().get(key);

        if (cache != null) {
            System.out.println("走 Redis");

            return objectMapper.readValue(cache, User.class);
        }

        System.out.println("走 MySQL");
        User user = userRepository.findSlowUserById(id);

        redisTemplate.opsForValue().set(
                key,
                objectMapper.writeValueAsString(user)
        );

        return user;
    }
}

并发测试:

➜  ~ ab -n 1000 -c 100 http://localhost:8080/user/1
This is ApacheBench, Version 2.3 <$Revision: 1903618 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking localhost (be patient)
Completed 100 requests
Completed 200 requests
Completed 300 requests
Completed 400 requests
Completed 500 requests
Completed 600 requests
Completed 700 requests
Completed 800 requests
Completed 900 requests
Completed 1000 requests
Finished 1000 requests


Server Software:        
Server Hostname:        localhost
Server Port:            8080

Document Path:          /user/1
Document Length:        27 bytes

Concurrency Level:      100
Time taken for tests:   0.672 seconds
Complete requests:      1000
Failed requests:        0
Total transferred:      152000 bytes
HTML transferred:       27000 bytes
Requests per second:    1487.64 [#/sec] (mean)
Time per request:       67.220 [ms] (mean)
Time per request:       0.672 [ms] (mean, across all concurrent requests)
Transfer rate:          220.82 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    1   0.9      1       6
Processing:     2   14  18.4     11     530
Waiting:        1   13  18.4     10     528
Total:          3   15  18.5     12     530

Percentage of the requests served within a certain time (ms)
  50%     12
  66%     15
  75%     18
  80%     20
  90%     32
  95%     35
  98%     36
  99%     38
 100%    530 (longest request)

日志:

走 MySQL
Hibernate: SELECT *
FROM user u
WHERE u.id = ?
AND SLEEP(0.2) = 0

走 Redis
走 Redis
走 Redis
......
走 Redis

三次实验关键数据对比表

实验场景QPS(每秒请求数)平均耗时(ms)P99耗时(ms)系统核心状态
正常MySQL查询30373260流畅无压力
慢查询MySQL(SLEEP 200ms)48.7620512025数据库连接池耗尽,请求排队,系统瘫痪
引入Redis缓存1487.646738避开数据库瓶颈,性能大幅回升

这里有一个很有意思的现象:引入 Redis 后,QPS 虽然从 48 提升到了 1487,但依然低于最开始的 3037。这说明:Redis 并不意味着性能无限提升

因为当前场景下:最开始的 MySQL 查询本身就非常快。

而 Redis 方案中,还额外增加了:

  • JSON 序列化
  • JSON 反序列化
  • Redis 网络通信
  • Redis Client 操作

因此:

Redis 真正适合的场景,并不是替代所有数据库查询

而是:

当数据库已经成为瓶颈时,减少数据库压力。

五、Redis 解决了什么,又带来了什么

从这次实验里,我第一次真正观察到:

Redis 解决的问题,并不只是

更准确地说:

Redis 解决的是:高并发场景下,数据库资源被大量占用后导致的系统阻塞问题。

之前学习 Redis 时,总会下意识把 Redis 理解成:

比 MySQL 更快的数据库

但这个理解其实并不准确。

因为数据库真正昂贵的地方,从来不只是 SQL 执行本身。

一次普通查询背后,还涉及:

  • 数据库连接池
  • SQL 解析
  • ORM 映射
  • 网络通信
  • Buffer Pool
  • 锁竞争
  • 磁盘 IO

在低并发下,这些问题并不明显。

但在高并发场景中,真正可怕的并不是:

单次 SQL 很慢

而是:

大量请求开始排队

而 Redis 的核心价值就在这里。因为 Redis 直接使用内存读取数据:

它绕过了:

  • 数据库连接池
  • SQL 解析
  • ORM
  • 磁盘 IO

因此,Redis 真正减少的,并不是接口时间本身。

而是:

对数据库这种慢资源的访问次数。

六、实验中暴露出的潜在问题:缓存击穿

虽然这次实验中,只有第一次请求真正访问了 MySQL,后续请求都成功命中了 Redis。

但这里实际上已经暴露出了redis 面试中的一个经典的生产问题:

缓存击穿

因为当前代码并没有任何并发保护。理论上:如果在 Redis 缓存失效的一瞬间,同时来了大量请求,那么这些请求依然可能同时访问数据库。

例如:

String cache = redisTemplate.opsForValue().get(key);

if (cache == null) {
    // 多个线程可能同时进入这里
}

在高并发场景下:这会导致大量请求同时穿透缓存层,直接访问数据库。

这就是典型的缓存击穿问题。

因此在真实项目中,通常还会进一步引入:

  • 分布式锁
  • setnx
  • 热点数据不过期

等方案。

Redis 并不是引入之后就自动高性能

缓存本身,也需要治理。


七、成本问题

另外,Redis 本身也不是完全没有成本。

例如我这里:

objectMapper.writeValueAsString(user)

以及:

objectMapper.readValue(cache, User.class)

本质上都属于 JSON 序列化与反序列化,这同样会消耗 CPU。

因此:

Redis 的价值并不是:

任何情况下都比 MySQL

而是:

当数据库成为瓶颈时,Redis 能显著减轻数据库压力。

这一点非常重要。

因为很多系统最终崩溃,并不是 CPU 打满。

而是:

  • 数据库连接数耗尽
  • 请求排队
  • 慢 SQL 堆积
  • 线程阻塞

最终导致整个系统雪崩。


八、总结

这次实验最大的价值,不是学会了如何使用 Redis API。

而是第一次真正观察到了:

高并发系统为什么会突然卡死。

以前学习 Redis 时,我一直停留在概念层面。

比如:

  • Redis 为什么快
  • 为什么要缓存
  • 什么是缓存击穿

但这些内容如果只是背诵,其实很难形成真正的理解。

只有亲手压测之后才会发现:很多时候系统真正的问题,并不是代码逻辑。

而是:

请求开始排队了。

而 Redis 的核心作用,本质上就是:

尽可能减少请求进入这些慢资源。

姜姜
61 声望9 粉丝

行百里者半九十