Redis分布式锁、Redisson原理

文章目录

    • 简单的分布式锁实现流程
    • Lua脚本介绍
    • Redisson实现分布式锁原理
      • 基本使用
      • 原理
      • 首先是lock加锁逻辑
      • 锁续命逻辑
      • 自旋重试逻辑
      • 释放锁唤醒其他阻塞线程逻辑
    • RedLock红锁
      • 介绍与基本使用
      • 问题
    • 分布式锁性能提升

简单的分布式锁实现流程

最初的版本,使用setnx命令加锁,判断加锁是否成功。–> 执行业务代码 —> 释放锁

问题: 业务代码出异常了就没有释放锁

**优化:**使用try{}finally{}包起来释放锁

**问题:**执行业务代码时服务器宕机了,锁就不会释放了

优化: 加过期时间,和setnx一起,保证操作的原子性

**问题:**业务代码执行耗时超过了锁过期时间,其他进程加锁了,前一个进程业务代码执行释放锁时把其他进程加的锁给释放掉了

**优化:**生成唯一id放value中,释放锁时判断是否相等

**问题:**校验value是否相等与释放锁不是原子性的,可能会出现高并发问题

**优化: ** 锁续命 + lua脚本保证校验value是否相等与释放锁的原子性



Lua脚本介绍

Redis2.6推出脚本功能

使用脚本的好处:

  • 减少网络开销,可以一次执行多条命令
  • 原子操作,保证了多条命令的原子性
  • 代替redis事务功能,redis事务一般不用,官方推荐如果要使用redis的事务功能可以用redis lua替代。

在redis-cli中可以使用EVAL命令对lua脚本进行求值,EVAL命令格式如下:

EVAL script numbers key [key...] arg [arg...]
  • script是一断lua脚本
  • numbers的指定之后的多个参数,其中前面多少个是Key
  • Key 从第三个参数开始算起,表示脚本中用到的哪些Key,这些Key是通过全局变量KEYS数组,用1为基数的访问形式KEYS[1]、KEYS[2]… …
  • arg,这些不是键名参数的附加参数,可以用全局变量ARGV数组访问,ARGV[1],ARGV[2]… …

案例:

127.0.0.1:6379> eval "return {KEYS[1],KEYS[2],ARGV[1],ARGV[2]}" 2 key1 key2 arg1 arg2
1) "key1"
2) "key2"
3) "arg1"
4) "arg2"

java代码案例

// 一个扣减库存的操作,把剩余库存和要减的数量先变为能比较的数字型,然后在进行比较和减法操作

jedis.set("product_stock_10016", "15");  //初始化商品10016的库存
String script = " local count = redis.call('get', KEYS[1]) " +
                " local a = tonumber(count) " +
                " local b = tonumber(ARGV[1]) " +
                " if a >= b then " +
                "   redis.call('set', KEYS[1], a-b) " +
                "   return 1 " +
                " end " +
                " return 0 ";
Object obj = jedis.eval(script, Arrays.asList("product_stock_10016"), Arrays.asList("10"));
System.out.println(obj);



Redisson实现分布式锁原理

基本使用

引入依赖

<!--使用redisson作为分布式锁-->
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.6.5</version>
</dependency>

配置Redisson

@Configuration
public class RedissonConfig {

    @Bean
    public Redisson redissonClient() {
        // 创建配置 指定redis地址及节点信息
        // 我们要在地址前加上redis:// ,SSL连接则需要加上rediss://
        Config config = new Config();
        config.useSingleServer().setAddress("redis://82.156.9.191:6379").setPassword("XXX");
        return (Redisson) Redisson.create(config);
    }

}

业务代码测试

@RunWith(SpringRunner.class)
@SpringBootTest
public class RedissonTest {

    @Autowired
    private Redisson redisson;


    @Test
    public void redisson() {

        String myLock = "my_lock";
        // 1.获取一把锁,只要锁的名字一样,就是同一把锁
        RLock lock = redisson.getLock(myLock);
        // 加锁
        lock.lock();
        try {
            System.out.println("加锁成功,执行业务代码..." + Thread.currentThread().getId());
            Thread.sleep(1000);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            // 释放锁
            lock.unlock();
        }
    }
}



原理

Redisson的核心流程图如下图所示

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-a2fkoGMV-1679679092949)(picture/Redis/Redisson分布式锁原理.png)]

刚开始会有两个线程去调用lock()方法加锁,但是只会有一个线程加锁成功,如果线程1加锁成功了那么就会另外开启一个线程,默认每隔10s去检查锁是否还存在,如果还存在则重新设置锁过期时间为30秒。默认锁的过期时间是30秒,看门狗间隔时间是 key过期时间的1/3

