20200610 千锋教育 Redis 2. Redis 命令、数据类型

Redis 命令、数据类型

Redis 命令用于在 Redis 服务上执行操作。要在 Redis 服务上执行命令需要一个 Redis 客户端。Redis 客户端在我们之前下载的的 Redis 的安装包中。

Redis 主要支持五种数据类型:string(字符串),hash(哈希),list(列表),set(集合)及 zset(sorted set:有序集合)

key 管理

常用命令

Redis 命令

#	返回满足的所有键,可以模糊匹配比如 keys abc* 代表 abc 开头的 key
keys *
#	返回 key 所储存的值的类型
type key
#	是否存在指定的 key,存在返回 1,不存在返回 0
exists key
#	删除某个 key
del key
#	设置某个 key 的过期时间 单位为秒
expire key second
#	查看剩余时间
##	ttl:time to live
##	当 key 不存在时,返回 -2
##	存在但没有设置剩余生存时间时,返回-1
##	否则,以秒为单位,返回 key 的剩余时间
ttl key
#	查看剩余时间,以毫秒为单位
pttl key
#	移除 key 的过期时间
persist key
#	修改key的过期时间,单位为毫秒
pexpire key mi11seconds
#	选择数据库
##	数据库为 0-15(默认一共 16 个数据库)设计成多个数据库实际上是为了数据库安全和备份
select index
#	将当前数据中的 key 转移到其他数据库
move key dbindex
#	随机返回一个 key
randomkey
#	重命名 key
rename key key2
#	打印命令
echo
#	查看数据库的 key 数量
dbsize
#	查看数据库信息	
info
#	实时传储收到的请求,返回相关的配置
config get *
#	清空当前数据库
flushdb
#	清空所有数据库
flusha11

应用场景

EXPIRE key seconds
exists key
  1. 限时的优惠活动信息
  2. 网站数据缓存(对于一些需要定时更新的数据,例如:积分排行榜)
  3. 手机验证码
  4. 限制网站访客访问频率(例如:1分钟最多访问10次)

key 的命名建议

Redis 单个 key 允许存入 512M 大小

非关系型数据库:数据与数据之间没有关联关系,需要通过 key 的名称风格来归类数据。

  1. key 不要太长,尽量不要超过 1024 字节。不仅消耗内存,也会降低查找的效率
  2. key 不要太短,太短可读性会降低
  3. 在一个项目中,key 最好使用统一的命名模式,如 user:123:password
  4. key 区分大小写

Redis 数据类型

Redis 提供多种数据类型:

  • String
  • Hash
  • List
  • Set
  • ZSet(Sorted Set)
  • Bitmaps
  • HyperLogLogs

String 类型

简介

string 类型是 Redis 最基本的数据类型,一个键最大能存储 512MB

string 数据结构是简单的 key-value类型,value 不仅可以是 string,也可以是数字,是包含很多种类型的特殊类型。

string 类型是二进制安全的。意思是 Redis 的 string 可以包含任何数据,比如序列化的对象进行存储,比如一张图片进行二进制存储,比如一个简单的字符串、数值等等

string 命令

################### 赋值语法 ##########################
#	SET
##	多次设置name会覆盖
##	Redis SET 命令用于设置给定 key 的值。如果 key 已经存储值,SET 就覆写旧值,且无视类型
SET KEY VALUE

#	SETNX
##	如果 key 不存在,则设值并返回1,如果 key 存在,则不设值并返回0;
##	解决分布式锁方案之一,只有在 key 不存在时设置 key 的值。
##	setnx(SET if Not exists)命令在指定的key不存在时,为key设置指定的值
SETNX key value

#	SETEX
##	设置 key 的值,并设置过期时间,单位为秒,过期时间后 key 被清除
SETEX key seconds value

#	SETRANGE
##	替换字符串
SETRANGE string range value


################### 取值语法 ##########################
#	GET
##	Redis GET 命令用于获取指定 key 的值
##	如果 key 不存在,返回 ni1。如果 key 储存的值不是字符串类型,返回一个错误。
GET KEY

#	GETRANGE
##	用于获取存储在指定 key 中字符串的子字符串。字符串的截取范围由 start 和 end 两个偏移量决定(包括  start 和 end 在内)
GETRANGE key start end

