写缓存还是删缓存
- 为了提高查询效率,通常会将
**热点**
、且**修改较少**
的数据存放到缓存中,因为单机**Redis**
可以轻松达到**数万**
的并发,单机MySQL在500w数据量情况下能够达到聚簇索引数万,**普通索引两千**
的QPS - 在对数据库操作时,就会引起
**数据库中的数据与缓存中数据不一致**
的问题,因此需要通过更新缓存或删除缓存来保证数据库与缓存中数据的一致性 **删除缓存**
,即利用删除缓存让**请求重新请求数据库重建缓存**
,优点是节**约内存空间**
;缺点是每次重建缓存时**需要额外查询数据库**
。**更新缓存**
,直接更新缓存从而使得数据库和缓存的数据保持一致,优点是不需要额外查询数据库重建缓存;缺点是**大量没有被访问的缓存会占用空间**
。- 在
**多线程并发场景**
下**更新缓存**
会产生**顺序性问题**
,可能会导致后发送的请求被前面的请求覆盖,从而缓存中存放了错误的值,因此**不能使用更新缓存**
的方式。
数据库和删除缓存的顺序性
先删除缓存,后修改数据库
- 假如此时同时存在
**线程A**
和**线程B**
同时请求车站余票数量,此时**车站余票数量=16**
- 此时线程A先将缓存删除,然后请求数据库将车辆余票改为15
- 线程B请求查询车站余票数量,因为线程A将缓存删除,因此线程B请求数据库查询出车站余票数量为16,此时线程B将 车站余票数量=16 的数据插入到缓存中
- 此时线程A修改数据库将车站余票数量=15
- 此时缓存中的车站余票数量为16,数据库中的车站余票数量为15,导致了缓存与数据库中的数据不一致
先修改数据库,后删除缓存
- 假如此时存在线程A和线程B同时请求车站余票数量,此时车站余票数量为16
- 线程A请求数据库将车站余票数量改为15
- 在线程A修改数据库时,线程B请求车站余票数量,因为此时缓存中没有数据,因此线程B会请求数据库读到车站余票数量为16
- 线程A修改余票数量完毕,此时数据库中车站余票数量为15,并且删除缓存中的数据
- 线程B将请求到的车站余票数量为16的数据写入到缓存中
- 此时数据库中的车票余量数据与缓存中的数据不一致
对比
- 无论是先修改数据库后删除缓存,还是先删除缓存后修改数据库,
**都可能**
会导致数据库与缓存数据不一致的情况,但是两种情况的发生概率不同。 - 先删缓存后修改数据库的情况下,只需要满足在
**删除缓存到数据库修改成功的时间段内存在查询**
,就会出现数据库和缓存数据不一致的情况。 - 先修改数据库后删除缓存的情况下,需要满足 在修改数据库时,
**缓存中没有对应的数据**
,并且需要在**数据库成功修改前查询数据**
,在删除缓存后添加缓存 - 先删除缓存后修改数据库出现问题的概率很大,而先修改数据库后删除缓存的概率远小于前者,因为数据库的操作速率要远远慢于缓存
- 因此在缓存更新的场景中,使用先修改数据库后删除缓存的方式保证数据的一致性
存在的问题
- 在先修改数据库,后删除缓存的模式中,如果
**删除缓存失败**
,则也会导致数据的不一致性 - 对于某些热点数据,删除缓存的方式不一定合适,可能会导致
**缓存击穿**
问题,如果有大量的数据修改删除缓存,可能会导致**缓存雪崩**
问题 - 为了提高系统吞吐量,通常会将
**库存数据在Redis**
中,从而应对**读多写少**
的场景,因此**不能直接删除**
。 - 但直接修改也可能会存在问题,当修改了Redis的数据后,发生
**异常事务回滚**
,但是Redis中的数据并不会随之回滚
最终一致性方案
- 针对于
**删除缓存失败**
的问题,可以引入**MQ**
,通过**消息重试**
来保证缓存删除成功 - 针对于操作Redis库存不能回滚的问题,可以通过引入
**Cannl**
,通过监听Binlog日志,保证在事务提交后才会扣减相关库存 - 因此最终操作缓存的链路变为下图,这种方案因为引入了Canal和MQ,整体处理Redis的链路变长,因此
**仍然会存在短暂的数据不一致**
,但能够保证数据库和缓存的**最终一致性**
- 采用Binlog和MQ的处理方式会引入一些新的问题,因为投放到MQ中的消息默认是无序的,且消息可能会被重复消费
- 因此针对不同的场景中,需要使用不同的方式进行处理,如果是采用库存扣减的方式,则不关心消息的顺序,只关心消息的重复消费问题,因此需要做好幂等处理。
- 如果是采用数据更新的方式,则需要保证新的消息不能被旧的消息所覆盖,因此需要保证消息的顺序性。可以通过顺序队列,或是通过增加版本号的方式,保证只会消费新的消息。
Canal使用
Binlog日志
- Canal的本质是作为MySQL的
**slave节点**
接受Binlog日志,因此需要配置MySQL节点Binlog的相关配置 - Binlog有三种格式,statement、row、mixed,另外两种都可能会存在数据丢失的问题,只有
**row格式能够保证数据不会丢失**
,但是会占用较大的空间,因此在Canal的使用过程中一般使用row格式
[mysqld]
log-bin=mysql-bin # 开启 binlog
binlog-format=ROW # 选择 ROW 模式
server_id=1 # 配置 MySQL replaction 需要定义,不要和 canal 的 slaveId 重复
Canal配置
- Canal有TCP模式和MQ模式,在生产环境中会使用MQ模式
- 在 conf/canal.properties 中,配置RocketMQ相关信息
rocketmq.producer.group = test
rocketmq.enable.message.trace = false
rocketmq.customized.trace.topic =
rocketmq.namespace =
rocketmq.namesrv.addr = common-rocketmq-dev.magestack.cn:9876
rocketmq.retry.times.when.send.failed = 0
rocketmq.vip.channel.enabled = false
rocketmq.tag =
canal.aliyun.accessKey =
canal.aliyun.secretKey =
canal.aliyun.uid=
canal.mq.flatMessage = true
canal.mq.canalBatchSize = 50
canal.mq.canalGetTimeout = 100
# Set this value to "cloud", if you want open message trace feature in aliyun.
canal.mq.accessChannel = local
canal.mq.database.hash = true
canal.mq.send.thread.size = 30
canal.mq.build.thread.size = 8
- 在conf/example/instance.properties 中配置MySQL实例和要处理的表的相关信息
canal.instance.dbUsername=canal
canal.instance.dbPassword=canal
canal.instance.connectionCharset = UTF-8
# enable druid Decrypt database password
canal.instance.enableDruid=false
#canal.instance.pwdPublicKey=MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBALK4BUxdDltRRE5/zXpVEVPUgunvscYFtEip3pmLlhrWpacX7y7GCMo2/JM6LeHmiiNdH1FWgGCpUfircSwlWKUCAwEAAQ==
# table regex
canal.instance.filter.regex=^(12306|12306_order_[0-9]+|12306_ticket)\\.(t_seat|t_order_([0-9]+|1[0-6]))$
# table black regex
canal.instance.filter.black.regex=mysql\\.slave_.*
# table field filter(format: schema1.tableName1:field1/field2,schema2.tableName2:field1/field2)
#canal.instance.filter.field=test1.t_product:id/subject/keywords,test2.t_company:id/name/contact/ch
# table field black filter(format: schema1.tableName1:field1/field2,schema2.tableName2:field1/field2)
#canal.instance.filter.black.field=test1.t_product:subject/product_image,test2.t_company:id/name/contact/ch
# mq config
canal.mq.topic=index12306_canal_ticket-service_common-sync_topic-justin
# dynamic topic route by schema or table regex
#canal.mq.dynamicTopic=mytest1.user,topic2:mytest2\\..*,.*\\..*
canal.mq.partition=0
默认的JSON消息体格式
{
"data":[
{
"id":"1684913289981231104",
"train_id":"1",
"carriage_number":"01",
"seat_number":"02C",
"seat_type":"0",
"start_station":"北京南",
"end_station":"南京南",
"price":"186400",
"seat_status":"1",
"create_time":"2023-07-28 21:06:47",
"update_time":"2023-08-17 15:41:00",
"del_flag":"0"
}
],
"database":"12306",
"es":1692516745000,
"id":2,
"isDdl":false,
"mysqlType":{
"id":"bigint(20) unsigned",
"train_id":"bigint(20)",
"carriage_number":"varchar(64)",
"seat_number":"varchar(64)",
"seat_type":"int(3)",
"start_station":"varchar(256)",
"end_station":"varchar(256)",
"price":"int(11)",
"seat_status":"int(3)",
"create_time":"datetime",
"update_time":"datetime",
"del_flag":"tinyint(1)"
},
"old":[
{
"seat_status":"0"
}
],
"pkNames":[
"id"
],
"sql":"",
"sqlType":{
"id":-5,
"train_id":-5,
"carriage_number":12,
"seat_number":12,
"seat_type":4,
"start_station":12,
"end_station":12,
"price":4,
"seat_status":4,
"create_time":93,
"update_time":93,
"del_flag":-6
},
"table":"t_seat",
"ts":1692516746008,
"type":"UPDATE"
}
package org.opengoofy.index12306.biz.ticketservice.mq.event;
import lombok.Data;
import java.util.List;
import java.util.Map;
/**
* Canal Binlog 监听触发时间
*
* @公众号:马丁玩编程,回复:加群,添加马哥微信(备注:12306)获取项目资料
*/
@Data
public class CanalBinlogEvent {
/**
* 变更数据
*/
private List<Map<String, Object>> data;
/**
* 数据库名称
*/
private String database;
/**
* es 是指 Mysql Binlog 里原始的时间戳,也就是数据原始变更的时间
* Canal 的消费延迟 = ts - es
*/
private Long es;
/**
* 递增 ID,从 1 开始
*/
private Long id;
/**
* 当前变更是否是 DDL 语句
*/
private Boolean isDdl;
/**
* 表结构字段类型
*/
private Map<String, Object> mysqlType;
/**
* UPDATE 模式下旧数据
*/
private List<Map<String, Object>> old;
/**
* 主键名称
*/
private List<String> pkNames;
/**
* SQL 语句
*/
private String sql;
/**
* SQL 类型
*/
private Map<String, Object> sqlType;
/**
* 表名
*/
private String table;
/**
* ts 是指 Canal 收到这个 Binlog,构造为自己协议对象的时间
* 应用消费的延迟 = now - ts
*/
private Long ts;
/**
* INSERT(新增)、UPDATE(更新)、DELETE(删除)等等
*/
private String type;
}
SpringBoot程序监听MQ消息
- 在数据库表中通常会需要监听很多的字段改动,因此可以采用策略模式,针对于不同的表数据变更,采用不同的处理方式
- 这里监听的topic是在Canal中设置生产者发送的topic,因此消费者处会接收到所有的消息,再通过表名找到对应的策略
- Canal的消息发送通常不是一条变更语句发送一次,而是多次变更数据组合在一起进行批量发送
@Slf4j
@Component
@RequiredArgsConstructor
@RocketMQMessageListener(
topic = TicketRocketMQConstant.CANAL_COMMON_SYNC_TOPIC_KEY,
consumerGroup = TicketRocketMQConstant.CANAL_COMMON_SYNC_CG_KEY
)
public class CanalCommonSyncBinlogConsumer implements RocketMQListener<CanalBinlogEvent> {
private final AbstractStrategyChoose abstractStrategyChoose;
@Value("${ticket.availability.cache-update.type:}")
private String ticketAvailabilityCacheUpdateType;
@Override
public void onMessage(CanalBinlogEvent message) {
// 如果是 DDL 返回
// 如果不是 UPDATE 类型数据变更返回
// 如果没有开启 binlog 数据同步模型返回
if (message.getIsDdl()
|| CollUtil.isEmpty(message.getOld())
|| !Objects.equals("UPDATE", message.getType())
|| !StrUtil.equals(ticketAvailabilityCacheUpdateType, "binlog")) {
return;
}
// 通过策略模式进行不同 Binlog 变更类型的监听,比如说订单和座位两个表就分别有两个处理类
abstractStrategyChoose.chooseAndExecute(
message.getTable(),
message,
CanalExecuteStrategyMarkEnum.isPatternMatch(message.getTable())
);
}
}
策略模式转发
座位状态变更
订单状态变为取消
- 因为Canal会发送Order表所有的变更数据,但这里只关心
**订单状态**
变更,并且是要**状态变更为30(已关闭)**
的订单记录信息,因此需要先对Binlog数据进行过滤 - 在过滤完毕后,则需要将已关闭的每一个订单进行相关数据的回滚,包括数据库中的座位状态,缓存中的余票数量,以及在购票过程的令牌桶。
- 在订单状态处只需要处理数据库
/**
* 订单关闭或取消后置处理组件
*
* @公众号:马丁玩编程,回复:加群,添加马哥微信(备注:12306)获取项目资料
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class OrderCloseCacheAndTokenUpdateHandler implements AbstractExecuteStrategy<CanalBinlogEvent, Void> {
private final TicketOrderRemoteService ticketOrderRemoteService;
private final SeatService seatService;
private final TicketAvailabilityTokenBucket ticketAvailabilityTokenBucket;
@Override
public void execute(CanalBinlogEvent message) {
List<Map<String, Object>> messageDataList = message.getData().stream()
.filter(each -> each.get("status") != null)
.filter(each -> Objects.equals(each.get("status"), "30"))
.toList();
if (CollUtil.isEmpty(messageDataList)) {
return;
}
for (Map<String, Object> each : messageDataList) {
Result<TicketOrderDetailRespDTO> orderDetailResult = ticketOrderRemoteService.queryTicketOrderByOrderSn(each.get("order_sn").toString());
TicketOrderDetailRespDTO orderDetailResultData = orderDetailResult.getData();
if (orderDetailResult.isSuccess() && orderDetailResultData != null) {
String trainId = String.valueOf(orderDetailResultData.getTrainId());
List<TicketOrderPassengerDetailRespDTO> passengerDetails = orderDetailResultData.getPassengerDetails();
seatService.unlock(trainId, orderDetailResultData.getDeparture(), orderDetailResultData.getArrival(), BeanUtil.convert(passengerDetails, TrainPurchaseTicketRespDTO.class));
ticketAvailabilityTokenBucket.rollbackInBucket(orderDetailResultData);
}
}
}
@Override
public String mark() {
return CanalExecuteStrategyMarkEnum.T_ORDER.getActualTable();
}
@Override
public String patternMatchMark() {
return CanalExecuteStrategyMarkEnum.T_ORDER.getPatternMatchTable();
}
}