RocketMQ分布式事务 -> 最终一致性实现

文章目录

  • 前言
  • 事务消息
  • 场景代码示例
    • 订单服务
      • 事务日志表
      • TransactionMQProducer
      • OrderTransactionListener
      • 业务实现类
      • 调用
      • 总结
    • 积分服务
      • 积分记录表
      • 消费者启动
      • 消费者监听器
      • 增加积分
      • 幂等性消费
      • 消费异常

前言

· 分布式事务的问题常在业务与面试中被提及, 近日摸鱼看到这篇文章, 阐述的非常通俗易懂, 固持久化下来我博客中, 也以便于我二刷
转载源: 基于RocketMQ分布式事务 - 完整示例

  • 本文代码不只是简单的demo,考虑到一些异常情况、幂等性消费和死信队列等情况,尽量向可靠业务场景靠拢。

事务消息

在这里,笔者不想使用大量的文字赘述 RocketMQ事务消息的原理,我们只需要搞明白两个概念。

  • Half Message半消息

暂时不能被 Consumer消费的消息。Producer已经把消息发送到 Broker端,但是此消息的状态被标记为不能投递,处于这种状态下的消息称为半消息。事实上,该状态下的消息会被放在一个叫做 RMQ_SYS_TRANS_HALF_TOPIC的主题下

当 Producer端对它二次确认后,也就是 Commit之后,Consumer端才可以消费到;那么如果是Rollback,该消息则会被删除,永远不会被消费到。

  • 事务状态回查

我们想,可能会因为网络原因、应用问题等,导致Producer端一直没有对这个半消息进行确认,那么这时候 Broker服务器会定时扫描这些半消息,主动找Producer端查询该消息的状态。

当然,什么时候去扫描,包含扫描几次,我们都可以配置,在后文我们再细说。

简而言之,RocketMQ事务消息的实现原理就是基于两阶段提交和事务状态回查,来决定消息最终是提交还是回滚的。

在本文,我们的代码就以 订单服务、积分服务 为例。结合上文来看,整体流程如下:

在这里插入图片描述

场景代码示例

订单服务

在订单服务中,我们接收前端的请求创建订单,保存相关数据到本地数据库。

事务日志表

在订单服务中,除了有一张订单表之外,还需要一个事务日志表。 它的定义如下:

