阅读本文约“12分钟”适读人群:初级Java
这周出差福建龙岩了解到一种叫牛兜汤的小吃
类似牛杂碎组成的,汤偏稠,味咸
Pexels 上的 Vova Krasilnikov 拍摄的图片 前言
最近开发的项目中需要实现一个用户累计签到的功能,看到这个需求的时候第一反应就是利用Redis位图来实现。之前在学习Redis数据结构的时候就有接触到位图,不过位图的应用场景不多,所以一直没有机会使用到。先简单介绍一下Redis的位图吧。
位图的原理
位图不是特殊的数据结构,它的内容其实就是普通的字符串,也就是 byte 数组。我们可以使用普通的 get/set 直接获取和设置整个位图的内容,也可以使用位图操作 getbit/setbit 等将 byte 数组看成「位数组」来处理。
——《Redis深度历险 核心原理与应用实践》
实际上,位图的本质还是操作Redis里的"String"数据结构。我们都知道字符串是由多个字节组成的,每个字节都有对应的ASCII码,每个ASCII码的二进制都是8位数。操作位图实际上就是操作String字符串里的字节的二进制数字。听起来有点拗口?咱们撸个指令看看位图的基本使用。
假如我们现在要向Redis插入一个key为"name",value为"lee"的字符串。我们有两种办法:
1.通过set指令
1本地redis:0>set "name" lee2"OK"3本地redis:0>get "name"4"lee"
2.通过位图的setbit指令首先在开撸之前,我们要先拆解一下value值,“lee"实际上是由"l”,“e”,"e"这三个字节组成的。‘l’ 的ASCII码的二进制是0110 1100
'e’ 的ASCII码的二进制是0110 0101
如图表所示,将"lee"的字符的ASCII码的二进制连起来是
这里需要注意一下:ASCII的低位到高位是从右到左的,而我们在用setbit定位是从左到右的。
SETBIT key offset value
可用版本:>= 2.2.0
时间复杂度: O(1)
对 key 所储存的字符串值,设置或清除指定偏移量上的位(bit)。
位的设置或清除取决于 value 参数,可以是 0 也可以是 1 。
当 key 不存在时,自动生成一个新的字符串值。
字符串会进行伸展(grown)以确保它可以将 value 保存在指定的偏移量上。当字符串值进行伸展时,空白位置以 0 填充。
offset 参数必须大于或等于 0 ,小于 2^32 (bit 映射被限制在 512 MB 之内)。所以在Redis中字符串类型的Value最多可以容纳的数据长度是 512 MB 。
Redis 的位数组是自动扩展,如果设置了某个偏移位置超出了现有的内容范围,就会自动将位数组进行零扩充。
所以我们在操作过程中只需要去处理设置值为1的位,可以看到,第一个字符"l",需要将下标1,2,4,5设置为1。第二个字符"e",需要下标9,10,13,15设置为1。第三个字符"e",需要下标17,18,21,23设置为1。
1本地redis:0>del "name" 2"1" 3本地redis:0>setbit "name" 1 1 4"0" 5本地redis:0>setbit "name" 2 1 6"0" 7本地redis:0>setbit "name" 4 1 8"0" 9本地redis:0>setbit "name" 5 110"0"11本地redis:0>get "name"12"l" #第一个字符录入成功13本地redis:0>setbit "name" 9 114"0"15本地redis:0>setbit "name" 10 116"0"17本地redis:0>setbit "name" 13 118"0"19本地redis:0>setbit "name" 15 120"0"21本地redis:0>get "name"22"le" #第二个字符录入成功23本地redis:0>setbit "name" 17 124"0"25本地redis:0>setbit "name" 18 126"0"27本地redis:0>setbit "name" 21 128"0"29本地redis:0>setbit "name" 23 130"0"31本地redis:0>get "name"32"lee" #第三个字符录入成功
同理,有setbit指令,就有getbit指令
GETBIT key offset可用版本:>= 2.2.0时间复杂度:O(1)
另外还有bitcount指令
BITCOUNT key [start] [end]
可用版本:>= 2.6.0
时间复杂度:O(N)
计算给定字符串中,被设置为 1 的比特位的数量。
一般情况下,给定的整个字符串都会被进行计数,通过指定额外的 start 或 end 参数,可以让计数只在特定的位上进行。
start 和 end 参数的设置和 GETRANGE key start end 命令类似,都可以使用负数值:比如 -1 表示最后一个字节, -2 表示倒数第二个字节,以此类推。
不存在的 key 被当成是空字符串来处理,因此对一个不存在的 key 进行 BITCOUNT 操作,结果为 0 。
利用位图来实现签到功能
我们可以利用用户的唯一标识来做为key值,以项目正式上线日期到当前时间的日期差做为偏移值。利用setbit和bitcount来做为签到以及统计签到的功能。上一个签到实现的Java代码吧。(为什么用日期差来做偏移值,而不直接用日期的Long类型来做,原因在于日期的数字比较长,会浪费很多空间存储0)
1 private final String onlineDate = "2020-05-03"; 2 @Override 3 public void signed(String userId) { 4 LocalDate beginday = LocalDate.parse(onlineDate, DateTimeFormatter.ofPattern("yyyy-MM-dd")); 5 LocalDate today = LocalDate.now(); 6 //获取上线之日到今天过了多久,做为redis的offset 7 long offset = beginday.until(today, ChronoUnit.DAYS); 8 String key = "signed" + userId; 9 //使用redis的位图机制来做登录记录,签到为1(true),未签到自动为0(false)10 Boolean flag = redisTemplate.execute((RedisCallback<Boolean>) con -> con.setBit(key.getBytes(), offset, true));11 //这个flag返回值是存储位原来的值,所以返回false说明签到成功,返回true说明重复签到12 if (!flag) {13 //获取用户累计签到次数14 Long signedNum = redisTemplate.execute((RedisCallback<Long>)con -> con.bitCount(key.getBytes()));15 //根据累计签到次数做业务处理16 }1718 }
位图的好处
速度快、节省空间
位图的setbit和getbit的时间复杂度都是O(1),而bitcount的时间复杂度虽然是O(N),但是在刚才描述的用户签到业务场景下,即使运行 10 年,占用的空间也只是每个用户 10*365 比特位,也即是每个用户 456 字节。对于这种大小的数据来说, BITCOUNT key [start] [end] 的处理速度就像 GET key 和 INCR key 这种 O(1) 复杂度的操作一样快。 面试相关 了解完redis的位图,最后上一道大厂的经典算法面试题,大家一起思考一下吧
给20亿个不重复的unsigned int的整数,没排过序的,然后再给一个数,如何快速判断这个数是否在那40亿个数当中并且所耗内存尽可能的少?