如果线程2没有加锁成功,那么它会进行自旋,阻塞一段时间不断去重试获取锁

当线程1执行完后,调用unlock()方法释放锁后,会唤醒其他正在等待锁的线程。



首先是lock加锁逻辑

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-iJFdq1rN-1679679092950)(picture/Redis/image-20230325004929875.png)]

接下来点进tryAcquire()方法,再会进入到tryAcquireAsync() —> tryLockInnerAsync()

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1rTUhFl5-1679679092950)(picture/Redis/image-20230325005241899.png)]

tryLockInnerAsync()方法的代码如下,其实就是使用的lua脚本去加锁,

第一段if是判断锁对象是否存在,如果=0就表示不存在,然后就使用hset存一个值

ARGV[2] 也就是 getLockName(threadId) 就是一个uuid+线程id。接下来再指定过期时间

第二段if就是可重入锁的逻辑,给hset最后一个参数加1

最后一行就表示没有加锁成功,把当前锁的过期时间返回

<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
        internalLockLeaseTime = unit.toMillis(leaseTime);

        return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
                  "if (redis.call('exists', KEYS[1]) == 0) then " +
                      "redis.call('hset', KEYS[1], ARGV[2], 1); " +
                      "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                      "return nil; " +
                  "end; " +
                  "if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
                      "redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
                      "redis.call('pexpire', KEYS[1], ARGV[1]); " +
                      "return nil; " +
                  "end; " +
                  "return redis.call('pttl', KEYS[1]);",
                    Collections.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
}

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-5ktXAGiD-1679679092950)(picture/Redis/image-20230325005722390.png)]



锁续命逻辑

当锁添加成功之后才会有锁续命的逻辑,当上面的tryLockInnerAsync()方法尝试加锁之后,方法的返回值是Future对象,然后这里会添加一个监听器,当tryLockInnerAsync()方法执行完有返回之后,如果加锁成功则返回null,加锁失败就返回锁过期时间,所以最终就会调用到scheduleExpirationRenewal方法中去进行锁续命逻辑。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-qAN2ykwO-1679679092950)(picture/Redis/image-20230325011615907.png)]

详细的scheduleExpirationRenewal()代码如下

核心思想是首先等一段时间,延迟执行TimerTask类的run()方法,等待的时间是key过期时间的三分之一,默认是10s。

在run()方法中重新执行lua脚本为key设置默认30s的过期时间。

然后再递归调用自己scheduleExpirationRenewal(),然后又等一段时间执行run()方法

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-sWXdZqCF-1679679092951)(C:/Users/Administrator/AppData/Roaming/Typora/typora-user-images/image-20230325012512934.png)]



自旋重试逻辑

从加锁逻辑中我们可以知道,如果某个线程调用tryLockInnerAsync()方法没有加锁成功,那么返回的是这个锁的过期时间,那么接下来也就回到了加锁部分的第一张图中了

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-AOb7XreB-1679679092951)(picture/Redis/image-20230325011040067.png)]

如果加锁成功是返回null,如果加锁没成功是返回的锁过期时间,所以这里接下来就是一个while(true)死循环,不断尝试获取锁

try {
    while (true) {
        // 每一次都去尝试加锁
        ttl = tryAcquire(leaseTime, unit, threadId);
        // lock acquired
        if (ttl == null) {
            break;
        }

        // getEntry(threadId).getLatch()获取的是一个信号量对象,这个信号量对象在下面 释放锁唤醒其他阻塞线程 中会出现
        // tryAcquire()就是阻塞方法,会阻塞ttl时间
        if (ttl >= 0) {
            getEntry(threadId).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
        } else {
            getEntry(threadId).getLatch().acquire();
        }
    }
} finally {
    unsubscribe(future, threadId);
}



释放锁唤醒其他阻塞线程逻辑

实际上使用的是Redis的发布订阅功能来实现的,首先是在加锁的业务逻辑中,如果加锁失败了 则去订阅一个channel

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-EHeNyzVP-1679681573990)(picture/Redis/image-20230325014422552.png)]

进入到subscribe()方法中就能发现实际上是调用了getChannelName()方法得到一个ChannelName,并订阅它

protected RFuture<RedissonLockEntry> subscribe(long threadId) {
    return PUBSUB.subscribe(getEntryName(), getChannelName(), commandExecutor.getConnectionManager().getSubscribeService());
}

// 也就是说实际上所有加锁失败的线程都会订阅 redisson_lock__channel 名字的channel
String getChannelName() {
    return prefixName("redisson_lock__channel", getName());
}

接下来再就是解锁的逻辑