CREATE TABLE `transaction_log` (
  `id` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '事务ID',
  `business` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '业务标识',
  `foreign_key` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '对应业务表中的主键',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;

这张表专门作用于事务状态回查。当提交业务数据时,此表也插入一条数据,它们共处一个本地事务中。通过事务ID查询该表,如果返回记录,则证明本地事务已提交;如果未返回记录,则本地事务可能是未知状态或者是回滚状态。

TransactionMQProducer

我们知道,通过 RocketMQ发送消息,需先创建一个消息发送者。值得注意的是,如果发送事务消息,在这里我们的创建的实例必须是 TransactionMQProducer

@Component
public class TransactionProducer {
	
    private String producerGroup = "order_trans_group";
    private TransactionMQProducer producer;
 
    //用于执行本地事务和事务状态回查的监听器
    @Autowired
    OrderTransactionListener orderTransactionListener;
    //执行任务的线程池
    ThreadPoolExecutor executor = new ThreadPoolExecutor(5, 10, 60,
            TimeUnit.SECONDS, new ArrayBlockingQueue<>(50));
            
    @PostConstruct
    public void init(){
        producer = new TransactionMQProducer(producerGroup);
        producer.setNamesrvAddr("127.0.0.1:9876");
        producer.setSendMsgTimeout(Integer.MAX_VALUE);
        producer.setExecutorService(executor);
        producer.setTransactionListener(orderTransactionListener);
        this.start();
    }
    private void start(){
        try {
            this.producer.start();
        } catch (MQClientException e) {
            e.printStackTrace();
        }
    }
    //事务消息发送 
    public TransactionSendResult send(String data, String topic) throws MQClientException {
        Message message = new Message(topic,data.getBytes());
        return this.producer.sendMessageInTransaction(message, null);
    }
}

上面的代码中,主要就是创建事务消息的发送者。在这里,我们重点关注 OrderTransactionListener,它负责执行本地事务和事务状态回查。

OrderTransactionListener

@Component
public class OrderTransactionListener implements TransactionListener {
 
    @Autowired
    OrderService orderService;
 
    @Autowired
    TransactionLogService transactionLogService;
 
    Logger logger = LoggerFactory.getLogger(this.getClass());
 
    @Override
    public LocalTransactionState executeLocalTransaction(Message message, Object o) {
        logger.info("开始执行本地事务....");
        LocalTransactionState state;
        try{
            String body = new String(message.getBody());
            OrderDTO order = JSONObject.parseObject(body, OrderDTO.class);
            orderService.createOrder(order,message.getTransactionId());
            state = LocalTransactionState.COMMIT_MESSAGE;
            logger.info("本地事务已提交。{}",message.getTransactionId());
        }catch (Exception e){
            logger.info("执行本地事务失败。{}",e);
            state = LocalTransactionState.ROLLBACK_MESSAGE;
        }
        return state;
    }
 
    @Override
    public LocalTransactionState checkLocalTransaction(MessageExt messageExt) {
        logger.info("开始回查本地事务状态。{}",messageExt.getTransactionId());
        LocalTransactionState state;
        String transactionId = messageExt.getTransactionId();
        if (transactionLogService.get(transactionId)>0){
            state = LocalTransactionState.COMMIT_MESSAGE;
        }else {
            state = LocalTransactionState.UNKNOW;
        }
        logger.info("结束本地事务状态查询:{}",state);
        return state;
    }
}

在通过 producer.sendMessageInTransaction发送事务消息后,如果消息发送成功,就会调用到这里的executeLocalTransaction方法,来执行本地事务。在这里,它会完成订单数据和事务日志的插入。

该方法返回值 LocalTransactionState 代表本地事务状态,它是一个枚举类。

public enum LocalTransactionState {
    //提交事务消息,消费者可以看到此消息
    COMMIT_MESSAGE,
    //回滚事务消息,消费者不会看到此消息
    ROLLBACK_MESSAGE,
    //事务未知状态,需要调用事务状态回查,确定此消息是提交还是回滚
    UNKNOW;
}

那么, checkLocalTransaction 方法就是用于事务状态查询。在这里,我们通过事务ID查询transaction_log这张表,如果可以查询到结果,就提交事务消息;如果没有查询到,就返回未知状态。

注意,这里还涉及到另外一个问题。如果是返回未知状态,RocketMQ Broker服务器会以1分钟的间隔时间不断回查,直至达到事务回查最大检测数,如果超过这个数字还未查询到事务状态,则回滚此消息。

当然,事务回查的频率和最大次数,我们都可以配置。在 Broker 端,可以通过这样来配置它:

brokerConfig.setTransactionCheckInterval(10000); //回查频率10秒一次
brokerConfig.setTransactionCheckMax(3);  //最大检测次数为3

业务实现类

@Service
public class OrderServiceImpl implements OrderService {
    @Autowired
    OrderMapper orderMapper;
    @Autowired
    TransactionLogMapper transactionLogMapper;
    @Autowired
    TransactionProducer producer;
 
    Snowflake snowflake = new Snowflake(1,1);
    Logger logger = LoggerFactory.getLogger(this.getClass());
 
    //执行本地事务时调用,将订单数据和事务日志写入本地数据库
    @Transactional
    @Override
    public void createOrder(OrderDTO orderDTO,String transactionId){
 
        //1.创建订单
        Order order = new Order();
        BeanUtils.copyProperties(orderDTO,order);
        orderMapper.createOrder(order);
 
        //2.写入事务日志
        TransactionLog log = new TransactionLog();
        log.setId(transactionId);
        log.setBusiness("order");
        log.setForeignKey(String.valueOf(order.getId()));
        transactionLogMapper.insert(log);
 
        logger.info("订单创建完成。{}",orderDTO);
    }
 
    //前端调用,只用于向RocketMQ发送事务消息
    @Override
    public void createOrder(OrderDTO order) throws MQClientException {
        order.setId(snowflake.nextId());
        order.setOrderNo(snowflake.nextIdStr());
        producer.send(JSON.toJSONString(order),"order");
    }
}

在订单业务服务类中,我们有两个方法。一个用于向RocketMQ发送事务消息,一个用于真正的业务数据落库。

至于为什么这样做,其实有一些原因的,我们后面再说。

调用

@RestController
public class OrderController {
 
    @Autowired
    OrderService orderService;
    Logger logger = LoggerFactory.getLogger(this.getClass());
 
    @PostMapping("/create_order")
    public void createOrder(@RequestBody OrderDTO order) throws MQClientException {
        logger.info("接收到订单数据:{}",order.getCommodityCode());
        orderService.createOrder(order);
    }
}

总结

目前已经完成了订单服务的业务逻辑。我们总结流程如下:
在这里插入图片描述
考虑到异常情况,这里的要点如下:

  • 第一次调用createOrder,发送事务消息。如果发送失败,导致报错,则将异常返回,此时不会涉及到任何数据安全。
  • 如果事务消息发送成功,但在执行本地事务时发生异常,那么订单数据和事务日志都不会被保存,因为它们是一个本地事务中。
  • 如果执行完本地事务,但未能及时的返回本地事务状态或者返回了未知状态。那么,会由Broker定时回查事务状态,然后根据事务日志表,就可以判断订单是否已完成,并写入到数据库。

基于这些要素,我们可以说,已经保证了订单服务和事务消息的一致性。那么,接下来就是积分服务如何正确的消费订单数据并完成相应的业务操作。

积分服务

在积分服务中,主要就是消费订单数据,然后根据订单内容,给相应用户增加积分。

积分记录表

CREATE TABLE `t_points` (
  `id` bigint(16) NOT NULL COMMENT '主键',
  `user_id` bigint(16) NOT NULL COMMENT '用户id',
  `order_no` bigint(16) NOT NULL COMMENT '订单编号',
  `points` int(4) NOT NULL COMMENT '积分',
  `remarks` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '备注',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin;

在这里,我们重点关注order_no字段,它是实现幂等消费的一种选择。

消费者启动

@Component
public class Consumer {
 
    String consumerGroup = "consumer-group";
    DefaultMQPushConsumer consumer;
 
    @Autowired
    OrderListener orderListener;
    
    @PostConstruct
    public void init() throws MQClientException {
        consumer = new DefaultMQPushConsumer(consumerGroup);
        consumer.setNamesrvAddr("127.0.0.1:9876");
        consumer.subscribe("order","*");
        consumer.registerMessageListener(orderListener);
        consumer.start();
    }
}

启动一个消费者比较简单,我们指定要消费的 topic 和监听器就好了。

消费者监听器

@Component
public class OrderListener implements MessageListenerConcurrently {
 
    @Autowired
    PointsService pointsService;
    Logger logger = LoggerFactory.getLogger(this.getClass());
 
    @Override
    public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> list, ConsumeConcurrentlyContext context) {
        logger.info("消费者线程监听到消息。");
        try{
            for (MessageExt message:list) {
                logger.info("开始处理订单数据,准备增加积分....");
                OrderDTO order  = JSONObject.parseObject(message.getBody(), OrderDTO.class);
                pointsService.increasePoints(order);
            }
            return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
        }catch (Exception e){
            logger.error("处理消费者数据发生异常。{}",e);
            return ConsumeConcurrentlyStatus.RECONSUME_LATER;
        }
    }
}

监听到消息之后,调用业务服务类处理即可。处理完成则返回CONSUME_SUCCESS以提交,处理失败则返回RECONSUME_LATER来重试。

增加积分

在这里,主要就是对积分数据入库。但注意,入库之前需要先做判断,来达到幂等性消费。

@Service
public class PointsServiceImpl implements PointsService {
 
    @Autowired
    PointsMapper pointsMapper;
 
    Snowflake snowflake = new Snowflake(1,1);
    Logger logger = LoggerFactory.getLogger(this.getClass());
 
    @Override
    public void increasePoints(OrderDTO order) {
		
        //入库之前先查询,实现幂等
        if (pointsMapper.getByOrderNo(order.getOrderNo())>0){
            logger.info("积分添加完成,订单已处理。{}",order.getOrderNo());
        }else{
            Points points = new Points();
            points.setId(snowflake.nextId());
            points.setUserId(order.getUserId());
            points.setOrderNo(order.getOrderNo());
            Double amount = order.getAmount();
            points.setPoints(amount.intValue()*10);
            points.setRemarks("商品消费共【"+order.getAmount()+"】元,获得积分"+points.getPoints());
            pointsMapper.insert(points);
            logger.info("已为订单号码{}增加积分。",points.getOrderNo());
        }
    }
}

幂等性消费

实现幂等性消费的方式有很多种,具体怎么做,根据自己的情况来看。

比如,在本例中,我们直接将订单号和积分记录绑定在同一个表中,在增加积分之前,就可以先查询此订单是否已处理过。

或者,我们也可以额外创建一张表,来记录订单的处理情况。

再者,也可以将这些信息直接放到redis缓存里,在入库之前先查询缓存。

不管以哪种方式来做,总的思路就是在执行业务前,必须先查询该消息是否被处理过。那么这里就涉及到一个数据主键问题,在这个例子中,我们以订单号为主键,也可以用事务ID作主键,如果是普通消息的话,我们也可以创建唯一的消息ID作为主键。

消费异常

我们知道,当消费者处理失败后会返回 RECONSUME_LATER ,让消息来重试,默认最多重试16次。

那,如果真的由于特殊原因,消息一直不能被正确处理,那怎么办 ?

我们考虑两种方式来解决这个问题。

第一,在代码中设置消息重试次数,如果达到指定次数,就发邮件或者短信通知业务方人工介入处理。

@Component
public class OrderListener implements MessageListenerConcurrently {
 
    Logger logger = LoggerFactory.getLogger(this.getClass());
 
    @Override
    public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> list, ConsumeConcurrentlyContext context) {
        logger.info("消费者线程监听到消息。");
        for (MessageExt message:list) {
            if (!processor(message)){
                return ConsumeConcurrentlyStatus.RECONSUME_LATER;
            }
        }
        return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
    }
 
    /**
     * 消息处理,第3次处理失败后,发送邮件通知人工介入
     * @param message
     * @return
     */
    private boolean processor(MessageExt message){
        String body = new String(message.getBody());
        try {
            logger.info("消息处理....{}",body);
            int k = 1/0;
            return true;
        }catch (Exception e){
            if(message.getReconsumeTimes()>=3){
                logger.error("消息重试已达最大次数,将通知业务人员排查问题。{}",message.getMsgId());
                sendMail(message);
                return true;
            }
            return false;
        }
    }
}

