【Redis】秒杀业务设计、悲观锁与乐观锁

1 全局ID生成器

一些情境下,使用数据库的ID自增将会产生一些问题。

  • 一方面,自增ID规律性明显,可能被猜测出来并产生一些漏洞
  • 另一方面,当数据量很大很大很大时,单表数据量可能会受到限制,需要分表,多个表之间的ID自增策略受限

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-wUDx8NLF-1688908161475)(【Redis】秒杀业务设计与分析/image-20230708120919560.png)]

@Component
public class RedisIdWorker {
    /**
     * 开始时间戳
     */
    private static final long BEGIN_TIMESTAMP = 1640995200L;
    /**
     * 序列号的位数
     */
    private static final int COUNT_BITS = 32;

    private StringRedisTemplate stringRedisTemplate;

    public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
        this.stringRedisTemplate = stringRedisTemplate;
    }

    /**
     * 根据KeyPrefix生成Id,key为 "icr:" + keyPrefix + ":" + date,每天一个Key,方便统计订单量
     * @param keyPrefix
     * @return
     */
    public long nextId(String keyPrefix) {
        
        // 1、生成时间戳
        LocalDateTime now = LocalDateTime.now();
        // 转换成当前的秒数
        long second = now.toEpochSecond(ZoneOffset.UTC);
        long timeStamp = second - BEGIN_TIMESTAMP;

        // 2、构造存入的key,并增加count值
        String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
        Long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);

        // 3、拼接并返回
        return timeStamp << COUNT_BITS | count;
    }
}

测试:

	// 建一个线程池
    private ExecutorService es = Executors.newFixedThreadPool(500);

    @Test
    void testIdWorker() throws InterruptedException {
        // 如果没有CountDownLatch ,
        // 由于程序是异步的,当异步程序没有执行完时,主线程就已经执行完了,
        // 我们期望的是分线程全部走完之后,主线程再走,所以我们此时需要使用到CountDownLatch
        CountDownLatch latch = new CountDownLatch(300);

        // 所以使用await可以让main线程阻塞,什么时候main线程不再阻塞呢?
        // 当CountDownLatch内部维护的变量变为0时,就不再阻塞,直接放行,
        // 调用一次countDown ,内部变量就减少1,我们让分线程和变量绑定, 执行完一个分线程就减少一个变量,
        // 当分线程全部走完,CountDownLatch 维护的变量就是0,此时await就不再阻塞
        
        Runnable task = () -> {
            for (int i = 0; i < 100; i++) {
                long id = redisIdWorker.nextId("order");
                System.out.println("id = " + id);
            }
            latch.countDown();
        };

        long begin = System.currentTimeMillis();
        for (int i = 0; i < 300; i++) {
            es.submit(task);
        }
        latch.await();
        long end = System.currentTimeMillis();
        System.out.println("time = " + (end - begin));
    }
  • Runnable接口是一个函数式接口,即只有一个方法。可以通过Lambda函数指定其run方法对应的代码。

2 秒杀下单

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-eyYhbfF6-1688908161476)(【Redis】秒杀业务设计与分析/image-20230708224926473.png)]

  • Q1:是否在抢购时间内
  • Q2:库存是否充足
  • Q3:多个用户并发访问同一张优惠券,需要加锁

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-BiXbwuRI-1688908161476)(【Redis】秒杀业务设计与分析/image-20230708232049291.png)]

乐观锁

更新数据时去判断有没有其他线程对数据进行了修改。

版本号法:设置一个版本号,每次操作数据会对版本号+1,再提交回数据时,会去校验是否比之前的版本大1 ,如果大1,意味着操作过程中没有人对他进行过修改,则进行操作成功。

CAS法(compare and set):直接使用Stock进行判断,检查修改时库存是否大于0。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-mjCxJfYb-1688908161477)(【Redis】秒杀业务设计与分析/image-20230708232543952.png)]

	@Override
    @Transactional(rollbackFor = Exception.class)
    public Result seckillVoucher(Long voucherId) {
        // 1、查询优惠券信息
        SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);

        LocalDateTime now = LocalDateTime.now();
        LocalDateTime begin = seckillVoucher.getBeginTime();
        LocalDateTime end = seckillVoucher.getEndTime();

        if (now.isBefore(begin)) {
            return Result.fail("秒杀尚未开始!");
        }

        if (now.isAfter(end)) {
            return Result.fail("秒杀已经结束!");
        }

        int stock = seckillVoucher.getStock();
        if (stock <= 0) {
            return Result.fail("库存不足");
        }

        // 2、购买优惠券,加入Order表,Stock更新
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1")
                .eq("voucher_id", voucherId).gt("stock", 0).update();

        if (!success) {
            return Result.fail("库存不足");
        }

        VoucherOrder voucherOrder = new VoucherOrder();
        Long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        voucherOrder.setUserId(UserHolder.getUser().getId());
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);

        return Result.ok(orderId);
    }

