9. RabbitMQ 消息队列幂等性,优先级队列,惰性队列的详细说明

9. RabbitMQ 消息队列幂等性,优先级队列,惰性队列的详细说明

文章目录

  • [9. RabbitMQ 消息队列幂等性,优先级队列,惰性队列的详细说明](#9. RabbitMQ 消息队列幂等性,优先级队列,惰性队列的详细说明)
  • [1. RabbitMQ 消息队列的 " 幂等性 " 的问题](#1. RabbitMQ 消息队列的 “ 幂等性 ” 的问题)
    • [1.1 RabbitMQ 消息队列的"幂等性"的概念](#1.1 RabbitMQ 消息队列的“幂等性”的概念)
  • [2. RabbitMQ 消息队列的 " 优先级队列 " 的问题](#2. RabbitMQ 消息队列的 “ 优先级队列 ” 的问题)
    • [2.1 创建交换机](#2.1 创建交换机)
    • [2.2 创建队列](#2.2 创建队列)
    • [2.3 队列绑定交换机](#2.3 队列绑定交换机)
    • [2.4 RabbitMQ 结合 Spring Boot (分模块微服务)实现 "优先级队列"](#2.4 RabbitMQ 结合 Spring Boot (分模块微服务)实现 “优先级队列”)
  • [3. RabbitMQ 消息队列的 " 惰性队列 " 的问题](#3. RabbitMQ 消息队列的 “ 惰性队列 ” 的问题)
    • [3.1 RabbitMQ 消息队列 " 惰性队列 "的概念](#3.1 RabbitMQ 消息队列 “ 惰性队列 ”的概念)
    • [3.2 基于策略方式设定](#3.2 基于策略方式设定)
    • [3.3 在声明队列时使用参数设定](#3.3 在声明队列时使用参数设定)
    • [3.4 实操演练](#3.4 实操演练)
  • [4. 最后:](#4. 最后:)

1. RabbitMQ 消息队列的 " 幂等性 " 的问题

1.1 RabbitMQ 消息队列的"幂等性"的概念

用户对于同一操作发起的一次请求或者多次请求的结果是一致的,不会因为多次点击而产生了副作用。举个最简单的例子:那就是支付,用户购买商品后支付,支付扣款成功,但是返回结果的时候网络异常,此时钱已经扣了,用户再次点击按钮,此时会进行第二次扣款,返回结果成功,用户查询余额发现多扣钱了,流水记录也变成了两条。在以前的单应用系统中,我们只需要把数据操作放入到事务中即可,发生错误立即回滚,但是再响应客户端的时候也有可能出现网络中断或者异常等等。

消息消费时的幂等性(消息不被重复消费)

同一个消息,第一次接收,正常处理业务,如果该消息第二次再接收,那就不能再处理业务,否则就处理重复了;

**幂等性是:**对于一个资源,不管你请求一次还是请求多次,对该资源本身造成的影响应该是相同的,不能因为重复的请求而对该资源重复造成影响;

以接口幂等性举例:

接口幂等性是指: 一个接口用同样的参数反复调用,不会造成业务错误,那么这个接口就是具有幂等性的;

注册接口;

发送短信验证码接口; 比如同一个订单我支付两次,但是只会扣款一次,第二次支付不会扣款,这说明这个支付接口是具有幂等性的;

如何避免消息的重复消费问题?(消息消费时的幂等性)

全局唯一 ID + Redis

生产者在发送消息时,为每条消息设置一个全局唯一的 messageId,消费者拿到消息后,使用 setnx 命令,将 messageId 作为 key 放到 Redis 中:setnx(messageId, 1);

  • 若返回1,说明之前没有消费过,正常消费;
  • 若返回0,说明这条消息之前已消费过,抛弃;

这里是利用 Redis 的一个 setnx 不可重复,原子性的特征。

消息重复消费

消费者在消费 MQ 中的消息时,MQ 已把消息发送给消费者,消费者在给 MQ 返回 ack 时网络中断, 故 MQ 未收到确认信息,该条消息会重新发给其他的消费者,或者在网络重连后再次发送给该消费者,但 实际上该消费者已成功消费了该条消息,造成消费者消费了重复的消息。

解决思路

MQ 消费者的幂等性的解决一般使用全局 ID 或者写个唯一标识比如时间戳 或者 UUID 或者订单消费 者消费 MQ 中的消息也可利用 MQ 的该 id 来判断,或者可按自己的规则生成一个全局唯一 id,每次消费消 息时用该 id 先判断该消息是否已消费过。

消费端的幂等性保障

在海量订单生成的业务高峰期,生产端有可能就会重复发生了消息,这时候消费端就要实现幂等性, 这就意味着我们的消息永远不会被消费多次,即使我们收到了一样的消息。业界主流的幂等性有两种操作:

  • a. 唯一 ID+指纹码机制, 利用数据库主键去重
  • b.利用 redis 的原子性去实现

唯一 ID+指纹码机制
指纹码: 我们的一些规则或者时间戳加别的服务给到的唯一信息码,它并不一定是我们系统生成的,基 本都是由我们的业务规则拼接而来,但是一定要保证唯一性,然后就利用查询语句进行判断这个 id 是否存 在数据库中,优势就是实现简单就一个拼接,然后查询判断是否重复;劣势就是在高并发时,如果是单个数 据库就会有写入性能瓶颈当然也可以采用分库分表提升性能,但也不是我们最推荐的方式。

Redis 原子性

关于 Redis 的内容,感兴趣的大家可以移步至:✏️✏️✏️ Redis_ChinaRainbowSea的博客-CSDN博客

利用 redis 执行 setnx 命令,天然具有幂等性。从而实现不重复消费

2. RabbitMQ 消息队列的 " 优先级队列 " 的问题

在我们系统中有一个订单催付 的场景,我们的客户在天猫下的订单,淘宝会及时将订单推送给我们,如果在用户设定的时间内未付款那么就会给用户推送一条短信提醒,很简单的一个功能对吧,但是 , tmall 商家对我们来说,肯定是要分为大客户小客户 的对吧,比如像苹果,小米这样大商家一年起码能给我们创造很大的利润,所以理应当然,它们的订单必须得到优先处理,而曾经我们的后端系统是使用 Redis 来存放的定时轮询,大家都知道 Redis 只能用 List 做一个简简单单的消息队列,并不能实现一个优先级的场景,所以订单量大了后采用 RabbitMQ 进行改造和优化,如果发现是大客户的订单给一个相对比较高的优先级,否则就是默认的优先级。

如何添加:

  1. 控制台页面添加
  1. 队列代码中添加优先级
java 复制代码
Map<String, Object> params = new HashMap();
params.put("x-max-priority", 10);
channel.queueDeclare("hello", true, false, false, params);
  1. 消息中代码添加优先级
java 复制代码
AMQP.BasicProperties  properties = new  AMQP.BasicProperties().builder().priority(5).build();
  1. 注意事项:

要让队列实现优先级需要做的事情有如下事情:队列需要设置为优先级队列,消息需要设置消息的优先级,消费者需要等待消息已经发送到队列中才去消费,因为:这样才有机会对消息进行排序

简单的来说就是:我们需要让生产者(含有优先级)发送的消息,必须让其存放在队列当中,并且要是到达一定的量,让其在队列当中进行可以有一个时间进行一个(根据优先级上进行一个排序),不然,如果只有一个,就不需要将队列进行排序,因为只有一个就不存在一个优先级的问题。只有存在多个的时候,这样才有机会对消息进行一个排序。

实战:消息队列的"优先级队列" 的具体代码实现:

  1. 生产者------发送消息
java 复制代码
package com.rainbowsea.rabbitmq.nine;

import com.rabbitmq.client.AMQP;
import com.rabbitmq.client.Channel;
import com.rainbowsea.rabbitmq.utils.RabbitMQUtils;

public class Producer {
    private static final String QUEUE_NAME = "hello";

    public static void main(String[] args) throws Exception {
        try (Channel channel = RabbitMQUtils.getChannel();) {
//给消息赋予一个 priority 属性
            AMQP.BasicProperties properties = new AMQP.BasicProperties().builder().priority(5).build();
            for (int i = 1; i < 11; i++) {
                String message = "info" + i;
                if (i == 5) {
                    channel.basicPublish("", QUEUE_NAME, properties, message.getBytes());
                } else {
                    channel.basicPublish("", QUEUE_NAME, null, message.getBytes());
                }
                System.out.println("发送消息完成:" + message);
            }
        }
    }
}
  1. 消费者------消费
java 复制代码
package com.rainbowsea.rabbitmq.nine;

import com.rabbitmq.client.AMQP;
import com.rabbitmq.client.Channel;
import com.rabbitmq.client.DeliverCallback;
import com.rainbowsea.rabbitmq.utils.RabbitMQUtils;

import java.util.HashMap;
import java.util.Map;

public class Consumer {
    private static final String QUEUE_NAME = "hello";

    public static void main(String[] args) throws Exception {
        Channel channel = RabbitMQUtils.getChannel();
//设置队列的最大优先级  最大可以设置到 255 官网推荐 1-10 如果设置太高比较吃内存和 CPU 
        Map<String, Object> params = new HashMap();
        params.put("x-max-priority", 10);
        channel.queueDeclare(QUEUE_NAME, true, false, false, params);
        System.out.println("消费者启动等待消费......");
        DeliverCallback deliverCallback = (consumerTag, delivery) -> {
            String receivedMessage = new String(delivery.getBody());
            System.out.println("接收到消息:" + receivedMessage);
        };
        channel.basicConsume(QUEUE_NAME, true, deliverCallback, (consumerTag) -> {
            System.out.println("消费者无法消费消息时调用,如队列被删除");
        });
    }
}

2.1 创建交换机

exchange.test.priority

2.2 创建队列

queue.test.priority

x-max-priority

2.3 队列绑定交换机

2.4 RabbitMQ 结合 Spring Boot (分模块微服务)实现 "优先级队列"

生产者发送消息

1. 配置POM

xml 复制代码
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.1.5</version>
</parent>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-amqp</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
    </dependency>
</dependencies>

2. 配置 YAML 也可以使用 properties

yaml 复制代码
spring:
  rabbitmq:
    host: 192.168.200.100
    port: 5672
    username: guest
    password: 123456
    virtual-host: /

3. 主启动类

java 复制代码
package com.rainbowsea.mq;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class RabbitMQPriorityProducer {

    public static void main(String[] args) {
        SpringApplication.run(RabbitMQPriorityProducer.class, args);
    }

}

4. 发送消息

  • 不要启动消费者程序,让多条不同优先级的消息滞留在队列中
  • 第一次发送优先级为1的消息
  • 第二次发送优先级为2的消息
  • 第三次发送优先级为3的消息
  • 先发送的消息优先级低,后发送的消息优先级高,将来看看消费端是不是先收到优先级高的消息

第一次发送优先级为1的消息

java 复制代码
package com.rainbowsea.mq.test;

import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
public class RabbitMQTest {

    public static final String EXCHANGE_PRIORITY = "exchange.test.priority";
    public static final String ROUTING_KEY_PRIORITY = "routing.key.test.priority";

    @Resource
    private RabbitTemplate rabbitTemplate;

    @Test
    public void testSendMessage() {
        rabbitTemplate.convertAndSend(EXCHANGE_PRIORITY, ROUTING_KEY_PRIORITY, "I am a message with priority 1.", message->{
            message.getMessageProperties().setPriority(1);
            return message;
        });
    }

}

第二次发送优先级为2的消息

java 复制代码
@Test
public void testSendMessage() {
    rabbitTemplate.convertAndSend(EXCHANGE_PRIORITY, ROUTING_KEY_PRIORITY, "I am a message with priority 2.", message->{
        message.getMessageProperties().setPriority(2);
        return message;
    });
}

第三次发送优先级为3的消息

java 复制代码
@Test
public void testSendMessage() {
    rabbitTemplate.convertAndSend(EXCHANGE_PRIORITY, ROUTING_KEY_PRIORITY, "I am a message with priority 3.", message->{
        message.getMessageProperties().setPriority(3);
        return message;
    });
}

消费端接收消息:

  1. 配置POM
xml 复制代码
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.1.5</version>
</parent>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-amqp</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
    </dependency>
</dependencies>
  1. 配置YAML
yaml 复制代码
spring:
  rabbitmq:
    host: 192.168.200.100
    port: 5672
    username: guest
    password: 123456
    virtual-host: /

3. 主启动类

java 复制代码
package com.rainbowsea.mq;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class RabbitMQPriorityConsumer {

    public static void main(String[] args) {
        SpringApplication.run(RabbitMQPriorityConsumer.class, args);
    }

}

4.监听器

java 复制代码
package com.rainbowsea.mq.listener;

import com.rabbitmq.client.Channel;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.rabbit.annotation.*;
import org.springframework.stereotype.Component;

@Slf4j
@Component
public class MyMessageProcessor {

    public static final String QUEUE_PRIORITY = "queue.test.priority";

    @RabbitListener(queues = {QUEUE_PRIORITY})
    public void processPriorityMessage(String data, Message message, Channel channel) throws IOException {
        log.info(data);

        channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
    }

}

5. 测试效果

对于已经滞留服务器的消息,只要消费端一启动,就能够收到消息队列的投递,打印效果如下:


3. RabbitMQ 消息队列的 " 惰性队列 " 的问题

3.1 RabbitMQ 消息队列 " 惰性队列 "的概念

"惰性队列" 的应用场景:

RabbitMQ 从 3.6.0 版本开始引入了惰性队列的概念。惰性队列会尽可能的将消息存入磁盘中,而在消 费者消费到相应的消息时才会被加载到内存中,它的一个重要的设计目标是能够支持更长的队列,即支持 更多的消息存储。当消费者由于各种各样的原因(比如消费者下线、宕机亦或者是由于维护而关闭等)而致 使长时间内不能消费消息造成堆积时,惰性队列就很有必要了。

​ 默认情况下,当生产者将消息发送到 RabbitMQ 的时候,队列中的消息会尽可能的存储在内存之中, 这样可以更加快速的将消息发送给消费者。即使是持久化的消息,在被写入磁盘的同时也会在内存中驻留 一份备份。当 RabbitMQ 需要释放内存的时候,会将内存中的消息换页至磁盘中,这个操作会耗费较长的 时间,也会阻塞队列的操作,进而无法接收新的消息。虽然 RabbitMQ 的开发者们一直在升级相关的算法, 但是效果始终不太理想,尤其是在消息量特别大的时候。

官网说明

队列可以创建为默认惰性模式,模式指定方式是:

  • 使用队列策略(建议)
  • 设置queue.declare参数

如果策略和队列参数同时指定,那么队列参数有更高优先级。如果队列模式是在声明时通过可选参数指定的,那么只能通过删除队列再重新创建来修改。

3.2 基于策略方式设定

shell 复制代码
# 登录Docker容器
docker exec -it rabbitmq /bin/bash

# 运行rabbitmqctl命令
rabbitmqctl set_policy Lazy "^lazy-queue$" '{"queue-mode":"lazy"}' --apply-to queues

命令解读:

  • rabbitmqctl 命令所在目录是:/opt/rabbitmq/sbin,该目录已配置到Path环境变量

  • set_policy 是子命令,表示设置策略

  • Lazy 是当前要设置的策略名称,是我们自己自定义的,不是系统定义的

  • "^lazy-queue$" 是用正则表达式限定的队列名称,凡是名称符合这个正则表达式的队列都会应用这里的设置

  • '{"queue-mode":"lazy"}' 是一个JSON格式的参数设置指定了队列的模式为"lazy"

  • ---apply-to参数指定该策略将应用于队列(queues)级别

  • 命令执行后,所有名称符合正则表达式的队列都会应用指定策略,包括未来新创建的队列

如果需要修改队列模式可以执行如下命令(不必删除队列再重建):

shell 复制代码
rabbitmqctl set_policy Lazy "^lazy-queue$" '{"queue-mode":"default"}' --apply-to queues

3.3 在声明队列时使用参数设定

  • 参数名称:x-queue-mode
  • 可用参数值:
    • default
    • lazy
  • 不设置就是取值为default

Java代码原生API设置方式:

java 复制代码
Map<String, Object> args = new HashMap<String, Object>();
args.put("x-queue-mode", "lazy");
channel.queueDeclare("myqueue", false, false, false, args);

Java代码注解设置方式:

java 复制代码
@Queue(value = QUEUE_NAME, durable = "true", autoDelete = "false", arguments = {
	@Argument(name = "x-queue-mode", value = "lazy")
})

队列具备两种模式:default 和 lazy。默认的为 default 模式,在 3.6.0 之前的版本无需做任何变更。lazy 模式即为惰性队列的模式,可以通过调用 channel.queueDeclare 方法的时候在参数中设置,也可以通过 Policy 的方式设置,如果一个队列同时使用这两种方式设置的话,那么 Policy 的方式具备更高的优先级。 如果要通过声明的方式改变已有队列的模式的话,那么只能先删除队列,然后再重新声明一个新的。

在队列声明的时候可以通过 "x-queue-mode" 参数来设置队列的模式,取值为"default""lazy" 。下面示 例中演示了一个惰性队列的声明细节:

java 复制代码
Map<String, Object> args = new HashMap<String, Object>(); 
args.put("x-queue-mode", "lazy");
channel.queueDeclare("myqueue", false, false, false, args);

内存开销对比:

在发送 1 百万条消息,每条消息大概占 1KB 的情况下,普通队列占用内存是 1.2GB,而惰性队列仅仅占用 1.5MB

3.4 实操演练

生产者端代码

配置POM

xml 复制代码
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.1.5</version>
    </parent>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-amqp</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
    </dependencies>

配置YAML

yaml 复制代码
spring:
  rabbitmq:
    host: 192.168.200.100
    port: 5672
    username: guest
    password: 123456
    virtual-host: /

主启动类

java 复制代码
package com.rainbowsea.mq;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class RabbitMQLazyProducer {

    public static void main(String[] args) {
        SpringApplication.run(RabbitMQLazyProducer.class, args);
    }

}

发送消息

java 复制代码
package com.rainbowsea.mq.test;

import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.boot.test.context.SpringBootTest;

@SpringBootTest
public class RabbitMQTest {

    public static final String EXCHANGE_LAZY_NAME = "exchange.rainbowsea.lazy";
    public static final String ROUTING_LAZY_KEY = "routing.key.rainbowsea.lazy";

    @Resource
    private RabbitTemplate rabbitTemplate;

    @Test
    public void testSendMessage() {
        rabbitTemplate.convertAndSend(EXCHANGE_LAZY_NAME, ROUTING_LAZY_KEY, "I am a message for test lazy queue.");
    }

}

消费者端代码

配置POM

xml 复制代码
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.1.5</version>
    </parent>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-amqp</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
    </dependencies>

配置YAML

yaml 复制代码
spring:
  rabbitmq:
    host: 192.168.200.100
    port: 5672
    username: guest
    password: 123456
    virtual-host: /

主启动类

java 复制代码
package com.rainbowsea.mq;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class RabbitMQLazyConsumerMainType {

    public static void main(String[] args) {
        SpringApplication.run(RabbitMQLazyConsumerMainType.class, args);
    }
    
}

监听器

java 复制代码
package com.rainbowsea.mq.listener;

import com.rabbitmq.client.Channel;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.rabbit.annotation.*;
import org.springframework.stereotype.Component;

@Component
@Slf4j
public class MyLazyMessageProcessor {

    public static final String EXCHANGE_LAZY_NAME = "exchange.rainbowsea.lazy";
    public static final String ROUTING_LAZY_KEY = "routing.key.rainbowsea.lazy";
    public static final String QUEUE_LAZY_NAME = "queue.rainbowsea.lazy";

    @RabbitListener(bindings = @QueueBinding(
        value = @Queue(value = QUEUE_LAZY_NAME, durable = "true", autoDelete = "false", arguments = {
            @Argument(name = "x-queue-mode", value = "lazy")
        }),
        exchange = @Exchange(value = EXCHANGE_LAZY_NAME, durable = "true", autoDelete = "false"),
        key = {ROUTING_LAZY_KEY}
    ))
    public void processMessageLazy(String data, Message message, Channel channel) {
        log.info("消费端接收到消息:" + data);
    }

}

测试:

  • 先启动消费端

  • 基于消费端@RabbitListener注解中的配置,自动创建了队列

  • 发送消息

4. 最后:

"在这个最后的篇章中,我要表达我对每一位读者的感激之情。你们的关注和回复是我创作的动力源泉,我从你们身上吸取了无尽的灵感与勇气。我会将你们的鼓励留在心底,继续在其他的领域奋斗。感谢你们,我们总会在某个时刻再次相遇。"

相关推荐
安防视频中间件/视频资源汇聚平台25 分钟前
SVMSPro分布式综合安防管理平台--地图赋能智慧指挥调度新高度
分布式
喜欢便码1 小时前
JS小练习0.1——弹出姓名
java·前端·javascript
SYC_MORE1 小时前
vLLM实战:多机多卡大模型分布式推理部署全流程指南
分布式
chase。1 小时前
【学习笔记】MeshCat: 基于three.js的远程可控3D可视化工具
javascript·笔记·学习
Asthenia04122 小时前
为什么说MVCC无法彻底解决幻读的问题?
后端
Asthenia04122 小时前
面试官问我:三级缓存可以解决循环依赖的问题,那两级缓存可以解决Spring的循环依赖问题么?是不是无法解决代理对象的问题?
后端
Asthenia04122 小时前
面试复盘:使用 perf top 和火焰图分析程序 CPU 占用率过高
后端
Asthenia04122 小时前
面试复盘:varchar vs char 以及 InnoDB 表大小的性能分析
后端
weifexie2 小时前
ruby可变参数
开发语言·前端·ruby
Asthenia04122 小时前
面试问题解析:InnoDB中NULL值是如何记录和存储的?
后端