#	GETBIT
##	对 key 所储存的字符串值,获取指定偏移量上的位(bit)
GETBIT key offset

#	GETSET
##	Getset 命合用于设置指定 key 的值,并返回 key 的旧值,当key不存在时,返回ni1
GETSET KEY VALUE

#	STRLEN
##	返回 key 所储存的字符串值的长度值
STRLEN key

################### 其他语法 ##########################


#	DELKEY
##	删除指定的 KEY,如果存在,返回值数字类型。
DELKEY key

#	MSET
##	批量写
##	一次性写入多个值
MSET k1 v1 k2 v2 ...

#	MGET
##	批量读
MGET k1 k2 k3 ...


#	INCR
##	自增
##	将 key 中存储的数字值增 1。如果 key 保存在,那么 key 的值被初始化为 0,然后执行 INCR 操作。
##	key 对应的 value 必须是数字
INCR KEY
##	指定增量
INCRBY KEY INCREMENT

#	DECR
##	自减
##	将 key 中存储的数字减 1
##	key 对应的 value 必须是数字
DECR KEY
##	指定减量
DECRBY KEY DECREMENT 

#	APPEND
##	字符串拼接
##	为指定的 key 追加至末尾,如果不存在,为其赋值
APPEND KEY VALUE

应用场景

  1. string 通常用于保存单个字符串或 JSON 字符串数据

  2. 因 string 是二进制安全的,所以你完全可以把一个图片文件的内容作为字符串来存储

  3. 计数器(常规 key-value 缓存应用。常规计数:微博数,粉丝数)

    INCR 等指令本身就具有原子操作的特性,所以我们完全可以利用 Redis 的 INCR、 INCRBY、DECR、 DECRBY 等指令来实现原子计数的效果。假如,在某种场景下有 3 个客户端同时读取了 mynum 的值(值为2),然后对其同时进行了加 1 的操作,那么,最后 mynum 的值一定是 5。

    不少网站都利用 Redis 的这个特性来实现业务上的统计计数需求。

Hash 类型

简介

hash 类型是 string 类型的 field 和 value 的映射表,或者说是一个 string 集合。hash 特别适合用于存储对象,相比较而言,将一个对象类型存储在 hash 类型要存储在 string 类型里占用更少的内存空间,并对整个对象的存取可以看成具有 KEY 和 VALUE 的 MAP 容器,该类型非常适合于存储值对象的信息,如:uname,upass,age等。该类型的数据仅占用很少的磁盘空间(相比于 JSON)。

Redis 中每个 hash 可以存储(2 的 32 次方减 1)个键值对(40多亿)

hash 命令

###################	赋值语法 ####################

#	HSET
##	为指定的KEY,设定 FIELD/VALUE
HSET KEY FIELD VALUE

#	HMSET
##	同时将多个 field-value(域-值)对设置到哈希表 key 中
HMSET KEY FIELD VALUE[ FIELD1, VALUE1] ...

###################	取值语法 ####################

#	HGET
##	获取存储在 HASH 中的值,根据 FIELD 得到 VALUE
HGET KEY FIELD

#	HMGET
##	获取 key 所有给定字段的值
HMGET KEY field[field1]

#	HGETALL
##	返回 HASH 表中所有的字段和值
HGETALL KEY

#	HKEYS
##	获取所有哈希表中的字段
HKEYS KEY

#	HLEN
##	获取哈希表中字段的数量
HLEN KEY

###################	其他语法 ####################

#	HDEL
##	删除一个或多个 HASH 表字段
HDEL KEY field1[field2]...

#	HSETNX
##	只有在字段 field 不存在时,设置哈希表字段的值
HSETNX key field value

#	HINCRBY
##	为哈希表 key 中的指定字段的差数值加上增量 increment
HINCRBY key field increment

#	HINCRBYFLOAT
##	为哈希表key中的指定字段的浮点数值加上増量 increment
HINCRBYFLOAT key field increment

#	HEXISTS
##	查看哈希表 key 中,指定的字段是否存在
HEXISTS key field

应用场景

Hash的应用场景:(存储一个用户信息对象数据)

  1. 常用于存储一个对象

  2. 为什么不用 string 存储一个对象?

hash 是最接近关系数据库结构的数据类型,可以将数据库一条记录或程序中一个对象转换成 hashmap 存放在 Redis 中。