一人一单:悲观锁

  • 当同一个用户同时向数据库发送多条相同请求时,由于多个请求查找到数据库的结果相同,多个请求均有可能满足条件进行购买,从而产生错误。
  • 需要对同一个user的购买操作加锁。

将购买逻辑(是否购买过,更新stock,加入order表)封装为一个事务,必须把查询订单信息放在这个函数里,而不是外面。如果先在外面判断是否购买过优惠券,再放入该函数,相当于没有加上锁:

	@Override
    @Transactional(rollbackFor = Exception.class)
    public Result createVoucherOrder(Long voucherId) {
        // 2、查询订单信息:
        Long userId = UserHolder.getUser().getId();
        long count = query().eq("user_id", userId).eq("voucher_id", voucherId).count();
        if (count > 0) {
            return Result.fail("您已购买过这张优惠券,不能重复购买");
        }

        // 3、购买优惠券,Stock更新,加入Order表
        boolean success = seckillVoucherService.update()
                .setSql("stock = stock - 1")
                .eq("voucher_id", voucherId).gt("stock", 0).update();

        if (!success) {
            return Result.fail("库存不足");
        }

        VoucherOrder voucherOrder = new VoucherOrder();
        Long orderId = redisIdWorker.nextId("order");
        voucherOrder.setId(orderId);
        voucherOrder.setUserId(UserHolder.getUser().getId());
        voucherOrder.setVoucherId(voucherId);
        save(voucherOrder);

        return Result.ok(orderId);
    }

2、如果直接在上述方法上加锁,锁的粒度太粗,不同的用户进入该方法时也会被锁住。因此在调用上述方法时,对userId.toString.intern()加锁,保证相同的userId从常量池中拿到的数据为同一个对象。同时,为了使事务注解生效,需要调用代理对象 AopContext.currentProxy()而不是该对象本身的方法。

	@Override
    public Result seckillVoucher(Long voucherId) {
        // 1、查询优惠券信息
        SeckillVoucher seckillVoucher = seckillVoucherService.getById(voucherId);

        LocalDateTime now = LocalDateTime.now();
        LocalDateTime begin = seckillVoucher.getBeginTime();
        LocalDateTime end = seckillVoucher.getEndTime();

        if (now.isBefore(begin)) {
            return Result.fail("秒杀尚未开始!");
        }

        if (now.isAfter(end)) {
            return Result.fail("秒杀已经结束!");
        }

        int stock = seckillVoucher.getStock();
        if (stock <= 0) {
            return Result.fail("库存不足");
        }

        // 2、查询订单信息:
        Long userId = UserHolder.getUser().getId();
        
        synchronized (userId.toString().intern()) {
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            return proxy.createVoucherOrder(voucherId);
        }

    }

由于默认不可获得代理对象,需要在启动类上加入注释:

@EnableAspectJAutoProxy(exposeProxy = true)

并加入maven依赖:

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>

        <dependency>
            <groupId>org.aspectj</groupId>
            <artifactId>aspectjweaver</artifactId>
            <version>1.9.5</version>
        </dependency>

3 分布式锁

满足分布式系统或集群模式下多进程可见并且互斥的锁。

使用MySQL比较少,Redis和Zookeeper比较常见。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7Hhc74ZV-1688908161477)(【Redis】秒杀业务设计与分析/image-20230709142035406.png)]

3.1 Redis实现分布式锁

  • 获取锁:

    • 互斥:确保只能有一个线程获取锁

    • 非阻塞:尝试一次,成功返回true,失败返回false

  • 释放锁:

    • 手动释放:只能释放属于该线程的锁
    • 超时释放:获取锁时添加一个超时时间

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-y30rWwUQ-1688908161477)(【Redis】秒杀业务设计与分析/image-20230709145851592.png)]


@Component
public class SimpleRedisLock {
    /**
     * 标识这个锁
     */
    private System name;
    private static final String KEY_PREFIX = "lock:";
    /**
     * 由于不同的JVM可能有相同的线程号,所以需要ID_PREFIX来表示属于哪个服务,拼接threadId来唯一标识线程
     */
    private static final String ID_PREFIX = UUID.randomUUID() + "-";

    @Resource
    StringRedisTemplate stringRedisTemplate;

