likes
comments
collection
share

记一次线上间隙锁引发的死锁问题

作者站长头像
站长
· 阅读数 25

最近线上钉钉群告警 mysql.jdbc.exception异常,这种db层面的异常一般都需要重视起来,于是抓紧排查和bugfix,没想到居然是一个死锁,于是有了这篇文章。

前提说明:

  • mysql版本: 8.0.27
  • 隔离级别: REPEATABLE-READ
  • 事务自动提交:是
  • 死锁检测机制:开启
  • 数据库引擎:InnoDB

1、现象

1.1、钉钉群报警:

记一次线上间隙锁引发的死锁问题

1.2、查看elk日志发现有死锁异常

记一次线上间隙锁引发的死锁问题

2、复现 + 排查过程

2.1、业务以及代码逻辑说明

在说问题前,先把什么场景,干了什么事,代码逻辑说明一下,要不然会比较懵。

1、接口是干啥的?: 是预支付接口, 保存预支付记录,逻辑比较简单,直接贴项目真实代码感觉不好 (我这人保密意识比较强) ,所以我直接在我的项目 模拟了下主流程(模拟代码中 省略了些 非重要逻辑),复现了一下,主流程代码如下:

2、代码一览 (show code ~ ~):

/**
 * 模拟用户预支付业务逻辑
 *
 * @param ao
 */
@Override
@Transactional(rollbackFor = Exception.class)
public void prePayOrder(PrePayOrderAo ao) {
   log.info("用户预支付-入参:{}", JSONUtil.toJsonStr(ao));
   RLock lock = redissonClient.getLock(PRE_PAY_RECORD_KEY + ao.getOrderId());
   try {
      //1. 预支付 加锁
      boolean result = lock.tryLock(5, 10, TimeUnit.SECONDS);
      if (!result) {
         log.info("获取预支付锁失败orderId:{}", ao.getOrderId());
         throw new XzllBusinessException(PRE_PAY_FAIL_MSG);
      }
      //2. 查询是否有该笔订单是否有预支付,为了期间不被修改(虽然有分布式锁,但是这个表有好几个地方都有 读和写 数据),所以这里加了X类型的行锁
      LambdaQueryWrapper<PrePayOrderRecordDO> queryWrapper = new LambdaQueryWrapper<>();
      queryWrapper.eq(PrePayOrderRecordDO::getOrderId, ao.getOrderId()).last(" for update ");
      List<PrePayOrderRecordDO> prePayOrderRecordDOS = prePayOrderRecordMapper.selectList(queryWrapper);

      //3. 检查该订单是否有预支付过
      if (!CollectionUtils.isEmpty(prePayOrderRecordDOS) && prePayOrderRecordDOS.stream().anyMatch(item -> Objects.equals(ORDER_STATUS_SUCCESS, item.getStatus()))) {
         log.info("已预支付成功:" + ao.getOrderId() + "订单信息:" + JSONUtil.toJsonStr(prePayOrderRecordDOS));
         throw new XzllBusinessException("已预支付成功无需再次支付");
      }
      //4. 插入该订单的预支付记录
      PrePayOrderRecordDO prePayOrderRecordDO = new PrePayOrderRecordDO();
      BeanUtils.copyProperties(ao, prePayOrderRecordDO);
      Date date = new Date();
      prePayOrderRecordDO.setCreateTime(date);
      prePayOrderRecordDO.setUpdateTime(date);
      int insert = prePayOrderRecordMapper.insert(prePayOrderRecordDO);
      log.info("插入成功影响行数:{}", insert);
   } catch (InterruptedException e) {
      log.error("获取预支付记录锁失败");
   } finally {
      lock.unlock();
   }
}

注意的是 for update 查询即步骤2, 实际项目中是个小方法,有很多地方调用这个方法。所以就算这个预支付有分布式锁,但是你其实无法真正的防止并发查询。

代码逻辑比较简单,注释很清晰,我们不再过多讨论。下边开始复现下死锁。

2.2、本地项目复现死锁

光有service不行,得写个 controller ,然后postman调用下,controller 代码如下:

@Slf4j
@RestController
@RequestMapping("/mysql")
public class MysqlDeadLockController {

   @Autowired
   private ThreadPoolTaskExecutor taskExecutor;
   @Autowired
   private PrePayOrderRecordService prePayOrderRecordService;