unlock()方法的业务逻辑如下图所示

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jjuVF6Xx-1679681573991)(picture/Redis/image-20230325014930861.png)]

我们接下来再进入到unlockInnerAsync()方法中

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Ln8brjbZ-1679681573991)(picture/Redis/image-20230325020100687.png)]

我们可以知道只要释放锁了那么就会往redisson_lock__channel 名字的channel 中发送一个 0 的消息。

这里发布了一条消息,接下来就会订阅者这边的代码,走到onMessage()方法中

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1Nf6MszW-1679681573991)(picture/Redis/image-20230325020952156.png)]



RedLock红锁

介绍与基本使用

我们redis生产环境一般都是以集群的方式存在的,而Redis主从数据复制是异步的,那么就会有可能出现master节点加锁成功了,但是在数据同步给从节点之前宕机了,然后从节点重新选举出主节点,这个时候其他线程就又能加锁了。

Redisson中提供了一种红锁的机制来解决这种主从异步复制数据导致的问题,但是RedLock并没有完全解决,它还存在一些缺陷。

RedLock的核心思想是往多个redis节点中同时执行加锁setnx命令,这些节点互相独立存在,没有主从关系,如果超过半数的节点加锁成功才会认为本次加锁成功

基于这种实现原理我们就能发现客户端在进行加锁时效率是变低了,因为需要往多个节点发送命令并且等待执行结果返回;并且还牺牲了一些AP,保证了一些CP,因为多个节点中如果挂了一半,那么就永远加锁不成功了。

RedLock的基本使用

@RestController
public class IndexController {

    @Autowired
    private Redisson redisson1;

    @Autowired
    private Redisson redisson2;
    
    @Autowired
    private Redisson redisson3;

    @RequestMapping("/redlock")
    public String redlock() {
        String lockKey = "product_001";
        
        //这里需要自己实例化不同redis实例的redisson客户端连接
        // 要往ioc容器中注册多个Redisson的bean对象,这些多个redis节点是独立存在的,没有主从关系
        RLock lock1 = redisson1.getLock(lockKey);
        RLock lock2 = redisson2.getLock(lockKey);
        RLock lock3 = redisson3.getLock(lockKey);

        /**
         * 根据多个 RLock 对象构建 RedissonRedLock (最核心的差别就在这里)
         */
        RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
        try {
            /**
             * waitTimeout 尝试获取锁的最大等待时间,超过这个值,则认为获取锁失败
             * leaseTime   锁的持有时间,超过这个时间锁会自动失效(值应设置为大于业务处理的时间,确保在锁有效期内业务能处理完)
             */
            boolean res = redLock.tryLock(10, 30, TimeUnit.SECONDS);
            if (res) {
                //成功获得锁,在这里处理业务
            }
        } catch (Exception e) {
            throw new RuntimeException("lock fail");
        } finally {
            //无论如何, 最后都要解锁
            redLock.unlock();
        }

        return "end";
    }

}

问题

使用Redlock的一些问题:

  • 这些多个redis节点,如果给他们各自也加一个slave节点,那么就有可能出现主从异步复制数据的问题,可以某个或多个master节点中加了lockKey,但是还没有同步给slave节点就宕机了,从节点变为主节点后这时它是没有lockKey的,就可能又会出现其他线程来加锁并超过半数节点加锁成功。

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-cvmAyQEX-1679716392528)(picture/Redis/image-20230325114254588.png)]

  • 如果不给各个redis节点加Slave,那么如果挂了一半数量的节点,那么就永远不会加锁成功

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-HhBzoqOL-1679716392529)(picture/Redis/image-20230325113348788.png)]

  • 如果多加一些redis节点,总不能挂那么多吧,但是影响加锁性能,加一次锁需要往这么多的节点发送命令,还有等待加锁成功超过半数的响应。我们使用Redis就是因为它的高性能,

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ukyEPQyv-1679716392529)(picture/Redis/image-20230325113523313.png)]

  • 其中还有持久化机制可能导致某个节点加锁丢失数据。假如使用aof持久化机制,一般我们采用的是每秒持久化一次。如果这个时候有三个节点,前两个加锁成功后一个加锁失败了,这个时候已经返回给客户端加锁成功,在这一秒内持久化前某个节点宕机了,然后又重启,那么这个时候三个节点中有两个节点没有lockKey。

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-RgOQVrQh-1679716392529)(picture/Redis/image-20230325114110198.png)]


分布式锁性能提升

分布式锁的本质是将多线程并行变为了串行,但是串行就有点违背高并发了。