    public boolean tryLock(long timeoutSec) {
        // 设置锁的值为获得当前锁的线程
        long threadId = Thread.currentThread().threadId();
        // 尝试获得锁,设置锁的过期时间以防止死锁
        return Boolean.TRUE.equals(
                stringRedisTemplate.opsForValue()
                        .setIfAbsent(KEY_PREFIX + name, ID_PREFIX + threadId, 
                                     timeoutSec, TimeUnit.SECONDS)
        );
    }

    public void unlock() {
        // 先判断当前线程有没有资格删掉这个锁,即redis中存储的线程id和当前线程id是否一致
        String threadId = ID_PREFIX + Thread.currentThread().threadId();
        String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
        // 如果这个锁确实是当前服务器上 & 当前线程的锁
        if (threadId.equals(id)) {
            stringRedisTemplate.delete(KEY_PREFIX + name);
        }
    }

}
        Long userId = UserHolder.getUser().getId();

        SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);

        // 获取锁失败,已有线程进入该段逻辑
        if (!lock.tryLock(1200)) {
            return Result.fail("请勿重复下单");
        }

        try {
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            return proxy.createVoucherOrder(voucherId);
        }
        finally {
            lock.unlock();
        }

启动两个SpringBoot服务模拟分布式进行测试:

E:\leetcode\project_pre\Dianping\Front\conf\nginx.conf

			# proxy_pass http://127.0.0.1:8081;
            proxy_pass http://backend;
        }
    }

    upstream backend {
        server 127.0.0.1:8081 max_fails=5 fail_timeout=10s weight=1;
        server 127.0.0.1:8082 max_fails=5 fail_timeout=10s weight=1;
    }  

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jAFwYfyy-1688908161477)(【Redis】秒杀业务设计与分析/image-20230709154854091.png)]

  • 可以使用LUA脚本进一步保证拿锁/还锁的原子性

3.2 Redisson实现分布式锁

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-El9KrSFN-1688908161478)(【Redis】秒杀业务设计与分析/image-20230709163829087.png)]

3.2.1 maven依赖

        <dependency>
            <groupId>org.redisson</groupId>
            <artifactId>redisson</artifactId>
            <version>3.13.6</version>
        </dependency>

3.2.2 使用示例

 @Test
    void testRedisson() throws Exception{
        // 创建锁对象
        RLock lock = redissonClient.getLock("anyLock");
        //尝试获取锁,参数分别是:获取锁的最大等待时间(期间会重试),锁自动释放时间,时间单位
        boolean isLock = lock.tryLock(1, 10, TimeUnit.SECONDS);
        //判断获取锁成功
        if(isLock){
            try{
                System.out.println("执行业务");
            }finally{
                //释放锁
                lock.unlock();
            }
        }
    }

ServiceImpl

		RLock lock = redissonClient.getLock("order:" + userId);

        // 获取锁失败,已有线程进入该段逻辑
        if (!lock.tryLock()) {
            return Result.fail("请勿重复下单");
        }

        try {
            IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
            return proxy.createVoucherOrder(voucherId);
        }
        finally {
            lock.unlock();
        }

3.3 Redisson可重入锁原理

逻辑如下右图:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-6ffxDW1t-1688908161478)(【Redis】秒杀业务设计与分析/image-20230709175343635.png)]

method1调用method2,一个线程连续两次获取锁:重入。

在Lock锁中借助底层的一个voaltile的state变量来记录重入的状态。

  • 比如当前没有人持有这把锁,那么state=0
  • 假如有人持有这把锁,那么state=1,如果持有这把锁的人再次持有这把锁,那么state就会+1 ,
  • 释放一次就-1 ,直到减少成0 时,表示当前这把锁没有被人持有

采用hash结构用来存储锁,其中大key表示表示这把锁是否存在,用小key表示当前这把锁被哪个线程持有。

KEYS[1]: 锁名称

ARGV[1]: 锁失效时间

ARGV[2] id + ":" + threadId,锁的小key

			 如果当前这把锁不存在,向redis中写一个hash数据并设置expire
			  "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; " +
			  如果这个锁已经存在,通过大key + 小key判断当前这把锁是否是属于自己的,如果是自己的,+1,重置锁时间
              "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]);"

3.4 Redisson锁重试与WatchDog机制

waitTime,leaseTime

boolean isLock = lock.tryLock(1, 10, TimeUnit.SECONDS);

第一个参数为重试等待时间,加入该参数以后,成为一个可重试的锁。

第二个参数为持有锁时间,默认为30s。

img

3.5 主从一致性问题