   @PostMapping("/deadLock/rangeGap")
   public List<Long> deadLock(@RequestBody PrePayOrderAo ao) {
      List<Long> add = Lists.newArrayList();

      //模拟并发 预支付
      int i = ao.getBegin();
      for (int j = i; j <= ao.getEnd(); j++) {
         PrePayOrderAo prePayOrderAo = new PrePayOrderAo();
         prePayOrderAo.setChannelId(10);
         prePayOrderAo.setStatus(1);
         prePayOrderAo.setOrderPrice(200);
         prePayOrderAo.setOrderId(j);

         taskExecutor.execute(()->{
            //*********  用户预支付  ************
            prePayOrderRecordService.prePayOrder(prePayOrderAo);
         });
      }
      return add;
   }
}

接口调用之前有2条数据: 记一次线上间隙锁引发的死锁问题 表结构如下:(注意该表有普通二级索引即非唯一二级索引: idx_orderId_status这个索引比较关键 后续分析都会围绕他,需要关注一下。 )

create table order_dead_lock_test
(
    id         bigint auto_increment comment '主键'
        primary key,
    orderId    int                                not null comment '订单id',
    channelId  int                                not null comment '渠道id',
    orderPrice int                                not null comment '订单金额单位分',
    status     int                                not null comment '订单状态',
    createTime datetime default CURRENT_TIMESTAMP not null comment '创建时间',
    updateTime datetime default CURRENT_TIMESTAMP not null on update CURRENT_TIMESTAMP comment '更新时间'
)
    comment '订单死锁测试表';

create index idx_orderId_status
    on order_dead_lock_test (orderId, status);

我们使用postman调用下接口(目的是让 [9-13] 这个范围的 orderId 都插入到db),postman请求如下: 记一次线上间隙锁引发的死锁问题

调用后的异常(可以看到 提示db层面的死锁发生,和线上报错一致,并且死锁日志逻辑也是一样的,后续会看到): Caused by: com.mysql.cj.jdbc.exceptions.MySQLTransactionRollbackException: Deadlock found when trying to get lock; try restarting transaction 记一次线上间隙锁引发的死锁问题

本来我们 期待着:(orderId 是 9,10,11,12,13这几条数据都插入到数据库中)但是数据库却只插入成功了 一条数据 ,如图: 记一次线上间隙锁引发的死锁问题

ok 到这里就成功复现了死锁,后续我们就根据死锁日志排查一下,到底是哪里出了问题,但是在解读死锁日志前,我们有必要讲下间隙锁和插入意向锁以及几种锁模型-> Lock Model;

(只是简单介绍,具体的更深层次的更细节的我们不在本文赘述)

2.3、间隙锁与插入意向锁以及锁模型

