Redis学习总结

参考:

Redis之AOF重写及其实现原理
Redis面试题(2020最新版)

一、概述

Redis 是一个使用 C 语言开发的速度非常快的非关系型(NoSQL)内存键值数据库,可以存储键和五种不同类型的值之间的映射。
键的类型只能为字符串,值支持五种数据类型:字符串、列表、集合、散列表、有序集合。
• Redis将所有的数据都存放在内存中数据结构简单,所以它的读写性能十分惊人。
 同时,Redis还可以将内存中的数据以快照或日志的形式保存到硬盘上,以保证数据的安全性。
 Redis 支持很多特性,例如将内存中的数据持久化到硬盘中,使用复制来扩展读性能,使用分片来扩展写性能。
• Redis典型的应用场景包括:缓存、排行榜、计数器、社交网络(关注与粉丝)、消息队列等

二、Redis 常见数据结构以及使用场景分析

你可以自己本机安装 redis 或者通过 redis 官网提供的在线 redis 环境

字符串string:

  1. 介绍 :string 数据结构是简单的 key-value 类型。虽然 Redis 是用 C 语言写的,但是 Redis 并没有使用 C 的字符串表示,而是自己构建了一种 简单动态字符串(simple dynamic string,SDS)。相比于 C 的原生字符串,Redis 的 SDS 不光可以保存文本数据还可以保存二进制数据,并且获取字符串长度复杂度为 O(1)(C 字符串为 O(N)),除此之外,Redis 的 SDS API 是安全的,不会造成缓冲区溢出。
  2. 常用命令: set,get,strlen,exists,dect,incr,setex 等等。    
  3. 应用场景 :一般常用在需要计数的场景(字符串的内容为整数的时候可以使用),比如用户的访问次数、热点文章的点赞转发数量等等。
set test:count 1
incr test:count
decr test:count
 

列表 list

  1. 介绍 :list 即是 链表。链表是一种非常常见的数据结构,特点是易于数据元素的插入和删除并且且可以灵活调整链表长度,但是链表的随机访问困难。许多高级编程语言都内置了链表的实现比如 Java 中的 LinkedList,但是 C 语言并没有实现链表,所以 Redis 实现了自己的链表数据结构。Redis 的 list 的实现为一个 双向链表,即可以支持反向查找和遍历,更方便操作,不过带来了部分额外的内存开销。
  2. 常用命令: rpush,lpop,lpush,rpop,lrange、llen 等。
  3. 应用场景: 发布与订阅或者说消息队列、慢查询。
lpush test:ids 101 102 103    // 每次把数据从列表的左边压入
lindex test:ids 0;    // 103
lrange test:ids 0 -1    // 列出所有元素    103 102 101
rpop test:ids    // 101
lpop test:ids    // 103


rpush test:ids 101 102 103    // 每次把数据从列表的右边压入
lindex test:ids 0;    // 101
lrange test:ids 0 -1    // 列出所有元素    101 102 103
rpop test:ids    // 103
lpop test:ids    // 101

Hash: 每个键对应的值是一个HashMap

  1. 介绍 :hash 类似于 JDK1.8 前的 HashMap,内部实现也差不多(数组 + 链表)。不过,Redis 的 hash 做了更多优化。另外,hash 是一个 string 类型的 field 和 value 的映射表,特别适合用于存储对象,后续操作的时候,你可以直接仅仅修改这个对象中的某个字段的值。 比如我们可以 hash 数据结构来存储用户信息,商品信息等等。
  2. 常用命令: hset,hmset,hexists,hget,hgetall,hkeys,hvals 等。
  3. 应用场景: 系统中对象数据的存储
 hset test:user username zhangsan
 hset test:user id 1
 hget test:user id

无序集合set

  1. 介绍 : set 类似于 Java 中的 HashSet 。Redis 中的 set 类型是一种无序集合,集合中的元素没有先后顺序。当你需要存储一个列表数据,又不希望出现重复数据时,set 是一个很好的选择,并且 set 提供了判断某个成员是否在一个 set 集合内的重要接口,这个也是 list 所不能提供的。可以基于 set 轻易实现交集、并集、差集的操作。比如:你可以将一个用户所有的关注人存在一个集合中,将其所有粉丝存在一个集合。Redis 可以非常方便的实现如共同关注、共同粉丝、共同喜好等功能。这个过程也就是求交集的过程。
  2. 常用命令 sadd,spop,smembers,sismember,scard,sinterstore,sunion 等。
  3. 应用场景: 需要存放的数据不能重复以及需要获取多个数据源交集和并集等场景