为了提高Redis的可用性,我们会搭建集群或者主从。

以主从为例:我们执行写命令,写在主机上, 主机会将数据同步给从机。但是假设在主机还没有来得及把数据写入到从机去的时候,此时主机宕机,哨兵会发现主机宕机,并且选举一个slave变成master,而此时新的master中实际上并没有锁信息,此时锁信息就丢失了

为了解决这个问题,Redisson提出来了MutiLock锁,每个节点的地位都是一样的, 加锁的逻辑需要写入到每一个主丛节点上,只有所有的服务器都写入成功,此时才是加锁成功。

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

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

相关文章

网络编程5——TCP协议的五大效率机制:滑动窗口+流量控制+拥塞控制+延时应答+捎带应答

文章目录 前言一、TCP协议段与机制TCP协议的特点TCP报头结构TCP协议的机制与特性 二、TCP协议的 滑动窗口机制 三、TCP协议的 流量控制机制 四、TCP协议的 拥塞控制机制 五、TCP协议的 延时应答机制 六、TCP协议的 捎带应答机制 总结 前言 本人是一个普通程序猿!分享一点自己的…

RabbitMQ在SpringBoot中的高级应用(2)

过期时间 1.单独的设置队列的存活时间,队列中的所有消息的过期时间一样 Bean//创建交换机public DirectExchange ttlQueueExchange(){// 交换机名称 是否持久化 是否自动删除return new DirectExchange("ttl_queue_log",true,false);}Bean//创建队列publ…

吴恩达ChatGPT《LangChain for LLM Application Development》笔记

基于 LangChain 的 LLM 应用开发 1. 介绍 现在&#xff0c;使用 Prompt 可以快速开发一个应用程序&#xff0c;但是一个应用程序可能需要多次写Prompt&#xff0c;并对 LLM 的输出结果进行解析。因此&#xff0c;需要编写很多胶水代码。 Harrison Chase 创建的 LangChain 框…

需求分析引言:架构漫谈(五)架构师成长之路

我研发领域也从事了一些年&#xff0c;期间也做过一些架构设计工作&#xff0c;包括C#单体转型为Java微服务、Python单体转型为Java微服务等&#xff0c; 也尝试着从自己的经验角度&#xff0c;来汇总一些知识点&#xff0c;同时描述一下如何成长为一个合格的软件架构师&#x…

基于SpringBoot+Vue+微信小程序的电影平台

✌全网粉丝20W,csdn特邀作者、博客专家、CSDN新星计划导师、java领域优质创作者,博客之星、掘金/华为云/阿里云/InfoQ等平台优质作者、专注于Java技术领域和毕业项目实战✌ &#x1f345;文末获取项目下载方式&#x1f345; 一、项目背景介绍&#xff1a; 研究背景&#xff1a;…

Docker 中的 .NET 异常了怎么抓 Dump (转载)

一、背景 1. 讲故事 有很多朋友跟我说&#xff0c;在 Windows 上看过你文章知道了怎么抓 Crash, CPU爆高&#xff0c;内存暴涨 等各种Dump&#xff0c;为什么你没有写在 Docker 中如何抓的相关文章呢&#xff1f;瞧不上吗&#xff1f; 哈哈&#xff0c;在DUMP的分析旅程中&a…

提升工作效率:推荐几款实用的Mac项目管理工具!

在当今软件和技术高度发达的时代&#xff0c;项目管理依然是一项非常重要的任务。现在&#xff0c;有越来越多的人喜欢使用mac电脑进行项目管理&#xff0c;因为mac众所周知的稳定性和使用便捷性。但问题是&#xff0c;mac系统自带的项目管理工具并不是非常完美&#xff0c;因此…

Linux——进程信号详解

目录 一.进程信号的理解 1.1定义&#xff1a; 1.2举例&#xff1a; 1.3总结&#xff1a; 二.进程信号地使用&#xff1a; 2.1信号种类&#xff1a; 2.2而操作系统向进程发送信号地方式有四种&#xff1a; 2.2.1以键盘的方式向进程发送信号 接下来介绍一个系统调用函数sign…

Windows系统上安装Node.js图文步骤流程

Windows系统上安装Node.js图文步骤流程&#xff0c;本文以安装Node.js v4.4.3 LTS(长期支持版本)版本为例&#xff1a; 目录 Node.js下载 Windows 上安装 Node.js 1、Windows 安装包(.msi) 2、Windows 二进制文件 (.exe)安装 版本测试 Node.js下载 Node.js 安装包及源码…

nginx七层代理和四层转发的理解