第二,等待消息重试最大次数后,进入死信队列。

消息重试最大次数默认是16次,我们也可以在消费者端设置这个次数。

consumer.setMaxReconsumeTimes(3);//设置消息重试最大次数

死信队列的主题名称是 %DLQ% + 消费者组名称,比如在订单数据中,我们设置了消费者组名:

String consumerGroup = "order-consumer-group";

那么这个消费者,对应的死信队列主题名称就是%DLQ%order-consumer-group

在这里插入图片描述

如上图,我们还需要点击TOPIC配置,来修改里面的 perm 属性,改为 6 即可。在这里插入图片描述

最后就可以通过程序代码监听这个主题,来通知人工介入处理或者直接在控制台查看处理了。通过幂等性消费和对死信消息的处理,基本上就能保证消息一定会被处理。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/43218.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

认识主被动无人机遥感数据、预处理无人机遥感数据、定量估算农林植被关键性状、期刊论文插图精细制作与Appdesigner应用开发

目录 第一章、认识主被动无人机遥感数据 第二章、预处理无人机遥感数据 第三章、定量估算农林植被关键性状 第四章、期刊论文插图精细制作与Appdesigner应用开发 更多推荐 遥感技术作为一种空间大数据手段&#xff0c;能够从多时、多维、多地等角度&#xff0c;获取大量的…