sadd test:teacher aaa bbb bbb ccc
scard test:teacher	// 统计大小 3
smembers test:teacher	//列出所有成员 "aaa" "bbb" "ccc"

有序集合zset (sorted set)

  1. 介绍: 和 set 相比,sorted set 增加了一个权重参数 score,使得集合中的元素能够按 score 进行有序排列,还可以通过 score 的范围来获取元素的列表。有点像是 Java 中 HashMap 和 TreeSet 的结合体。
  2. 常用命令 zadd,zcard,zscore,zrange,zrevrange,zrem 等。
  3. 应用场景: 需要对数据根据某个权重进行排序的场景。比如在直播系统中,实时排行信息包含直播间在线用户列表,各种礼物排行榜,弹幕消息(可以理解为按消息维度的消息排行榜)等信息。
zadd test:student 10 aaa 20 bbb 30 ccc 40 ccc
zcard test:student        //元素个数 3
zrange test:student 0 -1    //列出所有元素(按分值的从小到大顺序列出) "aaa" "bbb" "ccc"
zscore test:student ccc        //查找某个key的分值 40
zrank test:student ccc        // 按分值的从小到大排名    2

Redis 其他常用命令

keys *    // 列出所有key
keys test:*    // 列出所有以test:开头的key
type test:user    // 打印test:user这个key的数据类型    hash/string/list/set/zset
exists test:user    // 判断当前数据库是否存在test:user这个key
del test:user        // 删除这个key
expire test:teacher 10    // 把test:teacher标记为10s后过期

跳跃表

是有序集合的底层实现之一。被广泛地运用到了各种缓存地实现当中,它的主要优点,就是可以跟红黑树、AVL等平衡树一样,做到比较稳定地插入、查询与删除。理论插入查询删除的算法时间复杂度为O(logN)。
跳跃表是基于多指针有序链表实现的,可以看成多个有序链表。
查找:
在查找时,从上层指针开始查找,找到对应的区间之后再到下一层去查找。
插入:
先生成一个随机数,确定该元素要占据的层数 K,然后从顶层开始,逐层找到每层需要插入的位置,再生成层数并插入
与红黑树等平衡树相比,跳跃表具有以下优点:
1. 插入速度非常快速,因为不需要进行旋转等操作来维护平衡性;
2. 代码相对简单,更容易实现;
3. 支持无锁操作。
 
参考文章:

三、分布式缓存常见的技术选型方案有哪些?

分布式缓存的话,使用的比较多的主要是 Memcached 和 Redis

过去分布式缓存最开始兴起的那会Memcached 比较常用,不过已经慢慢地被强大的Redis取代了。

分布式缓存主要解决的是单机缓存的容量受服务器限制并且无法保存通用的信息。部署了同一服务的多台机器的本地缓存之间是无法数据共享的。

 

四、说一下 Redis 和 Memcached 的区别和共同点

随着 Redis 越来越强大,现在公司一般都是用 Redis 来实现缓存!不过,了解 Redis 和 Memcached 的区别和共同点,有助于我们在做相应的技术选型的时候,能够做到有理有据!

共同点 :

  1. 都是基于内存的数据库,一般都用来当做缓存使用。
  2. 都有过期策略。
  3. 两者的性能都非常高。

区别 :

  1. Redis 支持更丰富的数据类型(支持更复杂的应用场景)。Redis 不仅仅支持简单的 k/v 类型的数据,同时还提供 list,set,zset,hash 等数据结构的存储。Memcached 只支持最简单的 k/v 数据类型。
  2. Redis 支持数据的持久化,可以将内存中的数据保持在磁盘中,重启的时候可以再次加载进行使用,而 Memecache 把数据全部存在内存之中。
  3. Redis 有灾难恢复机制。 因为可以把缓存中的数据持久化到磁盘上。
  4. Redis 在服务器内存使用完之后,可以将不用的数据放到磁盘上。但是,Memcached 在服务器内存使用完之后,就会直接报异常。
  5. Memcached 没有原生的集群模式,需要依靠客户端来实现往集群中分片写入数据;但是 Redis 目前是原生支持 cluster 模式的.
  6. Memcached 是多线程,非阻塞 IO 复用的网络模型;Redis 使用单线程的多路 IO 复用模型。 (Redis 6.0 引入了多线程 IO )
  7. Redis 支持发布订阅模型、Lua 脚本、事务等功能,而 Memcached 不支持。并且,Redis 支持更多的编程语言。
  8. Memcached过期数据的删除策略只用了惰性删除,而 Redis 同时使用了惰性删除与定期删除。