用户 ID 为查找的key,存储的 value 用户对象包含姓名,年龄,生日等信息,如果用普通的 key/value 结构来存储,主要有以下2种存储方式:

  1. 将用户 ID 作为查找 key,把其他信息封装成一个对象以序列化的方式存储,这种方式的缺点是,增加了序列化/反序列化的开销,并且在需要修改其中一项信息时,需要把整个对象取回,并且修改操作需要对并发进行保护,引入CAS等复杂问题

  2. 这个用户信息对象有多少成员就存成多少个 key-value 对,用【用户 ID + 对应属性的名称】作为唯一标识来取得对应属性的值,虽然省去了序列化开销和并发问题,但是用户ID为重复存储,如果存在大量这样的数据,内存浪费还是非常可观的

总结:Redis 提供的 Hash 很好的解决了这个问题,Redis 的 Hash 实际是内部存储的 Value 为一个 HashMap,

List 类型

简介

List 类型是一个链表结构的集合,其主要功能有 push、pop、获取元素等。更详细的说,List 类型是一个双端链表的集合,我们可以通过相关的操作在集合的头部或者尾部添加和删除元素, List 的设计非常简单精巧,即可以作为栈,又可以作为队列,满足绝大多数的需求。

按照插入顺序排序。你可以添加一个元素到列表的头部(左边)或者尾部(右边)一个列表最多可以包含 2的32次方减一个元素(4294967295,每个列表超过 40 亿个元素)

类似 JAVA 中的 LinkedList

常用命令

############## 赋值语法 ####################

#	LPUSH
##	将一个或多个值插入到列表头部(从左侧添加)
LPUSH key value1[value2]

#	RPUSH
##	在列表中添加一个或多个值(从右侧添加)
RPUSH key value1[value2]

#	LPUSHX
##	将一个值插入到已存在的列表头部。如果列表不在,操作无效  
LPUSHX key value

#	RPUSHX
##	一个值插入已存在的列表尾部(最右边)。如果列表不在,操作无效。
RPUSHX key value


############## 取值语法 ####################

#	LLEN
##	获取列表长度
LLEN key

#	LINDEX
##	通过索引获取列表中的元素
LINDEX key index

#	LRANGE
##	获取列表指定范围内的元素
LRANGE key start stop



#######
#	描述:返回列表中指定区间内的元素,区间以偏移量 START 和 END 指定。
#	其中 0 表示列表的第一个元素,1 表示列表的第二个元素,以此类推。
#	也可以使用负数下标,以- 1 表示列表的最后一个元素,- 2 表示列表的倒数第二个元素,以此类推。
#	
#	用于分页时:
#	start :页大小 *(页数 - 1) stop :(页大小 * 页数)- 1 
#######

############## 删除语法 ####################

#	LPOP
##	移出并获取列表的第一个元素(从左侧删除)
LPOP key

#	RPOP
##	移除列表的最后一个元燾,返回值为移除的元素(从右侧删除)
RPOP key

#	BLPOP
##	移出并获取列表的第一个元素,如果列表没有元素会阻塞列表直到等待超时后返回 nil 或发现可弹出元素为止。
###	timeout 单位为秒
BLPOP key1 [key2] timeout

#	BRPOP
##	移出并获取列表的最后一个元素,如果列表没有元素会阻塞列表直到等待超时后返回 nil 或发现可弹出元素为止
###	timeout 单位为秒
BRPOP key1 [key2] timeout 

#	LTRIM
##	对一个列表进行修剪(trim),就是说,让列表只保留指定区间内的元素,不在指定区间之内的元素都将被删除。
###	左右边界都包含在结果内
LTRIM key start stop


############## 修改语法 ####################

##	通过索引设置列表元素的值
LSET key index value

##	在列表的元素前或者后插入元素
##	将值 value 插入到列表 key 当中,位于值 pivot 之前或之后。
###	有多个相同元素时,只插入一次,以第一个元素为基准
LINSERT key BEFORE|AFTER pivot value

############## 高级命令 ####################

#	RPOPLPUSH
##	移除列表的最后一个元素,并将该元素添加到另一个列表并返回
RPOPLPUSH source destination

#	BRPOPLPUSH
##	从列表中弹出一个值,将弹出的元素插入到另外一个列表中并返回它
##	如果列表没有元素会阻塞列表直到等待超时或发现可弹出元素为止。
BRPOPLPUSH source destination timeout

