整理 Canal 监听 MySQL Binlog 的缓存失效流程,并开始分析听书项目订单模块和结算入口。

本篇要点

  • 理解 Binlog 与 Canal 的关系
  • 梳理变更后的缓存失效
  • 进入订单模块的需求分析

**学习提示:**Canal 相关示例账号已占位;这里的处理重点是识别数据变更并删除相应缓存,并非直接同步完整 Redis 对象。

内容回顾

1、专辑缓存+分布式锁

  • 使用分布式锁解决缓存击穿问题
  • 一、使用Redis的setnx + 过期时间 + uuid + lua脚本
  • 二、Redisson框架使用
  • Redisson实现分布式锁有两种机制

– 重试机制

– 看门狗机制

2、自定义注解+AOP实现缓存

  • 切面类ProceedingJoinPoint使用

3、布隆过滤器

  • 解决缓存穿透问题

  • 存储0或者1数组

  • 使用多个映射函数计算数据在数组位置

  • 存在误判率

4、mysql和redis数据一致性

  • 延迟双删策略:保证数据最终一致性

– 删除缓存

– 修改数据库

– 休眠一会

– 再删除缓存

  • 数据同步canal工具

– 相当于mysql从机,监控mysql主机里面二进制日志变化

今天内容

1、数据同步工具canal

作用

  • 阿里巴巴提供数据同步工具

  • 添加缓存之后,可能产生四个问题:穿透、雪崩、击穿、数据一致性

  • 使用canal解决mysql和redis数据一致性问题

底层原理

  • mysql有主从复制机制,主机里面写操作,同步到从机里面,读在从机里面进行读操作
  • canal相当于mysql从机的角色
  • 流程

1 在mysql主机里面有二进制日志,二进制日志记录主机变化的数据

2 canal实时监控主机里面二进制日志,当日志文件有数据变化,canal读取变化数据

3 canal根据变化数据操作redis

canal使用

第一,安装canal服务

操作mysql数据库
  • 查看mysql二进制日志是否开启,默认开启的
1
SHOW VARIABLES LIKE '%log_bin%';
  • 为了操作方便,可以在mysql创建用户,专门做同步使用的
1
2
3
4
5
6
7
8
#创建用户
create user canal@'%' IDENTIFIED by 'canal';
#给用户授权
GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT,SUPER ON *.* TO 'canal'@'%';
#如果是MySQL8.X以上需要对加密方式进行设置
ALTER USER 'canal'@'%' IDENTIFIED WITH mysql_native_password BY '<CANAL_PASSWORD>';
#刷新生效
FLUSH PRIVILEGES;
使用docker安装canal
1
2
3
4
5
6
7
8
9
10
docker run -p 11111:11111 --name canal \
-e canal.destinations=tingshuTopic \
-e canal.instance.master.address=192.168.200.130:3306 \
-e canal.instance.dbUsername=canal \
-e canal.instance.dbPassword=<CANAL_PASSWORD> \
-e canal.instance.connectionCharset=UTF-8 \
-e canal.instance.tsdb.enable=true \
-e canal.instance.gtidon=false \
-e canal.instance.filter.regex=.*\\..* \
-d canal/canal-server:v1.1.5
  • docker logs canal查看日志

image-20251104092357850

第二,SpringBoot整合canal

第一步 引入依赖
  • canal目前不支持jdk17,支持jdk1.8
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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<groupId>com.atguigu</groupId>
<artifactId>service-cdc</artifactId>
<version>1.0-SNAPSHOT</version>

<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.3.6.RELEASE</version>
<relativePath/>
</parent>
<properties>
<maven.compiler.source>8</maven.compiler.source>
<maven.compiler.target>8</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<!--web 需要启动项目-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>top.javatool</groupId>
<artifactId>canal-spring-boot-starter</artifactId>
<version>1.2.1-RELEASE</version>
</dependency>
<dependency>
<groupId>javax.persistence</groupId>
<artifactId>persistence-api</artifactId>
<version>1.0</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
</dependencies>