对于并发要求较高的场景我们通过一些优化手段来提升分布式锁的效率

  • 锁的粒度控制的越小越好,从业务功能上以及锁的代码段都是越少越好

  • 考虑分段锁,比如扣减库存操作,库存有1000,我们之前就是一个lockKey来控制,我们可以进行拆分为10个lockKey,他们各自负责扣减100次。

    但是这其中有很多细节性的问题需要考虑,比如客户端如何决定要使用哪一个lockKey、某个lockKey的库存减完后就不能再被客户端继续拿到使用、某个key库存只有1但是这次客户端要下单了5个,那么还需要使用下一个lockKey去减4…

  • 读多写少的场景使用读写锁

  • 对于类似于单例模式的双重检测机制这一类场景,可以使用tryLock()方法来指定一个最大的等待时长

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

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

相关文章

python+appium+pytest自动化测试-参数化设置

来自APP Android端自动化测试初学者的笔记&#xff0c;写的不对的地方大家多多指教哦。&#xff08;所有内容均以微博V10.11.2版本作为例子&#xff09;在自动化测试用例执行过程中&#xff0c;经常出现执行相同的用例&#xff0c;但传入不同的参数&#xff0c;导致我们需要重复…

腾讯后端开发实习一面(24届)

毫无准备的腾讯一面&#xff0c;最近都在忙比赛去了&#xff0c;突然收到腾讯一面的邮件&#xff0c;直接没准备。。。 总结&#xff0c;除了Vue其他的都挺好&#xff0c;但是腾讯hr为啥Vue面我四个问题&#xff0c;不是面的后端开发吗&#xff0c;好难呀&#xff0c;都只能随…

java基础知识——12.小练习

这篇文章就是主要做一些算法小练习用的 1.求机票价格 题目如下&#xff1a; 需求&#xff1a;机票价格按照淡旺季&#xff0c;头等舱经济舱来收费。输入机票原价&#xff0c;淡旺季&#xff0c;头等舱或经济舱来获取机票价格 规则如下&#xff1a;旺季&#xff08;5-10&…

【 Bean 作⽤域和⽣命周期 】

文章目录引用一、认识 Bean的作用域二、作⽤域定义三、Bean 的 6 种作⽤域四、Bean 原理分析4.1 Bean 执行流程4.2 Bean ⽣命周期引用 从前⾯的课程我们可以看出 Spring 是⽤来读取和存储 Bean&#xff0c;因此在 Spring 中 Bean 是最核⼼的操作资源&#xff0c;所以接下来我们…

你真的会写 git commit message 吗?

作者&#xff1a;明明如月学长&#xff0c; CSDN 博客专家&#xff0c;蚂蚁集团高级 Java 工程师&#xff0c;《性能优化方法论》作者、《解锁大厂思维&#xff1a;剖析《阿里巴巴Java开发手册》》、《再学经典&#xff1a;《EffectiveJava》独家解析》专栏作者。 热门文章推荐…

用GPT-4写代码不用翻墙了?Cursor告诉你:可以~~

目录 一、介绍 二、使用方法 三、其他实例 1.正则表达式 2.自动化测试脚本 3.聊聊技术 一、介绍 Cursor主要功能是根据用户的描述写代码或者进行对话&#xff0c;对话的范围仅限技术方面。优点是不用翻墙、不需要账号。Cursor基于GPT模型&#xff0c;具体什么版本不祥&#…

Powershell脚本自动化登录网站的简单实例,命令行方式实现Http(s)的GET、POST请求

自动化登录网站的流程比较简单&#xff0c;如果不懂 Python、JavaScript、C 等编程语言&#xff0c;又没有安装这些编程语言环境软件&#xff0c;我们还要新的点子&#xff1a;用Windows系统自带的 Powershell 运行自编的脚本来实现。 PowerShell 是一种功能强大的自动化工具&…

大型软件外包项目的开发流程

与中小型软件项目相比&#xff0c;大型软件项目的管理和开发难度更大&#xff0c;需要在开发过程中严格执行软件的项目管理流程&#xff0c;不能有比较明显的问题&#xff0c;有问题也要及时解决。软件项目的开发过程环环相扣&#xff0c;如果在前流程中有明显问题&#xff0c;…

【fluent UDF】保留UDM变量Reserving UDM Variables Using Reserve_User_Memory_Vars-测试分析篇

一、问题背景 因为对Reserve_User_Memory_Vars这个函数比较陌生&#xff0c;在学习官方文档过程中发现难以理解&#xff0c;于是只好亲手在fluent 2022上测试一番。 直接上结论&#xff1a; 此保留函数运行一次后&#xff08;设置GUI界面中的UDM数目第一次加载共享库成功&…

