学成-缓存与锁

一、项目优化

在用户未认证状态下也可以访问,如果接口的性能不高,当高并发到来很可能耗尽整个系统的资源,将整个系统压垮,所以特别需要对这些暴露在外边的接口进行优化,这一类接口我们称之为白名单接口

1.常见性能指标

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
1. 吞吐量 (TPS - Transactions Per Second)
核心理解: 系统每秒能搞定多少个“完整的业务流程”。
外卖比喻: 餐厅每秒钟能完整接单、做好饭菜并交到外卖员手里的总单量。
技术剖析: 这里的“事务(Transaction)”千万不要局限于数据库层面的事务(比如代码里的 @Transactional)。它指的是业务层面的一次闭环。比如用户发起一次“提交订单”:前端发送请求 -> 网关路由 -> 订单微服务创建记录 -> 扣减商品库存 -> 发起支付处理,这整个链路全部走完,向客户端返回成功,才算作 1 个完整的 TPS。

2. 响应时间 (Response Time, RT)
核心理解: 用户需要等待多久。它是决定用户体验的最直观指标。
外卖比喻: 顾客从手机上点击“下单”,到外卖员把饭菜送到手里的总耗时。
技术剖析: 响应时间不仅包含服务器执行代码和查询数据库的时间,还包含了网络请求在路上的传输时间、以及在网关或线程池里的排队等待时间。
除了你提到的最大、最小和平均响应时间,实际工程中通常更看重 P99 响应时间(即 99% 的请求都在多少毫秒内完成)。因为平均值很容易掩盖掉那 1% 体验极差的卡顿请求。

3. 每秒查询数 (QPS - Queries Per Second)
核心理解: 系统每秒能处理多少次具体的“查询/请求”动作。
外卖比喻: 顾客们每秒钟疯狂刷新了多少次外卖商家的菜单界面。
TPS 与 QPS 的深层关系:
简单链路 (QPS = TPS): 如果是一个单纯的“查询商品详情”接口,只查一次数据库就返回,那么完成一次查询业务(1 TPS)就对应一次接口请求(1 QPS)
复杂链路 (QPS > TPS): 你总结的 QPS = 2 * TPS 非常精准。在分布式架构中,假设一个“加入购物车”的业务动作(算作 1 TPS),需要系统内部先去请求“商品服务”查询状态,再去请求“营销服务”算满减。那么完成这 1 个 TPS 的背后,系统实际承受了 1 次主接口请求 + 2 次内部 RPC 调用的压力,此时对整个系统集群而言,QPS = 3。

4. 错误率 (Error Rate)
核心理解: 系统“掉链子”的概率。
外卖比喻: 餐厅接了 100 个单,结果因为厨房太忙,有 5 个单做错了或者直接漏单了,错误率就是 5%。
技术剖析: 性能测试时,脱离了错误率去谈吞吐量是毫无意义的。必须在“可接受的错误率”前提下寻找最大 TPS。当并发压力持续增大,一旦错误率飙升,通常意味着系统资源(CPU、内存或数据库连接池)已经耗尽。此时往往需要触发服务降级或熔断机制,以保护核心链路不被彻底打垮。

还有系统状态

image-20260330125255235

2.jmeter工具

双击bat文件启动

1
apache-jmeter-5.5\bin\jmeter.bat

新建线程组

image-20260330130205872

创建线程

image-20260330130611601

创建 http 请求

image-20260330130745744

具体配置

image-20260330131136513

设置结果展示

image-20260330132004116

3.性能初步优化

image-20260330133847169

日志等级为info

二、引入redis缓存

1.缓存实现

image

2.引入

src/main/resources/bootstrap.yml

1
2
3
- data-id: redis-${spring.profiles.active}.yaml
group: xuecheng-plus-common
refresh: true

添加依赖

1
2
3
4
5
6
7
8
9
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
<version>2.6.2</version>
</dependency>

实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public CoursePublish getCoursePublishCache(Long courseId){
//查询缓存
Object jsonObj = redisTemplate.opsForValue().get("course:" + courseId);
if(jsonObj!=null){
String jsonString = jsonObj.toString();
System.out.println("=================从缓存查=================");
CoursePublish coursePublish = JSON.parseObject(jsonString, CoursePublish.class);
return coursePublish;
} else {
System.out.println("从数据库查询...");
//从数据库查询
CoursePublish coursePublish = getCoursePublish(courseId);
if(coursePublish!=null){
redisTemplate.opsForValue().set("course:" + courseId, JSON.toJSONString(coursePublish));
}
return coursePublish;
}
}

3.缓存穿透

image-20260330150730004

解决方式

  • 方案一:缓存空值(Null 缓存)

    查询数据库返回空时,主动将空结果写入缓存,并设置较短的 TTL(比如 5 分钟),之后从缓存查到null直接返回

  • 方案二:布隆过滤器(Bloom Filter)

    ✅ 推荐 在缓存层前再加一道”门”,启动时将所有合法 key 加载到布隆过滤器,请求进来先经过它过滤:

    1
    2
    3
    请求 → 布隆过滤器判断         
    ↓ 不存在 → 直接返回空(拦截,不查缓存和DB)
    ↓ 可能存在 → 查缓存 → 查数据库

    布隆过滤器有极小的误判率(认为存在但实际不存在),但永不漏判(认为不存在则一定不存在)

  • 方案三:接口层拦截

    对于可穷举的 key(如商品 ID 必须为正整数),在业务层直接做参数校验,非法格式直接 reject,不进入缓存逻辑

