[Redis] Redis日常学习总结一

一  Redis使用bitset(bitmap)来统计日活跃量

1  BitMap介绍

  Bitmap(即Bitset),是一串连续的2进制数字(0或1),每一位所在的位置为偏移(offset),bitmap就是通过最小的单位bit来进行0或者1的设置,表示某个元素对应的值或者状态。

  Redis从2.2.0版本开始新增了setbit,getbit,bitcount等几个bitmap相关命令。虽然是新命令,但是并没有新增新的数据类型,因为setbit等命令只不过是在set上的扩展。在bitmap上可执行AND,OR,XOR以及其它位操作。

2  相关命令

  (1)SETBIT key offset value

  对 key 所储存的字符串值,设置或清除指定偏移量上的位(bit)。位的设置或清除取决于 value 参数,可以是 0 也可以是 1 。当 key 不存在时,自动生成一个新的字符串值。

  字符串会进行伸展(grown)以确保它可以将 value 保存在指定的偏移量上。当字符串值进行伸展时,空白位置以 0 填充。

  offset 参数必须大于或等于 0 ,小于 2^32 (bit 映射被限制在 512 MB 之内)。

  对使用大的 offset 的 SETBIT 操作来说,内存分配可能造成 Redis 服务器被阻塞。

  redis> SETBIT bit 10086 1
  (integer) 0

  即将偏移量10086上的位设置为1.

  (2)GETBIT key offset

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

  当 offset 比字符串值的长度大,或者 key 不存在时,返回 0 。

  redis> SETBIT bit 10086 1
  (integer) 0

  redis> GETBIT bit 10086
  (integer) 1

  (3)BITCOUNT key [start] [end]

  计算给定字符串中,被设置为 1 的比特位的数量。

  一般情况下,给定的整个字符串都会被进行计数,通过指定额外的 start 或 end 参数,可以让计数只在特定的位上进行。

  start 和 end 参数的设置和 GETRANGE 命令类似,都可以使用负数值:比如 -1 表示最后一个位,而 -2 表示倒数第二个位,以此类推。

  不存在的 key 被当成是空字符串来处理,因此对一个不存在的 key 进行 BITCOUNT 操作,结果为 0 。

  redis> SETBIT bits 0 1          # 0001
  (integer) 0

  redis> BITCOUNT bits
  (integer) 1

3  使用BitMap统计日活跃量

  假设这样一个场景,假如每个网站有1亿的用户,那么我们怎么来统计这个网站的日登陆数或者说有哪些用户登录过这个网站。

  最常见的做法就是设计一张用户登录表user_login:

  user_uid          login_date

  0                   2017-7-1

  1                   2017-7-1

  0                   2017-7-2

  如果平均一个人一天登录1次,那么1亿个用户一个星期就会产生1 * 1 * 7 = 7亿条数据,一个月就会产生30亿条数据,这对数据库的压力是很大的,只是统计一下用户登录,没必要花费这么多的资源。

  这个时候我们就可以用reids 的bitmap来解决。

  用户是否登录可以用0/1来表示,0代表用户不登陆,1表示登录,那么1bit 就可以表示用户是否登录。

  1亿个用户一天的数据量也就 1 0000 0000bit  = 11.92m,也就是说用户一天的登录信息也就产生11.92m的数据量。一个月也就357.63m的数据量。

  具体实现过程(为了实验方便,我们就假设4个用户的id分别为:0,1,2,3,统计两天的登录量):

  mon: 1010 (用户0未登录,用户1登录,用户2未登录,用户3登录)

  tue: 1101 (用户0登录,用户1未登录,用户2登录,用户3登录)

127.0.0.1:6379> setbit mon 0 0
(integer) 1
127.0.0.1:6379> setbit mon 1 1
(integer) 1
127.0.0.1:6379> setbit mon 2 0
(integer) 0
127.0.0.1:6379> setbit mon 3 1
(integer) 0
127.0.0.1:6379> 
127.0.0.1:6379> setbit tue 0 1
(integer) 1
127.0.0.1:6379> setbit tue 1 0
(integer) 1
127.0.0.1:6379> setbit tue 3 1
(integer) 0
127.0.0.1:6379> setbit tue 4 1
(integer) 1
127.0.0.1:6379> 

  如果要统计这两天都登陆的用户,可以使用位运算AND:

127.0.0.1:6379> bitop AND result mon tue
(integer) 1
127.0.0.1:6379> getbit result 0
(integer) 0
127.0.0.1:6379> getbit result 1
(integer) 0
127.0.0.1:6379> getbit result 2
(integer) 0
127.0.0.1:6379> getbit result 3
(integer) 1
127.0.0.1:6379> 

  可以看到mon 和 tue做and运算,得到结果result 为 :1000,则表示用户3连续两天都登陆,其他用户两天中只有一天登录。

4  使用 bitmap 实现用户上线次数统计

  假设现在我们希望记录自己网站上的用户的上线频率,比如说,计算用户 A 上线了多少天,用户 B 上线了多少天,诸如此类,以此作为数据,从而决定让哪些用户参加 beta 测试等活动 —— 这个模式可以使用 SETBIT 和 BITCOUNT 来实现。

  比如说,每当用户在某一天上线的时候,我们就使用 SETBIT ,以用户名作为 key ,将那天所代表的网站的上线日作为 offset 参数,并将这个 offset 上的为设置为 1 。

  举个例子,如果今天是网站上线的第 100 天,而用户 peter 在今天阅览过网站,那么执行命令 SETBIT peter 100 1 ;如果明天 peter 也继续阅览网站,那么执行命令 SETBIT peter 101 1 ,以此类推。

  当要计算 peter 总共以来的上线次数时,就使用 BITCOUNT 命令:执行 BITCOUNT peter ,得出的结果就是 peter 上线的总天数。

   也可以实现类似签到的功能。

5  总结

  优点占用内存更小,查询方便,可以指定查询某个用户,数据可能略有瑕疵,对于非登陆的用户,可能不同的key映射到同一个id,否则需要维护一个非登陆用户的映射,有额外的开销。

  缺点如果用户非常的稀疏,那么占用的内存可能会很大。

参考:

  拼多多面试真题:如何用Redis统计独立用户访问量!  https://mp.weixin.qq.com/s?__biz=MzUxOTAxODc2Mg==&mid=2247485077&idx=1&sn=9adbf940d7e821d73bff4247e17dda41&chksm=f98146f0cef6cfe652aba3381b7babcc260e6d4a10dd66b90bae1abd552e7d765486d90dc069&scene=21#wechat_redirect

  用redis的bitmap方式统计上亿访问量的周活跃用户  https://www.jianshu.com/p/62cf39db5c2f

二   Redis的热key问题如何解决

1  什么是热key问题

  所谓热key问题就是,热点 key,指的是在一段时间内,该 key 的访问量远远高于其他的 redis key, 导致大部分的访问流量在经过 proxy 分片之后,都集中访问到某一个 redis 实例上。

  突然有几十万的请求去访问redis上的某个特定key。那么,这样会造成流量过于集中,达到物理网卡上限,从而导致这台redis的服务器宕机。那接下来这个key的请求,就会直接怼到你的数据库上,导致你的服务不可用。

  其实生活中也是有不少这样的例子。比如XX明星结婚。那么关于XX明星的Key就会瞬间增大,就会出现热数据问题。

  ps:hot key和big key问题,大家一定要有所了解。

2  怎么发现热key

  方法一:凭借业务经验,进行预估哪些是热key

    其实这个方法还是挺有可行性的。比如某商品在做秒杀,那这个商品的key就可以判断出是热key。缺点很明显,并非所有业务都能预估出哪些key是热key。

  方法二:在客户端进行收集

    这个方式就是在操作redis之前,加入一行代码进行数据统计。那么这个数据统计的方式有很多种,也可以是给外部的通讯系统发送一个通知信息。缺点就是对客户端代码造成入侵。

  方法三:在Proxy层做收集

    有些集群架构是下面这样的,Proxy可以是Twemproxy,是统一的入口。可以在Proxy层做收集上报,但是缺点很明显,并非所有的redis集群架构都有proxy。

  方法四:用redis自带命令

    (1) monitor命令,该命令可以实时抓取出redis服务器接收到的命令,然后写代码统计出热key是啥。当然,也有现成的分析工具可以给你使用,比如redis-faina。但是该命令在高并发的条件下,有内存增暴增的隐患,还会降低redis的性能。

    (2) hotkeys参数,redis 4.0.3提供了redis-cli的热点key发现功能,执行redis-cli时加上–hotkeys选项即可。但是该参数在执行的时候,如果key比较多,执行起来比较慢。

  方法五:自己抓包评估

    Redis客户端使用TCP协议与服务端进行交互,通信协议采用的是RESP。自己写程序监听端口,按照RESP协议规则解析数据,进行分析。缺点就是开发成本高,维护困难,有丢包可能性。

