1. 20230614学习记录
目标
- 缓存预热的实现
- Redisson的使用
- Canal的使用
1.1. 缓存预热
假设缓存中存储用户名
- 先去缓存读取,如果缓存内有用户名信息,返回前端以存在用户
- 如果缓存中不存在,可以注册写入数据库
缓存中的初始值哪里来
: 可以在系统启动前,提前将相关的缓存数据从数据库加载到缓存系统中,即缓存预热
实现缓存预热,这里使用Quartz
的任务定时能力
Quartz是一个完全由Java编写的开源任务调度的框架,用来执行定时任务
参考:https://zhuanlan.zhihu.com/p/522284183
1.1.1. 使用
1.1.1.1. 环境准备
创建数据库user_table、搭建springboot项目引入依赖
1.1.1.2. 引入quartz依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-quartz</artifactId>
</dependency>
1.1.1.3. 创建任务类
任务需要做什么
public class UserPreHotJob extends QuartzJobBean {
@Autowired
private IUserService userService;
private StringRedisTemplate redisTemplate;
@Override
protected void executeInternal(JobExecutionContext context) throws JobExecutionException {
//缓存预热
//先清空缓存
redisTemplate.delete("username");
//查询数据库,将用户名全部存入 key="username" 的set类型value中
List<User> allUsers = userService.findAll();
allUsers.forEach(u -> redisTemplate.opsForSet().add("username",u.getUsername()));
}
}
1.1.1.4. 配置任务信息和触发器
@Configuration
public class JobConfig {
/**
* 任务的细节配置
* @return
*/
@Bean
public JobDetail jobDetail() {
return JobBuilder.newJob()
.ofType(UserPreHotJob.class) //指定需要执行的具体任务实现类,执行时根据类创建job实例
.storeDurably() //
.build();
}
/**
* 定义触发器
* 配置何时执行计划任务
* @return
*/
@Bean
public Trigger trigger(){
return TriggerBuilder.newTrigger()
.forJob(jobDetail()) //关联
.startNow()
//使用调度器,规定任务的执行时间策略,此处表达式表示每天的11:55执行计划
.withSchedule(CronScheduleBuilder.cronSchedule("0 55 11 * * ?"))
.build();
}
}
1.1.2. cron
cron表达式用于表示调度(执行)的时间策略。
"0 55 11 * * ?"
从左到右 表示秒、分、时、日、月、周、年(可为空)
名称 | 允许值 | 特殊字符 |
---|---|---|
秒 | 允许值范围: 0~59 ,不允许为空值 | , - * / |
分 | 允许值范围: 0~59 ,不允许为空值 | , - * / |
时 | 允许值范围: 0~23 ,不允许为空值 | , - * / |
日 | 允许值范围: 1~31 ,不允许为空值 | , - * ? / L W C |
月 | 允许值范围: 1~12 (JAN-DEC),不允许为空值 | , - * / |
周 | 允许值范围: 1~7 (SUN-SAT),1代表星期天(一星期的第一天),7代表星期六(一星期的最后一天),不允许为空值 | , - * ? / L C # |
年 | 允许值范围: 1970~2099 ,允许为空 | , - * / |
结果特殊字符的含义
1.1.2.1. *
表示当前时间域的任意值,在
月
上,表示每个月
;在分
上表示每分钟
1.1.2.2. ?
只能使用在
周
和日
上,当其中一个时间域有明确值时,另一个域用?表示无含义
周
和日
会产生含义上的冲突,当日
设有值时,周
需要使用?来表示无含义;当周
设有值时,日
需要使用?来表示无含义如: 10 20 14 11 * WED
此表达式没有清晰含义,每个月的周三且是11号的14时20分10秒 触发?
因此
周
和日
不能同时设定值
1.1.2.3. ,
给当前时间域设置一组指定的值
在
分
上使用 0 10,15,30 * * * * 在每个小时的10分 15分 30分 触发
1.1.2.4. /
1/10 1代表初始值,10代表递增步长 放在
分
域上表示 1分的时候触发,然后每隔10分触发一次
1.1.2.5. -
给当前域指定一个范围 在
分
域上 10-20 指10分-20分钟内 每分都触发一次
1.1.2.6. L
只能用于
日
和周
在
日
域上,表示最后一天,假如设定了3月,即3月最后一天;设定每个月,每个月的最后一天在
周
域上,单一个L表示最后一个周六,数字L 表示最后一个周几,注意的是(,和-)无法和L一起用
1.1.2.7. W
只能使用在
日
域上,表示本月内离指定日最近的工作日触发,5W,在离5号最近的工作日触发
1.1.2.7.1.
只能使用在
周
域,用来表示月份中的第几周的星期几 5#3 表示一个月的第3周的星期四 A#B A表示星期,B表示第几周
1.2. Redisson
为了保证一个方法或属性在高并发情况下的同一时间只能被同一个线程执行,单机情况下使用我们可以使用学过的锁进行处理;而分布式情况下不同
分布式情况下应用部署于不同的服务器,当有多个请求同一方法被分配到不同的服务器进行处理时,A请求读缓存发现无数据a,于是往数据库写数据a,同时B数据也向另一台服务器也向缓存读数据a也没有,此时A的请求处理可还没有走到写数据的操作,那B请求的服务器也开始进行写操作,就会产生线程安全问题,我们现学的锁方法无法应对,只能控制单机的线程安全,由此抛出分布式锁。
1.2.1. 简介
基于redis实现的分布式锁
- 一个进程进入访问时,指定一个 key 作为锁标记,存入 Redis 中,指定一个 唯一的用户标识 作为 value,并设置key的过期时间
- 另一个进程进入,查询redis,只有当 key 不存在时才能设置值,进入访问,确保同一时间只有一个客户端进程获得锁,满足 互斥性 特性。
- 设置一个过期时间,防止因系统异常导致没能删除这个 key,满足 防死锁 特性。
- 当处理完业务之后需要清除这个 key 来释放锁,清除 key 时需要校验 value 值,需要满足 只有加锁的人才能释放锁 。
看一下以下应用实践时redis生成的hash结构数据
myLock是使用Redisson生成锁时自定义的标识,以此标识为key;一串生成的唯一标识为filed,value为 为了可重入机制做的计数统计。
1.2.2. 使用
https://github.com/redisson/redisson
按单机的情况演示使用
1.2.2.1. 引入依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.22.0</version>
</dependency>
1.2.2.2. 创建Redisson实例
- 创建一个Config对象,设置redis的连接信息
- 使用conifg 创建Redisson实例
RedissonClient
。交给Spring容器
@Configuration
public class RedissonConfig {
//读取项目配置的reids主机ip
@Value("${spring.redis.host}")
private String host;
//读取项目配置的reids主机端口
@Value("${spring.redis.port}")
private String port;
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer()
.setAddress("redis:"+host+":"+port); // redis://ip:port
return Redisson.create(config);
}
}
1.2.2.3. 锁使用
在需要加锁的程序前获取锁对象并加锁,最后一定需要释放锁
@Service
public class BookServiceImpl implements IBookService {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Autowired
private RedissonClient redissonClient;
@Override
public void rushBuy() {
//获得锁对象
RLock myLock = redissonClient.getLock("myLock");
try {
myLock.lock(); //该方法还可以指定锁的时间按
//以下为要加锁的代码
String snum = stringRedisTemplate.opsForValue().get("amount");
int num = Integer.parseInt(snum);
if(num>0){
num--;
stringRedisTemplate.opsForValue().set("amount",String.valueOf(num));
System.out.println("剩余"+num);
}
}finally {
//最后一定要释放锁
myLock.unlock();
}
}
}
1.3. Canal
数据库和缓存一致性问题
当使用redis作缓存,对访问作缓冲操作时,读取缓存没有问题,但是设计添加更新等写操作时,就会遇到数据库和缓存一致性的问题。
例如 处理请求 删除数据A,缓存作了删除,还没来的及让数据库删除,另一个请求来读取数据A,发现缓存中没有,于是继续向数据库中读取到数据A同时向缓存中写入,此时的数据A是脏数据,是逻辑上已经删除的数据。
由此看我们至少要先保证数据库的数据总是正确,再完善数据库到缓存的更新机制
- 读的时候,先读缓存,缓存没有的话,就读数据库,然后取出数据后放入缓存,同时返回响应。
- 更新的时候,先更新数据库,然后再删除缓存。
基于订阅binlog的同步机制
是一种更新缓存的方案,通过获取binlog日志,对日志进行解析,来获取主库的数据变更。
Canal
就是这种方案中获取数据库具体实现环节,
Canal是阿里开源的一款基于Mysql binlog的增量订阅和消费组件,通过它可以订阅数据库的binlog日志,然后进行一些数据消费,如数据镜像、数据异构、数据索引、缓存更新等。相对于消息队列,通过这种机制可以实现数据的有序化和一致性。
canal模拟的mysql主从交互协议,伪装自己是一个mysql slave,获取master的binlog日志
1.3.1. mysql配置
1.3.1.1. 进入mysql中检查binlog是否开启。
show VARIABLES like 'log_%'
ON开启状态
1.3.1.2. 在mysql中创建用户canal
#此处设置了 账户canal 密码 canal
create user 'canal'@'%' IDENTIFIED with mysql_native_password by 'canal';
grant SELECT, REPLICATION SLAVE, REPLICATION CLIENT on *.* to 'canal'@'%';
FLUSH PRIVILEGES;
1.3.2. Canal安装(docker)
1.3.2.1. 查询拉取镜像
docker search canal
docker pull docker.io/canal/canal-server
1.3.2.2. 创建挂载的目录,并上传配置文件
在usr/local/software下创建canal/conf
上传配置文件canal.properties
和instance.properties
1.3.2.3. 启动容器并挂载
docker run -itd --name canal -p 11111:11111 --privileged=true -v /usr/local/software/canal/conf/instance.properties:/home/admin/canal-server/conf/example/instance.properties -v /usr/local/software/canal/conf/canal.properties:/home/admin/canal-server/conf/example/canal.properties --restart=always --net mynetwork --ip 172.18.0.7 canal/canal-server
1.3.2.4. 修改配置文件instance:.properties
1.3.3. 测试是否配置成功
按照官方https://github.com/alibaba/canal/wiki/ClientExample
1.3.3.1. 引入依赖
<dependency>
<groupId>com.alibaba.otter</groupId>
<artifactId>canal.client</artifactId>
<version>1.1.0</version>
</dependency>
1.3.3.2. ClientSample代码
copy一下
CanalConnectors.newSingleConnector(new InetSocketAddress(AddressUtils.getHostIp()
,11111),“example”,“”, “”);
此处改为自己canal服务的ip
package com.alibaba.otter.canal.sample;
import java.net.InetSocketAddress;
import java.util.List;
import com.alibaba.otter.canal.client.CanalConnectors;
import com.alibaba.otter.canal.client.CanalConnector;
import com.alibaba.otter.canal.common.utils.AddressUtils;
import com.alibaba.otter.canal.protocol.Message;
import com.alibaba.otter.canal.protocol.CanalEntry.Column;
import com.alibaba.otter.canal.protocol.CanalEntry.Entry;
import com.alibaba.otter.canal.protocol.CanalEntry.EntryType;
import com.alibaba.otter.canal.protocol.CanalEntry.EventType;
import com.alibaba.otter.canal.protocol.CanalEntry.RowChange;
import com.alibaba.otter.canal.protocol.CanalEntry.RowData;
public class SimpleCanalClientExample {
public static void main(String args[]) {
// 创建链接,此处AddressUtils.getHostIp()改为自己canal服务的ip
CanalConnector connector = CanalConnectors.newSingleConnector(new InetSocketAddress(AddressUtils.getHostIp(),
11111), "example", "", "");
int batchSize = 1000;
int emptyCount = 0;
try {
connector.connect();
connector.subscribe(".*\\..*");
connector.rollback();
int totalEmptyCount = 120;
while (emptyCount < totalEmptyCount) {
Message message = connector.getWithoutAck(batchSize); // 获取指定数量的数据
long batchId = message.getId();
int size = message.getEntries().size();
if (batchId == -1 || size == 0) {
emptyCount++;
System.out.println("empty count : " + emptyCount);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
}
} else {
emptyCount = 0;
// System.out.printf("message[batchId=%s,size=%s] \n", batchId, size);
printEntry(message.getEntries());
}
connector.ack(batchId); // 提交确认
// connector.rollback(batchId); // 处理失败, 回滚数据
}
System.out.println("empty too many times, exit");
} finally {
connector.disconnect();
}
}
private static void printEntry(List<Entry> entrys) {
for (Entry entry : entrys) {
if (entry.getEntryType() == EntryType.TRANSACTIONBEGIN || entry.getEntryType() == EntryType.TRANSACTIONEND) {
continue;
}
RowChange rowChage = null;
try {
rowChage = RowChange.parseFrom(entry.getStoreValue());
} catch (Exception e) {
throw new RuntimeException("ERROR ## parser of eromanga-event has an error , data:" + entry.toString(),
e);
}
EventType eventType = rowChage.getEventType();
System.out.println(String.format("================> binlog[%s:%s] , name[%s,%s] , eventType : %s",
entry.getHeader().getLogfileName(), entry.getHeader().getLogfileOffset(),
entry.getHeader().getSchemaName(), entry.getHeader().getTableName(),
eventType));
for (RowData rowData : rowChage.getRowDatasList()) {
if (eventType == EventType.DELETE) {
printColumn(rowData.getBeforeColumnsList());
} else if (eventType == EventType.INSERT) {
printColumn(rowData.getAfterColumnsList());
} else {
System.out.println("-------> before");
printColumn(rowData.getBeforeColumnsList());
System.out.println("-------> after");
printColumn(rowData.getAfterColumnsList());
}
}
}
}
private static void printColumn(List<Column> columns) {
for (Column column : columns) {
System.out.println(column.getName() + " : " + column.getValue() + " update=" + column.getUpdated());
}
}
}
1.3.3.3. 运行
启动Canal Client后,可以从控制台从看到类似消息:
empty count : 1
empty count : 2
empty count : 3
empty count : 4
此时代表当前数据库无变更数据
修改一下主mysql
控制台出现有关修改的信息,即配置成功