Spring 能解决所有循环依赖吗?

以下内容基于 Spring6.0.4。 看了上篇文章的小伙伴&#xff0c;对于 Spring 解决循环依赖的思路应该有一个大致了解了&#xff0c;今天我们再来看一看&#xff0c;按照上篇文章介绍的思路&#xff0c;有哪些循环依赖 Spring 处理不了。 严格来说&#xff0c;其实也不是解决不了…

PoseiSwap 即将开启 POSE 单币质押,治理体系将全面运行

PoseiSwap 是目前行业首个将支持 RWA 资产交易的 DEX&#xff0c;其构建在 Nautilus Chain 上&#xff0c;并通过模块化的形式单独构建了 zk-Rollup 应用层&#xff0c;具备并行化运行、隐私特性&#xff0c;并从 Cosmos、Celestia、Eclipse 等 Layer0 设施中获得高度可组合性、…

MySQL 中NULL和空值的区别

MySQL 中NULL和空值的区别&#xff1f; 简介NULL也就是在字段中存储NULL值&#xff0c;空值也就是字段中存储空字符(’’)。区别 1、空值不占空间&#xff0c;NULL值占空间。当字段不为NULL时&#xff0c;也可以插入空值。 2、当使用 IS NOT NULL 或者 IS NULL 时&#xff0…