通过对比,可以看出Redis的优势确实非常明显。
 

五、为什么要用 Redis/为什么要用缓存?

简单来说,使用缓存主要是为了提升用户体验以及应对更多的用户,也就是为了解决高性能和高并发的问题。

高性能 :

如果命中缓存,可以直接在内存中取出数据返回响应,不需要和数据库交互,效率高。降低了服务的请求响应延迟,提升用户体验。

不过,要保持数据库和缓存中的数据的一致性。 如果数据库中的对应数据改变的之后,同步改变缓存中相应的数据即可!

高并发:

一般像 MySQL 这类的数据库的 每秒大概能执行1w条简单SQL ,太多的请求就会把数据库压死,导致数据库宕机。但是加上缓存后,大部分请求都会命中缓存然后直接返回,绝大部分流量都不会打到数据库中,这样极大的降低了数据库的压力。这样也就提高的系统整体的并发能力。

六、Redis 单线程模型详解

底层使用的是 NIO 的多路复用技术。会有一个 Selector 线程不断轮询多个 Socket 的状态,只有当 Socket 真正有读写事件时才通知用户线程进行实际的 IO 读写操作。只需要一个线程管理多个 Socket,并且只在真正有读写事件时才会使用操作系统的 IO 资源,大大节约了系统资源。

Redis 基于 Reactor 模式开发了自己的网络事件处理器:这个处理器被称为文件事件处理器(file event handler)。文件事件处理器使用 I/O 多路复用(multiplexing)程序来同时监听多个套接字,并根据 套接字目前执行的任务来为套接字关联不同的事件处理器。

当被监听的套接字准备好执行连接应答(accept)、读取(read)、写入(write)、关 闭(close)等操作时,与操作相对应的文件事件就会产生,这时文件事件处理器就会调用套接字之前关联好的事件处理器来处理这些事件。

虽然文件事件处理器以单线程方式运行,但通过使用 I/O 多路复用程序来监听多个套接字,文件事件处理器既实现了高性能的网络通信模型,又可以很好地与 Redis 服务器中其他同样以单线程方式运行的模块进行对接,这保持了 Redis 内部单线程设计的简单性。

可以看出,文件事件处理器(file event handler)主要是包含 4 个部分:

  • 多个 socket(客户端连接)
  • IO 多路复用程序(支持多个客户端连接的关键)
  • 文件事件分派器(将 socket 关联到相应的事件处理器)
  • 事件处理器(连接应答处理器、命令请求处理器、命令回复处理器)

七、Redis 没有使用多线程?为什么不使用多线程?

虽然说 Redis 是单线程模型,但是, 实际上,Redis 在 4.0 之后的版本中就已经加入了对多线程的支持。

不过,Redis 4.0 增加的多线程主要是针对一些大键值对的删除操作的命令,使用这些命令就会使用主线程之外的其他线程来“异步处理”。

大体上来说,Redis 6.0 之前主要还是单线程处理。

那Redis6.0 之前 为什么不使用多线程?

  1. 单线程编程容易并且更容易维护;
  2. Redis 的性能瓶颈不在 CPU ,主要在内存和网络;
  3. 多线程就会存在死锁、线程上下文切换等问题,甚至会影响性能。

八、Redis6.0 之后为何引入了多线程?

Redis6.0 引入多线程主要是为了提高网络 IO 读写性能,因为这个算是 Redis 中的一个性能瓶颈(Redis 的瓶颈主要受限于内存和网络)。

虽然,Redis6.0 引入了多线程,但是 Redis 的多线程只是在网络数据的读写这类耗时操作上使用了, 执行命令仍然是单线程顺序执行。因此,你也不需要担心线程安全问题。

Redis6.0 的多线程默认是禁用的,只使用主线程。如需开启需要修改 redis 配置文件 redis.conf :