先来理解一下osi七层模型 应用层 应用层是ISO七层模型的最高层&#xff0c;它直接与用户和应用程序交互&#xff0c;提供用户与网络的接口。它包括各种应用协议&#xff0c;如HTTP、FTP、SMTP等&#xff0c;用于实现特定应用的功能和通信表示层 表示层…

Java进程ProcessBuilder类的介绍及使用,ProcessBuilder调用外部程序执行shell命令Linux命令

目录 ProcessBuilder类的介绍及使用 【前言】 【正文】 --构造方法-- --常用方法-- --使用技巧-- --调用本地Shell命令&#xff0c;实例-- 【总结】 【注意】 ProcessBuilder类的介绍及使用 【前言】 在做一个项目的时候需要用到运行时动态执行JAVA命令&#xff0c;一…

leetcode 225.用队列实现栈

⭐️ 题目描述 &#x1f31f; leetcode链接&#xff1a;用队列实现栈 1️⃣ 思路和图解&#xff1a; push&#xff1a; 入栈操作只需要往不为空的队列入数据即可&#xff0c;如果都为空&#xff0c;其中任意一个队列都可以。 void myStackPush(MyStack* obj, int x) {// 往…

CS EXE上线主机+文件下载上传键盘记录

前言 书接上文&#xff0c;CobaltStrike_1_部署教程及CS制作office宏文档钓鱼教程&#xff0c;该篇介绍【使用CS生成对应exe木马&#xff0c;上线主机&#xff1b;对上线主机进行&#xff0c;文件下载&#xff0c;文件上传&#xff0c;键盘记录】。 PS&#xff1a;文章仅供学习…

数仓建设中最常用模型--Kimball维度建模详解

数仓建模首推书籍《数据仓库工具箱&#xff1a;维度建模权威指南》&#xff0c;本篇文章参考此书而作。文章首发公众号&#xff1a;五分钟学大数据&#xff0c;公众号后台发送“维度建模”即可获取此书籍第三版电子书 先来介绍下此书&#xff0c;此书是基于作者 60 多年的实际业…

SpringBoot前后端分离项目,打包、部署到服务器详细图文流程

文章目录 实施步骤一、修改配置文件地址1.修改MySQL配置2.修改Redis配置3.修改日志路径和字符集配置 二、将源码压缩并上传服务器1.上传前端文件2.上传后端文件&#xff08;同上&#xff09; 三、前端项目打包1.安装依赖2.项目打包 四、后端项目打包1.项目打包&#xff08;jar包…

Ubuntu 20.04 LTS 安装 nvidia 驱动 + cuda 11.8 从开始到放弃!

升级 sources.list # 默认注释了源码镜像以提高 apt update 速度&#xff0c;如有需要可自行取消注释 deb http://mirrors.tuna.tsinghua.edu.cn/ubuntu/ jammy main restricted universe multiverse deb-src http://mirrors.tuna.tsinghua.edu.cn/ubuntu/ jammy main restri…

java的断言

断言介绍 Java的断言就是一条assert 声明&#xff0c;其中包含了一个布尔表达式。 断言可以被启用或者禁用&#xff0c;默认是禁用的。 断言被启用的情况下&#xff0c;执行到断言的声明&#xff0c;就会计算布尔表达式的值。如果表达式的值为false&#xff0c;那么就会抛出一…

9、架构:CLI 设计

通常大部分的程序员会更加习惯使用 CLI&#xff08;Command-Line Interface 命令行界面&#xff09;来辅助开发业务&#xff0c;包括初始化、更新、构建、发布等功能&#xff0c;可以获得沉浸式一站的开发体验。 在之前有一篇企业级 CLI 开发实战介绍过如何开发一款适用团队的…

阿里开业项目chat2DB-人工智能SQL分析介绍

1. chat2DB简介 1-1. 简介 ​ chat2DB是一款有开源免费的多数据库客户端工具&#xff0c;支持windows、mac本地安装&#xff0c;也支持服务器端部署&#xff0c;web网页访问。和传统的数据库客户端软件Navicat、DBeaver 相比Chat2DB集成了AIGC的能力&#xff0c;能够将自然语…

从C语言到C++_23(多态)抽象类+虚函数表VTBL+多态的面试题

目录 1. 多态&#xff08;polymorphism&#xff09; 1.1 构成多态的两个条件 1.2 虚函数重写(覆盖) 1.3 协变构成多态 1.4 父虚子非虚构成多态 1.5 析构函数的重写 1.6 final 和 override 关键字&#xff08;C11&#xff09; 1.7 重载、覆盖、隐藏的对比 2. 抽象类&am…