</project>
第二步 创建实体类
1
2
3
4
5
6
@Data
public class CDCEntity {
// 注意Column 注解必须是persistence包下的
@Column(name = "id")
private Long id;
}
第三步 创建类实现canal里面的接口EntryHandler
第四步 实现EntryHandler接口方法实现同步逻辑
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
34
35
36
37
38
@Slf4j
@Component
@CanalTable("album_info") ////监听变更表
public class AlbumInfoCdcHandler implements EntryHandler<CDCEntity> {

@Autowired
private RedisTemplate redisTemplate;

@Override
public void insert(CDCEntity cdcEntity) {
System.out.println(cdcEntity.getId());
}
/**
* 监听到数据修改
*
* @param before 变更前数据
* @param after 变更后数据
*/
@Override
public void update(CDCEntity before, CDCEntity after) {
log.info("监听到数据修改,ID:{}", after.getId());
String key = "album:info:" + after.getId();
redisTemplate.delete(key);

}

/**
* 监听到删除操作
*
* @param cdcEntity 删除前数据
*/
@Override
public void delete(CDCEntity cdcEntity) {
log.info("监听到数据删除,ID:{}", cdcEntity.getId());
String key = "album:info:" + cdcEntity.getId();
redisTemplate.delete(key);
}
}
第五步 创建配置文件
1
2
3
4
5
6
server:
port: 7080
#canal配置
canal:
destination: tingshuTopic1 #Canal服务端发送数据的话题名称跟上面容器里参数destinations的一样
server: 192.168.200.130:11111

可能遇到两个问题

第一个问题:因为canal不支持jdk17的,支持jdk1.8,修改idea环境jdk1.8环境

方式一:

image-20251104094258241

image-20251104094356078

方式二:

image-20251104094559345

第二个问题:启动springBoot项目监控不到mysql日志

重启canal服务和SpringBoot项目:docker restart canal

如果还不行,把mysql服务重启

2、订单模块

  • 登录
  • 搜索
  • 详情
  • 订单

订单模块需求

  • 购买订单包含三部分:购买vip、购买专辑、购买声音

购买vip

  • 开通vip,最终:在user_info表里面,修改is_vip=1,添加vip过期时间

image-20251104101742601

  • 执行流程

image-20251104102431619

购买专辑

  • 首先 专辑不是免费的pay_type不是0101
  • 其次 购买专辑price_type是0202
  • 第三 购买专辑有折扣,普通用户使用普通折扣,如果vip用户使用vip折扣
  • 购买专辑成功之后,最终user_paid_album添加一条记录

image-20251104103104852

  • 执行流程

image-20251104103557922

  • 优惠金额如何计算?

image-20251104104028727

购买声音

  • 专辑类型不是免费的,专辑price_type值0201
  • 使用账户余额购买声音
  • 声音购买没有折扣

image-20251104104934065

获取vip服务配置信息

  • 在service-user模块创建这个接口

image-20251104111121319

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Tag(name = "VIP服务配置管理接口")
@RestController
@RequestMapping("api/user/vipServiceConfig")
@SuppressWarnings({"all"})
public class VipServiceConfigApiController {

@Autowired
private VipServiceConfigService vipServiceConfigService;

@Operation(summary = "获取全部VIP 服务配置信息")
@GetMapping("findAll")
public Result<List<VipServiceConfig>> findAll(){
// 调用服务层方法
List<VipServiceConfig> list = this.vipServiceConfigService.list();
return Result.ok(list);
}
}

image-20251104111325136

跳转(确认)结算页面接口

