1 附近的店铺
1.1 GEO数据结构的基本用法
GEO就是Geolocation的简写形式,代表地理坐标。Redis在3.2版本中加入了对GEO的支持,允许存储地理坐标信息,帮助我们根据经纬度来检索数据。常见的命令有:
- GEOADD:添加一个地理空间信息,包含:经度(longitude)、纬度(latitude)、值(member)
- GEODIST:计算指定的两个点之间的距离并返回
- GEOHASH:将指定member的坐标转为hash字符串形式并返回
- GEOPOS:返回指定member的坐标
- GEORADIUS:指定圆心、半径,找到该圆内包含的所有member,并按照与圆心之间的距离排序后返回。6.以后已废弃
- GEOSEARCH:在指定范围内搜索member,并按照与指定点之间的距离排序后返回。范围可以是圆形或矩形。6.2.新功能
- GEOSEARCHSTORE:与GEOSEARCH功能一致,不过可以把结果存储到一个指定的key。 6.2.新功能。
1.2 附近的店铺导入GEO

分析shop对应的数据库表,发现有一个typeId字段,表明是什么类型的,那么此时可以根据这个字段将店铺进行分组

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| @Test void loadShopData() { List<Shop> list = shopService.list(); Map<Long, List<Shop>> map = list.stream().collect(Collectors.groupingBy(Shop::getTypeId));
Set<Map.Entry<Long, List<Shop>>> entries = map.entrySet(); for (Map.Entry<Long, List<Shop>> entry : entries) { Long typeId = entry.getKey();
List<Shop> value = entry.getValue();
String key = RedisConstants.SHOP_GEO_KEY + typeId;
List<RedisGeoCommands.GeoLocation<String>> locations = new ArrayList<>(value.size()); for (Shop shop : value) { locations.add(new RedisGeoCommands.GeoLocation<>(shop.getId().toString(), new Point(shop.getX(), shop.getY()))); } stringRedisTemplate.opsForGeo().add(key, locations); } }
|
1.3 获取附近的店铺
SpringDataRedis的2.3.9版本并不支持Redis 6.2提供的GEOSEARCH命令,因此我们需要提示其版本,修改自己的POM
1 2 3 4 5 6 7 8 9 10
| <dependency> <groupId>org.springframework.data</groupId> <artifactId>spring-data-redis</artifactId> <version>2.6.2</version> </dependency> <dependency> <groupId>io.lettuce</groupId> <artifactId>lettuce-core</artifactId> <version>6.1.6.RELEASE</version> </dependency>
|
1 2 3 4 5 6 7 8 9
| @GetMapping("/of/type") public Result queryShopByType( @RequestParam("typeId") Integer typeId, @RequestParam(value = "current", defaultValue = "1") Integer current, @RequestParam(value = "x", required = false) Double x, @RequestParam(value = "y", required = false) Double y ) { return shopService.queryShopByType(typeId, current, x, y); }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66
|
@Override public Result queryShopByType(Integer typeId, Integer current, Double x, Double y) { if (null == x || null == y) { Page<Shop> page = query() .eq("type_id", typeId) .page(new Page<>(current, SystemConstants.DEFAULT_PAGE_SIZE)); return Result.ok(page.getRecords()); }
int from = (current - 1) * SystemConstants.DEFAULT_PAGE_SIZE;
int end = current * SystemConstants.DEFAULT_PAGE_SIZE;
String key = RedisConstants.SHOP_GEO_KEY + typeId; GeoResults<RedisGeoCommands.GeoLocation<String>> results = stringRedisTemplate.opsForGeo() .search(key, GeoReference.fromCoordinate(x, y), new Distance(RedisConstants.GEO_DISTANT), RedisGeoCommands.GeoRadiusCommandArgs.newGeoRadiusArgs().includeDistance().limit(end) );
if (null == results) { return Result.ok(Collections.emptyList()); } List<GeoResult<RedisGeoCommands.GeoLocation<String>>> list = results.getContent(); ArrayList<Long> shopIds = new ArrayList<>(list.size()); Map<String, Distance> map = new HashMap<>(list.size()); list.stream().skip(from).forEach(result -> { String shopIdStr = result.getContent().getName(); shopIds.add(Long.valueOf(shopIdStr));
Distance distance = result.getDistance();
map.put(shopIdStr, distance); });
String joinStr = StrUtil.join(",", shopIds); List<Shop> shops = query().in("id", shopIds).last("order by field(id," + joinStr + ")").list(); for (Shop shop : shops) { shop.setDistance(map.get(shop.getId().toString()).getValue()); }
return Result.ok(shops); }
|
但是此时测试的时候,到最后的时候向下拉却报错了

