Zset排序
有一个分数,所以是三个字段,key,value,score,score用于排序。
添加元素,add
java
stringRedisTemplate.opsForZSet().add(key, value, score);
移除元素
java
stringRedisTemplate.opsForZSet().remove(key, value);
判断元素不存在的方式,以及获取分数
java
stringRedisTemplate.opsForZSet().score(key, value);
查询前五条
java
// 查询前五条
Set<String> range = stringRedisTemplate.opsForZSet().range(key, 0, 4);
注意事项
因为sql语句的,in并不是按照id排序的,需要手动指定排序,使用 order by field
sql
SELECT * FROM user_table
WHERE id IN (1, 2, 3, 4, 5)
ORDER BY FIELD(id, 1, 2, 3, 4, 5);
Set API
交集,共同好友等使用
java
Set<String> strings = stringRedisTemplate.opsForSet().intersect(key, key2);
Feed 流实现方案
关注推送也叫做Feed流,直译为投喂。为用户持续的提供"沉浸式"的体验,通过无限下拉刷新获取新的信息。
Feed流产品有两种常见模式:
1. Timeline
不做内容筛选,简单的按照内容发布时间排序,常用于好友或关注。
例如朋友圈
- 优点:信息全面,不会有缺失。并且实现也相对简单
- 缺点:信息噪音较多,用户不一定感兴趣,内容获取效率低
2. 智能排序
利用智能算法屏蔽掉违规的、用户不感兴趣的内容。推送用户感兴趣信息来吸引用户
- 优点:投喂用户感兴趣信息,用户粘度很高,容易沉迷
- 缺点:如果算法不精准,可能起到反作用
本例中的个人页面,是基于关注的好友来做Feed流,因此采用Timeline的模式。该模式的实现方案有三种:
拉模式
也叫做,读扩散,每个人都有一个发件箱,携带一个时间戳,因为按照时间排序。
而(粉丝,例子)的人,有一个收件箱,用于接收关注的好友的信息。
只在每次查看的时候,去拉取信息,所以叫做,拉模式。
优点:不占用内存,缺点:关注过多,耗时,临时拉取,速度慢
推模式
也叫做,写扩散,直接写到粉丝用户里。
缺点:内存占用过高。
优点:延迟低
推拉结合
推拉结合模式:也叫做读写混合,兼具推和拉两种模式的优点。
活跃用户采用推模式,而普通用户,采用临时拉取信息,僵尸粉直接不拉取信息。
总结
拉模式 | 推模式 | 推拉结合 | |
---|---|---|---|
写比例 | 低 | 高 | 中 |
读比例 | 高 | 低 | 中 |
用户读取延迟 | 高 | 低 | 低 |
实现难度 | 复杂 | 简单 | 很复杂 |
使用场景 | 很少使用 | 用户量少、没有大V | 过千万的用户量,有大V |
Feed 流分页
Feed流中的数据会不断更新,所以数据的角标也在变化,因此不能采用传统的分页模式。
第一次从10开始,0-5,所以是,10,9,8,7,6
新加了11,元素,继续读取,就变成了6~~,读取到重复元素了。
所以在数据变化的情况下,需要使用Feed流的滚动分页
基于最小值,下次从小最小值往下继续分页。
思路
我们使用 zrevrange 降序排列,去掉rev是升序。查询 0 - 2的数据。withscores 表示查看分数。
我们新增元素后,继续查询发现,数据错乱了。
txt
zrevrange z1 0 2 withscores
zadd z1 7 m7
zrevrange z1 3 5 withscores
我们试一下滚动分页的方式
zrevrangebyscore 滚动查询
其中 z1 表示是哪个key,1000表示最大值,这个根据情况定,0表示最小值, 最大值表示上一次的最小值。
其中我们先设置,最大值,最小值,跳过几个,每页的数量。
变化的是最大值,和跳过几个。
txt
# 第一次查询,1000 表示最大值,0 表示最小值
zrevrangebyscore z1 1000 0 withscores limit 0 3
# 第二次查询,最大值就是上一次的最小值
zrevrangebyscore z1 5 0 withscores limit 1 3
如果出现两个相同的score 值,那么查询会出现重复查询。
假设 m7 的值也是 6。我们看看第二页的查询结果。
可以看到这一页中,有出现了 6,按正常逻辑,6已经出现过了,我不想它在出现了。
这就是 跳过几个 的作用了。在命令中limit 1 3
其中 1 表示跳过几个,是根据,上一页中最小值出现的次数判断的。上一条命令应该写2才对。
代码中使用
java
// 使用了 Redis 中的有序集合(Sorted Set)进行范围查询,并按照分数(Score)进行降序排列,并返回成员。
stringRedisTemplate.opsForZSet().reverseRangeByScore(key, 0, max, offset, 2);
实战,滚动分页
java
@Override
public Result queryBlogOfFollow(Long max, Integer offset) {
// 1. 第一步,找到收件箱---需要当前用户。
Long id = UserHolder.getUser().getId(); // 获取当前用户的 ID
String key = RedisConstants.FEED_KEY + id; // 构建当前用户收件箱的 key
// 使用了 Redis 中的有序集合(Sorted Set)进行范围查询,并按照分数(Score)进行降序排列。
Set<ZSetOperations.TypedTuple<String>> typedTuples = stringRedisTemplate.opsForZSet()
.reverseRangeByScoreWithScores(key, 0, max, offset, 2);
// 非空校验
if (typedTuples == null || typedTuples.isEmpty()) {
// 若查询结果为空,则直接返回空列表
return Result.ok();
}
// 2. 解析数据(blodId、score、offset)
ArrayList<Long> list = new ArrayList<>();
// 最小时间初始化为 0
long minTime = 0;
// 偏移量初始化为 1
int os = 1;
for (ZSetOperations.TypedTuple<String> typedTuple : typedTuples) {
// 解析出博客 ID,并添加到列表中
list.add(Long.valueOf(typedTuple.getValue()));
// 获取博客的时间戳
long time = typedTuple.getScore().longValue();
if (time == minTime) {
// 如果时间戳相同,则偏移量加 1
os++;
} else {
// 更新最小时间
minTime = time;
// 重置偏移量为 1
os = 1;
}
}
// 构建 ID 列表的字符串表示,用于 SQL 查询排序
String str = StrUtil.join(",", list);
// 查询博客列表,并按 ID 排序
List<Blog> blogs = query().in("id", list).last("order by field(id," + str + ")").list();
// 3. 根据ID,封装数据并返回。
ScrollResult scrollResult = new ScrollResult();
// 设置博客列表
scrollResult.setList(blogs);
// 设置偏移量
scrollResult.setOffset(os);
// 设置最小时间
scrollResult.setMinTime(minTime);
// 返回结果
return Result.ok(scrollResult);
}
附近商户功能实现 GEO 数据结构
GEO就是Geolocation的简写形式,代表地理坐标。
Redis在3.2版本中加入了对GEO的支持,允许存储地理坐标信息,帮助我们根据经纬度来检索数据。
常见的命令有:
- GEOADD:添加一个地理空间信息,包含:经度(longitude)、纬度(latitude)、值(member)
- GEODIST:计算指定的两个点之间的距离并返回
- GEOHASH:将指定member的坐标转为hash字符串形式并返回
- GEOPOS:返回指定member的坐标
- GEORADIUS:指定圆心、半径,找到该圆内包含的所有member,并按照与圆心之间的距离排序后返回。6.2以后已废弃
- GEOSEARCH:在指定范围内搜索member,并按照与指定点之间的距离排序后返回。范围可以是圆形或矩形。6.2.新功能
- GEOSEARCHSTORE:与GEOSEARCH功能一致,不过可以把结果存储到一个指定的key。 6.2.新功能
地理位置集合创建并添加数据
创建地理位置集合 g1,它会自动把地理位置转换成score,并以zset 的方式存入。
- 116.378248 经度
- 39.865275 维度
- bjn 是该数据的标识
txt
geoadd g1 116.378248 39.865275 bjn
使用代码进行添加,单添加
单添加,需要用 Point 添加 x 和 y。
add 添加 参数一 是 point表示坐标,参数2 是 id。
java
stringRedisTemplate.opsForGeo()
.add(key.toString(), new Point(shop.getX(), shop.getY()), shop.getId().toString())));
计算两个坐标有多远
计算 北京西到北京南站的位置。单位是km。可选 m、km、ft、mi
txt
geodist g1 bjn bjx km
查找当前集合中距离某个位置10km的位置
txt
# 查找当前集合中 指定位置 10 km内的所有位置。默认升序。
geosearch g1 fromlonlat 116.397904 39.909005 byradius 10 km withdist
查看集合中指定位置的hash值
txt
# 查看 g1 集合中 bjz 的 hash值
geohash g1 bjz
查看集合中指定位置的坐标
txt
# 查看 g1 集合中 bjz 的坐标
geopos g1 bjz
实战
将数据存入 Redis
一次插入一个
java
@Test
public void ssw2() {
// 查询店铺信息
List<Shop> shopList = shopService.list();
// 把店铺分组,按照 TypeId 分
Map<Long, List<Shop>> listMap = shopList.stream()
.collect(Collectors.groupingBy(Shop::getTypeId));
// 分批写入 Redis
listMap.forEach((key, value) -> {
value.forEach(shop ->
// 一次插入一个用 new Point
stringRedisTemplate.opsForGeo()
.add(key.toString(), new Point(shop.getX(), shop.getY()), shop.getId().toString()));
});
}
一次插入一个集合
java
@Test
public void ssw2() {
// 查询店铺信息
List<Shop> shopList = shopService.list();
// 把店铺分组,按照 TypeId 分
Map<Long, List<Shop>> listMap = shopList.stream()
.collect(Collectors.groupingBy(Shop::getTypeId));
// 分批写入 Redis
listMap.forEach((key, value) -> {
// 创建 RedisGeoCommands.GeoLocation 集合,里面存入name 和 point,
// org.springframework.data.redis.connection
List<RedisGeoCommands.GeoLocation<String>> locations = new ArrayList<>(value.size());
value.forEach(shop -> {
locations.add(new RedisGeoCommands.GeoLocation<>(shop.getId().toString(), new Point(shop.getX(), shop.getY())));
});
stringRedisTemplate.opsForGeo().add(key.toString(), locations);
});
}
在以下版本可以使用 redis 6.2 的功能
xml
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-redis</artifactId>
<version>2.6.2</version>
</dependency>
<dependency>
<artifactId>lettuce-core</artifactId>
<groupId>io.lettuce</groupId>
<version>6.1.6.RELEASE</version>
</dependency>
代码实现距离查询
java
@Override
public Object queryShopByType(Integer typeId, Integer current, Double x, Double y) {
// 1. 判断是否需要根据坐标查询
if (x == null || y == null) {
// 根据类型分页查询
Page<Shop> page = shopService.query()
.eq("type_id", typeId)
.page(new Page<>(current, SystemConstants.DEFAULT_PAGE_SIZE));
// 返回数据
return page.getRecords();
}
// 2. 计算分页参数
int from = (current - 1) * 5; // 每次查询 5 条
int end = current * 5;
// 3. 查询 redis 按照距离排序,分页。
String key = RedisConstants.SHOP_GEO_KEY + typeId;
// 为什么 from 没有使用呢,因为不支持分页,需要自己截取
GeoResults<RedisGeoCommands.GeoLocation<String>> results = stringRedisTemplate.opsForGeo() // geosearch key bylonlat x y byradius 10 withdistance
// GeoReference.fromCoordinate() 根据坐标, = bylonlat
.search(key, GeoReference.fromCoordinate(x, y),
// 单位默认是米,5000 = 5公里
new Distance(5000),
// 携带 withdistance
RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs().includeDistance()
// 是获取多少条-- end 是每一页的数量。
.limit(end));
if (results == null) {
return null;
}
// 获取结果
List<GeoResult<RedisGeoCommands.GeoLocation<String>>> list = results.getContent();
if (list.size() <= from) {
// 没有下一页,返回空
return null;
}
// 截取数据
ArrayList<Long> ids = new ArrayList<>(list.size());
HashMap<String, Distance> distanceMap = new HashMap<>();
list.stream()
// 跳过 from 个
.skip(from).forEach(geoLocationGeoResult -> {
// 店铺ID
String id = geoLocationGeoResult.getContent().getName();
ids.add(Long.valueOf(id));
// 获取距离
Distance distance = geoLocationGeoResult.getDistance();
distanceMap.put(id, distance);
});
// 5. 根据id查询shop
String idStr = StrUtil.join(",", ids);
List<Shop> shopList = query().in("id", ids).last("order by field(id, " + idStr + ")").list();
shopList.forEach(shop -> {
Distance distance = distanceMap.get(shop.getId().toString());
// 获取距离
double value = distance.getValue();
shop.setDistance(value);
});
return shopList;
}
BitMap 签到
假如我们用一张表来存储用户签到信息,其结构应该如下:
假如有1000万用户,平均每人每年签到次数为10次,则这张表一年的数据量为 1亿条
每签到一次需要使用(8 + 8 + 1 + 1 + 3 + 1)共22 字节的内存,一个月则最多需要600多字节
这种方式,非常的耗内存,对服务器的压力也非常的大。
我们按月来统计用户签到信息,签到记录为1,未签到则记录为0.
这样一个月下来,一个用户消耗 2 个字节。
把每一个bit位对应当月的每一天,形成了映射关系。用0和1标示业务状态,这种思路就称为位图(BitMap)。
Redis中是利用string类型数据结构实现BitMap,因此最大上限是512M,转换为bit则是 2^32个bit位。
BitMap的操作命令有:
- SETBIT:向指定位置(offset)存入一个0或1
- GETBIT :获取指定位置(offset)的bit值
- BITCOUNT :统计BitMap中值为1的bit位的数量
- BITFIELD :操作(查询、修改、自增)BitMap中bit数组中的指定位置(offset)的值
- BITFIELD_RO :获取BitMap中bit数组,并以十进制形式返回
- BITOP :将多个BitMap的结果做位运算(与 、或、异或)
- BITPOS :查找bit数组中指定范围内第一个0或1出现的位置
设置值
1 有 0 没有
txt
# 将位图bm1中偏移量为0的位的值设置为1。
SETBIT bm1 0 1
获取值
txt
# 查看位图 bm1 的 2 位置是否有数据。返回 0 没有
getbit bm1 2
获取总数
txt
# 查看bm1总数
bitcount bm1
批量查询,返回十进制
txt
BITFIELD GET key [GET type offset]
key:指定要操作的键名。
GET type:指定要获取的位的类型,可以是u{n}、i{n}或{n}@{offset}。
u{n}表示无符号整数,即返回的位被解释为无符号整数。
i{n}表示有符号整数,即返回的位被解释为有符号整数。
{n}@{offset}表示获取从偏移量offset开始的n个位,并将其作为整数返回。
offset:指定要获取的位的偏移量。
# 将位图bm1中偏移量为0的位的值作为无符号整数返回
BITFIELD GET bm1 u1 0
查询第一个 0 或1 出现的位置
txt
# 查询 0 出现的位置
bitpos bm1 1
实战签到功能
通过 setBit 去设置key,但是通过 LocalDateTime 获取的一个月的第几天是从1 开始的,所以需要 - 1
java
Boolean bit = stringRedisTemplate.opsForValue().setBit(key, dayOfMonth - 1, true);
java
@Override
public Result sign() {
// 获取用户
Long userId = UserHolder.getUser().getId();
// 获取日期
LocalDateTime dateTime = LocalDateTime.now();
// 拼接key
String keySuffix = dateTime.format(DateTimeFormatter.ofPattern(":yyyyMM"));
String key = RedisConstants.USER_SIGN_KEY + userId + keySuffix;
// 获取今天是本月的第几天
int dayOfMonth = dateTime.getDayOfMonth();
// 写入redis setbit key
Boolean bit = stringRedisTemplate.opsForValue().setBit(key, dayOfMonth - 1, true);
return Result.ok(bit);
}
统计连续签到天数
首先说一下位移运算。这是向右位移后赋值的方式。比如:110101 位移后是 011010,右移后,左侧后补 0,并丢弃最右边的数据。
java
>>>=
问题1:什么叫做连续签到天数?
从最后一次签到开始向前统计,直到遇到第一次未签到为止,计算总的签到次数,就是连续签到天数。
问题2:如何得到本月到今天为止的所有签到数据?
BITFIELD key GET u[dayOfMonth] 0
问题3:如何从后向前遍历每个bit位?
与 1 做与运算,就能得到最后一个bit位。 随后右移1位,下一个bit位就成为了最后一个bit位。
首先获取十进制数,表示本月签到的结果,与 1 做位运算,之后的到的结果是最后一个bit位,从后往前做统计。
java
@Override
public Result signCount() {
// 获取用户
Long userId = UserHolder.getUser().getId();
// 获取日期
LocalDateTime dateTime = LocalDateTime.now();
// 拼接key
String keySuffix = dateTime.format(DateTimeFormatter.ofPattern(":yyyyMM"));
String key = RedisConstants.USER_SIGN_KEY + userId + keySuffix;
// 获取今天是本月的第几天
int dayOfMonth = dateTime.getDayOfMonth();
// 1. 获取本月截至今天的所有签到记录,返回一个十进制数。
List<Long> result = stringRedisTemplate.opsForValue().bitField(
key, BitFieldSubCommands.create().get(
// 无符号数,获取到第几天
BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth))
// 从第几天开始
.valueAt(0)
);
if (result == null || result.isEmpty()) {
// 没有任何签到结果
return Result.ok(0);
}
Long num = result.get(0);
if (num == null || num == 0) {
return Result.ok(0);
}
// 2. 循环遍历
int count = 0;
while (true) {
// 1. 用这个数字与 1 做运算,得到数字的最后一个 bit 位
long n = num & 1;
// 2. 判断这个 bit 位是不是 0
if (n == 0) {
// 3. 如果是 0 ,说明未签到,结束
break;
}
// 4. 如果不为 0,说明签到了,计数 + 1
count++;
// 5. 把数字右移,抛弃最后一个 bit 位,继续下一个
num >>>= 1;
}
return Result.ok(count);
}
HyperLogLog用法
UV:全称 Unique Visitor,也叫独立访客量,是指通过互联网访问、浏览这个网页的自然人。1天内同一个用户多次访问该网站,只记录1次。
PV:全称Page View,也叫页面访问量或点击量,用户每访问网站的一个页面,记录1次PV,用户多次打开页面,则记录多次PV。往往用来衡量网站的流量。
UV统计在服务端做会比较麻烦,因为要判断该用户是否已经统计过了,需要将统计过的用户信息保存。但是如果每个访问的用户都保存到Redis中,数据量会非常恐怖。
Hyperloglog(HLL)是从Loglog算法派生的概率算法,用于确定非常大的集合的基数,而不需要存储其所有值。相关算法原理大家可以参考:juejin.cn/post/684490...
Redis中的HLL是基于string结构实现的,单个HLL的内存永远小于16kb,内存占用低的令人发指!作为代价,其测量结果是概率性的,有小于0.81%的误差。不过对于UV统计来说,这完全可以忽略。
加入元素
java
# 给 hl1 加入三个元素,会自动去重的
pfadd hl1 e1 e2 e3
PV 统计
java
# 统计 hl1 有多少元素
pfcount hl1
实战UV测试
首先记录下 Redis 内存
txt
info memory
# Memory
used_memory:1665400
used_memory_human:1.59M
代码实战
java
@Test
public void ssw2() {
String[] values = new String[1000];
int j;
for (int i = 0; i < 1000000; i++) {
j = i % 1000;
values[j] = "user_" + i;
if (j == 999) {
// 发送到 redis
stringRedisTemplate.opsForHyperLogLog().add("hl2", values);
}
}
// 统计数量
Long count = stringRedisTemplate.opsForHyperLogLog().size("hl2");
System.out.println("count = " + count);
}
结果 count = 997593
997593 / 1000000 = 0.997593
误差 0.997593
我们在看看内存消耗
之前是 1665400,插入一百万数据后是 1924712
1924712 - 1665400 = 259312
这是字节,转成 kb
259312 ➗ 1024 = 253.234375
消耗 253k
txt
"# Memory
used_memory:1924712
used_memory_human:1.84M
总结
HyperLogLog的作用:
做海量数据的统计工作
HyperLogLog的优点:
- 内存占用极低
- 性能非常好
HyperLogLog的缺点:
- 有一定的误差