3  如何解决

  目前业内的方案有两种

  (1) 利用二级缓存

    比如利用ehcache,或者一个HashMap都可以。在你发现热key以后,把热key加载到系统的JVM中。

    针对这种热key请求,会直接从jvm中取,而不会走到redis层。

    假设此时有十万个针对同一个key的请求过来,如果没有本地缓存,这十万个请求就直接怼到同一台redis上了。

    现在假设,你的应用层有50台机器,OK,你也有jvm缓存了。这十万个请求平均分散开来,每个机器有2000个请求,会从JVM中取到value值,然后返回数据。避免了十万个请求怼到同一台redis上的情形。

  (2) 备份热key

    这个方案也很简单。不要让key走到同一台redis上不就行了。我们把这个key,在多个redis上都存一份不就好了。接下来,有热key请求进来的时候,我们就在有备份的redis上随机选取一台,进行访问取值,返回数据。

    假设redis的集群数量为N,步骤如下图所示

   

  伪代码如下:

  

4  业内解决方案

   在项目运行中,自动发现热key,并使程序自动处理,主要有两步:

  (1)监控热key (2)通知系统做处理

(1)监控热key

  1、有赞解决方案:对原生jedis包的JedisPool和Jedis类做了改造,在JedisPool初始化过程中集成TMC“热点发现”+“本地缓存”功能Hermes-SDK包的初始化逻辑,使Jedis客户端与缓存服务端代理层交互时先与Hermes-SDK交互,从而完成 “热点探测”+“本地缓存”功能的透明接入。从监控的角度看,该包对于Jedis-Client的每次key值访问请求,Hermes-SDK 都会通过其通信模块将key访问事件异步上报给Hermes服务端集群,以便其根据上报数据进行“热点探测”。

   

  模块划分:

    Jedis-Client: Java 应用与缓存服务端交互的直接入口,接口定义与原生 Jedis-Client 无异;

    Hermes-SDK:自研“热点发现+本地缓存”功能的SDK封装, Jedis-Client 通过与它交互来集成相应能力;

    Hermes服务端集群:接收 Hermes-SDK 上报的缓存访问数据,进行热点探测,将热点 key 推送给 Hermes-SDK 做本地缓存;

    缓存集群:由代理层和存储层组成,为应用客户端提供统一的分布式缓存服务入口;

    基础组件: etcd 集群、 Apollo 配置中心,为 TMC 提供“集群推送”和“统一配置”能力;

  基本流程:

  1) key 值获取

    1.Java 应用调用 Jedis-Client 接口获取key的缓存值时,Jedis-Client 会询问 Hermes-SDK 该 key 当前是否是 热点key

    2.对于 热点key ,直接从 Hermes-SDK 的 热点模块 获取热点 key 在本地缓存的 value 值,不去访问 缓存集群 ,从而将访问请求前置在应用层;

    3.对于非 热点keyHermes-SDK 会通过Callable回调 Jedis-Client 的原生接口,从 缓存集群 拿到 value 值;

    4.对于 Jedis-Client 的每次 key 值访问请求,Hermes-SDK 都会通过其 通信模块 将 key访问事件 异步上报给 Hermes服务端集群 ,以便其根据上报数据进行“热点探测”;

  2)key值过期

    1.Java 应用调用 Jedis-Clientset() del() expire()接口时会导致对应 key 值失效,Jedis-Client 会同步调用 Hermes-SDKinvalid()方法告知其“ key 值失效”事件;

    2.对于 热点keyHermes-SDK 的 热点模块 会先将 key 在本地缓存的 value 值失效,以达到本地数据强一致。同时 通信模块 会异步将“ key 值失效”事件通过 etcd集群 推送给 Java 应用集群中其他 Hermes-SDK 节点;

    3.其他Hermes-SDK节点的 通信模块 收到 “ key 值失效”事件后,会调用 热点模块 将 key 在本地缓存的 value 值失效,以达到集群数据最终一致

  3)热点发现

    1.Hermes服务端集群 不断收集 Hermes-SDK上报的 key访问事件,对不同业务应用集群的缓存访问数据进行周期性(3s一次)分析计算,以探测业务应用集群中的热点key列表;

    2.对于探测到的热点key列表,Hermes服务端集群 将其通过 etcd集群 推送给不同业务应用集群的 Hermes-SDK通信模块,通知其对热点key列表进行本地缓存;

  4)配置读取

    1.Hermes-SDK 在启动及运行过程中,会从 Apollo配置中心 读取其关心的配置信息(如:启动关闭配置、黑白名单配置、etcd地址...);

    2.Hermes服务端集群 在启动及运行过程中,会从 Apollo配置中心 读取其关心的配置信息(如:业务应用列表、热点阈值配置、 etcd 地址...);

  2、其他解决方案:自己抓包评估

    先利用flink搭建一套流式计算系统。然后自己写一个抓包程序抓redis监听端口的数据,抓到数据后往kafka里丢。

    接下来,流式计算系统消费kafka里的数据,进行数据统计即可,也能达到监控热key的目的。