应用场景

List 常应用于:

  1. 对数据量大的集合数据删减

    列表数据显示、关注列表、粉丝列表、留言评价等

    分页、热点新闻(Top5)等

    利用 LRANGE 还可以很方便的实现分页的功能,在博客系统中,每片博文的评论也可以存入一个单独的 List 中。

  2. 任务队列

    (List 通常用来实现一个消息队列,而且可以确保先后顺序,不必像 MYSQL 那样还需要通过 ORDER BY 来进行排序)

任务队列介绍(生产者和消费者模式)

在处理 web 客户端发送的命令请求时,某些操作的执行时间可能会比我们预期的更长一些,通过将待执行任务的相关信息放入队列里面,并在之后对队列进行处理,用户可以推迟执行那些需要一段时间才能能完成的操作,这种将工作交给任务处理器来执行的做法被称为任务队列(task queue)。

示例

当用户完成付款后:
会生成一个物流队列(发货地址 --》 收货地址)
生成物流队列(发货地址:北京海淀、收货地址:南京建邺)
1、商家发货(北京海淀) 
2、快递小哥取货 
3、北京首都机场 —— 南京禄口机场
4、南京禄口机场 —— 建邺区 
5、建邺区 —— 居民小区
6、收货
  1. 业务类

    @Service
    @Slf4j
    public class ListQueueCacheService {
    
        @Resource(name = "redisTemplate")
        private ListOperations<String, String> opsForList;
    
        /**
         * 生成物流队列(发货地址:北京海淀、收货地址:南京建邺)
         * 1、商家发货(北京海淀)
         * 2、快递小哥取货
         * 3、北京首都机场 —— 南京禄口机场
         * 4、南京禄口机场 —— 建邺区
         * 5、建邺区 —— 居民小区
         * 6、收货
         *
         * @param orderId
         */
        public void orderQueue(String orderId) {
            // 待执行任务的队列 key
            String key = "queue:" + orderId;
    
    
            opsForList.leftPush(key, "1、商家发货(北京海淀)");
            opsForList.leftPush(key, "2、快递小哥取货");
            opsForList.leftPush(key, "3、北京首都机场 —— 南京禄口机场");
            opsForList.leftPush(key, "4、南京禄口机场 —— 建邺区");
            opsForList.leftPush(key, "5、建邺区 —— 居民小区");
            opsForList.leftPush(key, "6、收货");
        }
    
        /**
         * 快递小哥触发 队列事件
         * 消费一个任务
         */
        public String orderTouch(String orderId) {
            // 待执行任务的队列 key
            String key = "queue:" + orderId;
            // 执行成功的队列的 key
            String keySucc = "queue:" + orderId + ":succ";
    
            return opsForList.rightPopAndLeftPush(key, keySucc);
        }
    
        /**
         * 快递公司
         * 关注点:快递任务还有几项完成
         *
         * @param orderId
         * @return
         */
        public List<String> orderSelect(String orderId) {
            // 待执行任务的队列 key
            String key = "queue:" + orderId;
            return opsForList.range(key, 0, -1);
        }
    
        /**
         * 用户
         * 关注点:快递到哪了
         *
         * @param orderId
         * @return
         */
        public List<String> orderSelectSucc(String orderId) {
            // 执行成功的队列的 key
            String keySucc = "queue:" + orderId + ":succ";
    
            return opsForList.range(keySucc, 0, -1);
        }
    
    }
    
  2. 测试类

    @RunWith(SpringRunner.class)
    @SpringBootTest
    public class ListQueueCacheServiceTest {
        @Autowired
        private ListQueueCacheService service;
    
        private String orderId;
    
        @Before
        public void init() {
            orderId = "0001";
        }
    
        @Test
        public void testOrderQueue() {
            System.out.println("============> 生成物流任务列表");
            service.orderQueue(orderId);
        }
    
        @Test
        public void testOrderSelect() {
            System.out.println("============> 需要执行的物流任务列表");
    
            List<String> list = service.orderSelect(orderId);
            System.out.println(list);
        }
    
        @Test
        public void testOrderTouch() {
            System.out.println("============> 物流小哥消费一个任务");
            String s = service.orderTouch(orderId);
            System.out.println("============> 被消费的任务是:" + s);
    
            testOrderSelect();
        }
    
        @Test
        public void testOrderSelectSucc() {
            System.out.println("============> 用户查询物流进度");
            List<String> list = service.orderSelectSucc(orderId);
            System.out.println(list);
    
            testOrderSelect();
        }
        
    }
    

