Redis面试题一(基本概念)

目录

[1.redis 为何这么快](#1.redis 为何这么快)

基于内存的操作

单线程模型

C语言实现

高效的数据结构

避免磁盘I/O

网络模型优化

2.redis为什么使用单线程

3.缓存三大问题以及解决方案

4.先删后写还是先写后删

[先删缓存后写 DB](#先删缓存后写 DB)

[先写 DB 再删缓存](#先写 DB 再删缓存)

[5.如何保证 Redis 的高并发](#5.如何保证 Redis 的高并发)

[6.redis 如何保证原子性](#6.redis 如何保证原子性)

7.redis的使用场景

8.redis分布式锁

场景一:分布式锁

场景二:计数器初始化

场景三:一次性任务调度

命令格式与参数

[9. redis数据结构](#9. redis数据结构)

字符串(String)

列表(List)

哈希(Hash)

集合(Set)

[有序集合(Sorted Set,简称ZSet)](#有序集合(Sorted Set,简称ZSet))

位图(Bitmaps)

HyperLogLog

地理位置(Geospatial)

[10.redis String 类型的底层实现](#10.redis String 类型的底层实现)

[11.有序集合 Zset 的底层实现](#11.有序集合 Zset 的底层实现)

[12.Zset 为何不使用红黑树等平衡树](#12.Zset 为何不使用红黑树等平衡树)


1.redis 为何这么快

Redis的快速性能主要归因于其基于内存的数据存储、单线程无锁并发模型、使用C语言实现、精心设计的数据结构、对磁盘I/O的谨慎处理以及高效的网络通信机制。这些特性共同作用,使得Redis能够在处理大量高速数据访问场景时表现出卓越的性能。

基于内存的操作

  • Redis将数据存储在内存中,而非传统的磁盘存储。内存的访问速度相比磁盘高出几个数量级,通常在纳秒级别,而磁盘I/O则可能达到毫秒甚至更长。这意味着Redis能够几乎实时地对数据进行读取和写入,极大地减少了数据访问延迟,从而实现极高的响应速度。

单线程模型

  • Redis采用了单线程处理命令请求的设计。尽管现代计算机多核环境下,多线程或多进程能够更好地利用硬件资源,但对于Redis这种主要服务于快速数据访问的系统来说,单线程避免了线程上下文切换带来的开销,以及对共享数据进行并发控制(如锁机制)的复杂性。这使得Redis的执行流程更为简单且高效,尤其是在数据集较小、操作复杂度较低的情况下,单线程足以提供很高的吞吐量。

C语言实现

  • Redis是使用C语言编写的,C语言贴近操作系统底层,执行效率高。C语言编译后的代码直接与硬件交互,没有高级语言的运行时开销,能够有效利用CPU资源,实现高效的内存管理与数据结构操作。

高效的数据结构

  • Redis内建了多种高度优化的数据结构(如字符串、哈希表、列表、集合、有序集合等),这些数据结构不仅在内存中紧凑存储,还针对常见操作(如插入、删除、查找、排序等)进行了算法优化,确保了在处理大量数据时仍能保持出色的性能。

避免磁盘I/O

  • 在正常操作过程中,Redis尽可能减少与磁盘的交互。数据持久化(如RDB快照和AOF日志)通常在后台异步进行,不影响主线程的处理速度。即使启用持久化,Redis也通过使用内存映射文件(mmap)和批量写入等技术来尽量降低对磁盘I/O的影响。

网络模型优化

  • Redis使用非阻塞的I/O多路复用技术(如epoll、kqueue),可以在单线程中高效地处理多个客户端连接,避免了为每个连接创建独立线程带来的开销。这种模型允许Redis在等待数据到达期间不会阻塞,而是继续处理其他已准备好的请求,充分利用CPU时间。

2.redis为什么使用单线程

Redis使用单线程模型是基于内存操作的高效性、对I/O多路复用技术的充分利用、避免上下文切换成本、简化编程与测试,以及历史沿革与兼容性等方面的综合考量。这一设计使得Redis能够在保证高吞吐量的同时,保持代码简洁、执行确定性强,并适应其作为内存数据库的核心应用场景。

  1. 内存操作高效性: Redis是一个基于内存的数据存储系统,它的大部分操作(如读取、写入、哈希计算等)都是在内存中完成的,这些操作本身非常快速,几乎没有明显的CPU瓶颈。因此,相比于通过多线程来并行处理请求以提高CPU利用率,单线程模型足以高效地处理大量的内存操作。

  2. 简化数据结构与算法设计: 单线程环境使得Redis可以避免复杂的锁机制,确保了数据结构操作的原子性,无需担心并发写入导致的数据竞争问题。这大大简化了代码实现,降低了编程难度和潜在的bug风险,同时也提高了系统的稳定性和可维护性。

  3. 充分利用I/O多路复用技术: 即使是单线程,Redis依然能够高效地服务于大量并发客户端。它采用了I/O多路复用技术(如epoll、kqueue),通过一个线程轮询监听多个文件描述符(即连接),当某个描述符就绪(如可读或可写)时,才进行相应的I/O操作。这种模型使得Redis在单线程中就能同时处理多个客户端的请求,而非阻塞等待某个客户端的响应,从而最大化地利用了网络资源,避免了无谓的线程切换带来的上下文切换开销。

  4. 避免上下文切换成本: 在多线程环境中,线程间的上下文切换会消耗一定的系统资源。由于Redis的主要性能瓶颈在于网络延迟和内存访问速度,而非CPU计算能力,因此,避免上下文切换有助于保持高性能。单线程模型保证了所有的操作在一个连续的执行流中完成,避免了线程调度带来的额外开销。

  5. 简化编程与测试: 单线程模型使得Redis的编程逻辑相对简单,开发者无需处理多线程环境中的同步问题、死锁等问题,这有利于代码的编写、调试和测试。同时,单线程也使得Redis的执行行为更易于预测,对开发者更加友好。

  6. 历史沿革与兼容性: Redis早期设计时选择了单线程模型,并且在实践中证明了其在许多场景下的高效性。随着Redis的发展,尽管在某些版本(如Redis 6)中引入了多线程特性用于特定场景(如网络数据传输),但其核心处理逻辑仍然保持单线程,以保持向后兼容性和对已有部署的平滑升级。

3.缓存三大问题以及解决方案

缓存穿透

缓存穿透是指查询的数据既不在缓存中,也不在数据库中。通常由非法或不存在的key引发,这类请求持续不断地直接打到数据库,对数据库造成压力。

解决方案

  1. 布隆过滤器(Bloom Filter):在访问缓存之前,先通过布隆过滤器判断请求的key是否可能存在。若布隆过滤器判断key不存在,则直接返回,避免无效请求穿透到数据库和缓存。
  2. 空值缓存:即使查询数据库得到空结果,也将这个空结果(如特殊标记)写入缓存,设定较短的过期时间。这样,后续相同的无效请求就可以直接从缓存中获取空结果,而不必穿透到数据库。
  3. 后台任务定期清理无效缓存:对于可能存在的误判,可以通过后台任务定期清理已过期的实际为空的缓存,避免缓存空间浪费。

缓存击穿

缓存击穿是指针对某个热点key,缓存刚好失效,而此时大量并发请求同时访问该key,所有请求都直接穿透到数据库,对数据库造成巨大压力。

解决方案

  1. 使用互斥锁(如Redis的setnxlua脚本:在缓存失效时,第一个请求去数据库加载数据的同时,其他请求等待锁释放,待第一个请求将数据写回缓存后,其他请求再从缓存获取,避免大量请求穿透到数据库。
  2. 设置热点数据永不过期或延长过期时间:同缓存雪崩的解决方案,对热点key采取特殊过期策略,降低击穿风险。
  3. 缓存永远不删除热点数据:对于绝对不允许出现击穿情况的极热点数据,可以采取始终保留缓存的策略,仅在后台更新缓存值。

缓存雪崩

缓存雪崩是指在某一时刻,大量缓存在同一时间点失效,导致大量请求直接落至数据库,造成数据库压力激增,严重时可能导致数据库崩溃。

解决方案

  1. 设置合理的缓存过期时间:避免大量缓存在同一时间点集中失效,可以采用不同的过期策略,如均匀分布的过期时间、基于访问频率动态调整过期时间等。
  2. 缓存预热:在缓存大规模失效前,提前进行缓存数据的加载,避免短时间内大量请求直接打到数据库。
  3. 热点数据永不过期或延长过期时间:对于访问频繁、重要性高的热点数据,可以考虑设置永不过期或使用较长的过期时间,降低雪崩风险。
  4. 缓存降级与服务熔断:在缓存雪崩发生时,通过降级策略暂时屏蔽非核心功能或返回兜底数据,减轻数据库压力;同时,结合服务熔断机制,限制对数据库的请求频率,防止数据库被过度压垮。
  5. 使用分布式缓存集群:通过多节点分摊请求压力,即使部分节点失效,其他节点仍能提供服务,减少雪崩影响。

4.先删后写还是先写后删

先删缓存后写 DB

产生脏数据的概率较大(若出现脏数据,则意味着再不更新的情况下,查询得到的数据均为旧的数据)。

比如两个并发操作,一个是更新操作,另一个是查询操作,更新操作删除缓存后,查询操作没有命中缓存,先把老数据读出来后放到缓存中,然后更新操作更新了数据库。于是,在缓存中的数据还是老的数据,导致缓存中的数据是脏的,而且还一直这样脏下去了。

先写 DB 再删缓存

产生脏数据的概率较小,但是会出现一致性的问题;若更新操作的时候,同时进行查询操作并命中,则查询得到的数据是旧的数据。但是不会影响后面的查询。

比如一个是读操作,但是没有命中缓存,然后就到数据库中取数据,此时来了一个写操作,写完数据库后,让缓存失效,然后之前的那个读操作再把老的数据放进去,所以会造成脏数据。

5.如何保证 Redis 的高并发

Redis 通过主从加集群架构,实现读写分离,主节点负责写,并将数据同步给其他从节点,从节点负责读,从而实现高并发。

6.redis 如何保证原子性

Redis通过原生支持原子操作的命令、事务机制、Lua脚本执行以及各种锁机制,从不同层面确保了数据操作的原子性。这些机制确保了即使在高并发环境下,对Redis数据的访问和更新也能保持一致性和完整性,不会出现数据竞争或中间状态的问题。

  1. 原子操作命令 : Redis提供了众多原生支持原子操作的命令,如SETGETHSETSADDINCRDEL等。这些命令在Redis内部实现为不可分割的操作,从接收到命令到完成数据更新的全过程不会被其他命令打断,保证了单一操作的原子性。即使在高并发环境下,对同一键的原子操作命令也不会相互干扰,始终保持数据的完整性。

  2. 事务(Transactions) : Redis支持事务机制,通过MULTIEXECDISCARDWATCH等命令实现。事务允许将一组命令作为一个整体执行,保证这些命令的原子性:

    • MULTI:开始一个事务,后续的命令被放入队列中暂不执行。
    • 命令入列 :将事务中的命令逐个发送至Redis,此时命令仅被记录而不被执行,Redis返回QUEUED响应。
    • EXEC:执行事务中的所有命令。在此期间,Redis会锁住涉及的键,确保事务内的命令序列按预定顺序执行,不受其他客户端干扰。如果事务中任何一个命令执行失败,其余命令都不会被执行,整个事务被视为失败。
    • DISCARD:取消当前事务,清除已入列的命令。
    • WATCH :监视一个或多个键,如果在EXEC前这些键被其他客户端修改,那么当前事务将被打断(EXEC返回空结果)。

    通过事务,Redis能保证一个事务内所有命令的原子性,即要么全部执行成功,要么全部不执行,不会出现部分命令生效、部分命令失败的情况。

  3. Lua脚本 : Redis支持在服务器端执行Lua脚本,通过EVALEVALSHA等命令提交。Lua脚本在Redis内部执行时,会被视为一个单一的操作,从开始到结束不会被其他客户端的命令打断,因此脚本内的所有操作具有原子性。使用Lua脚本可以实现更复杂的原子操作逻辑,同时避免了客户端在多个命令间进行协调的复杂性。

  4. 锁机制 : 对于跨越多个键或者需要在多个客户端间协调的更复杂场景,Redis提供了多种锁实现,如SETEXSETNXBLPOPBRPOPLPUSHXRPUSHX等命令可以用于实现特定条件下的原子操作。此外,Redis还支持更高级的分布式锁,如Redlock算法,通过在多个独立的Redis实例上获取和释放锁来确保在分布式环境下的操作原子性。

7.redis的使用场景

Redis凭借其高性能、丰富的数据结构、原子操作和灵活的使用方式,适用于各种需要快速访问、高并发、数据共享和实时处理的场景,尤其在缓存、排行榜、计数器、分布式会话、分布式锁、社交网络功能、消息队列、数据存储、限流器、登录鉴权以及实时分析与监控等方面有着广泛应用。

  1. 缓存(Cache)

    • 数据加速:作为数据库查询结果的缓存,减少对数据库的访问压力,显著提高应用的响应速度。例如,经常访问的用户信息、商品详情、网页内容等静态或半静态数据。
    • 热点数据存储:存储高频访问的热点数据,如排行榜、热门文章、实时统计信息等,利用Redis的内存存储特性实现极低延迟访问。
  2. 排行榜(Leaderboards)

    • 实时排行:利用有序集合(Sorted Set)数据类型,轻松实现基于分数或时间戳的实时排名,适用于游戏得分、用户积分、商品销量等场景。
  3. 计数器(Counters)

    • 统计计数 :使用INCRDECR等原子命令实现计数器功能,常用于统计页面访问量、用户点击数、商品库存变化等需要快速累加或减量的场景。
  4. 分布式会话(Distributed Session Management)

    • 会话存储:替代传统的基于文件或数据库的会话存储方式,利用Redis的高并发能力和数据持久化选项,实现跨服务器的用户会话共享,确保用户在集群环境下的无缝漫游。
  5. 分布式锁(Distributed Locking)

    • 同步控制 :使用SETNXEXPIRE等命令实现分布式锁,确保在分布式系统中对共享资源的互斥访问,如库存扣减、任务分配、数据库事务等需要防止并发冲突的场景。
  6. 社交网络功能

    • 好友关系:利用集合(Set)和有序集合存储用户的好友关系、关注者/被关注者列表,实现好友推荐、共同好友查询等功能。
    • 消息通知:如点赞、评论、消息推送等,通过发布/订阅(Pub/Sub)机制实现实时消息传递。
  7. 消息队列(Message Queue)

    • 轻量级队列:虽然Redis并非专门的消息队列系统,但可以利用列表(List)数据结构实现简单的消息队列或任务队列,适用于消息堆积不大、对消息持久化要求不高的场景。
  8. 数据存储(Data Storage)

    • 持久化数据:结合Redis的数据持久化功能(RDB/AOF),可将部分数据直接存储在Redis中作为轻量级数据库使用,特别适合存储小规模、读多写少、需要快速访问的数据。
  9. 限流器(Rate Limiter)

    • 流量控制:利用Redis的原子操作实现访问速率限制,如IP访问频率限制、API调用次数限制等,防止恶意攻击或保护系统资源。
  10. 登录鉴权(Authentication)

    • Token管理:存储和验证用户的登录令牌(Token)、验证码等短期敏感数据,利用Redis的过期机制自动处理令牌过期。
  11. 实时分析与监控

    • 实时统计分析:收集并汇总实时统计数据,如网站PV、UV、用户行为数据等,用于实时监控和数据分析。

8.redis分布式锁

SETNX (SET if Not eXists)是Redis提供的一种命令,用于原子性地设置键值对,仅在键不存在时才执行设置操作。如果键已经存在,命令将不做任何改变并返回0。反之,如果键不存在,命令将成功创建键并设置其值,同时返回1SETNX常用于实现分布式锁、计数器初始化等场景,确保在多客户端并发访问时,只有第一次执行命令的客户端能够成功设置键值。

以下是使用SETNX命令的一些典型场景和示例:

场景一:分布式锁

在分布式系统中,SETNX可用于实现简单的分布式锁,确保在同一时间内只有一个客户端能持有锁:

bash 复制代码
# 客户端A尝试获取锁
SETNX lock_key unique_identifier_A

# 如果返回1,表示客户端A成功获取锁
if [ $? -eq 1 ]; then
  # 执行临界区代码...
  # 在代码执行完毕后,释放锁
  DEL lock_key
else
  # 返回0,表示锁已被其他客户端持有,客户端A等待或重试
fi

注意,为了防止客户端在持有锁期间崩溃导致锁无法释放,通常还需要结合使用EXPIRE命令为锁设置一个超时时间。此外,为了实现更健壮的分布式锁,现代实践中往往推荐使用SET命令配合NXPX选项,或者使用如Redlock算法这样的高级方案。

场景二:计数器初始化

SETNX可以确保某个计数器只被初始化一次,避免重复初始化导致的计数错误:

bash 复制代码
# 初始化计数器,仅在未初始化时执行
SETNX counter 0

# 获取计数器当前值并递增
INCR counter

场景三:一次性任务调度

假设需要确保某个任务仅被执行一次,可以先使用SETNX创建一个标记键:

bash 复制代码
# 检查任务是否已执行
SETNX task_executed 1

# 如果返回1,表示任务尚未执行
if [ $? -eq 1 ]; then
  # 执行任务...
fi

命令格式与参数

bash 复制代码
SETNX key value
  • key:要设置的键名。
  • value:要设置的值。如果键不存在,该值将被设置;如果键已存在,此参数会被忽略。

总结来说,SETNX命令在Redis中提供了一种简单而有效的原子性设置操作,尤其适用于需要确保某项操作仅被执行一次或某个资源仅被首次请求者占用的场景。在实际使用时,应结合具体业务需求和Redis的其他功能(如键过期、脚本等)来构建更为健壮和高效的解决方案。

9. redis数据结构

Redis 支持的数据结构包括字符串、列表、哈希、集合、有序集合、位图、HyperLogLog 和地理位置。这些数据结构各具特色,适用于不同的应用场景,用户可以根据实际需求选择合适的数据结构来实现高效的数据存储和操作。

字符串(String)

  • 基本使用:是最基础的数据类型,可以存储任何类型的字符串数据,包括文本、序列化对象、JSON、图片的二进制数据等。
  • 特点
    • 二进制安全:可以存储任何形式的二进制数据,没有字符集限制。
    • 容量限制:单个键值对的最大容量为 512 MB。
    • 内部编码:有多种内部编码,如整数值(int)、简单动态字符串(embstr)、原始字符串(raw),根据存储的数据类型和大小自动选择最合适的编码方式以节省内存。

列表(List)

  • 基本使用:是一个有序的字符串列表,支持两端插入(LPUSH/RPUSH)、弹出(LPOP/RPOP)、获取指定范围内的元素(LRANGE)等操作。
  • 特点
    • 双向链表:底层实现通常为双向链表(如quicklist),支持高效地在头部和尾部进行增删操作。
    • 重复元素:允许存储重复的字符串元素。
    • 列表长度:可动态增长,适用于实现消息队列、社交动态等场景。

哈希(Hash)

  • 基本使用:存储键值对集合,类似关联数组或对象,支持添加、删除、查询特定字段的值(HSET/HGET/HDEL等)以及获取全部字段值(HGETALL)。
  • 特点
    • 字段值为字符串:每个字段和对应的值都是字符串类型。
    • 无序:虽然内部存储是无序的,但可以通过HMGET等命令一次性获取多个字段的值。
    • 空间效率:对于大量小字段和值,Redis可能会使用压缩列表(ziplist,旧版)或listpack(新版)作为底层实现以节省内存。

集合(Set)

  • 基本使用:存储不重复的字符串元素集合,支持添加、删除元素(SADD/SREM)、判断元素是否存在(SISMEMBER)、获取集合的所有元素(SMEMBERS)以及集合间运算(如交集、并集、差集)。
  • 特点
    • 无序:元素无固定顺序,但操作结果集通常按字典序返回。
    • 唯一性:自动去重,不允许重复元素。
    • 高效集合操作:对集合间的数学运算提供了原生支持,非常适合进行成员关系分析。

有序集合(Sorted Set,简称ZSet)

  • 基本使用:与集合类似,但每个成员都有一个分数(score),根据分数进行排序。支持添加、删除元素(ZADD/ZREM)、查询元素分数(ZSCORE)、按分数范围查询成员(ZRANGE/ZREVRANGE)等。
  • 特点
    • 排序:成员按分数值进行升序或降序排列。
    • 唯一性:成员是唯一的,但分数可以相同(此时成员的相对顺序不确定,但可以通过ZADD的NX或XX选项控制)。
    • 高效范围查询:能快速获取指定分数范围内的成员,适合排行榜、带权重的数据存储等场景。

位图(Bitmaps)

  • 基本使用:特殊的字符串类型,每个字节代表8个位,可以进行位级别的操作,如设置、清零、计数、测试位状态等。
  • 特点
    • 位操作:支持对位进行原子操作,如统计用户签到、存储用户特征标志等。
    • 空间效率:对于需要存储大量布尔状态的数据,位图极其节省空间。

HyperLogLog

  • 基本使用:用于估算一个集合中不重复元素(即基数)的数量,而不需要实际存储所有元素。
  • 特点
    • 近似计数:提供一个估算值,具有较小的误差率(通常为0.81%)。
    • 极高空间效率:只需固定的小量内存即可处理极大数量的唯一元素。

地理位置(Geospatial)

  • 基本使用:存储地理位置信息(经纬度),并提供基于地理位置的距离查询、范围查询、地理围栏(GEOHASH)等功能。
  • 特点
    • 地理空间索引:对地理位置数据进行索引,支持快速查找附近的点、计算两点间距离等。
    • 灵活查询:支持按距离、边界框等方式筛选数据,适用于LBS(Location-Based Services)应用。

10.redis String 类型的底层实现

Redis 中 String 类型的底层实现采用了名为 Simple Dynamic String(SDS)的数据结构。SDS 是 Redis 团队设计的一种自定义字符串表示方式,它相较于传统的 C 语言字符串(以 \0 结尾的字符数组)具有多项优势,尤其是在性能、安全性、操作便利性等方面。
SDS 在结构中显式记录了字符串的长度(len 字段),无需像 C 字符串那样通过遍历直到遇到 \0 来确定长度,从而实现了 O(1) 时间复杂度获取字符串长度。

SDS 结构定义

cpp 复制代码
struct sdshdr {
    int len;      // 记录字符串的长度(不包括末尾的 '\0')
    int free;     // 记录已分配空间中未使用的字节数
    char buf[];   // 字符数组,用于存储字符串内容,末尾会自动添加 '\0'
};

11.有序集合 Zset 的底层实现

zset 是 Redis 中一个非常重要的数据结构,其底层是基于跳表(skip list) 实现的。

跳表是一种随机化的数据结构,基于并联的链表,实现简单,插入、删除、查找的复杂度均为 O(logN)。简单说来跳表也是链表的一种,只不过它在链表的基础上增加了跳跃功能,正是这个跳跃的功能,使得在查找元素时,跳表能够提供 O(logN) 的时间复杂度。

跳表为了避免每次插入或删除带来的额外操作,不要求上下相邻两层链表之间的节点个数有严格的对应关系,而是为每个节点随机出一个层数(level)。而且新插入一个节点不会影响其它节点的层数。因此,插入操作只需要修改插入节点前后的指针,而不需要对很多节点都进行调整。

12.Zset 为何不使用红黑树等平衡树

  1. 跳跃表范围查询比平衡树操作简单。 因为平衡树在查询到最小值的时还需要采用中序遍历去查询最大值。 而跳表只需要在找到最小值后,对第一层的链表遍历即可。
  2. 平衡树的删除和插入需要对子树进行相应的调整,而跳表只需要修改相邻的节点即可。
  3. 跳表和平衡树的查询操作都是O(logN)的时间复杂度。
  4. 从整体上来看,跳表算法实现的难度要低于平衡树。
相关推荐
一叶屋檐17 分钟前
Neo4j 图书馆借阅系统知识图谱设计
服务器·数据库·cypher
想躺平的小农33 分钟前
EasyExcel详解
java
慧一居士34 分钟前
EasyExcel集成使用总结与完整示例
java·excel
呦呦彬34 分钟前
【问题排查】easyexcel日志打印Empty row!
java·开发语言·log4j
九章云极AladdinEdu1 小时前
GPU与NPU异构计算任务划分算法研究:基于强化学习的Transformer负载均衡实践
java·开发语言·人工智能·深度学习·测试工具·负载均衡·transformer
佩奇的技术笔记1 小时前
Java学习手册:客户端负载均衡
java·负载均衡
好吃的肘子1 小时前
MongoDB 应用实战
大数据·开发语言·数据库·算法·mongodb·全文检索
weixin_472339461 小时前
MySQL MCP 使用案例
数据库·mysql
可乐加.糖1 小时前
项目版本管理和Git分支管理方案
java·git·目标跟踪·gitlab·敏捷流程·源代码管理
wowocpp2 小时前
spring boot Controller 和 RestController 的区别
java·spring boot·后端