文章目录
一、bitmap
1. bitmap的应用
应用场景:
例如签到,打卡的应用,每天的状态只需要用一个bit来存储,就算一年365天也只需要356bit
按年去存储一个用户的签到情况,365天只需要365/8~46 Byte,1000W用户量一年也只需要44 MB就足够了。
假如是亿级的系统,每天使用1个1亿位的Bitmap约占12MB的内存(10^8/8/1024/1024),10天的Bitmap的内存开销约为120MB,内存压力不算太高。
在实际使用时,最好对Bitmap设置过期时间,让Redis自动删除不再需要的签到记录以节省内存开销。
使用举例:
127.0.0.1:6379> SETBIT sign:user1:202106 1 1 # 第一天签到
(integer) 0
127.0.0.1:6379> SETBIT sign:user1:202106 2 1 # 第二天签到
(integer) 0
127.0.0.1:6379> SETBIT sign:user1:202106 3 1 # 第三天签到
(integer) 0
127.0.0.1:6379> SETBIT sign:user1:202106 8 1 # 第八天签到
(integer) 0
127.0.0.1:6379> GETBIT sign:user1:202106 1 # 获取第一天是否签到,1表示签到
(integer) 1
127.0.0.1:6379> GETBIT sign:user1:202106 2 # 获取第二天是否签到,1表示签到
(integer) 1
127.0.0.1:6379> GETBIT sign:user1:202106 5 # 获取第五天是否签到,0表示未签到
(integer) 0
127.0.0.1:6379> BITCOUNT sign:user1:202106 # 获取本月总共签到几次
(integer) 4
高级用法:
对指定key按位进行交、并、非、异或操作,并将结果保存到destKey中
bitop op destKey key1 [key2...]
- and:交
- or:并
- not:非
- xor:异或
使用举例:
127.0.0.1:6379> SETBIT sign:20210619 1 1 # 日期20210619 用户id为 1 的用户签到
(integer) 0
127.0.0.1:6379> SETBIT sign:20210619 2 1 # 日期20210619 用户id为 2 的用户签到
(integer) 0
127.0.0.1:6379> SETBIT sign:20210620 1 1 # 日期20210620 用户id为 1 的用户签到
(integer) 0
127.0.0.1:6379> BITOP and destKey sign:20210619 sign:20210620 # 两个bitmap取与运算
(integer) 1
127.0.0.1:6379> BITCOUNT destKey # 两天都签到的人有 1 个
(integer) 1
127.0.0.1:6379> GETBIT destKey 1 # 结果为 1 表示用户 1 两都签到了
(integer) 1
127.0.0.1:6379> GETBIT destKey 2 # 结果为 0 表示用户 2 某一天没有签到
(integer) 0
2. bitmap的实现
Redis 使用字符串对象来表示位数组,因为字符串对象时二进制安全的,所以可以直接存储位数组。
- buf数组的每个字节都用一行来表示,每行的第一个格子buf[i]表示这是buf数组的哪个字节,而buf[i]之后的八个格子则分别代表这一字节中的八个位。
- buf数组保存位数组的顺序和我们平时书写位数组的顺序是完全相反的,也就是逆序,例如buf[2] 0000 1111 实际保存的位数组是 1111 0000。这有利于SETBIT操作的实现。
2.1 bitmap 命令的实现
GETBIT 命令实现
GETBIT 用于返回在该位数组中某偏移量上的二进制位。
GETBIT <bitarray> <offset>
具体过程是:
- 计算byte= offset ÷ 8 ,byte值记录了offset偏移量指定的二进制位保存在位数组的哪个字节。
- 计算bit=(offset mod 8)+1,bit值记录了offset偏移量指定的二进制位是byte字节的第几个二进制位。
- 根据byte值和bit值,在位数组bitarray中定位offset偏移量指定的二进制位,并返回这个位的值。
所以总体的时间复杂度为 O(1)。
SETBIT 命令实现
SETBIT 操作可以将位数组的某个偏移量上的二进制位设置为value
SETBIT <bitarray> <offset> <value>
具体操作过程:
- 计算len= offset ÷ 8 + 1,len值记录了保存offset偏移量指定的二进制位至少需要多少字节。
- 检查bitarray键保存的位数组(也即是SDS)的长度是否小于 len,如果是的话,将SDS的长度扩展为len字节,并将所有新扩展空间的二进制位的值设置为0。
- SDS 会对字符串扩展做空间的预分配,如果长度小于1MB则分配长度双倍的空间,如果大于 1MB,则多分配1MB的空间。
- 然后和上面GETBIT的操作一样获取到定位,之后对该位进行修改,并且返回原值。
因为buf数组使用逆序来保存位数组,所以当程序对buf数组进行扩展之后,写入操作可以直接在新扩展的二进制位中完成,而不必改动位数组原来已有的二进制位。所以时间复杂度也是 O(1)。
BITCOUNT 命令实现
BITCOUNT命令用于统计给定位数组中,值为1的二进制位的数量。
BITCOUNT key [start end]
先介绍几种二进制统计的算法:
- 遍历法:遍历整个数组通过计数器计算二进制位数。
- 查表法:维护一张表,表中每个键是对应位数组,而值是1的数量,如果键是八位的位数组,则一次可以检查8个二进制位。
- variable-precision SWAR算法:具体可以看JDK 实现的 Integer.bitCount() ;
- Redis 的实现:如果二进制数量小于128位,则通过查表法来统计,如果大于,则使用SWAR算法。
BITOP 命令实现
该命令会创建一个新的空白位数组,然后将与,或,异或运算的结果,放到新数组中。
二、hyperloglog
通过牺牲准确率来换取空间,对于不要求绝对准确率的场景下可以使用,因为概率算法不直接存储数据本身.
通过一定的概率统计方法预估基数值,同时保证误差在一定范围内,由于又不储存数据故此可以大大节约内存。
hyperloglog就是一种概率算法的实现。
- 用于进行基数统计,不是集合,不保存数据,只记录数量而不是具体数据
- 核心是基数估算算法,最终数值存在一定误差
- 误差范围:基数估计的结果是一个带有 0.81% 标准错误的近似值
- 耗空间极小,每个hyperloglog key占用了12K的内存用于标记基数
- pfadd命令不是一次性分配12K内存使用,会随着基数的增加内存逐渐增大
- Pfmerge命令合并后占用的存储空间为12K,无论合并之前数据量多少
1. 基本操作
- 添加数据
PFADD key element1, element2...
- 统计数据
PFCOUNT key1 key2....
- 合并数据
PFMERGE destkey sourcekey [sourcekey...]
具体示例
主要应用于大数量的访问网站人数的统计
127.0.0.1:6379> PFADD taobao:uv 1 2 3 # 例如1 2 3 是用户ip 添加taobao 当天uv(Unique Visitor 独立访客)
(integer) 1
127.0.0.1:6379> PFCOUNT taobao:uv # 获取当日访问人数
(integer) 3
127.0.0.1:6379> PFADD jd:uv1 1 2 3 # 添加jd 第一天uv
(integer) 1
127.0.0.1:6379> PFADD jd:uv2 1 2 3 4 5 # 添加jd 第二天uv
(integer) 1
127.0.0.1:6379> PFMERGE total:uv jd:uv1 jd:uv2 # 合并jd两天uv
OK
127.0.0.1:6379> PFCOUNT total:uv # 获取jd两天uv总和
(integer) 5
如果对于HyperLogLog算法有兴趣了解的可以看这篇文章,HyperLogLog是大数据基数统计中的常见方法,无论是Redis,Spark还是 Flink都提供了这个功能,其目的就是在一定的误差范围内,用最小的空间复杂度来估算一个数据流的基数。
HyperLogLog 的结构也是raw编码形式的string
2. 简单案例
统计网站首页亿级UV的统计
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
/**
* @Description: 获取uv的controller
* @Date 2021/6/18 17:16
* @@author: A.iguodala
*/
@Api("案例实战:网站首页亿级UV的Redis统计方案")
@RestController
@Slf4j
public class HyperLogLogController {
@Autowired
private RedisTemplate redisTemplate;
@ApiOperation("获得ip去重复后的首页访问量,总数统计")
@RequestMapping(value = "/uv",method = RequestMethod.GET)
public long uv()
{
//pfcount
return redisTemplate.opsForHyperLogLog().size("uv");
}
}
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import javax.annotation.PostConstruct;
import java.util.Random;
import java.util.concurrent.TimeUnit;
/**
* @Description:service 模拟大量用户访问该网站
* @Date 2021/6/18 17:16
* @@author: A.iguodala
*/
@Service
@Slf4j
public class HyperLogLogService {
@Autowired
private RedisTemplate redisTemplate;
/**
* 模拟有用户来点击首页,每个用户就是不同的ip,不重复记录,重复不记录
*/
@PostConstruct
public void init() {
log.info("------模拟后台有用户点击,每个用户ip不同");
//自己启动线程模拟,实际上产不是线程
new Thread(() -> {
String ip = null;
for (int i = 1; i <= 20000; i++) {
Random random = new Random();
ip = random.nextInt(255) + "." + random.nextInt(255) + "." + random.nextInt(255) + "." + random.nextInt(255);
Long hll = redisTemplate.opsForHyperLogLog().add("uv", ip);
log.info("ip={},该ip访问过的次数={}", ip, hll);
//暂停3秒钟线程
try {
TimeUnit.SECONDS.sleep(3);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "t1").start();
}
}
三、GEO
GEO听名字都知道应用于地理位置的一些业务,例如打车附近的车辆,美团搜索附近的美食,微信附近的人等等
1. 基本操作
# 添加多个经度(longitude)、纬度(latitude)、位置名称(member)添加到指定的 key 中
GEOADD key longitude latitude member [longitude latitude member ...]
# 从键里面返回所有给定位置元素的位置(经度和纬度)
GEOPOS key member [member ...]
# 返回两个给定位置之间的距离。
GEODIST key member1 member2 [unit]
# 以给定的经纬度为中心, 返回与中心的距离不超过给定最大距离的所有位置元素。
GEORADIUS key longitude latitude radius m|km|ft|mi [withcoord] [withdist] [withhash] [count count]
# GEORADIUSBYMEMBER 跟GEORADIUS类似
GEORADIUSBYMEMBER key member radius m|km|ft|mi [withcoord] [withdist] [withhash] [count count]
# 返回一个或多个位置元素的 Geohash 表示
GEOHASH key member [member ...]
2. 数据结构
查询可知,GEO使用的数据类型是zset,底层结构采用的是ziplist ,因为我的数据量小,所以应该在数据量大的情况下,采用dict+ skiplist(字典保证O(1)的查询单个位置的效率,skiplist保证范围查找或者排序的效率)
3. 简单案例
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.geo.Distance;
import org.springframework.data.geo.GeoResults;
import org.springframework.data.geo.Metrics;
import org.springframework.data.geo.Point;
import org.springframework.data.geo.Circle;
import org.springframework.data.redis.connection.RedisGeoCommands;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* @Description
* @Date 2021/6/18 17:16
* @@author: A.iguodala
*/
@RestController
public class GeoController
{
public static final String CITY ="city";
@Autowired
private RedisTemplate redisTemplate;
@ApiOperation("新增天安门故宫长城经纬度")
@RequestMapping(value = "/geoadd",method = RequestMethod.POST)
public String geoAdd()
{
Map<String, Point> map= new HashMap<>();
map.put("天安门",new Point(116.403963,39.915119));
map.put("故宫",new Point(116.403414 ,39.924091));
map.put("长城" ,new Point(116.024067,40.362639));
redisTemplate.opsForGeo().add(CITY,map);
return map.toString();
}
@ApiOperation("获取地理位置的坐标")
@RequestMapping(value = "/geopos",method = RequestMethod.GET)
public Point position(String member) {
//获取经纬度坐标
List<Point> list= this.redisTemplate.opsForGeo().position(CITY,member);
return list.get(0);
}
@ApiOperation("geohash算法生成的base32编码值")
@RequestMapping(value = "/geohash",method = RequestMethod.GET)
public String hash(String member) {
//geohash算法生成的base32编码值
List<String> list= this.redisTemplate.opsForGeo().hash(CITY,member);
return list.get(0);
}
@ApiOperation("计算两个位置之间的距离")
@RequestMapping(value = "/geodist",method = RequestMethod.GET)
public Distance distance(String member1, String member2) {
Distance distance= this.redisTemplate.opsForGeo().distance(CITY,member1,member2, RedisGeoCommands.DistanceUnit.KILOMETERS);
return distance;
}
/**
* 通过经度,纬度查找附近的
* 北京王府井位置116.418017,39.914402,这里为了方便,故意写死
*/
@ApiOperation("通过经度,纬度查找附近的")
@RequestMapping(value = "/georadius",method = RequestMethod.GET)
public GeoResults radiusByxy() {
//这个坐标是北京王府井位置
Circle circle = new Circle(116.418017, 39.914402, Metrics.MILES.getMultiplier());
//返回50条
RedisGeoCommands.GeoRadiusCommandArgs args = RedisGeoCommands.GeoRadiusCommandArgs.newGeoRadiusArgs().includeDistance().includeCoordinates().sortAscending().limit(10);
GeoResults<RedisGeoCommands.GeoLocation<String>> geoResults= this.redisTemplate.opsForGeo().radius(CITY,circle, args);
return geoResults;
}
/**
* 通过地方查找附近
*/
@ApiOperation("通过地方查找附近")
@RequestMapping(value = "/georadiusByMember",method = RequestMethod.GET)
public GeoResults radiusByMember() {
String member="天安门";
//返回50条
RedisGeoCommands.GeoRadiusCommandArgs args = RedisGeoCommands.GeoRadiusCommandArgs.newGeoRadiusArgs().includeDistance().includeCoordinates().sortAscending().limit(10);
//半径10公里内
Distance distance=new Distance(10, Metrics.KILOMETERS);
GeoResults<RedisGeoCommands.GeoLocation<String>> geoResults= this.redisTemplate.opsForGeo().radius(CITY,member, distance,args);
return geoResults;
}
}