io-threads-do-reads yes

开启多线程后,还需要设置线程数,否则是不生效的。同样需要修改 redis 配置文件 redis.conf :


io-threads 4 #官网建议4核的机器建议设置为2或3个线程,8核的建议设置为6个线程

推荐阅读:

  1. Redis 6.0 新特性-多线程连环 13 问!
  2. 为什么 Redis 选择单线程模型

九、Redis 给缓存数据设置过期时间有啥用?

一般情况下,我们设置保存的缓存数据的时候都会设置一个过期时间。为什么呢?

因为内存是有限的,如果缓存中的所有数据都是一直保存的话,分分钟直接Out of memory。

Redis 自带了给缓存数据设置过期时间的功能,比如:

127.0.0.1:6379> exp key  60 # 数据在 60s 后过期
(integer) 1
127.0.0.1:6379> setex key 60 value # 数据在 60s 后过期 (setex:[set] + [ex]pire)
OK
127.0.0.1:6379> ttl key # 查看数据还有多久过期
(integer) 56

注意:Redis中除了字符串类型有自己独有设置过期时间的命令 setex 外,其他方法都需要依靠 expire 命令来设置过期时间 。另外, persist 命令可以移除一个键的过期时间: 

过期时间除了有助于缓解内存的消耗,还有什么其他用么?

很多时候,我们的业务场景就是需要某个数据只在某一时间段内存在,比如我们的短信验证码可能只在1分钟内有效,用户登录的 token 可能只在 1 天内有效。

如果使用传统的数据库来处理的话,一般都是自己判断过期,这样更麻烦并且性能要差很多。

十、Redis是如何判断数据是否过期的呢?

Redis 通过一个叫做过期字典(可以看作是hash表)来保存数据过期的时间。过期字典的键指向Redis数据库中的某个key(键),过期字典的值是一个long long类型的整数,这个整数保存了key所指向的数据库键的过期时间(毫秒精度的UNIX时间戳)。

过期字典是存储在redisDb这个结构里的:

typedef struct redisDb {
    ...

    dict *dict;     //数据库键空间,保存着数据库中所有键值对
    dict *expires   // 过期字典,保存着键的过期时间
    ...
} redisDb;

十一、过期的数据的删除策略了解么?

Redis会把设置了过期时间的key放入一个独立的字典里,在key过期时并不会立刻删除它。Redis会通过如下两种策略,来删除过期的key(重要!自己造缓存轮子的时候需要格外考虑的东西):

  1. 惰性删除 :客户端访问某个key时, Redis会检查该key是否过期,若过期则删除。只会在取出key的时候才对数据进行过期检查。这样对CPU很好,只消耗很少的 CPU 资源,但是可能会造成太多过期 key 没有被删除。
  2. 定期删除 : 每隔一段时间抽取一批 key 执行删除过期key操作。Redis默认每秒执行10次过期扫描(配置hz选项),扫描策略如下:
    1. 从过期字典中随机选择20个key;
    2. 删除这20个key中已过期的key;
    3. 如果过期的key的比例超过25%,则重复步骤1;
Redis 底层会通过限制删除操作执行的时长和频率来减少删除操作对CPU时间的影响。

定期删除对内存更加友好,可以及时清理过期的key, 给内存腾空间,但是定时任务会消耗cpu资源,惰性删除对CPU更加友好,只消耗很少的 CPU 资源。两者各有千秋,所以Redis 采用的是 定期删除+惰性/懒汉式删除 。

但是,仅仅通过给 key 设置过期时间还是有问题的。因为还是可能存在定期删除和惰性删除漏掉了很多过期 key 的情况。这样就导致大量过期 key 堆积在内存里,然后就Out of memory了。

怎么解决这个问题呢?答案就是: Redis 内存淘汰机制

十二、Redis 内存淘汰机制了解么?

相关问题:MySQL 里有 2000w 数据,Redis 中只存 20w 的数据,如何保证 Redis 中的数据都是热点数据?