Set 类型

简介

Redis 的 Set 是 String 类型的无序集合。集合成员是唯一的,这就意味着集合中不能出现重复的数据。

Redis 中集合是通过哈希表实现的,Set 是通过 hashtable 实现的

集合中最大的成员数为 2的32次方减一(4294967295 ,每个集合可存储 40 多亿个成员)

类似于 Java 中的 Hashtable 集合

命令

################ 赋值语法 ############################

#	SADD
##	向集合添加一个或多个成员
SADD key member1[member2]

################ 取值语法 ############################

##	获取集合的成员数
SCARD key

##	返回集合中的所有成员
SMEMBERS key

##	判断 member 元素是否是集合key的成员
SISMEMBER key member

##	返回集合中一个或多个随机数
SRANDMEMBER key [count]

################ 删除语法 ############################

##	移除集合中一个或多个成员
SREM key member1 [member2]

##	移除并返回集合中的一个随机元素
SPOP key [count]

##	将 member 元素从 source集合移动到 destination 集合
SMOVE source destination member

################ 差集语法 ############################

##	返回给定所有集台的差集(左侧)
SDIFF key1  [key2]

##	返回给定所有集合的差集并存储在 destination 中
SDIFFSTORE destination keyl [key2]

################ 交集语法 ############################

##	返回给定所有集合的交集(共有数据)
SINTER key1 [key2]

##	返回给定所有集合的交集并存储在 destination 中
SINTERSTORE destination key1 [key2]

################ 并集语法 ############################

##	返回所有给定集合的并集
SUNION key1  [key2]

##	所有给定集合的并集存储在 destination 集合中
SUNIONSTORE destination key1  [key2]

应用场景

常应用于:对两个集合间的数据【计算】进行交集、并集、差集运算

  1. 利用集合操作,可以取不同兴趣圏子的交集以非常方便的实现如共同关注、共同喜好、二度好友等功能。对上面的所有集合操作,你还可以使用不同的命令选择将结果返回给客户端还是存储到一个新的集合中。
  2. 利用唯一性,可以统计访问网站的所有独立 IP 、存取当天[或某天]的活跃用户列表。

Zset

有序集合(sorted set )

简介

  1. Redis 有序集合和集合一样也是 string 类型元素的集合,且不允许重复的成员。

  2. 不同的是每个元素都会关联一个 double 类型的分数。 redis 正是通过分数来为集合中的成员进行从小到大的排序

  3. 有序集合的成员是唯一的但分数(score)法可以重复

  4. 集合是通过哈希表实现的。集合中最大的成员数为 2的32次方减1 (4294967295,每个集合可存储 40 多亿个成员)。 Redis 的 ZSet 是有序、且不重复

    很多时候,我们都将 Redis 中的有序集合叫 zsets,这是因为在 Redis 中,有序集合相关的操作指令都是以 z 开头的)

    命令

    ################### 赋值语法 ##############################
    
    ##	向有序集合添加一个或多个成员,或者更新已存在成员的分数
    ZADD key scorel member1 [score2 member2]
    
    ################### 取值语法 ##############################
    
    ##	获取有序集台的成员数
    ZCARD key
    
    ##	计算在有序集台中指定区间分致的成员数
    ZCOUNT key min max
    
    ##	返回有序集合中指定成员的索引
    ZRANK key member
    
    ##	通过索引区间返回有序集合指定区间内的成员(低到高)
    ZRANGE key start stop[WITHSCORES]
    
    ##	通过分数返回有序集合指定区间内的成员
    ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT]
    
    ##	倒序返回有序集中指定区间内的成员,通过索引,分数从高到低
    ZREVRANGE key start stop [WITHSCORES]
    
    ##	返回有序集中指定分数区间内的成员,分数从高到低排序
    ZREVRANGEBYSCORE key max min [WITHSCORES]
    
    ################### 删除语法 ##############################
    
    ##	移除集合
    DEL key
    
    ##	移除有序集合中的一个或多个成员
    ZREM key member [member...]
    
    ##	移除有序集合中给定的排名区间的所有成员(第一名是 0)(从低到高排序)
    ZREMRANGEBYRANK key start stop
    
    ##	移除有序集合中给定的分数区间的所有成员
    ZREMRANGEBYSCORE key min max
    
    ##	増加 memeber 元素的分数 increment,返回值是更改后的分数
    ZINCRBY key increment member
    

    应用场景

    常应用于:排行榜,销量排名,积分排名等

    1. 比如 twitter 的 public timeline 可以以发表时间作为 score 来存,这样获取时就是自动按时间排好序的
    2. 比如一个存体全班同学成绩的 Sorted Set,其集合 value 可以是同学的学号,而 score 就可以是其考试得分,这样在数据插入集合的时候,就已经进行了天然的排序。
    3. 还可以用 Sorted Set 来做带权重的队列,比如普通消息的 score 为 1,重要消息的 score 为 2,然后工作线程可以选择按 score 的倒序来获取工作任务。让重要的任务优先执行。

