目录
1、初识Sentinel
Sentinel定位是分布式系统的流量防卫兵。目前互联网应用基本上都使用微服务,微服务的稳定性是一个很重要的问题,而限流、熔断降级是微服务保持稳定的一个重要的手段。
1.1、雪崩问题及解决方案
什么是雪崩问题?
- 微服务之间相互调用,因为调用链中的一个服务故障,引起整个链路都无法访问的情况。
如何避免因瞬间高并发流量而导致服务故障(预防)?
- 流量控制
如何避免因服务故障引起的雪崩问题(出现故障后的解决措施,避免雪崩)?
- 超时处理
- 线程隔离
- 降级熔断
1.2、限流规则
1.2.1、流控模式有哪些?
- 直接:对当前资源限流
- 关联:高优先级资源触发阈值,对低优先级资源限流
- 链路:阈值统计时,只统计从指定资源进入当前资源的请求,是对请求来源的限流
1.2.2、流控效果有哪些?
- 快速失败:QPS超过阈值时,拒绝新的请求
- warm up:QPS超过阈值时,拒绝新的请求;QPS阈值是逐渐提升的,可以避免冷启动时高并发导致服务宕机
- 排队等待:请求会进入队列,按照阈值允许的时间间隔依次执行请求;如果请求预期等待时长大于超时时间,直接拒绝
1.3、隔离和降级
1.3.1、Sentinel支持的雪崩解决方案
- 线程隔离(舱壁模式)
- 降级熔断
1.3.2、Feign整合Sentinel的步骤
- 在application.yml中配置:feign.sentinel.enable=true
- 给FeignClient编写FallbackFactory并注册为Bean
- 将FallbackFactory配置到FeignClient
1.3.3、线程隔离的两种手段是?
- 信号量隔离
- 线程池隔离
1.3.4、信号量隔离的特点是?
- 基于计数器模式,简单,开销小
1.3.5、线程池隔离的特点是?
- 基于线程池模式,有额外开销,但隔离控制更强
1.3.6、Sentinel熔断降级的策略有哪些?
- 慢调用比例:超过指定时长的调用为慢调用,统计单位时长内慢调用的比例,超过阈值则熔断
- 异常比例:统计单位时长内异常调用的比例,超过阈值则熔断
- 异常数:统计单位时长内异常调用的次数,超过阈值则熔断
1.4、授权规则
1.4.1、获取请求来源的接口是什么?
- RequestOriginParser
1.4.2、处理BlockException的接口是什么?
- BlockExceptionHandler
1.5、规则持久化
1.5.1、Sentinel的三种配置管理模式是什么?
- 原始模式:sentinel默认模式,将规则保存在内存,重启服务会丢失
- pull模式:保存在本地文件或数据库,定时去读取
- push模式:保存在nacos,监听变更实时更新
2、分布式事务理论基础
CAP原则又称CAP定理,指的是在一个分布式系统中, Consistency(一致性)、 Availability(可用性)、Partition tolerance(分区容错性),三者不可得兼。
一致性(C):在分布式系统中的所有数据备份,在同一时刻是否同样的值。(等同于所有节点访问同一份最新的数据副本)
可用性(A):保证每个请求不管成功或者失败都有响应。
分区容忍性(P):系统中任意信息的丢失或失败不会影响系统的继续运作。
2.1.1、简述CAP定理内容?
- 分布式系统节点通过网络连接,一定会出现分区问题(P)
- 当分区出现时,系统的一致性(C)和可用性(A)就无法同时满足
思考:elasticsearch集群是CP还是AP?
- ES集群出现分区时,故障节点就会被剔除集群,数据分片会重新分配到其它节点,保证数据一致。因此是低可用性,高一致性,属于CP。
2.1.2、简述BASE理论三个思想?
- 基本可用
- 软状态
- 最终一致
解决分布式事务的思想和模型:
- 全局事务:整个分布式事务
- 分支事务:分布式事务中包含的每个子系统的事务
- 最终一致思想:各分支事务分别执行并提交,如果有不一致的情况,再想办法恢复数据
- 强一致思想:各分支事务执行玩业务不要提交,等待彼此结果。而后统一提交或回滚
3、初识seata
Seata 是阿里开源的一款开源的分布式事务解决方案,致力于提供高性能和简单易用的分布式事务服务。
3.1、Seata架构
Seata事务管理中三个重要的角色:
- TC(Transaction Coordinator)-事务协调者:维护全局和分支事务的状态,协调全局事务提交或回滚。
- TM(Transaction Manager)-事务管理器:定义全局事务的范围,开始全局事务、提交或回滚全局事务。
- RM(Resource Manager)-资源管理器:管理分支事务处理的资源,与TC交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。
3.2、动手实践
- XA 模式
- AT 模式
- TCC 模式
- SAGA 模式
3.2.1、XA模式
XA模式原理
XA规范是 X/Open 组织定义的分布式事务处理(DTP,Distributed Transaction Processing)标准,XA规范描述了全局的TM与局部的RM之间的接口,几乎所有主流的数据库都对 XA 规范提供了支持。
XA模式的优点是什么?
- 事务的强一致性,满足ACID原则
- 常用数据库都支持,实现简单,并且没有代码侵入
XA模式的缺点是什么?
- 因为一阶段需要锁定数据库,等待二阶段结束才能释放,性能较差
- 依赖关系型数据库事务实现
3.2.2、AT模式
AT模式原理
AT模式同样是分阶段提交的事务模型,不过却弥补了XA模型中资源锁定周期过长缺陷
简述AT模式与XA模式最大的区别是什么?
- XA模式一阶段不提交事务,锁定资源;AT模式一阶段直接提交,不锁定资源
- XA模式依赖数据库机制实现回滚;AT模式利用数据快照实现数据回滚
- XA模式强一致;AT模式最终一致
AT模式的优点:
- 一阶段完成直接提交事务,释放数据库资源,性能比较好
- 利用全局锁实现读写隔离
- 没有代码侵入,框架自动完成回滚和提交
AT模式的缺点:
- 两阶段之间属于软状态,属于最终一致
- 框架的快照功能会影响性能,但比XA模式要好很多
3.2.3、TCC模式
TCC模式原理
TCC模式与AT模式非常相似,每阶段都是独立事务,不同的是TCC通过人工编码来实现数据恢复。需要实现三个方法:
- Try:资源的检测和预留
- Confirm:完成资源操作业务;要求 Try 成功 Confirm 一定要能成功
- Cancel:预留资源释放,可以理解为try的方向操作
TCC模式的每个阶段是做什么的?
- Try:资源检查和预留
- Confirm:业务执行和提交
- Cancel:预留资源的释放
TCC的优点是什么?
- 一阶段完成直接提交事务,释放数据库资源,性能好
- 相比AT模型,无需生成快照,无需使用全局锁,性能最强
- 不依赖数据库事务,而是依赖补偿操作,可以用于非事务型数据库
TCC的缺点是什么?
- 有代码侵入,需要人为编写Try、Confirm和Cancel接口,太麻烦
- 软状态,事务是最终一致
- 需要考虑Confirm和Cancel的失败情况,做好幂等处理
package cn.itcast.account.service;
import io.seata.rm.tcc.api.BusinessActionContext;
import io.seata.rm.tcc.api.BusinessActionContextParameter;
import io.seata.rm.tcc.api.LocalTCC;
import io.seata.rm.tcc.api.TwoPhaseBusinessAction;
@LocalTCC
public interface AccountTCCService {
/**
* Try逻辑,@TwoPhaseBusinessAction中的name属性要与当前方法名一致,用于指定Try逻辑对应的方法
* @param userId 用户id
* @param money 所剩余额
*/
@TwoPhaseBusinessAction(name = "deduct",commitMethod = "confirm",rollbackMethod = "cancel")
void deduct(@BusinessActionContextParameter(paramName = "userId") String userId,
@BusinessActionContextParameter(paramName = "money") int money);
/**
* 二阶段confirm确认方法,可以另命名,但要保证与commitMethod一致
* @param context 上下文,可以传递try方法的参数
* @return boolean 执行是否成功
*/
boolean confirm(BusinessActionContext context);
/**
* 二阶段回滚方法,要保证与rollbackMethod一致
*/
boolean cancel(BusinessActionContext context);
}
package cn.itcast.account.service.impl;
import cn.itcast.account.entity.AccountFreeze;
import cn.itcast.account.mapper.AccountFreezeMapper;
import cn.itcast.account.mapper.AccountMapper;
import cn.itcast.account.service.AccountTCCService;
import io.seata.core.context.RootContext;
import io.seata.rm.tcc.api.BusinessActionContext;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@Service
public class AccountTCCServiceImpl implements AccountTCCService {
@Autowired
private AccountMapper accountMapper;
@Autowired
private AccountFreezeMapper freezeMapper;
@Override
@Transactional
public void deduct(String userId, int money) {
//0.获取事务id
String xid = RootContext.getXID();
//判断freeze中是否有冻结记录,如果有,一定是CANCEL执行过,我要拒绝执行
AccountFreeze ordFreeze = freezeMapper.selectById(xid);
if (ordFreeze != null) {
return;
}
//1.扣除可用金额
accountMapper.deduct(userId,money);
//2.记录冻结金额,事务状态
AccountFreeze freeze = new AccountFreeze();
freeze.setUserId(userId);
freeze.setFreezeMoney(money);
freeze.setState(AccountFreeze.State.TRY);
freeze.setXid(xid);
freezeMapper.insert(freeze);
}
@Override
public boolean confirm(BusinessActionContext context) {
//1.获取事务id
String xid = context.getXid();
//2.根据id删除冻结记录
int count = accountMapper.deleteById(xid);
return count == 1;
}
@Override
public boolean cancel(BusinessActionContext context) {
//0.查询冻结记录
String xid = context.getXid();
String userId = context.getActionContext("userId").toString();
AccountFreeze freeze = freezeMapper.selectById(xid);
//空回滚的判断,判断freeze是否为null,为null证明try没执行,需要空回滚
if (freeze == null){
//证明try没执行,需要空回滚
freeze = new AccountFreeze();
freeze.setFreezeMoney(0);
freeze.setState(AccountFreeze.State.CANCEL);
freeze.setUserId(userId);
freeze.setXid(xid);
freezeMapper.insert(freeze);
return true;
}
//幂等判断
if (freeze.getState() == AccountFreeze.State.CANCEL){
//已经处理过一次CANCEL了,无需重复处理
return true;
}
//1.恢复可用余额
accountMapper.refund(freeze.getUserId(),freeze.getFreezeMoney());
//2.将冻结金额清零,修改为CANCEL
freeze.setFreezeMoney(0);
freeze.setState(AccountFreeze.State.CANCEL);
int count = freezeMapper.updateById(freeze);
return count == 1;
}
}
3.2.4、Saga模式
Saga模式是SEATA提供的长事务解决方案。也分为两个阶段:
- 一阶段:直接提交本地事务
- 二阶段:成功则什么都不做;失败则通过编写补偿业务来回滚
saga模式优点:
- 事务参与者可以基于事件驱动实现异步调用,吞吐高
- 一阶段直接提交事务,无锁,性能好
- 不用编写TCC中的三个阶段,实现简单
缺点:
- 软状态持续事件不确定,时效性差
- 没有锁,没有事务隔离,会有脏写
4、Redis
4.1、Redis持久化
4.1.1、Redis持久化-RDB持久化
RDB全称Redis Database Backup file (Redis数据备份文件),也被叫做Redis数据快照。简单来说就是把内存中的所有数据都记录到磁盘中。当Redis实例故障重启后,从磁盘读取快照文件,恢复数据。
快照文件称为RDB文件,默认是保存在当前运行目录。
持久化过程
bgsave开始时会fork主进程得到子进程,子进程共享主进程的内存数据。完成fork后读取内存数据并写入RDB文件中。
fork采用的是copy-on-write技术:
- 当主进程执行读操作时,访问共享内存;
- 当主进程执行写操作时,则会拷贝一份数据,执行写操作
RDB方式bgsave的基本流程?
- fork主进程得到一个子进程,共享内存空间
- 子进程读取内存数据并写入新的RDB文件
- 用新的RDB文件替换旧的RDB文件
RDB会在什么时候执行?save 60 1000 代表什么含义?
- 默认是服务停止时
- 代表60秒内至少执行1000次修改则触发RDB
RDB的缺点?
- RDB执行间隔时间长,两次RDB之间写入数据会有丢失的风险
- fork子进程、压缩、写出RDB文件都比较耗时
4.1.2、Redis持久化-AOP持久化
AOF全称为Append Only File (追加文件)。Redis处理的每一个命令都会记录在AOF文件,可以看做是命令日志文件。
AOF默认是关闭的,需要修改redis.conf配置文件来开启AOF:
#是否开启AOF功能,默认是no
appendonly yes
#AOF文件的名称
appendfilename "appendonly.aof"
AOF的命令记录频率也可以通过redis.conf文件来配:
#表示每执行一次写命令,立即记录到AOF文件
appendfsync always
#写命令执行完先放入AOF缓冲区,然后表示每个1秒将缓冲区数据写到AOF文件,是默认方案
appendfsync everysec
#写命令执行完先访谈缓冲区,由操作系统决定何时将缓冲区内容写回磁盘
appendfsync no
因为是记录命令,AOF文件会比RDB文件大的多。而且AOF会记录对同一个key的多次写操作,但只有最后一次写操作才有意义。通过执行bgrewriteaof命令,可以让AOF文件执行重写功能,用最少的命令达到相同效果。
Redis也会在触发阈值时自动去重写AOF文件。阈值也可以在redis.conf中配置:
#AOF文件比上次文件 增长超过多少百分比则触发重写
auto-aof-rewrite-percentage 100
#AOF文件体积最小多大以上才触发重写
auto-aof-rewrite-min-size 64mb
4.2、Redis主从
4.2.1、Redis主从-搭建主从架构
单节点Redis的并发能力是有限的,要进一步的提高Redis的并发能力,就需要搭建主从集群,实现读写分离。
假设有A、B两个Redis实例,如何将B作为A的slave节点?
- 在B节点执行命令:slaveof A的IP A的port
使用命令 info replication 查看节点信息
4.2.2、Redis主从-主从数据同步原理
主从同步第一次是全量同步
master如何判断slave是不是第一次来同步数据?这里会用到两个很重要的概念:
- Replication Id:简称replid,是数据集的标记,id一致则说明是同一数据集。每一个master都有唯一的replid,slave则会继承master节点的replid
- offset:偏移量,随着记录在repl_baklog中的数据增多而逐渐增大。slave完成同步时也会记录当前同步的offset。如果slave的offset小于master的offset,说明slave数据落后于master,需要更新。
因此slave做数据同步,必须向master声明自己的replication id 和 offset,master才可以判断到底需要同步哪些数据
简述全量同步的流程?
- slave节点请求增量同步
- master节点判断replid,发现不一致,拒绝增量同步
- master将完整内存数据生成RDB,发送RDB到slave
- slave清空本地文件,加载master的RDB
- master将RDB期间的命令记录在repl_baklog,并持续将log中的命令发送给slave
- slave执行接收到的命令,保持与master之间的同步
主从第一次同步是全量同步,但如果slave重启后同步,则执行增量同步
注意: repl_baklog大小有上限,写满后会覆盖最早的数据。如果slave断开时间过久,导致尚未备份的数据被覆盖,则无法基于log做增量同步,只能再次全量同步。
可以从以下几个方面来优化Redis主从同步:
- 在master中配置repl-diskless-sync yes启用无磁盘复制,避免全量同步时的磁盘IO。
- Redis单节点上的内存占用不要太大,减少RDB导致的过多磁盘IO。
- 适当提高repl_baklog的大小,发现slave宕机时尽快实现故障恢复,尽可能避免全量同步
- 限制一个master上的slave节点数量,如果实在是太多slave,则可以采用主-从-从链式结构,减少master压力
简述全量同步和增量同步的区别?
- 全量同步:master将完整内存数据生成RDB,发送RDB到slave。后续命令则记录在repl_baklog,逐个发送给slave
- 增量同步:slave提交自己的offset到master,master获取repl_baklog中从offset之后的命令给slave
什么时候执行全量同步?
- slave节点第一次连接master节点时
- slave节点断开时间太久,repl_baklog中offset已经被覆盖时
什么时候执行增量同步?
- slave节点断开又恢复,并且在repl_baklog中能找到offset时
4.3、Redis哨兵
4.3.1、哨兵的作用和原理
哨兵的作用
Redis提供了哨兵(Sentinel)机制来实现主从集群的自动故障恢复。
哨兵的结构和作用如下:
- 监控:Sentinel会不断检查你的master和slave是否按预期工作
- 自动故障恢复:如果master故障,Sentinel会将一个slave提升为master。当故障实例恢复后也以新的master为主
- 通知:Sentinel充当Redis客户端的服务发现来源,当集群发生故障转移时,会将最新信息推送给Redis的客户端
服务状态监控
Sentinel基于心跳机制监测服务状态,每隔1秒向集群的每个实例发送ping命令:
- 主观下线:如果某sentinel节点发现某实例未在规定时间响应,则认为该实例主观下线。
- 客观下线:若超过指定数量(quorum)的sentinel都认为该实例主观下线,则该实例客观下线。quorum值最好超过Sentinel实例数量的一半。
选举新的master
一旦发现master故障,sentinel需要在slave中选择一个作为新的master,选择依据是这样的:
- 首先会判断slave节点与master节点断开时间长短,如果超过指定值(down-after-milliseconds * 10)则会排除该slave节点
- 然后判断slave节点的slave-priority值,值越小优先级越高,如果是0则永不参与选举
- 如果slave-priority一样,则判断slave节点的offset值,越大说明数据越新,优先级越高
- 最后判断slave节点的运行id大小,越小优先级越高
如何实现故障转移
当选中了其中一个slave为新的master后(例如slave1),故障转移的步骤如下:
- sentinel给备选的slave1节点发送slaveof no one 命令,让该节点成为master
- sentinel给所有其它slave发送slaveof 192.168.16.3 7002命令,让这些slave成为新master的从节点,开始从新的master上同步数据
- 最后,sentinel将故障节点标记为slave,当故障节点恢复后会自动成为新的master的slave节点
4.3.2、搭建哨兵集群
分片集群结构
创建集群,我们使用的是Redis6.2.4版本,集群管理以及集成到了redis-cli中,格式如下:
redis-cli --cluster create --cluster-replicas 1 192.168.16.3:7001 192.168.16.3:7002 192.168.16.3:7003 192.168.16.3:8001 192.168.16.3:8002 192.168.16.3:8003
主从和哨兵可以解决高可用、高并发读的问题。但是依然有两个问题没有解决:
- 海量数据存储问题
- 高并发写的问题
使用分片集群可以解决上述问题,分片集群特征:
- 集群中有多个master,每个master保存不同数据
- 每个master都可以有多个slave节点
- master之间通过ping监测彼此健康状态
- 客户端请求可以访问集群任意节点,最终都会被转发到正确节点
散列插槽
Redis如何判断某个key应该在哪个实例?
- 将16384个插槽分配到不同的实例
- 根据key的有效部分计算哈希值,对16384取余
- 余数作为插槽,寻找插槽所在实例即可
如何将同一类数据固定的保存在同一个Redis实例?
- 这一类数据使用相同的有效部分,例如key都以{typeId}为前缀
集群伸缩
案例:向集群中添加一个新的master节点,并向其中存储 num = 10
需求:
- 启动一个新的redis实例,端口为7004
- 添加7004到之前的集群,并作为一个master节点
- 给7004节点分配插槽,使得num这个key可以存储到7004实例
向集群中添加一个master节点
- 192.168.16.3:7004 添加节点的ip和端口
- 192.168.16.3:7001 集群中已经存在的节点
redis-cli --cluster add-node 192.168.16.3:7004 192.168.16.3:7001
插槽分配
主从和哨兵可以实现读写分离和故障转移,但redis集群它也实现了自动故障转移(不需要配置哨兵模式)和读写分离的效果。
5、多级缓存
5.1、 JVM进程缓存
5.1.1、传统缓存的问题
传统的缓存策略一般是请求到达Tomcat后,先查询Redis,如果未命中则查询数据库,存在下面的问题:
- 请求要经过Tomcat处理,Tomcat的性能成为整个系统的瓶颈
- Redis缓存失效时,会对数据库产生冲击
5.1.2、多级缓存方案
多级缓存就是充分利用请求处理的每个环节,分别添加缓存,减轻Tomcat压力,提升服务性能:
5.1.3、本地进程缓存
缓存在日常开发中启动至关重要的作用,由于是存储在内存中,数据的读取速度是非常快的,能大量减少对数据库的访问,减少数据库的压力。我们把缓存分为两类:
分布式缓存,例如Redis:
- 优点:存储容量更大,可靠性更好、可以在集群中共享
- 缺点:访问缓存有网络开销
- 场景:缓存数据量较大、可靠性要求更高、需要在集群间共享
进程本地缓存,例如HashMap、GuavaCache:
- 优点:读取本地内存,没有网络开销,速度更快
- 缺点:存储容量有限、可靠性较低、无法共享
- 场景:性能要求较高,缓存数据量较小
案例:实现商品的查询的本地进程缓存
利用Caffeine实现下列需求:
- 给根据id查询商品的业务添加缓存,缓存未命中时查询数据库
- 给根据id查询商品库存的业务添加缓存,缓存未命中时查询数据库
- 缓存初始大小为100
- 缓存上限为10000
package com.heima.item.config;
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.heima.item.pojo.Item;
import com.heima.item.pojo.ItemStock;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class CaffeineConfig {
@Bean
public Cache<Long, Item> itemCache(){
return Caffeine.newBuilder() //构建
.initialCapacity(100) //初始化大小
.maximumSize(10_000) //上限最大值
.build();
}
@Bean
public Cache<Long, ItemStock> itemStockCache(){
return Caffeine.newBuilder() //构建
.initialCapacity(100) //初始化大小
.maximumSize(10_000) //上限最大值
.build();
}
}
package com.heima.item.web;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.github.benmanes.caffeine.cache.Cache;
import com.heima.item.pojo.Item;
import com.heima.item.pojo.ItemStock;
import com.heima.item.pojo.PageDTO;
import com.heima.item.service.IItemService;
import com.heima.item.service.IItemStockService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.stream.Collectors;
@RestController
@RequestMapping("item")
public class ItemController {
@Autowired
private IItemService itemService;
@Autowired
private IItemStockService stockService;
@Autowired
private Cache<Long,Item> itemCache;
@Autowired
private Cache<Long,ItemStock> itemStockCache;
@GetMapping("/{id}")
public Item findById(@PathVariable("id") Long id){
return itemCache.get(id, key -> itemService.query()
.ne("status", 3).eq("id", key)
.one());
}
@GetMapping("/stock/{id}")
public ItemStock findStockById(@PathVariable("id") Long id){
return itemStockCache.get(id,key -> stockService.getById(id));
}
}
5.2、Lua语法入门
5.2.1、初识Lua
Lua是一种轻量小巧的脚本语言,用标准C语言编写并以源码形式开放,其设计目的是为了嵌入应用程序中,从而为应用提供灵活的扩展和定制功能。官网:The Programming Language Lua
5.2.2、变量和循环
数据类型
变量
循环
5.2.3、条件控制、函数
函数
条件控制
print('hello world!')
local arr = {'java','lua','python'}
local map = {name='jack',age=21}
local function printArr(arr)
if (not arr) then
print('数组不能为空!')
return nil
end
for i, var in ipairs(arr) do
print(var)
end
end
printArr(arr)
local arr2 = {100, 200, 300}
printArr(arr2)
printArr(nil)
for key,value in pairs(map) do
print(key,value)
end
5.3、多级缓存
5.3.1、安装OpenResty
OpenResty是一个基于Nginx的高性能web平台,用于方便地搭建能够处理高并发,扩展性极高的动态web应用
web服务和动态网关,具体下列特点:
- 具备Nginx的完整功能
- 基于Lua语言进行扩展,集成了大量精良的Lua库,第三方模块
- 允许使用Lua自定义业务逻辑,自定义库
官方网站:OpenResty® - 中文官方站
5.4、缓存同步
5.4.1、缓存同步策略
缓存数据同步的常见方式有三种:
设置有效期:给缓存设置有效期,到期后自动删除。再次查询时更新
- 优势:简单、方便
- 缺点:时效性差,缓存过期之前可能不一致
- 场景:更新频率较低,时效性要求低的业务
同步双写:在修改数据库的同时,直接修改缓存
- 优势:时效性强,缓存与数据库强一致
- 缺点:有代码侵入,耦合度高
- 场景:对一致性,时效性要求较高的缓存数据
异步通知:修改数据库时发送事件通知,相关服务监听到通知后修改缓存数据
- 优势:低耦合,可以同时通知多个缓存服务
- 缺点:时效性一般,可以存在中间不一致状态
- 场景:时效性要求一般,有多个服务需要同步
6、服务异步通讯-RabbitMQ的高级特性
MQ的常见问题
6.1、消息可靠性
6.1.1、生产者消费确认
RabbitMQ提供了publisher confirm机制来避免消息发送到MQ过程中丢失。消息发送到MQ以后,会返回一个结果给发送者,表示消息是否处理成功。
结果有两种请求:
publisher-confirm,发送者确认
- 消息成功投递到交换机,返回ack
- 消息为投递到交换机,返回nack
publisher-return,发送者回执
- 消息投递到交换机了,但是没有路由到队列。返回ACK,及路由失败原因,调用ReturnCallback
6.1.2、消息持久化
发送者
@Test
public void testDurableMessage() {
// 1.准备消息
Message message = MessageBuilder.withBody("hello, spring".getBytes(StandardCharsets.UTF_8))
.setDeliveryMode(MessageDeliveryMode.PERSISTENT)
.build();
// 2.发送消息
rabbitTemplate.convertAndSend("simple.queue", message);
}
消费者,声明持久化,底层默认已经持久化
package cn.itcast.mq.config;
import org.springframework.amqp.core.DirectExchange;
import org.springframework.amqp.core.Queue;
import org.springframework.amqp.core.QueueBuilder;
import org.springframework.context.annotation.Bean;
// @Configuration
public class CommonConfig {
@Bean
public DirectExchange simpleDirect(){
return new DirectExchange("simple.direct");
}
@Bean
public Queue simpleQueue(){
return QueueBuilder.durable("simple.queue").build();
}
}
6.1.3、消费者消息确认
RabbitMQ支持消费者确认机制,即:消费者处理消息后可以向MQ发送ack回执,MQ收到ack回执后才会删除该消息。
而SpringAMQP则允许配置的三种确认模式:
- manual:手动ack,需要在业务代码结束后,调用api发送ack。
- auto:自动ack,由spring监测listener代码是否出现异常,没有异常则返回ack;抛出异常则返回nack
- none:关闭ack,MQ假定消费者获取信息后会成功处理,因此消息投递后立即被删除
配置方式是修改消费者的application.yml,添加下面配置:
spring:
rabbitmq:
listener:
simple:
prefetch: 1
acknowledge-mode: auto #none,关闭ack;manual,手动ack,auto,自动ack
6.1.4、消费失败重试机制
当消费者出现异常后,消息会不断requeue(重新入队)到队列,再重新发送给消费者,然后再次异常,再次requeue,无限循环,导致mq的消息处理飙升,带来不必要的压力:
我们可以利用Spring的resty机制,在消费者出现异常时利用本地重试,而不是无限制的requeue到mq队列。
spring:
rabbitmq:
listener:
simple:
prefetch: 1
retry:
enabled: true #开启消费者失败重试
initial-interval: 1000 #初始的失败等待时长为1秒
multiplier: 3 #下次失败的等待时长倍数,下次等待时长 = multiplier * last - interval
max-attempts: 4 #最大重试次数
stateless: true #true无状态,flase有状态。如果业务中包含事务,这里改为flase,默认是true
如何确保RabbitMQ消息的可靠性?
- 开启生产者确认机制,确保生产者的消息能到达队列
- 开启持久化功能,确保消息未消费前在队列中不会丢失
- 开启消费者确认机制为auto,由spring确认消息处理成功后完成ack
- 开启消费者失败重试机制,并设置MessageRecoverer,多次重试失败后将消息投递到异常交换机,交由人工处理
6.2、死信交换机
6.2.1、初识死信交换机
当一个队列中的消息满足下列情况之一时,可以成为死信(dead letter):
- 消费者使用basic.reject或basic.nack声明消费失败,并且消息的requeue参数设置为false
- 消息是一个过期信息,超时无人消费
- 要投递的队列消息堆积满了,最早的消息可能成为死信
如果该队列配置了dead-letter-exchange属性,指定了一个交换机,那么队列中的死信就会投递到这个交换机中,而这个交换机成为死信交换机(Dead Letter Exchange,简称DLX)
什么样的消息会成为死信?
- 消息被消费者reject或者返回nack
- 消息超时未消费
- 队列满了
如何给队列绑定死信交换机?
- 给队列设置dead-letter-exchange属性,绑定一个交换机
- 给队列设置dead-letter-exchange-key属性,设置死信交换机与死信队列的RoutingKey
6.2.2、TTL
TTL,也就是Time-To-Live。如果一个队列中的消息TTL结束仍未消费,则会变为死信,ttl超时分为两种情况:
- 消息所在的队列设置了存活时间
- 消息本身设置了存活时间
消息超时的两种方式是?
- 给队列设置ttl属性,进入队列后超过ttl时间的消息变为死信
- 给消息设置ttl属性,队列接收到消息超过ttl时间后变为死信
- 两者共存时,以时间短的ttl为准
给消费者队列设置超时时间 ttl = 10000
package cn.itcast.mq.config;
import org.springframework.amqp.core.*;
import org.springframework.context.annotation.Bean;
// @Configuration
public class TTLMessageConfig {
@Bean
public DirectExchange ttlDirectExchange(){
return new DirectExchange("ttl.direct");
}
@Bean
public Queue ttlQueue(){
return QueueBuilder
.durable("ttl.queue")
.ttl(10000)
.deadLetterExchange("dl.direct")
.deadLetterRoutingKey("dl")
.build();
}
@Bean
public Binding ttlBinding(){
return BindingBuilder.bind(ttlQueue()).to(ttlDirectExchange()).with("ttl");
}
}
给发送者设置消息超时时间 ttl = 5000
@Test
public void testTTLMessage() {
// 1.准备消息
Message message = MessageBuilder
.withBody("hello, ttl messsage".getBytes(StandardCharsets.UTF_8))
.setDeliveryMode(MessageDeliveryMode.PERSISTENT)
.setExpiration("5000")
.build();
// 2.发送消息
rabbitTemplate.convertAndSend("ttl.direct", "ttl", message);
// 3.记录日志
log.info("消息已经成功发送!");
}
如何实现发送一个消息20秒后消费者才能收到消息?
- 给消息的目标队列指定死信交换机
- 消费者监听与死信交换机绑定的队列
- 发送消息时给消息设置ttl为20秒
6.3、惰性队列
6.3.1、消息堆积问题
当生产者发送消息的速度超过了消费者处理消息的速度,就会导致队列中的消息堆积,直到队列存储消息达到上限。最早接收到的消息,可能就会成为死信,会被丢弃,这就是消息堆积问题。
解决消息堆积的三种思路:
- 增加更多的消费者,提高消费速度
- 在消费者内开启线程池加快消息处理速度
- 扩大队列容积,提高堆积上限
消息堆积问题的解决方案?
- 队列上绑定多个消费者,提高消费速度
- 给消费者开启线程池,提高消费速度
- 使用惰性队列,可以在mq中保存更多消息
生产者
/**
* 惰性队列
* @throws InterruptedException
*/
@Test
public void testLazyQueue() throws InterruptedException {
long b = System.nanoTime();
for (int i = 0; i < 1000000; i++) {
// 1.准备消息
Message message = MessageBuilder
.withBody("hello, Spring".getBytes(StandardCharsets.UTF_8))
.setDeliveryMode(MessageDeliveryMode.NON_PERSISTENT)
.build();
// 2.发送消息
rabbitTemplate.convertAndSend("lazy.queue", message);
}
long e = System.nanoTime();
System.out.println(e - b);
}
/**
* 普通队列
* @throws InterruptedException
*/
@Test
public void testNormalQueue() throws InterruptedException {
long b = System.nanoTime();
for (int i = 0; i < 1000000; i++) {
// 1.准备消息
Message message = MessageBuilder
.withBody("hello, Spring".getBytes(StandardCharsets.UTF_8))
.setDeliveryMode(MessageDeliveryMode.NON_PERSISTENT)
.build();
// 2.发送消息
rabbitTemplate.convertAndSend("normal.queue", message);
}
long e = System.nanoTime();
System.out.println(e - b);
}
消费者
package cn.itcast.mq.config;
import org.springframework.amqp.core.Queue;
import org.springframework.amqp.core.QueueBuilder;
import org.springframework.context.annotation.Bean;
// @Configuration
public class LazyConfig {
@Bean
public Queue lazyQueue() {
return QueueBuilder.durable("lazy.queue")
.lazy()
.build();
}
@Bean
public Queue normalQueue() {
return QueueBuilder.durable("normal.queue")
.build();
}
}
惰性队列的优点有哪些?
- 基于磁盘存储,消息上限高
- 没有间歇性的page-out,性能比较稳定
惰性队列的缺点有哪些?
- 基于磁盘存储,消息时效性降低
- 性能受限于磁盘的IO