间隙锁也是行锁,只不过锁的是多行,另外所有的锁 都是锁的索引记录这个我们要了解。

  • 间隙锁是什么? 记一次线上间隙锁引发的死锁问题 间隙锁是在索引记录(一个 或 多个间隙上)的锁,可能是在某个索引记录之前:

    1.比如我往索引列上插入目标值 12,假设目前索引列存在的数据是 ....2,5,8,14,....,那么间隙锁范围就是是8-14的开区间,我们习惯用(8,14) 这样标识:用小括号 ( 标识开区间,用中括号 [ 标识闭区间。

    也可能是(最后一个索引记录,正无穷)这个开区间间隙上的锁 的部分组成。注意此时其实真正加的不是间隙锁GAP,而是加的next-key lock锁(记录锁+GAP),这一点官方文档上有描述,如下: 记一次线上间隙锁引发的死锁问题

    另外间隙锁有个特性就是 不同事务,可以获取 同一个间隙/不同间隙上 的间隙锁。间隙锁存在的意义只是为了保证 :在当前事务持有 某段间隙上的 间隙锁 时,其他事务不能插入数据到这个间隙中(仅此而已)。 也就是说跟间隙锁存在冲突关系的是: “往这个间隙中插入一个记录” 这个操作(也就是insert操作)。 官方文档也是如此描述的:记一次线上间隙锁引发的死锁问题

  • 插入意向锁是啥?

    是在执行insert语句时才会去获取的,这个锁和间隙锁是互斥的,也就是说假设间隙 (8,14)上有事务1加的锁,那么事务2想去在这个区间插入数据时,事务2必须等待事务1的间隙锁释放,才可以真正拿到插入意向锁,从而执行插入操作,否则,只能等待。

小总结下间隙锁和插入意向锁:

  1. 间隙锁只有在事务隔离级别 RR(REPEATABLE READ可重复读) 中才会产生,如果隔离级别是 READ COMMITTED(读已提交),那么间隙锁将失效next-key lock也会失效;
  2. 间隙锁存在的意义就是防止其他事务往这个间隙插入数据
  3. 插入意向锁是在插入时必须获取的,而插入意向锁会和间隙锁互斥
  4. x型的间隙锁和s型的间隙锁 作用是一样的,没什么不同。
  5. 其他 (insert/update/delete)操作暂且不讨论,当查询条件是普通索引且等值查询时,如果记录不存在,将会产生间隙锁(这个我们后边会详细讨论+实践)提醒:(本文的索引idx_orderId_status就是一个普通二级索引即非唯一索引)。

其他补充的知识点:

我们可以通过 SELECT * FROM performance_schema.data_locks; 语句来 查看当前的锁信息。

  • 记一次线上间隙锁引发的死锁问题

  • 如果 LOCK_MODE 为 X,说明是 X 型的 next-key 锁

    next-key类型的锁:(锁定范围为 某一行记录+该行前边的一段间隙,是一个左开右闭的区间);

    一般死锁日志中这么提示:lock_mode X waiting

  • 如果 LOCK_MODE 为 X, REC_NOT_GAP,说明是 X 型的记录锁;

    x型的记录锁,锁定范围只是一行记录, 一般死锁日志这么提示:lock_mode X locks rec but not gap

  • 如果 LOCK_MODE 为 X, GAP,说明是 X 型的间隙锁;

    间隙锁锁定的是一段间隙,比如现在数据库有 8,10,14(假设这这些值是某个普通二级索引上的数据) ,你要插入12的话,将会锁定(10,14)这个间隙

    一般死锁日志这么提示:locks gap before rec

    如果你要插入20的话,将会锁定 (14,supernum也就是正无穷)这个间隙,但是其实此时锁类型并不是间隙锁,而是next-key lock类型的锁,上边我们也简单说过,这里我们实验一下,如下截图: 记一次线上间隙锁引发的死锁问题

ok,既然死锁已经模拟复现出来,间隙锁和插入意向锁以及一些锁类型是啥也了解了 (真正的锁结构,以及crud不同的加锁时机以及加什么样的锁,比较复杂,我们这里不展开了),那么接下来我们看下死锁日志 找下原因吧 ~~

2.4、死锁日志解读

如下是死锁日志(使用 show engine innodb status; 查看死锁):


//最后一次死锁日志
LATEST DETECTED DEADLOCK
------------------------
//最后一次死锁发生的时间
2023-09-11 11:53:37 0x700006852000

//**********************************下边是事务1 信息:**********************************
*** (1) TRANSACTION:  //事务1 ,id:3390471
TRANSACTION 3390471, ACTIVE 0 sec inserting
mysql tables in use 1, locked 1
//这里 标识产生了3个锁对象结构,占用内存大小1128字节 
LOCK WAIT 3 lock struct(s), heap size 1128, 2 row lock(s), undo log entries 1
//一些线程id
MySQL thread id 221, OS thread handle 123145433567232, query id 30062 localhost 127.0.0.1 root update
//事务1 想要执行的sql
INSERT INTO order_dead_lock_test  ( orderId,channelId,orderPrice,status,createTime,updateTime )  VALUES  ( 10,10,200,1,'2023-09-11 11:53:37.48','2023-09-11 11:53:37.48' )

//事务1 持有的锁以及锁信息(类型:间隙锁)
*** (1) HOLDS THE LOCK(S):  //事务1 持有的锁信息(可以看到锁的哪一行,以及是什么类型的锁)
RECORD LOCKS space id 372 page no 5 n bits 72 index idx_orderId_status of table `xzll-dev`.`order_dead_lock_test` trx id 3390471 lock_mode X locks gap before rec  //X类型的间隙锁
Record lock, heap no 3 PHYSICAL RECORD: n_fields 3; compact format; info bits 0
 0: len 4; hex 8000000e; asc     ;; //索引第一位 0e,十六进制的orderId,十进制就是14 
 1: len 4; hex 80000001; asc     ;;//索引第二位 01,十六进制的status,十进制就是1
 2: len 8; hex 80000000000000fc; asc         ;; //fc 十六进制的主键id ,十进制是252

//事务1 (想插入数据必须拿到插入意向锁) 在拿到插入意向锁之前,需要等待插入行上的间隙锁(注意此时间隙锁是事务2 事务1 都持有的呢)
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 372 page no 5 n bits 72 index idx_orderId_status of table `xzll-dev`.`order_dead_lock_test` trx id 3390471 lock_mode X locks gap before rec insert intention waiting
Record lock, heap no 3 PHYSICAL RECORD: n_fields 3; compact format; info bits 0
 0: len 4; hex 8000000e; asc     ;;//索引第一位 0e,十六进制的orderId,十进制就是14 
 1: len 4; hex 80000001; asc     ;;//索引第二位 01,十六进制的status,十进制就是1
 2: len 8; hex 80000000000000fc; asc         ;;//fc 十六进制的主键id ,十进制是252

//**********************************下边是事务2 信息:**********************************
*** (2) TRANSACTION:
TRANSACTION 3390472, ACTIVE 0 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1128, 2 row lock(s), undo log entries 1
MySQL thread id 224, OS thread handle 123145436762112, query id 30059 localhost 127.0.0.1 root update
//事务2 想要执行的sql
INSERT INTO order_dead_lock_test  ( orderId,channelId,orderPrice,status,createTime,updateTime )  VALUES  ( 12,10,200,1,'2023-09-11 11:53:37.48','2023-09-11 11:53:37.48' )

//事务2 持有的锁以及所信息(类型:间隙锁)
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 372 page no 5 n bits 72 index idx_orderId_status of table `xzll-dev`.`order_dead_lock_test` trx id 3390472 lock_mode X locks gap before rec
Record lock, heap no 3 PHYSICAL RECORD: n_fields 3; compact format; info bits 0
 0: len 4; hex 8000000e; asc     ;;//索引第一位 0e,十六进制的orderId,十进制就是14 
 1: len 4; hex 80000001; asc     ;;//索引第二位 01,十六进制的status,十进制就是1
 2: len 8; hex 80000000000000fc; asc         ;;//fc 十六进制的主键id ,十进制是252

*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
//事务2 (想插入数据必须拿到插入意向锁) 在拿到插入意向锁之前,需要等待插入行上的间隙锁(注意此时间隙锁是事务2 事务1 都持有的呢)
RECORD LOCKS space id 372 page no 5 n bits 72 index idx_orderId_status of table `xzll-dev`.`order_dead_lock_test` trx id 3390472 lock_mode X locks gap before rec insert intention waiting
Record lock, heap no 3 PHYSICAL RECORD: n_fields 3; compact format; info bits 0
 0: len 4; hex 8000000e; asc     ;;//索引第一位 0e,十六进制的orderId,十进制就是14 
 1: len 4; hex 80000001; asc     ;;//索引第二位 01,十六进制的status,十进制就是1
 2: len 8; hex 80000000000000fc; asc         ;;//fc 十六进制的主键id ,十进制是252

//**************** mysql死锁检测机制决定:回滚事务2 ,从而事务1拿到插入意向锁执行insert成功  *******************
*** WE ROLL BACK TRANSACTION (2)

从死锁日志可以看出 此次死锁发生的场景就是:

上边已经把流程写的比较清楚了,为了直观,我们画个图看一下本次死锁的产生过程: 记一次线上间隙锁引发的死锁问题

死锁原因找到了,那么如何解决呢?

3、解决死锁问题

3.1、死锁形成的必要条件

形成死锁有四个必要条件,如果打破其中一个死锁将被消灭~~:

1、互斥: 某种资源一次只允许一个进程访问,即该资源一旦分配给某个进程,其他进程就不能再访问,直到该进程访问结束。

2、占有且等待: 一个进程本身占有资源(一种或多种),同时还有资源未得到满足,正在等待其他进程释放该资源。

3、不可抢占: 别人已经占有了某项资源,你不能因为自己也需要该资源,就去把别人的资源抢过来。

4、循环等待: 存在一个进程链,使得每个进程都占有下一个进程所需的至少一种资源。

3.2、等值查询非唯一索引 for update 查询时的现象

首先,们要知道一个事实,就是 SELECT * FROM order_dead_lock_test WHERE orderId = 10 FOR UPDATE; (注意该表上有普通二级索引 idx_orderId_status) 这个语句,记录不存在和存在时,加的锁是不一样的,下边分别分析下:

先看下表中 现有数据 : 记一次线上间隙锁引发的死锁问题

1、如果 记录不存在(我们去查orderId=13的记录 ,注意13不存在): 该语句会被加(或者说是退化成)间隙锁, 锁定范围是: (10,14),如下图所示:记一次线上间隙锁引发的死锁问题

记录不存在时 加锁详情: 1、定位到第一条不符合查询条件的二级索引记录,即扫描到 orderId = 14,于是该二级索引的 next-key 锁(行级锁默认加next-key lock)会退化成间隙锁,范围是 (10,14) 。上图可以看到,事务 3390605和事务3390604都成功获取了 (8,14)的间隙锁。

2、如果 记录存在 则是加了: next-key lock锁 (记录锁+间隙锁) + 间隙锁 + 主键索引上的记录锁 ;见下图 事务id是 3390606 的加锁情况。

此时事务3390607再去执行 SELECT * FROM order_dead_lock_test WHERE orderId = 10 FOR UPDATE;的话,事务3390607则会被事务 3390606 阻塞!,因为 next-key lock 类型的锁是互斥 的,不能被不同事务同时拥有(不像间隙锁那样可以被不同事务同时拥有)。 我们根据锁日志看下: 记录存在时 非唯一索引 等值 锁定写查询(for update) 的加锁情况 记一次线上间隙锁引发的死锁问题 上图 倒数第三行是事务3390606 加的next-key lock锁 锁定范围是左开右必也就是 (8,10],除了这个锁还加了倒数第一行的间隙锁(X,GAP)锁定范围是(10,14) 并且给主键259 (id259对应的orderId是10因为orderId=10符合where条件) 加了个记录锁,所以最终得出结论: 记录存在时 非唯一索引 等值 锁定写查询(for update) 的锁定区间就是 : next-key lock (8,10] + GAP(10,14)+ (X,REC_NOT_GAP 主键记录锁) = (8,14)

记录存在时加锁细节补充:如下:

  • 1、next-key lock(图中倒数第三行的X锁): 由于不是唯一索引,所以肯定存在值相同的记录,于是非唯一索引等值查询的过程是一个扫描的过程,最开始要找的第一个匹配行是 orderId = 10,于是对该二级索引记录加上范围为 (8, 10] 的 next-key lock。

  • 2、记录锁X(图中的倒数第二行 X,REC_NOT_GAP): 因为 orderId = 10 符合查询条件即db 存在数据,于是对 orderId = 10 的记录的主键索引加上记录锁,即对 id = 259 这一行主键索引加记录锁。

  • 3、间隙锁(图中的倒数第一行 X,GAP): 继续扫描,扫描到的下一个数据是 orderId = 14,该记录是第一个不符合条件的二级索引记录,所以该二级索引的 next-key 锁会退化成间隙锁,范围是 (10, 14),到此停止扫描和加锁

到此我们了解了,记录不存在和存在时,非唯一索引 等值 锁定写查询(for update) 的加锁情况是不一样的。

3.3、解决本案例死锁

解决死锁的办法挺多的,但是具体问题具体分析,本案例的死锁解决需要选择个合适的方案。 我们看下常见的死锁解决办法:

  1. 降低隔离级别,这只能说是针对某些个别情况

    比如因为间隙锁引起的死锁,当调整至 READ COMMITTED(读已提交)时,间隙锁失效,那么自然本文这种场景就不会出现间隙锁了,在插入时也无需互相等待彼此的间隙锁释放,自然也不会死锁(但是你要解决 出现的数据和日志不一致问题,需要把 binlog 格式设置为 row)

    (ps: 我肯定不会采用这种方式,因为改隔离级别的话影响太大了,怕被祭天)。

  2. 尽量有序执行,减少嵌套(这个是预防死锁
  3. 防止太高的db层面并发 (比较笼统,具体实现就看编码时的场景了具体业务了)
  4. 破坏形成死锁的条件(发现死锁后的解决方案
  5. 设置死锁后自动回滚其中一个事务即修改参数:innodb_deadlock_detect为ON(补救措施)
  6. .....其他暂时没想到的

看到这里我似乎有了一个破解本示例死锁的办法,既然破坏其中一个必要条件就可以避免死锁,那么通过3.2的铺垫,我想有个条件我可以破坏,就是:

2、占有且等待: 一个进程本身占有资源(一种或多种),同时还有资源未得到满足,正在等待其他进程释放该资源。

也就是说不让事务b占用资源,即他执行select * from table where orderId=xx for update时,让他处于等待,这样他就没有持有锁了。事务a也就不会等待事务b的锁释放(没有了互相等待的场景),自然也就规避了死锁。如下图所示: 记一次线上间隙锁引发的死锁问题

而这种情况我们只需要确保一件事,就是db中 一定存在数据时 ,才去执行for update这种查询,因为只有数据存在时,for update会加X 类型的next-key lock锁,当其他事务去 for update查询时,就会进入等待,也就是破坏了(占有资源)这个条件,从而避免死锁。目标效果如下: 记一次线上间隙锁引发的死锁问题

说明:可能会有疑问 在我们代码中既然for update查询前都有分布式锁了,但是为啥在上图中还会并发查询呢?因为for update真实情况并不是这里一处使用,而是很多地方都用到了,有的入口并没有加分布式锁。所以for update仍然会并发查询。

ok既然思路定了,那我们看下改后的代码,如下:

controller: 记一次线上间隙锁引发的死锁问题 service: 记一次线上间隙锁引发的死锁问题

(因为测试时多数是关闭了自动提交,所以注意跑程序时一定要把自动提交改回成ON 否则程序中的事务不自动提交、引发奇怪的异常 开启自动提交命令:SET autocommit =1;

先使用postman插入 11-13的记录: 记一次线上间隙锁引发的死锁问题 结果: 记一次线上间隙锁引发的死锁问题

插入10-30的记录: 记一次线上间隙锁引发的死锁问题

插入1-100的记录: 记一次线上间隙锁引发的死锁问题

最后一次死锁日志: 记一次线上间隙锁引发的死锁问题

最后一次死锁日志是16:02(我测试导致的),而当前调用时间是16:58左右 可以知道没发生死锁,从数据观察看也都正常。于是在实际项目中我们这么做了并且fat,uat,灰度测试没问题后推到了线上,目前来看比较稳定,观察了一段时间没再报错死锁异常。应该算是解决了。后续再持续观察一段时间看看吧。

4、总结

  1. 以上就是本次线上问题死锁(通过本地复现)的排查过程,因为有些东西直接上来就说可能不太好,所以本文穿插了很多锁的知识点。

  2. 由于mysql锁比较复杂,在

    insert 、update、 for update/lock in share model、delete下的加锁规则可能不同。

    在堆主键索引,唯一索引,非唯一二级索引,以及无索引的字段上 加的锁又不一致。

    在等值匹配和范围匹配时加的锁又有可能不一样。 在不同隔离级别下加锁规则又不一致。

    再加上实际项目中复杂的业务逻辑。

    所以 想讲清楚这些东西,并不是三言两语可以解释清楚的,也并不是你读一本书或者官方文档的解释你看完就能懂的,需要大量的理论+实践和很强的自驱力

  3. 对于本次线上问题,引起死锁的原因是间隙锁,而在其他场景,也有可能产生死锁(我这篇文章不去一一举例和实践死锁发生的所有场景)。对于什么情况下产生死锁以及常见的死锁原因,请移步这个项目,该项目讲解了常见的死锁发生的场景,个人感觉比较全面(大概20种死锁案例)是不错的死锁学习资料,有兴趣可以自己复现一下。

  4. 这篇文章憋了好久,总是想把所有的东西都写全,但是事实就是越写越长,而且有些东西不能太深,太深了感觉跑偏了脱离了主题,索性关于锁更细节的东西,后续再 学习总结实践吧!

巨人的肩膀:


最后: 如果文章对你有帮助请点赞收藏,如果有错误请指出,不胜感激,共勉!

转载自:https://juejin.cn/post/7278238875448475706
评论
请登录