HyperLogLog

简介

Redis 在 2.8.9 版本添加了 HyperLogLog 结构。

Redis HyperLogLog 是用来做基数统计的算法,HyperLogLog 的优点是,在输入元素的数量或者体积非常非常大时,计算基数所需的空间总是固定的、并且是很小的。

在 Redis里面,每个 HyperLogLog 键只需要花表 12 KB 内存,就可以计算接近 2^64 个不同元素的基数。这和计算基数时,元素越多耗费内存就越多的集合形成鲜明对比。

但是,因为 HyperLogLog 只会根据输入元素来计算基数,而不会储存输入元素本身,所以 HyperLogLog 不能像集合那样,返回输入的各个元素。

什么是基数?比如数据集 { 1, 3, 5, 7, 5, 7, 8 },那么这个数据集的基数集为{ 1, 3, 5, 7, 8 },基数(不重复元素)为 5。

基数估计就是在误差可接受的范围内,快速计算基数。

为什么需要 HyperLogLog

如果要统计 1 亿个数据的基数值,大约需要内存 100000000/8/1024/1024 ≈ 12M,内存减少占用的效果显著。

然而统计一个对象的基数值需要 12M,如果统计 10000 个对象,就需要将近 120G,同样不能广泛用于大数据场景。

常用命令

##	添加指定元素到 HyperLogLog  中
PFADD key element [element ...]

##	返回给定 HyperLogLog 的基数估算值
PFCOUNT key [key ...]

##	将多个 HyperLogLog 合并为一个 HyperLogLog
PFMERGE destkey sourcekey [sourcekey ...]

应用场景

基数不大,数据量不大就用不上,会有点大材小用浪费空间

有局限性,就是只能统计基数数量,而没办法去知道具体的内容是什么

具体用途:

  • 统计注册 IP 数
  • 统计每日访问 IP 数
  • 统计页面实时 UV 数
  • 统计在线用户数
  • 统计用户每天搜索不同词条的个数
  • 统计真实文章阅读数

总结

HyperLogLog 是一种算法,并非 Redis 独有

目的是做基数统计,故不是集合,不会保存元数据,只记录数量而不是数值。

耗空间极小,支持输入非常大体积的数据量

核心是基数估算算法,主要表现为计算时内存的使用和数据合并的处理。最终数值存在一定误差

Redis 中每个 HyperLogLog key 占用了 12K 的内存用于标记基数(官方文档)

pfadd 命令并不会一次性分配 12k 内存,而是随着基数的增加而逐渐增加内存分配;而 pfmerge 操作则会将 sourcekey 合并后存储在 12k 大小的 key 中,这由 HyperLogLog 合并操作的原理(两个 HyperLogLog 合并时需要单独比较每个桶的值)可以很容易理解。

误差说明:基数估计的结果是一个带有 0.81% 标准错误(standard error)的近似值。是可接受的范围

Redis 对 HyperLogLog 的存储进行了优化,在计数比较小时,它的存储空间采用稀疏矩阵存储,空间占用很小,仅仅在计数慢慢变大,稀疏矩阵占用空间渐渐超过了阈值时才会一次性转变成稠密矩阵,才会占用 12k 的空间

原文地址:https://www.cnblogs.com/huangwenjie/p/13088693.html