当Redis占用内存超出最大限制(maxmemory)时,可采用如下策略(maxmemory-policy) , 让Redis淘汰一些数据,以腾出空间继续提供读写服务:

  1. noeviction:对可能导致增大内存的命令返回错误(大多数写命令,DEL除外);
  2. volatile-ttl: 在设置了过期时间的key中,选择剩余寿命(TTI)最短的key,将其淘汰;
  3. volatile-lru:在设置了过期时间的key中,选择最少使用的key(LRU),将其淘汰;
  4. volatile-random:在设置了过期时间的key中,随机选择一些key,将其淘汰;
  5. allkeys-lru:在所有的key中,选择最少使用的key (LRU),将其淘汰;
  6. allkeys-random:在所有的key中,随机选择一些key,将其淘汰;

4.0 版本后增加以下两种:

  1. volatile-lfu(least frequently used):在设置了过期时间的key中,选择使用频率最低的一些key, 进行淘汰。
  2. allkeys-lfu(least frequently used):在所有的key中,选择使用频率最低的一些key, 进行淘汰。

十三、持久化

Redis 是内存型数据库,为了保证数据在断电后不会丢失,需要将内存中的数据持久化到硬盘上。

RDB(Redis DataBase) 持久化

将某个时间点的所有数据都存放到硬盘上。
可以将快照复制到其它服务器从而创建具有相同数据的服务器副本。
如果系统发生故障,将会丢失最后一次创建快照之后的数据。
如果数据量很大,保存快照的时间会很长。

AOF 持久化

将写命令添加到 AOF 文件(Append Only File)的末尾。默认情况下 Redis 没有开启 AOF(append only file)方式的持久化,可以通过 appendonly 参数开启:
使用 AOF 持久化需要设置同步选项,从而确保写命令同步到磁盘文件上的时机。这是因为对文件进行写入并不会马上将内容同步到磁盘上,而是先存储到缓冲区,然后由操作系统决定什么时候同步到磁盘。有以下同步选项:
  • always 选项会严重减低服务器的性能;
  • everysec 选项比较合适,可以保证系统崩溃时只会丢失一秒左右的数据,并且 Redis 每秒执行一次同步对服务器性能几乎没有任何影响;
  • no 选项并不能给服务器性能带来多大的提升,而且也会增加系统崩溃时数据丢失的数量。
随着服务器写请求的增多,AOF 文件会越来越大。Redis 提供了一种将 AOF 重写的特性,能够去除 AOF 文件中的冗余写命令,极大的减少了AOF文件所占的内存和磁盘空间。
RDB以快照的形式进行数据备份,需要花费一定的时间,且写入磁盘的时候需要阻塞,会影响正在运行业务的运行,数据体积小,恢复速度快,适合长时间的备份,不适合太频繁的操作;以AOF以日志的形式,把每条执行的命令都存储在日志中,实时性好,但是恢复速度慢;

拓展:Redis 4.0 对于持久化机制的优化

Redis 4.0 开始支持 RDB 和 AOF 的混合持久化(默认关闭,可以通过配置项 aof-use-rdb-preamble 开启)。

如果把混合持久化打开,AOF 重写的时候就直接把 RDB 的内容写到 AOF 文件开头。这样做的好处是可以结合 RDB 和 AOF 的优点, 快速加载同时避免丢失过多的数据。当然缺点也是有的, AOF 里面的 RDB 部分是压缩格式不再是 AOF 格式,可读性较差。

AOF 重写

  • AOF 持久化是通过保存被执行的写命令来记录数据库状态的,所以AOF文件的大小随着时间的流逝一定会越来越大;影响包括但不限于:对于Redis服务器,计算机的存储压力;AOF还原出数据库状态的时间增加;
  • 为了解决AOF文件体积膨胀的问题,Redis提供了AOF重写功能:Redis服务器可以创建一个新的AOF文件来替代现有的AOF文件,新旧两个文件所保存的数据库状态是相同的,但是新的AOF文件不会包含任何浪费空间的冗余命令,通常体积会较旧AOF文件小很多。

十四、缓存穿透

查询根本不存在的数据,因为数据不存在,缓存中肯定没有该数据的副本,使得请求直达存储层,导致其负载过大,甚至宕机。

解决方案

1.缓存空对象
存储层未命中后,仍然将空值存入缓存层。再次访问该数据时,缓存层会直接返回空值。
2.布隆过滤器
将所有存在的key提前存入布隆过滤器,在访问缓存层之前,先通过过滤器拦截,若请求的是不存在的key,则直接返回空值。

十五、缓存雪崩