JDK、JRE、JVM三者之间的关系

总结 JDK包含JRE&#xff0c;JRE包含JVM。 JDK (Java Development Kit)----Java开发工具包&#xff0c;用于Java程序的开发。 JRE (Java Runtime Environment)----Java运行时环境&#xff0c;只能运行.class文件&#xff0c;不能编译。 JVM (Java Virtual Machine)----Java虚拟…

21matlab数据分析牛顿插值(matlab程序)

1.简述 一、牛顿插值法原理 1.牛顿插值多项式   定义牛顿插值多项式为&#xff1a; N n ( x ) a 0 a 1 ( x − x 0 ) a 2 ( x − x 0 ) ( x − x 1 ) ⋯ a n ( x − x 0 ) ( x − x 1 ) ⋯ ( x − x n − 1 ) N_n\left(x\right)a_0a_1\left(x-x_0\right)a_2\left(x-x_0\…

AI时代带来的图片造假危机,该如何解决

一、前言 当今&#xff0c;图片造假问题非常泛滥&#xff0c;已经成为现代社会中一个严峻的问题。随着AI技术不断的发展&#xff0c;人们可以轻松地通过图像编辑和AI智能生成来篡改和伪造图片&#xff0c;使其看起来真实而难以辨别&#xff0c;之前就看到过一对硕士夫妻为了骗…

子网划分路由网卡安全组

1."IPv4 CIDR" "IPv4 CIDR" 是与互联网协议地址&#xff08;IP address&#xff09;和网络的子网划分有关的概念。 - "IPv4" 代表 "Internet Protocol version 4"&#xff0c;也就是第四版互联网协议&#xff0c;这是互联网上最广泛使…

谷歌插件(Chrome扩展) “Service Worker (无效)” 解决方法

问题描述&#xff1a; 写 background 文件的时候报错了&#xff0c;说 Service Worker 设置的 background 无效。 解决&#xff08;检查&#xff09;方法&#xff1a; 检查配置文件&#xff08;manifest.json&#xff09; 中的 manifest_version 是否为 3。 background 中的…

办公软件ppt的制作

毕业找工作太难了&#xff0c;赶紧多学点什么东西吧&#xff0c;今天开始办公软件ppt的制作学习。 本文以WPS作为默认办公软件&#xff0c;问为什么不是PowerPoint&#xff0c;问就是没钱买不起&#xff0c;绝对不是不会破解的原因。 一.认识软件 在快捷工具栏中顾名思义就是一…

6.4.2 互联网路由探测与发现基本原理

6.4.2 互联网路由探测与发现基本原理 一、路由探测与发现背后的协议工作过程 我们主要使用三种方法来实现路由探测与发现 基于IP的记录路由选项功能&#xff08;RR&#xff09;和ICMP功能的路由探测&#xff0c;典型的例子就是带有参数“r”的ping命令&#xff0c;即ping -r …