远程调用接口:用户是否购买专辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Tag(name = "用户管理接口")
@RestController
@RequestMapping("api/user/userInfo")
@SuppressWarnings({"all"})
public class UserInfoApiController {

@Autowired
private UserInfoService userInfoService;

//用户是否购买专辑
@GuiguLogin
@GetMapping("isPaidAlbum/{albumId}")
public Result<Boolean> isPaidAlbum(@PathVariable("albumId") Long albumId) {
Long userId = AuthContextHolder.getUserId();
Boolean isPaid = userInfoService.isPaidAlbum(albumId,userId);
return Result.ok(isPaid);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
    ////用户是否购买专辑
@Override
public Boolean isPaidAlbum(Long albumId, Long userId) {
LambdaQueryWrapper<UserPaidAlbum> wrapper01 = new LambdaQueryWrapper<>();
wrapper01.eq(UserPaidAlbum::getAlbumId, albumId);
wrapper01.eq(UserPaidAlbum::getUserId, userId);
Long count = userPaidAlbumMapper.selectCount(wrapper01);
return count>0; //1>0 true 0>0 false

// UserPaidAlbum userPaidAlbum = userPaidAlbumMapper.selectOne(wrapper01);
// if(userPaidAlbum != null) {
// return true;
// } else {
// return false;
// }
}

远程调用接口:获取vip信息

1
2
3
4
5
6
7
8
9
10
/**
* 根据id获取VIP服务配置信息
* @param id
* @return
*/
@Operation(summary = "根据id获取VIP服务配置信息")
@GetMapping("getVipServiceConfig/{id}")
public Result<VipServiceConfig> getVipServiceConfig(@PathVariable Long id) {
return Result.ok(vipServiceConfigService.getById(id));
}
  • 在service-order模块编写这个接口
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Tag(name = "订单管理")
@RestController
@RequestMapping("api/order/orderInfo")
@SuppressWarnings({"all"})
public class OrderInfoApiController {

@Autowired
private OrderInfoService orderInfoService;

@GuiguLogin
@Operation(summary = "确认订单")
@PostMapping("trade")
public Result<OrderInfoVo> trade(@RequestBody @Validated TradeVo tradeVo) {
Long userId = AuthContextHolder.getUserId();
OrderInfoVo orderInfoVo = orderInfoService.trade(tradeVo,userId);
return Result.ok(orderInfoVo);
}
}
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
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
//跳转结算页面
@Override
public OrderInfoVo trade(TradeVo tradeVo, Long userId) {
//1 远程调用:根据userId获取用户信息
Result<UserInfoVo> resultUserInfoVo =
userInfoFeignClient.getUserInfoVo(userId);
UserInfoVo userInfoVo = resultUserInfoVo.getData();
Assert.notNull(userInfoVo,"用户信息为空");

// 订单原始金额
BigDecimal originalAmount = new BigDecimal("0.00");
// 减免总金额
BigDecimal derateAmount = new BigDecimal("0.00");
// 订单总价
BigDecimal orderAmount = new BigDecimal("0.00");
// 订单明细集合
List<OrderDetailVo> orderDetailVoList = new ArrayList<>();
// 订单减免明细列表
List<OrderDerateVo> orderDerateVoList = new ArrayList<>();

//2 获取购买类型 tradeVo里面itemType
//1001-专辑 1002-声音 1003-vip会员
String itemType = tradeVo.getItemType();
//3 购买专辑
if("1001".equals(itemType)) {
//3.1 远程调用:判断用户是否购买过专辑,
Result<Boolean> resultUserPaid =
userInfoFeignClient.isPaidAlbum(tradeVo.getItemId());
Boolean isPaid = resultUserPaid.getData();
if(isPaid) { //如果用户购买过,不需要购买
throw new GuiguException(300,"专辑购买过");
}

//3.2 根据专辑id获取专辑信息 tradeVo里面itemId
Result<AlbumInfo> albumInfoResult = albumInfoFeignClient.getAlbumInfo(tradeVo.getItemId());
AlbumInfo albumInfo = albumInfoResult.getData();
Assert.notNull(albumInfo,"专辑数据为空");

//3.3 判断当前用户如果不是vip,使用普通折扣,获取原始金额,计算优惠金额和最终金额
Integer isVip = userInfoVo.getIsVip();
if(isVip.intValue()==0) { //如果不是vip,使用普通折扣
//获取原始价格
originalAmount = albumInfo.getPrice();
//计算优惠金额
//获取折扣
BigDecimal discount = albumInfo.getDiscount();
if(discount.intValue()!=-1) { //打折
// 原始金额 * (10-折扣) / 10
derateAmount = originalAmount.multiply(
new BigDecimal("10").subtract(discount))
.divide(new BigDecimal(10),2,RoundingMode.HALF_UP);
}
//最终金额 = 原始金额 - 优惠金额
orderAmount = originalAmount.subtract(derateAmount);

} else if(isVip.intValue()==1 && userInfoVo.getVipExpireTime().before(new Date())) {
//如果是vip但是vip过期了,使用普通折扣
//获取原始价格
originalAmount = albumInfo.getPrice();
//计算优惠金额
//获取折扣
BigDecimal discount = albumInfo.getDiscount();
if(discount.intValue()!=-1) { //打折
// 原始金额 * (10-折扣) / 10
derateAmount = originalAmount.multiply(
new BigDecimal("10").subtract(discount))
.divide(new BigDecimal(10),2,RoundingMode.HALF_UP);
}
//最终金额 = 原始金额 - 优惠金额
orderAmount = originalAmount.subtract(derateAmount);

} else {
//3.3 判断当前用户如果是vip,使用vip折扣,获取原始金额,计算优惠金额和最终金额
//是vip,使用vip折扣
BigDecimal vipDiscount = albumInfo.getVipDiscount();
///获取原始价格
originalAmount = albumInfo.getPrice();
//计算优惠金额
//获取折扣
if(vipDiscount.intValue()!=-1) { //打折
// 原始金额 * (10-折扣) / 10
derateAmount = originalAmount.multiply(
new BigDecimal("10").subtract(vipDiscount))
.divide(new BigDecimal(10),2,RoundingMode.HALF_UP);
}
//最终金额 = 原始金额 - 优惠金额
orderAmount = originalAmount.subtract(derateAmount);
}

//3.4 封装其他数据
//订单明细列表
OrderDetailVo orderDetailVo = new OrderDetailVo();
orderDetailVo.setItemId(tradeVo.getItemId());
orderDetailVo.setItemName(albumInfo.getAlbumTitle());
orderDetailVo.setItemUrl(albumInfo.getCoverUrl());
orderDetailVo.setItemPrice(albumInfo.getPrice());
orderDetailVoList.add(orderDetailVo);

//订单减免明细列表
//原始金额 - 最终金额 !=0
if (originalAmount.subtract(orderAmount).doubleValue() != 0) {
OrderDerateVo orderDerateVo = new OrderDerateVo();
orderDerateVo.setDerateType(SystemConstant.ORDER_DERATE_ALBUM_DISCOUNT);
orderDerateVo.setDerateAmount(originalAmount.subtract(orderAmount));
orderDerateVoList.add(orderDerateVo);
}

} else if("1003".equals(itemType)) { //4 购买vip
//4.1 根据开通vip的id tradeVo里面itemId获取vip信息
// 根据id 获取VIP 服务配置信息
Result<VipServiceConfig> vipServiceConfigResult =
vipServiceConfigFeignClient.getVipServiceConfig(tradeVo.getItemId());
VipServiceConfig vipServiceConfig = vipServiceConfigResult.getData();
Assert.notNull(vipServiceConfig,"返回vip配置对象不能为空");

//4.2 获取原始金额,计算优惠金额和最终金额
originalAmount = vipServiceConfig.getPrice();
orderAmount = vipServiceConfig.getDiscountPrice();
derateAmount = originalAmount.subtract(orderAmount);

//4.3 封装其他数据
//订单明细
OrderDetailVo orderDetailVo = new OrderDetailVo();
orderDetailVo.setItemId(tradeVo.getItemId());
orderDetailVo.setItemName("VIP会员"+vipServiceConfig.getName());
orderDetailVo.setItemUrl(vipServiceConfig.getImageUrl());
orderDetailVo.setItemPrice(vipServiceConfig.getDiscountPrice());
orderDetailVoList.add(orderDetailVo);

//添加订单减免
if (originalAmount.subtract(orderAmount).doubleValue() != 0) {
OrderDerateVo orderDerateVo = new OrderDerateVo();
orderDerateVo.setDerateType(SystemConstant.ORDER_DERATE_VIP_SERVICE_DISCOUNT);
orderDerateVo.setDerateAmount(originalAmount.subtract(orderAmount));
orderDerateVoList.add(orderDerateVo);
}
} else if("1002".equals(itemType)) {
//TODO 5 购买声音
}

//6 把最终的数据封装到OrderInfoVo,返回OrderInfoVo对象
OrderInfoVo orderInfoVo = new OrderInfoVo();
//购买类型:vip 专辑 声音
orderInfoVo.setItemType(tradeVo.getItemType());
//原始金额
orderInfoVo.setOriginalAmount(originalAmount);
//优惠金额
orderInfoVo.setDerateAmount(derateAmount);
//最终金额
orderInfoVo.setOrderAmount(orderAmount);
//订单明细和优惠明细
orderInfoVo.setOrderDetailVoList(orderDetailVoList);
orderInfoVo.setOrderDerateVoList(orderDerateVoList);
//时间
orderInfoVo.setTimestamp(SignHelper.getTimestamp());
// 支付方式默认值
orderInfoVo.setPayWay(SystemConstant.ORDER_PAY_WAY_WEIXIN);

//7 防止重复提交
//向redis添加数据
String key = "user:trade:" + userId;
String value = UUID.randomUUID().toString().replaceAll("-", "");
redisTemplate.opsForValue().set(key,value);
//orderInfoVo设置value值
orderInfoVo.setTradeNo(value);

//8 对生成订单提交数据加密处理,防止数据被修改
//对orderInfoVo数据加密
// orderInfoVo -- map
String jsonString = JSON.toJSONString(orderInfoVo);
Map<String, Object> parameterMap = JSON.parseObject(jsonString, Map.class);
String sign = SignHelper.getSign(parameterMap);

//把加密之后字符串设置到orderInfoVo
orderInfoVo.setSign(sign);

return orderInfoVo;
}

防止重复提交

image-20251104152742473

image-20251104153934175

防止数据被修改

image-20251104155728395