原因分析:

最后在截取的时候,跳过之后就没有任何值了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70
|
@Override public Result queryShopByType(Integer typeId, Integer current, Double x, Double y) { if (null == x || null == y) { Page<Shop> page = query() .eq("type_id", typeId) .page(new Page<>(current, SystemConstants.DEFAULT_PAGE_SIZE)); return Result.ok(page.getRecords()); }
int from = (current - 1) * SystemConstants.DEFAULT_PAGE_SIZE;
int end = current * SystemConstants.DEFAULT_PAGE_SIZE;
String key = RedisConstants.SHOP_GEO_KEY + typeId; GeoResults<RedisGeoCommands.GeoLocation<String>> results = stringRedisTemplate.opsForGeo() .search(key, GeoReference.fromCoordinate(x, y), new Distance(RedisConstants.GEO_DISTANT), RedisGeoCommands.GeoRadiusCommandArgs.newGeoRadiusArgs().includeDistance().limit(end) );
if (null == results) { return Result.ok(Collections.emptyList()); } List<GeoResult<RedisGeoCommands.GeoLocation<String>>> list = results.getContent(); ArrayList<Long> shopIds = new ArrayList<>(list.size()); if(list.size () <= from) { return Result.ok(Collections.emptyList()); } Map<String, Distance> map = new HashMap<>(list.size()); list.stream().skip(from).forEach(result -> { String shopIdStr = result.getContent().getName(); shopIds.add(Long.valueOf(shopIdStr));
Distance distance = result.getDistance();
map.put(shopIdStr, distance); });
String joinStr = StrUtil.join(",", shopIds); List<Shop> shops = query().in("id", shopIds).last("order by field(id," + joinStr + ")").list(); for (Shop shop : shops) { shop.setDistance(map.get(shop.getId().toString()).getValue()); }
return Result.ok(shops); }
|
2 签到
2.1 MySQL实现签到

缺点:
用户一次签到,就是一条记录,假如有1000万用户,平均每人每年签到次数为10次,则这张表一年的数据量为 1亿条
每签到一次需要使用(8 + 8 + 1 + 1 + 3 + 1)共22 字节的内存,一个月则最多需要600多字节
2.2 优化
我们按月来统计用户签到信息,签到记录为1,未签到则记录为0.
把每一个bit位对应当月的每一天,形成了映射关系。用0和1标示业务状态,这种思路就称为位图(BitMap)。这样我们就用极小的空间,来实现了大量数据的表示。
Redis中是利用string类型数据结构实现BitMap,因此最大上限是512M,转换为bit则是 232个bit位。