指的是由于数据没有被加载到缓存中,或者缓存数据在同一时间大面积失效(过期),又或者缓存服务器宕机,导致大量的请求都到达数据库。由于某些原因,缓存层不能提供服务,导致所有的请求或者大量请求直达存储层,造成存储层宕机。
在有缓存的系统中,系统非常依赖于缓存,缓存分担了很大一部分的数据请求。当发生缓存雪崩时,数据库无法处理这么大的请求,导致数据库崩溃。

解决方案:

1.避免同时过期
设置过期时间时,附加一个随机数,避免大量的key同时过期。
2.构建高可用的Redis缓存
部署多个Redis实例,个别节点宕机,依然可以保持服务的整体可用。
3.构建多级缓存
增加本地缓存,在存储层前面多加一级屏障,降低请求直达存储层的几率。
4.启用限流和降级措施
对存储层增加限流措施,当请求超出限制时,对其提供降级服务。
5.进行缓存预热
进行缓存预热,避免在系统刚启动不久由于还未将大量数据进行缓存而导致缓存雪崩。

十六、缓存击穿

场景

一份热点数据, 它的访问量非常大。在其缓存失效瞬间,大量请求直达存储层,导致服务崩溃。

解决方案

1.加互斥锁
对数据的访问加互斥锁,当一个线程访问该数据时,其他线程只能等待。这个线程访问过后,缓存中的数据将被重建,届时其他线程就可以直接从缓存取值。
2.永不过期
不设置过期时间,所以不会出现上述问题,这是"物理” 上的不过期。
为每个value设置逻辑过期时间,当发现该值逻辑过期时,使用单独的线程重建缓存。

十七、如何保证缓存和数据库数据的一致性?

Cache Aside Pattern(旁路缓存模式)

  1. 读:从 cache 中读取数据,读取到就直接返回,读取不到的话,就从 DB 中取数据返回,然后再把数据放到 cache 中。
  2. 写:更新 DB,然后直接删除 cache 。

十八、Redis 事务

不完全符合acid 4个事务基本特性,因为只有关系型数据库才完全支持acid;   Redis 不支持 roll back ,因而不满足原子性的(而且不满足持久性)。 
注意:同一个事务内部的查询不能查到本次事务的更新
声明式事务用的比较少,只演示编程式事务
 // 编程式事务
    @Test
    public void testTransaction(){
        Object result = redisTemplate.execute(new SessionCallback() {
            @Override
            public Object execute(RedisOperations redisOperations) throws DataAccessException {
                String redisKey = "test:tx";

                // 启用事务
                redisOperations.multi();
                redisOperations.opsForSet().add(redisKey, "zhangsan");
                redisOperations.opsForSet().add(redisKey, "lisi");
                redisOperations.opsForSet().add(redisKey, "wangwu");

                System.out.println(redisOperations.opsForSet().members(redisKey));    // [] 

                // 提交事务
                return redisOperations.exec();
            }
        });
        System.out.println(result);    // [1, 1, 1, [wangwu, lisi, zhangsan]]
    }

十九、Redis有哪些优缺点

优点

  • 读写性能优异, Redis读的速度是110000次/s,写的速度是81000次/s。
  • 支持数据持久化,支持AOF和RDB两种持久化方式。
  • 支持事务,Redis的所有操作都是原子性的,同时Redis还支持对几个操作合并后的原子性执行。
  • 数据结构丰富,除了支持string类型的value外还支持hash、set、zset、list等数据结构。
  • 支持主从复制,主机会自动将数据同步到从机,可以进行读写分离。

缺点

  • 数据库容量受到物理内存的限制,不能用作海量数据的高性能读写,因此Redis适合的场景主要局限在较小数据量的高性能操作和运算上。
  • Redis 不具备自动容错和恢复功能,主机从机的宕机都会导致前端部分读写请求失败,需要等待机器重启或者手动切换前端的IP才能恢复。
  • 主机宕机,宕机前有部分数据未能及时同步到从机,切换IP后还会引入数据不一致的问题,降低了系统的可用性。
  • Redis 较难支持在线扩容,在集群容量达到上限时在线扩容会变得很复杂。为避免这一问题,运维人员在系统上线时必须确保有足够的空间,这对资源造成了很大的浪费。
原文地址:https://www.cnblogs.com/hi3254014978/p/14154732.html