(2)通知系统做处理

  1、有赞解决方案:利用二级缓存进行处理。

     有赞在监控到热key后,Hermes服务端集群会通过各种手段通知各业务系统里的Hermes-SDK,告诉他们:"老弟,这个key是热key,记得做本地缓存。"

    于是Hermes-SDK就会将该key缓存在本地,对于后面的请求。Hermes-SDK发现这个是一个热key,直接从本地中拿,而不会去访问集群。

   2、其他解决方案:

    比如你的流式计算系统监控到热key了,往zookeeper里头的某个节点里写。然后你的业务系统监听该节点,发现节点数据变化了,就代表发现热key。最后往本地缓存里写,也是可以的。

参考

  有赞透明多级缓存解决方案(TMC)  https://www.jianshu.com/p/176c8f8b8eb1

  谈谈Redis的热key问题如何解决  https://mp.weixin.qq.com/s?__biz=MzUxOTAxODc2Mg==&mid=2247485004&idx=1&sn=5b5e3d188959a5055ec69eb6c16fe75f&chksm=f9814629cef6cf3f27fd3824a9f2849a5f6baa008f4b0d585631027e4988ed0215da55844138&scene=21#wechat_redirect

   [Redis] 20万用户同时访问一个热点Key,如何优化缓存架构?  https://www.cnblogs.com/aiqiqi/p/10976161.html

三  Redis实现分布式锁及可能出现的问题和解决方案

1  为什么需要分布式锁

  在单机的情况下,如果有多个线程要同时访问某个共享资源的时候,我们可以采用线程间加锁的机制,即当某个线程获取到这个资源后,就立即对这个资源进行加锁,当使用完资源之后,再解锁,其它线程就可以接着使用了。例如,在JAVA中,使用synchronize或者Lock等进行加锁。

  但是到了分布式系统的时代,这种线程之间的锁机制,就没作用了,系统可能会有多份并且部署在不同的机器上,这些资源已经不是在线程之间共享了,而是属于进程之间共享的资源。 因此,为了解决这个问题,我们就必须引入「分布式锁」。 分布式锁,是指在分布式的部署环境下,通过锁机制来让多客户端互斥的对共享资源进行访问。

  为了确保分布式锁可用,我们至少要确保锁的实现同时满足以下四个条件:

    * 互斥性。在任意时刻,只有一个客户端能持有锁。

    * 不会发生死锁。即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。

    * 具有容错性。只要大部分的Redis节点正常运行,客户端就可以加锁和解锁。

    * 解铃还须系铃人。加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了。

2  基于Redis分布式锁加锁方式