postgresql源码学习(58)—— 删除or重命名WAL日志?这是一个问题

最近因为WAL日志重命名踩到大坑&#xff0c;一直很纠结WAL日志在什么情况下会被删除&#xff0c;什么情况下会被重命名&#xff0c;钻研一下这个部分。 一、 准备工作 1. 主要函数调用栈 首先无用WAL日志的清理发生检查点执行时&#xff0c;检查点执行核心函数为CreateCheckPo…

华为OD机试真题 Java 实现【经典屏保】【2023 B卷 100分】,附详细解题思路

目录 专栏导读一、题目描述二、输入描述三、输出描述四、补充说明四、解题思路五、Java算法源码六、效果展示1、输入2、输出3、再输入4、再输出 华为OD机试 2023B卷题库疯狂收录中&#xff0c;刷题点这里 专栏导读 本专栏收录于《华为OD机试&#xff08;JAVA&#xff09;真题&…

Mysql基础(下)之函数,约束,多表查询,事务

&#x1f442; 回到夏天&#xff08;我多想回到那个夏天&#xff09; - 傲七爷/小田音乐社 - 单曲 - 网易云音乐 截图自 劈里啪啦 -- 黑马Mysql&#xff0c;仅学习使用 &#x1f447;原地址 47. 基础-多表查询-表子查询_哔哩哔哩_bilibili 目录 &#x1f982;函数 &#x1f3…

使用Plist编辑器——简单入门指南

本指南将介绍如何使用Plist编辑器。您将学习如何打开、编辑和保存plist文件&#xff0c;并了解plist文件的基本结构和用途。跟随这个简单的入门指南&#xff0c;您将掌握如何使用Plist编辑器轻松管理您的plist文件。 plist文件是一种常见的配置文件格式&#xff0c;用于存储应…

docker - prometheus+grafana监控与集成到spring boot 服务

一、Prometheus 介绍 1.数据收集器&#xff0c;它以配置的时间间隔定期通过HTTP提取指标数据。 2.一个时间序列数据库&#xff0c;用于存储所有指标数据。 3.一个简单的用户界面&#xff0c;您可以在其中可视化&#xff0c;查询和监视所有指标。二、Grafana 介绍 Grafana 是一…

Kubernetes对象深入学习之四:对象属性编码实战

欢迎访问我的GitHub 这里分类和汇总了欣宸的全部原创(含配套源码)&#xff1a;https://github.com/zq2599/blog_demos 本篇概览 本文是《Kubernetes对象深入学习》系列的第四篇&#xff0c;前面咱们读源码和文档&#xff0c;从理论上学习了kubernetes的对象相关的知识&#xff…

spring复习:(50)@Configuration注解配置的singleton的bean是什么时候被创建出来并缓存到容器的?

一、主类&#xff1a; 二、配置类&#xff1a; 三、singleton bean的创建流程 运行到context.refresh(); 进入refresh方法&#xff1a; 向下运行到红线位置时&#xff1a; 会实例化所有的singleton bean.进入finisheBeanFactoryInitialization方法&#xff1a; 向下拖动代…

(双指针) 剑指 Offer 57 - II. 和为s的连续正数序列 ——【Leetcode每日一题】

❓ 剑指 Offer 57 - II. 和为s的连续正数序列 难度&#xff1a;简单 输入一个正整数 target &#xff0c;输出所有和为 target 的连续正整数序列&#xff08;至少含有两个数&#xff09;。 序列内的数字由小到大排列&#xff0c;不同序列按照首个数字从小到大排列。 示例 1…

【CMU15-445 FALL 2022】Project #1 - Buffer Pool

About 实验官网 Project #1 - Buffer Pool在线评测网站 gradescope Lab Task #1 - Extendible Hash Table 详见——【CMU15-445 FALL 2022】Project #1 - Extendable Hashing 如果链接失效&#xff0c;请查看当前平台我之前发布的文章。 Task #2 - LRU-K Replacement Polic…