一、App DAU统计,留存统计 - SET

App每日活跃用户数,每日留存统计,是一个很常见的需求。在Redis中,我们刚好可以通过SET来记录所有的用户,并通过SET提供的各种操作API来实现对比统计。

每日DAU统计例子:

# 记录20210525这天的活跃用户
127.0.0.1:6379> SADD user:20210525 10010 10011
2
127.0.0.1:6379> SADD user:20210525 10010 10086 10090
2
127.0.0.1:6379> SADD user:20210525 10086 12345 999900 100000
3
# 可以看到没有重复的内容
127.0.0.1:6379> SMEMBERS user:20210525
10010
10011
10086
10090
12345
100000
999900
# 记录20210526这天的活跃用户
127.0.0.1:6379> SADD user:20210526 10010 10086
2
# 通过交集获取26号的留存
127.0.0.1:6379> SINTERSTORE result user:20210525 user:20210526
2
127.0.0.1:6379> SMEMBERS result
10010
10086

image.png

注意:SET数据类型的并集和交集计算复杂度比较高,如果SET数据量过大,可能会导致操作阻塞,建议此类操作放到单独的从库中进行。

二、评论分页 - ZSET

我们在给具有分页的评论列表添加缓存的时候,由于新的评论一直在入库,所以分页的界限也会在变化。如果按照以下得到的分页结果进行缓存:

select * from t_user order by id desc limit 10 offset 15;

那么,每当有新的记录插入表的时候,所有分页内容都将产生变化,导致所有分页缓存都会失效。

如何避免大量分页缓存失效?

如果是写评率比较少的场景,那么我们可以把读取评率比较高的前几页内容给缓存起来,每次只触发更新这几页缓存即可。

 

但是如果写的很频繁,那么就需要频繁的更新这几页的内容了,会导致写操作变重。或者业务需要,前几十页的访问评论都是比较高的场景,有什么比较好的缓存方法呢?

 

这个时候我们就可以使用Redis中的有序集合来实现分页缓存了:

  • 我们可以给每个评论设置一个权重值,可以是当前时间戳,通过ZADD添加到ZSET中;
  • 然后通过 ZRANGEBYSCORE 按照score进行分页查找

ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT offset count] Available since 1.0.5. 时间复杂度:O(log(N)+ M),其中N是排序集中的元素数,M是要返回的元素数。如果M为常数(例如,始终要求使用LIMIT限制前10个元素),则可以将其视为O(log(N))。

以下是具体的例子:

# 按照评论时间微秒设置每条评论的score
127.0.0.1:6379> ZADD comments 1621900305499398 '沙发'
127.0.0.1:6379> ZADD comments 1621900588459950 '板凳'
127.0.0.1:6379> ZADD comments 1621900628104233 '牛逼'
127.0.0.1:6379> ZADD comments 1621900661283904 'itzhai.com'

# 按照score排序进行范围查找,实现分页效果,查找第一页
127.0.0.1:6379> zrangebyscore comments 0 1621901132532229  withscores limit 0 3
沙发
1621900305499398
板凳
1621900588459950
牛逼
1621900628104233