4.缓存雪崩

平时 Redis 替数据库”挡住”大量请求,一旦大批 key 在同一时刻集体过期,所有流量瞬间直打数据库——就像山坡上的积雪同时滑落,势不可挡

image-20260330164522976

解决方式

1
2
3
4
5
6
7
8
9
10
11
过期时间加随机抖动
将所有 key 的 TTL 设为 基础时间 + random(0, 300s),让它们错峰失效,不再同时到期
最简单推荐首选
缓存永不过期 + 后台异步刷新
缓存本身不设 TTL,另起定时任务在后台定期更新数据写回缓存。请求永远打缓存,不穿透到 DB
彻底消除
互斥锁(Mutex)
缓存失效时,只允许一个线程去查 DB 并回填缓存,其他线程等待或返回旧数据,防止并发打穿 DB
防穿透
多级缓存 + 熔断降级
L1 本地缓存 + L2 Redis 双保险;超过 DB 阈值时自动熔断,返回托底数据,保护 DB 不被打垮

5.缓存击穿

一个并发访问量非常大的“热点”Key,在它的缓存过期的瞬间,有大量的并发请求同时涌入

image-20260330164910580

在添加锁的时候,最好不要添加到整体方法内

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public CoursePublish getCoursePublishCache(Long courseId){
//查询缓存
Object jsonObj = redisTemplate.opsForValue().get("course:" + courseId);
if(jsonObj!=null){
String jsonString = jsonObj.toString();
System.out.println("=================从缓存查=================");
if("null".equals(jsonString)){
return null;
}
CoursePublish coursePublish = JSON.parseObject(jsonString, CoursePublish.class);
return coursePublish;
} else {
synchronized (this) {
jsonObj = redisTemplate.opsForValue().get("course:" + courseId);

if(jsonObj!=null) {
String jsonString = jsonObj.toString();
if ("null".equals(jsonString)) {
return null;
}
CoursePublish coursePublish = JSON.parseObject(jsonString, CoursePublish.class);
return coursePublish;
}

System.out.println("从数据库查询...");
//从数据库查询
CoursePublish coursePublish = getCoursePublish(courseId);
redisTemplate.opsForValue().set("course:" + courseId, JSON.toJSONString(coursePublish));
return coursePublish;
}

}
}

三、多实例分布式锁

上述锁只有在同一个JVM进程中才能生效,为了多实例运行,使用到了同部锁,mysql乐观锁实现同步锁在之前有提到,但是这个方式对数据库查询数过多,故因此,利用其它方式

1.基于 Redis

Redis 凭借其极高的读写性能,是目前企业级项目中最常用的分布式锁方案

核心原理: 利用 Redis 的单线程机制和 SETNX(Set if Not eXists)命令来实现互斥

  • 基础实现: 使用 SET key value NX PX 30000 命令
    • NX:保证只有在键不存在时才能设置成功(获取锁)
    • PX 30000:设置过期时间为 30 秒,防止应用宕机导致死锁
    • value:通常设置为一个全局唯一的随机值,解锁时需要校验这个值,防止误删别人的锁(解锁通常配合 Lua 脚本以保证原子性)

2.存在问题

死锁问题与原子性问题

image-20260330173436507

误删别人的锁

image-20260330173458192

业务超时与锁提前过期

image-20260330173535331

3.redisson方式

3.1、阻塞与自旋

阻塞锁

image-20260330185631489

自旋锁

image-20260330185744373

redisson所使用的就是自旋锁的方式

3.2、redisson 具体原理

原子性

image-20260330190013461Hash 数据结构支持可重入锁

image-20260330190043862

看门狗机制(Watch Dog)- 解决锁提前过期

image-20260330190107427

3.3、具体实现

添加依赖

1
2
3
4
5
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.11.2</version>
</dependency>

引入配置文件

1
2
3
redisson:
# 配置文件目录
config: classpath:singleServerConfig.yaml

具体代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
RLock lock = redissonClient.getLock("course:" + courseId);
lock.lock();
try {
jsonObj = redisTemplate.opsForValue().get("course:" + courseId);

if (jsonObj != null) {
String jsonString = jsonObj.toString();
if ("null".equals(jsonString)) {
return null;
}
CoursePublish coursePublish = JSON.parseObject(jsonString, CoursePublish.class);
return coursePublish;
}

System.out.println("从数据库查询...");
//从数据库查询
CoursePublish coursePublish = getCoursePublish(courseId);
redisTemplate.opsForValue().set("course:" + courseId, JSON.toJSONString(coursePublish));
return coursePublish;

}finally {
lock.unlock();
}

最终实现缓存只查一次数据库