(1)错误方式一

  setnx  key  value;  (1)
  do something ...(2)
  del key;(3)

   以上方式如果在(1)加锁成功后,但是在(2)抛出异常,则可能导致del指令没有被调用,这样就陷入死锁,锁永远不会被释放。

(2)错误方式二

  为了解决(1)中可能出现的问题,可以在拿到锁后,给锁加上一个过期时间,比如:5秒,这样即使中间出现问题,也会在5秒后自动释放锁。

  setnx  key  value;  (1)
  expire key 5;(2)
  do something ...(3)
  del key;(4)

   这种方式,由于(1)和(2)不是原子操作,因此也有可能在(1)执行成功后即抛出异常,而(2)没有执行,所以仍会出现上例中的死锁问题。

(3)正确加锁方式

  方式一:

  在Redis2.8版本,加入了一个原子的指令:

  public static boolean tryLock(Jedis jedis, String lockName, String uniqueValue, int expireTime) {
        /*
          nxxx,这个参数我们填的是NX,意思是SET IF NOT EXIST,即当key不存在时,我们进行set操作;若key已经存在,则不做任何操作;
          expx,这个参数我们传的是PX,意思是我们要给这个key加一个过期的设置,具体时间由第五个参数决定。
         */
        String result = jedis.set(lockName, uniqueValue, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
        return LOCK_SUCCESS.equals(result);
    }

  方式二:

  public Boolean tryLock(String lockKey, String uniqueValue, long seconds) {
        return redisTemplate.execute((RedisCallback<Boolean>) redisConnection -> {
            Jedis jedis = (Jedis) redisConnection.getNativeConnection();
            String result = jedis.set(lockKey, uniqueValue, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, seconds);
            if (LOCK_SUCCESS.equals(result)) {
                return Boolean.TRUE;
            }
            return Boolean.FALSE;
        });
    }

  不能使用 spring-boot 提供的 redisTemplate.opsForValue().set() 命令是因为 spring-boot 对 jedis 的封装中没有返回 set 命令的返回值, 这就导致上层没有办法判断 set 执行的结果,因此需要通过 execute 方法调用 RedisCallback 去拿到底层的 Jedis 对象,来直接调用 set 命令。

  分布式锁要满足第四个条件解铃还须系铃人,通过给value赋值为uniqueValue,我们就知道这把锁是哪个请求加的了,在解锁的时候就可以有依据。uniqueValue可以使用UUID.randomUUID().toString()方法生成,或者使用当前线程的线程ID。而lockName则需要使用一个相同的常量,保证竞争的是同一个锁。

 3  Redis分布式锁解锁

  由于解锁即执行delete操作,将lockName的键值删除,但是如果直接使用Redis的del操作,无法判断当前的锁是否为当前线程加的锁,所以可以使用lua脚本的方式:

  方式一:

  /**
     * 释放分布式锁
     * @param jedis Redis客户端
     * @param lockName 锁
     * @param resourcePath 请求标识
     * @return 是否释放成功
     */
    public static boolean release(Jedis jedis, String lockName, String resourcePath) {
        //lua表达式,Redis执行是原子操作
        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
        Object result = jedis.eval(script, Collections.singletonList(lockName), Collections.singletonList(resourcePath));
        return RELEASE_SUCCESS.equals(result);
    }

   方式二:

  /**
     * 与 tryLock 相对应,用作释放锁
     * @param lockKey
     * @param clientId
     * @return
     */
  private static final String RELEASE_LOCK_SCRIPT = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
  public Boolean releaseLock(String lockKey, String clientId) {
        return redisTemplate.execute((RedisCallback<Boolean>) redisConnection -> {
            Jedis jedis = (Jedis) redisConnection.getNativeConnection();
            Object result = jedis.eval(RELEASE_LOCK_SCRIPT, Collections.singletonList(lockKey),
                    Collections.singletonList(clientId));
            if (RELEASE_SUCCESS.equals(result)) {
                return Boolean.TRUE;
            }
            return Boolean.FALSE;
        });
    }

4  超时问题

Redis的分布式锁不能解决超时问题。如果在加锁和释放锁之间的业务逻辑执行得太长,以至于超出了锁的超时时间,这时候第一个线程持有的锁过期了,但是临界区的业务逻辑还没有执行完,而同时第二个线程就提前重新持有了这把锁,导致出现问题。

解决方案:

  (1)是否可以通过合理地设置LockTime(锁超时时间)来解决这个问题?

  但LockTime的设置原本就很不容易。LockTime设置过小,锁自动超时的概率就会增加,锁异常失效的概率也就会增加。

  而LockTime设置过大,万一服务出现异常无法正常释放锁,那么出现这种异常锁的时间也就越长。我们只能通过经验去配置,一个可以接受的值,基本上是这个服务历史上的平均耗时再增加一定的buff。

  (2)既然(1)的方法走不通,那么可以采用如下方法

  我们可以先给锁设置一个LockTime,然后启动一个守护线程,让守护线程在一段时间后,重新去设置这个锁的LockTime。

  实际操作中,我们要注意以下几点:

    1、和释放锁的情况一致,我们需要先判断锁的对象是否没有变。否则会造成无论谁持有锁,守护线程都会去重新设置锁的LockTime。不应该续的不能瞎续。

    2、守护线程要在合理的时间再去重新设置锁的LockTime,否则会造成资源的浪费。不能动不动就去续。

    3、如果持有锁的线程已经处理完业务了,那么守护线程也应该被销毁。不能主人都挂了,守护者还在那里继续浪费资源。

  代码实现:

public class SurvivalClamProcessor implements Runnable {
    private static final int REDIS_EXPIRE_SUCCESS = 1;
    SurvivalClamProcessor(String field, String key, String value, int lockTime) {
        this.field = field;
        this.key = key;
        this.value = value;
        this.lockTime = lockTime;
        this.signal = Boolean.TRUE;
    }
    private String field;
    private String key;
    private String value;
    private int lockTime;
    //线程关闭的标记
    private volatile Boolean signal;
    void stop() {
        this.signal = Boolean.FALSE;
    }
    @Override
    public void run() {
        int waitTime = lockTime * 1000 * 2 / 3;
        while (signal) {
            try {
                Thread.sleep(waitTime);
                if (cacheUtils.expandLockTime(field, key, value, lockTime) == REDIS_EXPIRE_SUCCESS) {
                    if (logger.isInfoEnabled()) {
                        logger.info("expandLockTime 成功,本次等待{}ms,将重置锁超时时间重置为{}s,其中field为{},key为{}", waitTime, lockTime, field, key);
                    }
                } else {
                    if (logger.isInfoEnabled()) {
                        logger.info("expandLockTime 失败,将导致SurvivalClamConsumer中断");
                    }
                    this.stop();
                }
            } catch (InterruptedException e) {
                if (logger.isInfoEnabled()) {
                    logger.info("SurvivalClamProcessor 处理线程被强制中断");
                }
            } catch (Exception e) {
                logger.error("SurvivalClamProcessor run error", e);
            }
        }
        if (logger.isInfoEnabled()) {
            logger.info("SurvivalClamProcessor 处理线程已停止");
        }
    }
}

  在以上代码中,我们将waitTime设置为Math.max(1, lockTime * 2 / 3),即守护线程许需要等待waitTime后才可以去重新设置锁的超时时间,避免了资源的浪费。

  同时在expandLockTime时候也去判断了当前持有锁的对象是否一致,避免了胡乱重置锁超时时间的情况。

  然后我们在获得锁的代码之后,添加如下代码:

SurvivalClamProcessor survivalClamProcessor = new SurvivalClamProcessor(lockField, lockKey, randomValue, lockTime);//创建守护线程
Thread survivalThread = new Thread(survivalClamProcessor);
survivalThread.setDaemon(Boolean.TRUE);//后台线程
survivalThread.start();
Object returnObject = joinPoint.proceed(args);//执行业务代码
survivalClamProcessor.stop();//业务代码执行完成后停止守护线程
survivalThread.interrupt();//中断线程
return returnObject;

   这段代码会先初始化守护线程的内部参数,然后通过start函数启动线程,最后在业务执行完之后,设置守护线程的关闭标记,最后通过interrupt()去中断sleep状态,保证线程及时销毁。

参考:https://juejin.im/post/5c457f5a6fb9a049d37f6b55

   

猜你喜欢

转载自www.cnblogs.com/aiqiqi/p/11008749.html