↑↑↑请在文章开头处下载测试项目源代码↑↑↑
文章目录
- 前言
- 4.10 附近商户
- 4.10.1 GEO介绍
- 4.10.2 附近商户需求分析
- 4.10.3 实现新增商户功能
- 4.10.4 实现查询附近商户功能
- 4.11 用户签到
- 4.11.1 用户签到需求分析
- 4.11.2 BitMap介绍
- 4.11.3 实现用户签到
- 4.11.4 实现用户签到统计
- 4.11.4.1 需求分析
- 4.11.4.2 代码实现
- 4.11.4.3 功能测试
- 4.12 UV统计和PV统计
- 4.12.1 功能介绍
- 4.12.2 HyperLogLog介绍
- 4.12.3 测试百万数据的统计
- 4.13 小结
前言
Redis实战系列文章:
Redis从入门到精通(四)Redis实战(一)短信登录
Redis从入门到精通(五)Redis实战(二)商户查询缓存
Redis从入门到精通(六)Redis实战(三)优惠券秒杀
Redis从入门到精通(七)Redis实战(四)库存超卖、一人一单与Redis分布式锁
Redis从入门到精通(八)Redis实战(五)分布式锁误删与原子性问题、Redisson
Redis从入门到精通(九)Redis实战(六)基于Redis队列实现异步秒杀下单
Redis从入门到精通(十)Redis实战(七)达人探店、点赞与点赞排行榜
Redis从入门到精通(十一)Redis实战(八)关注、共同关注和Feed流
4.10 附近商户
4.10.1 GEO介绍
GEO是Geolocation的简写形式,代表地理坐标。Redis在3.2版本中加入了对GEO的支持,允许存储地理坐标信息,帮助我们根据经纬度来检索数据。
GEO常见的命令有:
GEOADD key longitude latitude member
添加一个地理坐标信息,包含经度(longitude)、纬度(latitude)、值(member)。
127.0.0.1:6379> GEOADD test:geo 123.456 45.67 1
(integer) 1
127.0.0.1:6379> GEOADD test:geo 111.123 8.67 2
(integer) 1
查看Redis中的数据:
可见在Redis底层,GEO地理坐标信息是用SortedSet数据结构存储的,经纬度经过计算可以转换为唯一的score。
GEODIST key member1 member2
计算指定的两个点之间的距离并返回(默认单位:米)。
127.0.0.1:6379> GEODIST test:geo 1 2
"4281337.5859"
GEOHASH key member
将指定member的坐标转为hash字符串形式并返回。
127.0.0.1:6379> GEOHASH test:geo 1
1) "y8pgc3czj20"
GEOPOS key member
返回指定member的坐标。
127.0.0.1:6379> GEOPOS test:geo 1
1) 1) "123.45600038766860962"
2) "45.66999878325155748"
GEOSEARCH key <FROMMEMBER member | FROMLONLAT longitude latitude> <BYRADIUS radius <M | KM | FT | MI> | BYBOX width height <M | KM | FT | MI>> [ASC | DESC] [COUNT count [ANY]] [WITHCOORD] [WITHDIST] [WITHHASH]
6.2版本新功能。在指定范围内搜索member,并按照与指定点之间的距离排序后返回。
范围中心可以指定一个member(FROMMEMBER)或者经纬度(FROMLONLAT)。
范围可以按按圆形(BYRADIUS)或矩形(BYBOX)。
排序可以按升序(ASC)或降序(DESC)。
# 获取以经纬度为(122.456,44.67)的地点为圆心,半径为10000km的圆形区域内的member,并显示距离
127.0.0.1:6379> GEOSEARCH test:geo FROMLONLAT 122.456 44.67 BYRADIUS 10000 km DESC WITHDIST
1) 1) "2"
2) "4150.4721"
2) 1) "1"
2) "136.0864"
4.10.2 附近商户需求分析
当我们点外卖时,进入美食页面后,会出现一系列商家,这些商家可以按照多种方式进行排序,其中就有根据距离进行排序。
根据距离进行排序,就用到了Redis的GEO。前端页面根据收集到的设备位置信息,调用后台接口,后台接口以该位置为中心,同时根据商户类型、分页信息等,查询出一定范围内的商户信息,排序后并返回。
在本项目中,添加商户信息时,除了把商户信息写入数据库,还要将商户信息中的商户类型、位置信息等写入到Redis中。那么在查询商户列表时,根据当前位置信息、商户类型、分页信息等条件查询数据即可。
4.10.3 实现新增商户功能
在ShopController类中编写一个add()
方法,用于新增商户。其接口文档和代码如下:
项目 | 说明 |
---|---|
请求方法 | POST |
请求路径 | /shop/add |
请求参数 | shop:Shop类型,商户信息 |
返回值 | 无 |
// com.star.redis.dzdp.controller.ShopController
/**
* 新增商户
* @author hsgx
* @since 2024/4/9 14:24
* @param shop
* @return com.star.redis.dzdp.pojo.BaseResult
*/
@PostMapping("/add")
public BaseResult add(@RequestBody Shop shop) {
return shopService.addShop(shop);
}
然后在IShopService接口定义一个addShop()
方法,并在ShopServiceImpl实现类中具体实现:
// com.star.redis.dzdp.service.impl.ShopServiceImpl
@Override
public BaseResult addShop(Shop shop) {
log.info("add {}", shop.toString());
// 1.保存商户信息
boolean save = save(shop);
if(save) {
// 2.将商户的位置信息存入GEO
// GEOADD shop:geo:{typeId} {x} {y} {id}
String key = "shop:geo:" + shop.getTypeId();
stringRedisTemplate.opsForGeo().add(key, new Point(shop.getX(), shop.getY()),
shop.getId().toString());
log.info("GEOADD {} {} {} {}", key, shop.getX(), shop.getY(), shop.getId());
return BaseResult.setOk("添加商户成功!");
}
return BaseResult.setFail("添加商户失败!");
}
编写完成后进行功能测试:
此时Redis中保存的数据:
4.10.4 实现查询附近商户功能
在ShopController类中编写一个list()
方法,用于查询附近商户列表。其接口文档和代码如下:
项目 | 说明 |
---|---|
请求方法 | GET |
请求路径 | /shop/list |
请求参数 | typeId:Integer,商户类型 current:Integer,当前页数 x:Double,经度 y:Double,纬度 |
返回值 | List<Shop>,符合条件的商户列表 |
// com.star.redis.dzdp.controller.ShopController
/**
* 查询商户列表
* @author xiaowd
* @since 2024/4/9 16:15
* @param typeId 商户类型
* @param current 当前页数
* @param x 经度
* @param y 纬度
* @return com.star.redis.dzdp.pojo.BaseResult<java.util.List<com.star.redis.dzdp.pojo.Shop>>
*/
@GetMapping("/list")
public BaseResult<List<Shop>> list(Integer typeId, Integer current,
Double x, Double y) {
return shopService.queryNearbyShops(typeId, current, x, y);
}
然后在IShopService接口定义一个queryNearbyShops()
方法,并在ShopServiceImpl实现类中具体实现:
// com.star.redis.dzdp.service.impl.ShopServiceImpl
@Override
public BaseResult<List<Shop>> queryNearbyShops(Integer typeId, Integer current, Double x, Double y) {
log.info("query Shops: typeId = {}, current = {}, x = {}, y = {}",
typeId, current, x, y);
// 1.判断是否需要根据坐标查询
if(x == null || y == null) {
// 不需要坐标查询,则直接按数据库查询(默认查5条,可以自定义)
Page<Shop> shopPage = query().eq("type_id", typeId)
.page(new Page<>(current, 5));
// 返回数据
return BaseResult.setOkWithData(shopPage.getRecords());
}
// 2.需要根据坐标查询,计算分页参数
int from = (current - 1) * 5;
int end = current * 5;
log.info("from = {}, end = {}", from, end);
// 3.查询Redis,按照距离排序
// GEOSEARCH key FROMLONLAT x y BYRADIUS 5000 m WITHDIST
String key = "shop:geo:" + typeId;
GeoResults<RedisGeoCommands.GeoLocation<String>> results = stringRedisTemplate.opsForGeo().search(key, GeoReference.fromCoordinate(x, y),
new Distance(2000),
RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs().includeDistance().limit(end));
log.info("GEOSEARCH {} FROMLONLAT {} {} BYRADIUS 2000 m WITHDIST", key, x, y);
if(results == null) {
// 没有查到数据
return BaseResult.setOkWithData(Collections.emptyList());
}
List<GeoResult<RedisGeoCommands.GeoLocation<String>>> list = results.getContent();
log.info("list form GEO : {}", list.size());
// 数据总数都达不到开始序号,说明这一页没有数据
if(list.size() <= from) {
return BaseResult.setOkWithData(Collections.emptyList());
}
// 4.解析数据
List<Long> ids = new ArrayList<>(list.size());
Map<String, Distance> distanceMap = new HashMap<>(list.size());
// 跳过前面from条数据
list.stream().skip(from).forEach(result -> {
// 解析商户ID
String shopIdStr = result.getContent().getName();
ids.add(Long.valueOf(shopIdStr));
// 解析商户距离
Distance distance = result.getDistance();
distanceMap.put(shopIdStr, distance);
});
// 5.根据商户ID查询商户数据
String idStr = StrUtil.join(",", ids);
List<Shop> shopList = query().in("id", ids).last("ORDER BY FIELD(id," + idStr + ")").list();
for (Shop shop : shopList) {
// 设置商户距离
shop.setDistance(distanceMap.get(shop.getId().toString()).getValue());
}
return BaseResult.setOkWithData(shopList);
}
编写完成后进行功能测试:
# 商户类型为1、圆心位置为(120.149,30.31)、半径2000m、查询具体距离
127.0.0.1:6379> GEOSEARCH shop:geo:1 FROMLONLAT 120.149 30.31 BYRADIUS 2000 m WITHDIST
1) 1) "4"
2) "378.8642"
2) 1) "5"
2) "845.9276"
3) 1) "1"
2) "676.2196"
4) 1) "6"
2) "959.2299"
5) 1) "3"
2) "1688.9495"
6) 1) "8"
2) "1700.3408"
7) 1) "9"
2) "1703.1773"
由以上结果可知,根据以上条件查询第一页数据(默认查5个),可以查询出ID=[4,5,1,6,3]的商户数据:
4.11 用户签到
4.11.1 用户签到需求分析
要实现用户签到功能,完全可以在数据库中创建一张签到表来实现,例如:
CREATE TABLE `tb_sign` (
`id` BIGINT(20) UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
`user_id` BIGINT(20) UNSIGNED NOT NULL COMMENT '用户id',
`year` YEAR(4) NOT NULL COMMENT '签到的年',
`month` TINYINT(2) NOT NULL COMMENT '签到的月',
`date` DATE NOT NULL COMMENT '签到的日期',
`is_backup` TINYINT(1) UNSIGNED DEFAULT NULL COMMENT '是否补签',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=INNODB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=COMPACT;
用户签到一次,就是一条记录。但有一个非常大的问题,假设有1000万人签到,每天签到2次,则这张表一年的数据量就是:1000万×2×200工作日=40亿。每签到一次需要使用(8+8+1+1+3+1)共22字节的空间,那么一年需要使用的空间是:40亿×22KB=880亿KB=85937500MB≈83923GB≈82T。
最大的问题就是数据量太大了,那该如何进行简化呢?
其实签到只是一个状态,要么签了,要么没签。因此,一个字节的8个bit位就可以存储8次签到数据了。我们把一个bit位对应一个月的一天,用0和1标识业务状态,这样就用极小的空间,来实现海量数据的存储。
对应到Redis,可以使用BitMap来实现这种数据存储。
4.11.2 BitMap介绍
BitMap的命令主要有:
SETBIT key offset value
向指定位置(offset)存入一个0或1。
127.0.0.1:6379> SETBIT test:bit 5 1
(integer) 0
此时Redis中保存的数据是:
可见在Redis底层,是使用String类型来实现BitMap的。
GETBIT key offset
获取指定位置(offset)的bit值。
127.0.0.1:6379> GETBIT test:bit 5
(integer) 1
127.0.0.1:6379> GETBIT test:bit 6
(integer) 0
BITCOUNT key
统计值为1的bit位的数量。
127.0.0.1:6379> BITCOUNT test:bit
(integer) 1
BITFIELD_RO key GET encoding offset
获取bit数组,并以十进制形式返回。
127.0.0.1:6379> BITFIELD_RO test:bit GET i8 1
1) (integer) 8
BITOP <AND | OR | XOR | NOT> destkey key [key ...]
将多个BitMap的结果做位运算(与 、或、异或)。
127.0.0.1:6379> SETBIT test:bit2 3 1
(integer) 0
127.0.0.1:6379> BITOP AND test:bit3 test:bit test:bit2
(integer) 1
127.0.0.1:6379> get test:bit3
"\x00"
BITPOS key bit
查找bit数组中指定范围内第一个0或1出现的位置。
127.0.0.1:6379> BITPOS test:bit 1
(integer) 5
BITFIELD key
操作(查询GET、修改SET、自增INCRBY)BitMap中bit数组中的指定位置(offset)的值。
# 查询GET
127.0.0.1:6379> BITFIELD test:bit GET i8 5
1) (integer) -128
# 修改SET
127.0.0.1:6379> BITFIELD test:bit SET i8 6 1
1) (integer) 0
# 自增INCRBY
127.0.0.1:6379> BITFIELD test:bit INCRBY i8 6 2
1) (integer) 3
4.11.3 实现用户签到
在UserController类中编写一个sign()
方法,用于实现用户签到。其接口文档和代码如下:
项目 | 说明 |
---|---|
请求方法 | POST |
请求路径 | /user/sign |
请求参数 | 无 |
返回值 | 无 |
// com.star.redis.dzdp.controller.UserController
/**
* 用户签到
* @author xiaowd
* @since 2024/4/9 18:22
* @param request
* @return com.star.redis.dzdp.pojo.BaseResult
*/
@PostMapping("/sign")
public BaseResult sign(HttpServletRequest request) {
Long userId = (Long) request.getAttribute("userId");
return userService.userSign(userId);
}
然后在IUserService接口定义一个userSign()
方法,并在UserServiceImpl实现类中具体实现:
// com.star.redis.dzdp.service.impl.UserServiceImpl
@Resource
private StringRedisTemplate stringRedisTemplate;
private static final SimpleDateFormat sdf = new SimpleDateFormat("yyyyMM");
@Override
public BaseResult userSign(Long userId) {
log.info("userSign: userId = {}", userId);
// 1.获取日期
Date date = new Date();
String keySuffix = sdf.format(date);
// 2.拼接key
String key = "sign:" + userId + ":" + keySuffix;
int dayOfMonth = date.getDay();
// 3.写入Redis:SETBIT key offset 1
stringRedisTemplate.opsForValue().setBit(key, dayOfMonth - 1, true);
log.info("SETBIT {} {} 1", key, dayOfMonth - 1);
return BaseResult.setOk("签到成功!");
}
编写完成后进行功能测试:
此时Redis中的数据为:
4.11.4 实现用户签到统计
4.11.4.1 需求分析
下面有一个需求:统计用户本月的签到天数。
我们可以从以下几个问题来考虑:
问题1:如果得到本月签到天数?
获得当前月的最后一次签到数据,定义一个计数器,然后不停的向前统计,每得到一个非0的数字则计数器+1,每得到一个为0的数字则跳过,这样就可以获得当前月的签到总天数了。
问题2:如何得到本月到今天为止的所有签到数据?
假设今天是10号,那么BITFIELD key GET u10 0
命令就表示从第0位开始,向右读取10位,那就得到了这10天的签到数据。
(PS:这篇文从9号写到了10号)
问题3:如何从后往前遍历每个bit位?
判断某一天的数据是0还是1,只需要和1做与运算。 由于1&1=1、0&1=0,因此结果为1则表示已签到,结果为0则表示未签到。
而BITFIELD
命令返回的数据是十进制的,如果将这个十进制数和1做与运算,则得到这个十进制数所对应的二进制数的最后一个bit位的数据。
例如:十进制8对应的二进制是1000,最后一个bit位为0,由于0&0=0。因此8&1=0;
十进制9对应的二进制是1001,最后一个bit位为1,由于1&0=1,因此9&1=1。
综上,从后往前遍历每个bit位的逻辑如下:让得到的十进制数与1做与运算,每与一次,就把数据向右移动一位,则参与下一次与运算的bit位就是原来的第2位,以此类推就完成了逐个遍历的效果。 例如:
public static void main(String[] args) {
int num = 203; // 对应的二进制是:1100 1011
for(int i = 0; i < 8; i++) {
// 打印每个bit位和1做与运算的结果
System.out.println(num & 1);
// 右移一位
num >>>= 1;
}
}
程序执行结果是:
1
1
0
1
0
0
1
1
4.11.4.2 代码实现
方案敲定之后,下面来实现用户签到统计功能。在UserController类中编写一个signCount()
方法,其接口文档和代码如下:
项目 | 说明 |
---|---|
请求方法 | POST |
请求路径 | /user/sign/count |
请求参数 | 无 |
返回值 | Long,本月签到天数 |
// com.star.redis.dzdp.controller.UserController
/**
* 用户签到统计
* @author xiaowd
* @since 2024/4/10 9:40
* @param request
* @return com.star.redis.dzdp.pojo.BaseResult<java.lang.Integer>
*/
@GetMapping("/sign/count")
public BaseResult<Integer> signCount(HttpServletRequest request) {
Long userId = (Long) request.getAttribute("userId");
return userService.userSignCount(userId);
}
然后在ISserService接口中定义一个userSignCount()
方法,并在UserServiceImpl实现类中具体实现:
// com.star.redis.dzdp.service.impl.UserServiceImpl
@Override
public BaseResult<Integer> userSignCount(Long userId) {
log.info("userSignCount: userId = {}", userId);
// 1.获取日期,拼接Key
LocalDateTime now = LocalDateTime.now();
String keySuffix = now.format(DateTimeFormatter.ofPattern("yyyyMM"));
String key = "sign:" + userId + ":" + keySuffix;
int dayOfMonth = now.getDayOfMonth();
// 2.从Redis中获取本月到今天为止的所有签到数据,返回的是一个十进制数
// BITFIELD sign:1012:202404 GET u10 0
List<Long> result = stringRedisTemplate.opsForValue().bitField(key,
BitFieldSubCommands.create()
.get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth)).valueAt(0));
log.info("call Redis: BITFIELD {} GET u{} 0", key, dayOfMonth);
if(result == null || result.isEmpty()) {
log.info("call Redis result: null or empty.");
return BaseResult.setOkWithData(0);
}
Long num = result.get(0);
log.info("call Redis result: num = {}", num);
if(num == null || num == 0) {
return BaseResult.setOkWithData(0);
}
// 3.循环遍历每一个bit位
int count = 0;
for(int i = 0; i < dayOfMonth; i++) {
// 十进制数和1做与运算,得到十进制数对应的二进制数的最后一个bit位
if((num & 1) == 1) {
// 如果不为0,说明已签到,计数器+1
count++;
}
// 右移一位
num >>>= 1;
}
log.info("count = {}", count);
return BaseResult.setOkWithData(count);
}
4.11.4.3 功能测试
在Redis中已经保存了这样数据:
现在是4月10号,因此BITFIELD
将获取前面10位:0100101010,转换为十进制数是298,其中1的个数为4,也就是签到总天数为4天。
日志如下:
[http-nio-8081-exec-1] userSignCount: userId = 1012
[http-nio-8081-exec-1] call Redis: BITFIELD sign:1012:202404 GET u10 0
[http-nio-8081-exec-1] call Redis result: num = 298
[http-nio-8081-exec-1] count = 4
可见,用户签到统计功能已实现。
4.12 UV统计和PV统计
4.12.1 功能介绍
-
UV:全称Unique Visitor,也叫独立访客量,是指通过互联网访问、浏览某个网页的自然人。一天内同一个用户多次访问该网站,只记录1次。
-
PV:全称Page View,也叫页面访问量或点击量,用户每访问网站的一个页面,记录1次PV,用户多次打开页面,则记录多次PV。往往用来衡量网站的流量。
UV统计和PV在服务端做会比较麻烦,因为要判断该用户是否已经统计过了,需要将统计过的用户信息保存。如果每个访问的用户都保存到Redis中,数据量则会非常大,那怎么处理呢?
可以使用Redis的HyperLogLog类实现。
4.12.2 HyperLogLog介绍
HyperLogLog(HLL),是LogLog算法的升级版,作用是能够提供不精确的去重计数。相关算法原理可以参考:https://juejin.cn/post/6844903785744056333#heading-0
HyperLogLog的主要命令有:
PFADD key element [element ...]
:添加数据PFCOUNT key
:返回数据个数PFMERGE destkey sourcekey1 sourcekey2
:合并数据到指定key
4.12.3 测试百万数据的统计
下面直接利用单元测试,向HyperLogLog中添加100万条数据,看看内存占用和统计效果如何:
@Test
public void testHLL() {
String[] users = new String[1000];
int index = 0;
for (int i = 1; i <= 1000000; i++) {
users[index++] = "user_" + i;
// 每1000条保存一次
if(i % 1000 == 0) {
index = 0;
stringRedisTemplate.opsForHyperLogLog().add("test:hll", users);
}
}
// 统计数量
Long size = stringRedisTemplate.opsForHyperLogLog().size("test:hll");
System.out.println("size = " + size);
}
程序运行结果:
size = 997593
此时在Redis中保存的数据如下:
由此可见,Redis中的HLL是基于String结构实现的。即使保存了100万数据,占用内存也只有12.02KB,但作为代价,其测量结果是有误差的,如上面的单元测试只统计出了997593条数据,不足100万。不过对于UV统计来说,这完全可以忽略。
4.13 小结
第4章到此就学习完毕了,本章的主题是:Redis实战项目。回顾一下本章的学习的内容:
(四)使用String结构保存短信验证码和登录用户信息。
(五)使用String结构保存商户信息的JSON字符串,并解决数据一致性问题、缓存穿透问题;利用SETNX
方法设置互斥锁,解决缓存击穿问题。
(六)使用String结构保存秒杀优惠券的库存,用于判断库存是否充足。
(七)解决库存超卖问题;利用SETNX
方法实现Redis分布式锁,处理一人一单问题。
(八)使用Lua脚本保证Redis命令的原子性;使用分布式锁-Redisson。
(九)使用基于Stream的消息队列实现异步秒杀下单。
(十)使用Set集合解决用户无限点赞问题;使用SortedSet集合实现点赞排行榜功能。
(十一)使用Set集合的交集实现共同关注好友功能;使用SortedSet集合实现Feed流功能。
(十二)使用GEO实现附近商户查询功能;使用BitMap实现用户签到和统计功能;使用HLL实现UV统计功能。
更多内容请查阅分类专栏:Redis从入门到精通
感兴趣的读者还可以查阅我的另外几个专栏:
- SpringBoot源码解读与原理分析(已完结)
- MyBatis3源码深度解析(已完结)
- 再探Java为面试赋能(持续更新中…)