【从零开始学习 UVM】3.8、UVM TestBench架构 —— UVM Agent [uvm_agent]

文章目录 什么是 UVM Agent?所有的Agent Type是什么?如何确定UVM Agent是主动还是被动的?创建UVM代理的步骤一个UVM代理做什么?如何将UVM代理配置为主动或被动?一个UVM Agent例子推荐做法什么是 UVM Agent? 一个 Agent 将Sequencer、Driver和Monitor封装成一个单一实体,…

图像分类的划分数据,dataset和dataloader的实现

目录 1. 介绍 2. 主函数代码 2. utils 模块代码 2.1 划分数据集 2.2 可视化数据集 3. dataset 数据处理 4. collate_fn 5. other 1. 介绍 图像分类一般来说不需要自定义的dataSet&#xff0c;因为pytorch自定义好的ImageFolder可以解决大部分的需求&#xff0c;更多的…

springBoot --- mybatisPlus自动生成代码

mybatisPlus自动生成代码mybatisPlus自动生成代码pom.xmlapplication.yml自动生成代码测试主启动类生成目录结果使用插件 --- 版本要求&#xff1a;3.4.0 版本以上pom.xml更新mybatisplus插件版本mp报错‘AutoGenerator()‘ has private access in ‘com.baomidou.mybatisplus.…

Linux系统中使任务后台挂起不停止的命令

在使用远程SSH连接工具时&#xff0c;退出工具时任务也停止&#xff0c;相当于远程连接工具在系统开启了一个Terminal终端&#xff0c;服务也会随着终端的中断而停止。Linux系统也提供了服务基于后台运行的命令&#xff0c;是独立于终端的进程。 nohup Linux nohup Linux no…

【Python】仅7行代码实现自动化天气报时

文章目录前言一、实现步骤二、请求天气接口1.引入库2.读入数据3.钉钉通知天气预报总结前言 早上出门上班前&#xff0c;我总是忘记查看天气预报&#xff0c;以至于通勤路上下雨来了个措手不及。 回想起来&#xff0c;大部分人早上出门前的行为模式是固定的&#xff0c;那么有…

一个基于stream的EPICS IOC应用程序

本文将介绍如何开发一个基于stream的EPICS IOC应用程序&#xff0c;其将作为一个简单的基于消息的设备&#xff08;用于EPICS stream练习的设备模拟程序_yuyuyuliang00的博客-CSDN博客中最后一个python程序模拟的设备)的IOC控制程序。 1&#xff09; 按如下步骤建立这个IOC程序…

vb+access大气污染模型系统

数据模型就是按专业的要求&#xff0c;用数字方式描述自然界的事物或现象以及他们的关系。 我们通过对地区的具体数值和情况的观察&#xff0c;对大气质量状况做出分析&#xff0c;建立一个符合当地情况的大气污染模型&#xff0c;用来测量大气污染浓度&#xff0c;并根据污染…

在公司兢兢业业5年,被新来的自动化测试倒挂了薪资…

去年年中朋友左思右想从工作了 5 年的企业离职&#xff0c;离职原因很简单&#xff0c;待疲了&#xff0c;薪资也没咋涨过&#xff0c;新来的自动化测试钱比 Ta 高一倍。但离职 Ta 还是很忐忑的&#xff0c;在这个公司待得久了&#xff0c;自己会的东西一直是那些&#xff0c;业…

Python3爬虫图片抓取

在上一章中&#xff0c;我们已经学会了如何使用Python3爬虫抓取文字&#xff0c;那么在本章教程中&#xff0c;将通过实例来教大家如何使用Python3爬虫批量抓取图片。注&#xff1a;该网站目前已经更换了图片的请求方式&#xff0c;以下爬虫方法只能作为思路参考&#xff0c;已…

【Linux】进程理解与学习-程序替换

环境&#xff1a;centos7.6&#xff0c;腾讯云服务器Linux文章都放在了专栏&#xff1a;【Linux】欢迎支持订阅 相关文章推荐&#xff1a; 【Linux】冯.诺依曼体系结构与操作系统 【Linux】进程理解与学习Ⅰ-进程概念 【Linux】进程理解与学习Ⅱ-进程状态 【Linux】进程理解与学…

想拿到10k-40k的offer,这些技能必不可少!作为程序员的你了解吗

总结了一份Java架构师的技能树&#xff0c;希望对Java编程的同学有点帮助 Java编程的技术点&#xff1a; ​ 计算机基础 ​ Java高级特性 设计模式 ​ 数据库 分布式系统 ​ 注意&#xff1a;下文主要是我个人的总结方法经验&#xff08;面试学习和刷题笔记&#xff09; 01…