BitMap的操作命令有:
- SETBIT:向指定位置(offset)存入一个0或1,从0开始
- GETBIT :获取指定位置(offset)的bit值
- BITCOUNT :统计BitMap中值为1的bit位的数量
- BITFIELD :操作(查询、修改、自增)BitMap中bit数组中的指定位置(offset)的值
- BITFIELD_RO :获取BitMap中bit数组,并以十进制形式返回
- BITOP :将多个BitMap的结果做位运算(与 、或、异或)
- BITPOS :查找bit数组中指定范围内第一个0或1出现的位置
那么,如何完成用户签到?
思路:我们可以把年和月作为bitMap的key,然后保存到一个bitMap中,每次签到就到对应的位上把数字从0变成1,只要对应是1,就表明说明这一天已经签到了,反之则没有签到。
1 2 3 4
| @PostMapping("/sign") public Result sign(){ return userService.sign(); }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
|
@Override public Result sign() { Long userId = UserHolder.getUser().getId(); LocalDateTime now = LocalDateTime.now(); String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
String key = RedisConstants.USER_SIGN_KEY + userId + keySuffix;
int dayOfMonth = now.getDayOfMonth();
stringRedisTemplate.opsForValue().setBit(key, dayOfMonth - 1, true);
return Result.ok(); }
|
2.3 统计连续签到
问题1:什么叫做连续签到天数?
从最后一次签到开始向前统计,直到遇到第一次未签到为止,计算总的签到次数,就是连续签到天数。
Java逻辑代码:获得当前这个月的最后一次签到数据,定义一个计数器,然后不停的向前统计,直到获得第一个非0的数字即可,每得到一个非0的数字计数器+1,直到遍历完所有的数据,就可以获得当前月的签到总天数了
问题2:如何得到本月到今天为止的所有签到数据?
BITFIELD key GET u[dayOfMonth] 0
假设今天是10号,那么我们就可以从当前月的第一天开始,获得到当前这一天的位数,是10号,那么就是10位,去拿这段时间的数据,就能拿到所有的数据了,那么这10天里边签到了多少次呢?统计有多少个1即可。
问题3:如何从后向前遍历每个bit位?
注意:bitMap返回的数据是10进制,哪假如说返回一个数字8,那么我哪儿知道到底哪些是0,哪些是1呢?我们只需要让得到的10进制数字和1做与运算就可以了,因为1只有遇见1 才是1,其他数字都是0 ,我们把签到结果和1进行与操作,每与一次,就把签到结果向右移动一位,依次内推,我们就能完成逐个遍历的效果了。
1 2 3 4
| @GetMapping("/sign/count") public Result signCount(){ return userService.signCount(); }
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59
|
@Override public Result signCount() { Long userId = UserHolder.getUser().getId(); LocalDateTime now = LocalDateTime.now(); String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
String key = RedisConstants.USER_SIGN_KEY + userId + keySuffix;
int dayOfMonth = now.getDayOfMonth();
List<Long> results = stringRedisTemplate.opsForValue().bitField( key, BitFieldSubCommands.create() .get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth)) .valueAt(0) );
if (null == results || results.isEmpty()) { return Result.ok(0); } Long num = results.get(0); if (null == num || 0 == num) { return Result.ok(0); }
int calCount = 0; while (true) { if ((num & 1) == 0) { break;
} else { calCount++; }
num = (num >>> 1);
}
return Result.ok(calCount); }
|
3 UV统计
3.1 UV统计的基本思路
- UV:全称Unique Visitor,也叫独立访客量,是指通过互联网访问、浏览这个网页的自然人。1天内同一个用户多次访问该网站,只记录1次。
- PV:全称Page View,也叫页面访问量或点击量,用户每访问网站的一个页面,记录1次PV,用户多次打开页面,则记录多次PV。往往用来衡量网站的流量。
UV统计在服务端做会比较麻烦,因为要判断该用户是否已经统计过了,需要将统计过的用户信息保存。但是如果每个访问的用户都保存到Redis中,数据量会非常恐怖,那怎么处理呢?
Hyperloglog(HLL)是从Loglog算法派生的概率算法,用于确定非常大的集合的基数,而不需要存储其所有值。相关算法原理大家可以参考:Hyperloglog算法
Redis中的HLL是基于string结构实现的,单个HLL的内存永远小于16kb,内存占用低的令人发指!作为代价,其测量结果是概率性的,有小于0.81%的误差。不过对于UV统计来说,这完全可以忽略。
3.2 HypeLogLog实现统计
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| @Test public void testHyperLog() { String[] values = new String[1000]; int j = 0; for (int i = 0; i < 1000000; i++) { j = i % 1000; values[j] = "user_" + i; if (j == 999) { stringRedisTemplate.opsForHyperLogLog().add("hl", values);
} }
Long count = stringRedisTemplate.opsForHyperLogLog().size("hl"); System.out.println("统计的总数是:" + count);
}
|