# 查找第二页,使用上一页返回的最后一个微秒时间戳作为查找的min参数
127.0.0.1:6379> zrangebyscore comments (1621900628104233 1621901132532229  withscores limit 0 3
itzhai.com
1621900661283904

image.png

另外,对于更新非常频繁需要排序的列表,都可以考虑使用ZSET。

三、每日签到统计 - BitMap

很多网站为了保持用户的活跃,都会搞签到活动,每日签到一次送金币啥的。

 

也许你会想到把签到状态放到一个HashMap中,标识用户已签到,但是随着用户数越来越多,我们就要寻找更加节省内存的存储结构了,这个时候,BitMap就派上用场了。

 

以下是记录某一天签到记录的例子:

# 通过 sign:20210525 记录5月25日的签到的用户记录
127.0.0.1:6379> SETBIT sign:20210525 10010 1
0
127.0.0.1:6379> SETBIT sign:20210525 10086 1
0
127.0.0.1:6379> SETBIT sign:20210525 99980 1
0
# 统计5月25日的前导记录
127.0.0.1:6379> BITCOUNT sign:20210525
3

以下是统计连续两天签到的用户记录数:

127.0.0.1:6379> SETBIT sign:20210526 10010 1
0
# 通过与操作获取连续两天都签到的用户数
127.0.0.1:6379> BITOP AND result sign:20210525 sign:20210526
12498
127.0.0.1:6379> BITCOUNT result
1

image.png

如果用户的标识比较复杂,不能直接作为BitMap的偏移量,或者用户标识已经超过了Redis的BitMap能够存储的范围,我们可以进一步使用BloomFilter,通过哈希函数去做映射,当然这意味着你需要接受一定范围内的偏差。

四、查找附近的景点 - Geospatial

这种场景,我们很自让的想到了要用Geospatial功能了。

下面我们初始化一些景点的坐标:

127.0.0.1:6379> GEOADD inner-mongolia 119.933018 50.829028 恩和俄罗斯民族乡
127.0.0.1:6379> GEOADD inner-mongolia 117.95893 49.493591 呼伦贝尔大草原
127.0.0.1:6379> GEOADD inner-mongolia 119.766941 49.219447 卢布里西餐厅
127.0.0.1:6379> GEOADD inner-mongolia 120.346281 47.208699 阿尔山国家森林公园
127.0.0.1:6379> GEOADD inner-mongolia 120.133019 50.536113 白桦林景区
127.0.0.1:6379> GEOADD inner-mongolia 117.684185 49.5045 猛犸公园旅游景区
127.0.0.1:6379> GEOADD inner-mongolia 119.752034 49.216568 呼伦贝尔古城
127.0.0.1:6379> GEOADD inner-mongolia 117.671052 49.339278 呼伦湖
127.0.0.1:6379> GEOADD inner-mongolia 121.481696 50.785409 敖鲁古雅使鹿部落景区
127.0.0.1:6379> GEOADD inner-mongolia 101.087036 41.967675 额济纳胡杨林

现在我们刚从海拉尔机场出来,坐标:119.83751,49.214948,查找附近150公里的景点:

127.0.0.1:6379> GEORADIUS inner-mongolia 119.83751 49.214948 150 km ASC COUNT 10 WITHDIST
卢布里西餐厅
5.1516
呼伦贝尔古城
6.2127
呼伦贝尔大草原
139.5841
白桦林景区
148.4668

五、消息队列 - LIST、STREAM

如果要实现一个消息队列,在Redis 5.0之前,我们可能会想到LIST。在前面我们也提到,通过LPUSH,LPOP,RPUSH,RPOP操作,可以把List当成队列或者栈来使用。

5.1 使用LIST

使用List实现的一个最简单的消息队列模型如下: image.png

消息队列由Redis的List充当,Provider使用LPUSH命令往消息队列中推送消息,Consumer使用RPOP从消息队列中拉取消息。

当然,可以支持多个消费者: image.png

List中的元素一旦出队列之后,就再也找不到了这会导致消息一旦消费失败,就会导致消息丢失。为了保证消息的可靠性,我们可以引入多一个备份消息列表。每当执行POP的时候,顺便把消息写入到备份消息队列中,等待消费者真正的处理完消息之后,再从备份的消息队列中删除掉消息: image.png

当然,也可以引入ACK机制,在消息消费完毕之后,再让消息从队列中POP出来,但这样会导致消息不能多个消费者并行消费,必须等到上一个消息处理完,并且发送了ACK之后,才会从List中取出,才能得继续读取下一条消息。

 

但是,使用List作为消息队列会存在以下问题:

  • ID由客户端自己生成,需要客户端另外准备一个唯一ID生成器组件;
  • 不支持消息分组:虽然可以支持多个消费者同时消费同一个消息队列,但是list这个结构不支持一条消息被多个消费者重复消费;
  • 消息可靠性无法保证,消息可能会丢失,导致未被消费到,虽然可以通过备份消息队列处理。

为此,在Redis 5.0中,引入了Stream数据类型,非常适合用作消息队列。

为了进一步提高CPU效率,我们可以使用阻塞式的API,如BRPOP、BRPOPLPUSH,这样可以挂起线程,让线程进入等待,避免在队列还没有元素的时候反复的进行网络请求,减少系统资源消耗。

5.2 使用STREAM

Stream,抽象日志类型,存储起来的数据,不会立刻删除掉,而是可以传入一个偏移量进行反复读取。

Stream支持一下特性:

  • 自动生成ID,ID顺序增长,保证有序性;
  • 支持消息的分组消费,这个特性借鉴了kafka;
  • 支持消息的ACK机制,支持重复读取消息,不像List中的消息,POP出来之后,就再也找不到了。

使用Stream实现消息队列的关键命令如下:

  • XADD:添加日志消息
  • XREAD:读取日志消息
  • XGROUP:创建分组
  • XREADGROUP:按分组读取消息
  • XPENDING:检查待处理消息列表,即每个消费组内消费者已读取,但是尚未得到确认的消息
  • XACK:用于消费完之后,发送ACK消息给Stream,这个时候消息将从XPENDING中移除
  • XCLAIM:如果某一个客户端挂了,可以使用此命令,让其他Consumer主动接管它的pending msg

具体命令使用,参考Part I中的相关内容。

使用Stream实现的消息队列,我们在讲Stream数据类型的时候已经讲过了,我们再来回顾一下这张图: image.png

  • 生产者通过XADD命令往Stream中添加消息:

    • XADD articles * title redis author arthinking

  • 默认的,会为每条消息生成一个唯一ID;

  • 通过XGROUP CREATE创建Group分组;

  • 消费者通过XREADGROUP命令消费分组中的消息,一条消息只会被同一个消费分组下的一个消费者消费,不同消费分组可以消费相同的消息

  • 如果XREADGROUP命令没有指定NOACK选项,那么默认的会把每个消费分组中被消费者取出的消息放入待处理消息列表中;

  • 消费者消费完消息后,执行XACK命令,把消息从待处理消息列表中删除。

使用消息队列需要注意什么

一般的,我们在使用消息队列的时候,业务实现尽量不要依赖消息的顺序,业务本身做好幂等,最后,要考虑消息可靠性,我们是否需要确保消息不能丢失,如果不能丢失,那么就要考虑装满的消息队列中间件了。

5.3 可以使用STREAM代替消息队列中间件吗?

使用Redis的Stream做消息队列的优势是,部署简单,不需要依赖其他第三方组件。

 

需要注意的是,由于Redis持久化机制会导致丢数据的问题,Stream也可能丢消息。如果需要更加强大的消息队列,比如,金融业务场景,不允许丢失消息的场景,那么就得用上专业的消息队列组件了。

 

而Redis的Stream则更适用于发短信、消息推送等对可靠性要求不高的业务场景。

六、统计不重复的访客 - HyperLogLog

当然,我们也可以使用BitMap进行统计,但是有没有一种更加节省内存,统计效率更高的方式呢?如果你允许支持一定范围内的误差,那么HyperLogLog就派上用场了。

 

关于HyperLogLog的原理,我在 Part 1部分已经做了介绍了,如果要用HyperLogLog统计不重复访客,操作起来很简单:

# 往HyperLogLog中记录访客信息
127.0.0.1:6379> PFADD visitors 10010 10086 itzhai.com arthinking itzhai.com 10010
1
# 获取不重复的访客
127.0.0.1:6379> PFCOUNT visitors
4

七、如何实现多个Redis命令原子操作

想象以下,我们要执行以下的操作:

a = GET test;
a ++;
SET test a

有没有办法保证原子性呢?如果直接这样顺序执行,多线程场景下,可能会导致数据错误。

为了实现这个功能更,Redis实现了原子操作命令:DECR, INCR

7.1 DECR, INCR

DECR key

时间复杂度: O(1),将存储在 key 中的数字减一。如果该键不存在,则在执行操作前将其设置为 0。如果键包含错误类型的值或包含不能表示为整数的字符串,则返回错误。此操作仅限于 64 位有符号整数。

返回:递减后key的值。

INCR key

时间复杂度: O(1),将存储在 key 中的数字增加 1。如果该键不存在,则在执行操作前将其设置为 0。如果键包含错误类型的值或包含不能表示为整数的字符串,则返回错误。此操作仅限于 64 位有符号整数。

注意:这是一个字符串操作,因为 Redis 没有专用的整数类型。存储在键中的字符串被转换为64 位有符号10进制整数来执行操作。

返回:递增后key的值。

通过这两个命令,可以保证整数的原子递增和递减。

如果我们要原子执行的是多个Redis命令,那么如何实现呢。这对这种场景,可以使用Lua脚本来实现。

REDIS中执行LUA脚本实现原子操作

7.2 REDIS中执行LUA脚本实现原子操作

我们先来举个需要原子执行多个Redis命令的例子,可能例子不是很恰当,不过足以说明没有原子性执行一批Redis命令导致的问题。

 

我们需要在Redis中分别记录4个商品的最后购买人,而在业务逻辑中,4个商品是批量下单更新的,更新完之后,再分别设置了商品的购买人,逻辑如下:

update product set buyer = Jack where id in(1, 2, 3, 4);
HSET prod:1 BUYER Jack;
HSET prod:2 BUYER Jack;
HSET prod:3 BUYER Jack;
HSET prod:4 BUYER Jack;

如果此时,有多个线程同时对这些商品执行了库存扣减操作,如果这几行代码不是原子性执行,那么就可能导致4个商品的最后购买人和product表里面的不一致了,如下图: image.png

为了避免这个问题,我们可以通过Lua脚本写一个批量更新商品最近购买人的脚本:

local name = ARGV[1];
for k, v in ipairs(KEYS) do
  redis.call("hset", v, "BUYER",  name);
end;

然后直接执行即可

redis-cli -p 6379 -c --eval batch_update.lua prod:1 prod:2 prod:3 prod:4 , arthinking

最终,可以发现,4个商品的BUYER字段都同时更新了:

➜  script redis-cli -p 6379 -c --raw
127.0.0.1:6379> HGET prod:1 BUYER
arthinking
127.0.0.1:6379> HGET prod:2 BUYER
arthinking
127.0.0.1:6379> HGET prod:3 BUYER
arthinking
127.0.0.1:6379> HGET prod:4 BUYER
arthinking

为了避免每次执行lua脚本,都需要通过网络传递脚本到Redis服务器,我们可以通过SCRIPT LOAD命令把lua脚本加载到Redis中,然后通过EVALSHA命令执行:

127.0.0.1:6379> script load 'local name = ARGV[1];for k, v in ipairs(KEYS) do redis.call("hset", v, "BUYER",  name); end;'
526b107ee608d0695e33f34f9358d5a18858400d

127.0.0.1:6379> EVALSHA 526b107ee608d0695e33f34f9358d5a18858400d 2 prod:1 prod:2 itzhai

127.0.0.1:6379> hget prod:1 BUYER
itzhai
127.0.0.1:6379> hget prod:2 BUYER
itzhai
  • SCRIPT LOAD:将脚本加载到脚本缓存中,但不执行它,并返回脚本的SHA1摘要。除非调用SCRIPT FLUSH,否则脚本会一直存在缓存中。

  • EVALSHA:使用该命令,通过脚本的SHA1进行调用脚本。

除了Redis命令的原子操作的场景,我们面临更多的问题是,在分布式系统中,对业务代码中的一组业务需要保证原子性。这个时候,就只能使用分布式锁了。

八、分布式锁:如何实现业务原子操作

在一个JVM,如果一组业务操作要确保原子性,我们可以通过JDK提供的各种锁,如synchronized和ReentrantLock等。

 

而在一个分布式如果一个业务操作必须要确保原子性,单靠JDK的锁是无法锁住的。此时,我们就需要借助一个共享存储系统来实现一个分布式锁。

8.1 可否直接使用数据库锁实现分布式锁?

在并发度不高的场景中,我们可以使用数据库的行锁或者间隙锁来作为分布式锁,只有获取到了数据库锁的节点才可以继续往下执行。这数据库行锁是悲观锁,在其他线程获取不到锁的情况下,会进入阻塞状态,如果这种并发竞争度高的话,那么就会对数据库性能有开销了。

总结下数据库悲观锁的优缺点:

优点

  • 部署成本低,除了数据库,无需依赖其他组件;
  • 数据库保证了持久化,可靠性高。

缺点

  • 如果并发度高,数据库锁的性能开销会增加,并且导致占用大量数据库连接,可能导致数据库连接池耗尽。

为了避免对数据库连接池造成影响,我们可以通过其他方式实现分布式锁,Redis就可以用来实现分布式锁。

8.2 如何用REDIS实现一把单机版的分布式锁

Redis中的SETNX命令可以在设置key的时候,同时返回key是否已经存在,这有点像上锁,判断锁是否已经存在的场景。

SETNX命令

  • 如果不存在,则设置key为保持字符串,并返回1;
  • 当已经持有一个值时,不执行任何操作,并返回0。

在Redis 2.6.12之前,可以通过这个命令实现分布式锁

不过从Redis2.6.12版本开始,可以使用以下更简单的锁定原语:

SET key value [EX seconds|PX milliseconds|EXAT timestamp|PXAT milliseconds-timestamp|KEEPTTL] [NX|XX] [GET]

  • NX:仅在不存在的情况下设置key;
  • XX:仅设置已存在的key;
  • GET:返回存储在key中的旧值,或者当key不存在时,返回nil。

如果是实现单Redis实例的分布式锁,则可以通过使用SET命令来实现。

获取锁

// 客户端client_01尝试获取分布式锁distributed_lock,锁的过期时间为10秒
SET distributed_lock client_01 PX 10000 NX

释放锁

释放锁的时候,为了避免勿删其他客户端的锁,我们需要先判断当前锁的持有者,如果当前锁的持有者为当前客户端,才可以发起释放锁,我们为了保证执行的原子性,这里用lua脚本来实现,release_lock.lua:

if redis.call("get",KEYS[1]) == ARGV[1] then
    return redis.call("del",KEYS[1])
else
    return 0
end

执行如下命令进行释放锁:

➜  script redis-cli -p 6379 -c --eval release_lock.lua distributed_lock , client_01
(integer) 1

如果我们这个时候,在另一个客户端client_02执行释放锁,那么将返回0,表示没有释放掉锁,因为该锁不属于client_02:

# 获取锁
➜  script redis-cli -p 6379 -c --raw
127.0.0.1:6379> SET distributed_lock client_01 PX 1000000 NX
OK

# 尝试用另一个客户端释放锁
127.0.0.1:6379>
➜  script redis-cli -p 6379 -c --eval del_lock.lua distributed_lock , client_03
(integer) 0

续命锁

细心的同学应该会看到,我在上面的例子中,给锁设置了10秒的过期时间。

 

那么问题来了:要是10秒内,业务没有执行完毕,而此时,锁有过期了,不是会被其他线程获取到锁就立刻开始执行业务了吗?

 

为了避免这种情况,我们需要给即将过期的锁进行续命。

 

如果我们的锁超时时间为10秒,那么我们可以在获取到锁之后,开启一个异步线程,设置一个间隔时间(10秒内)定时重新给锁过期时间为10秒后,直到业务执行完毕,然后再异步线程终止操作,再释放锁。这样就可以保证业务执行过程中,锁都不会过期了。

 

以上是实现单Redis实例的分布式锁。不过单Redis实例的分布式锁具有单点故障的风险,为了增加分布式锁的可靠性,我们需要实现多Redis节点的分布式锁。

8.3 如何用REDIS实现一把集群版的分布式锁

Redis的作者设计了用于实现多节点的Redis分布式锁算法:Redlock,并推荐使用该算法实现分布式。

 

该算法的关键思路是:让客户端依次向多个Redis节点请求加锁,如果能够获得半数以上的实例的锁,那么就表示获取锁成功。否则表示加锁失败。

 

加锁失败的情况下,需要向所有Redis节点发起释放锁的请求。

 

如果获取锁的过程消耗的时间超过了锁的有效时间,那么也算加锁失败。

 

可用的Redis分布式实现:

更多关于Redis分布式锁的相关内容,参考:Distributed locks with Redis

8.4 通过LUA脚本执行多个REDIS命令,原子性一定可以得到保证吗?

Redis作为内存数据库,其“事务”与大多数人认为的经典 DBMS 中的事务完全不同。

8.4.1 REDIS事务与MYSQL事务有何不同?

Redis并没有类似MySQL的redolog基于WAL机制去实现原子性和持久性,通过undolog的MVCC支持隔离性,从而避免幻读。

 

虽然Redis有MULTI和EXEC命令配合使用来实现多个操作的事务块,但并不能实现MySQL那样的ACID事务特性。为了保证原子性,MULTI/EXEC 块将 Redis 命令的执行延迟到 EXEC。所以客户端只会把命令命令堆在内存的一个指令队列里,直到执行EXEC的时候,一起执行所有的命令,从而实现原子性。

image.png

把指令记录到指令队列过程中,如果检测出语法有错误的命令,这种情况下执行EXEC命令会丢弃事务,原子性可以得到保证。

 

如果Redis事务块执行过程中部分命令报错报错之后,数据是不会回滚的。此时原子性得不到保证

 

例如,如果指令语法没问题,只是操作的类型不匹配,是检测不出来的,实际执行EXEC的时候,会正确执行没问题的指令,有问题的指令报错,导致事务块的原子性不能得到保证:

127.0.0.1:6379> MULTI
OK
127.0.0.1:6379(TX)> SET str1 itzhai
QUEUED
# 这里实际执行的时候应该报错
127.0.0.1:6379(TX)> LPUSH str1 com
QUEUED
127.0.0.1:6379(TX)> SET str2 arthinking
QUEUED
127.0.0.1:6379(TX)> EXEC
OK
WRONGTYPE Operation against a key holding the wrong kind of value

OK
127.0.0.1:6379> GET str1
itzhai
# 可以发现虽然执行过程中报错了,但是str2也成功设置了值
127.0.0.1:6379> GET str2
arthinking

另外,执行事务块的过程中,是不会触发执行RDB的,所以事务命令操作不会保存到RDB中。但是会记录到AOF文件中。

 

如果事务执行过程中异常停机,导致AOF文件出错,此时可以使用redis-check-aof对原来的 AOF 文件进行修复,清除事务中已完成的操作,进而再启动redis。这种情况,原子性是可以得到保证的。

 

既然谈到了原子性,我们再来看看Redis事务如何才能实现隔离性。

8.4.2 如何保证REDIS的隔离性?

隔离性:不同事务先后提交,最终的执行效果是串行的,也就是在执行过程中,事务能感知到数据的变化只有是自己操作引起的,不会因为其他事务操作导致感知到数据变化。

 

在MySQL的InnoDB引擎的可重复读隔离级别中,为了避免幻读,引入了间隙锁,为了避免不可重复读,引入了MVCC。

 

当需要修改数据的时候,会采用当前读模式,锁定需要修改的记录,从而避免多个事务同时更新同一条记录导致的并发过程中数据被覆盖,不能得到预期的执行结果。

 

而Redis中修改数据是不会锁定需要修改的记录的,并没有MySQL的当前读机制。

当前读和快照读

当前读:读取记录的最新版本,并且读取时要保证其他并发事务不能修改当前记录,为此会对读取的记录进行加锁。

  • 可以使用SELECT ... LOCK IN SHARE MODE、SELECT ... FOR UPDATE、UPDATE、DELETE、INSERT操作实现当前读。本质上都是在这些操作的过程中,先请求获取锁。

快照度:读取的是快照版本,也就是历史版本,通过MVCC + undo log实现。

为了保证隔离性,就需要通过其他方式来实现了。Redis是通过WATCH命令来实现MySQL的当前读机制的。

 

与MySQL的事前锁定记录不同,Redis采用的是事后通知记录变更进而取消需要当前读的操作。

WATCH命令:

Redis Watch 命令用于监视一个(或多个) key ,如果在事务执行之前这个(或这些) key 被其他命令所改动,那么事务将被打断。

 

以下是通过WATCH命令保证隔离性的例子: image.png

 

参考: https://www.itzhai.com/articles/redis-technology-insider-cache-data-structure-concurrency-clustering-and-algorithm.html