GEO
对于地理坐标(经纬度),Redis 以 GEO 的方式存储,可以通过以下命令查看相关命令:
127.0.0.1:6379> help @geo
添加 GEO 的命令为:
示例,添加北京和南京两个地理位置:
127.0.0.1:6379> GEOADD geo1 116.4074 39.9042 beijing (integer) 1 127.0.0.1:6379> GEOADD geo1 118.7969 32.0603 nanjing (integer) 1
比较两个 GEO 坐标的距离可以用以下命令:
示例,比较北京和南京的直线距离:
127.0.0.1:6379> GEODIST geo1 beijing nanjing km "898.4595"
从 Redis 获取地理位置信息可以用以下命令:
区别是 GEOHASH 会返回一个将经纬度哈希后的字符串,优点是节省存储空间,缺点是可读性不好。GEOPOS 则返回标准的经纬度信息。
示例:
127.0.0.1:6379> GEOHASH geo1 beijing 1) "wx4g0bm6c40" 127.0.0.1:6379> GEOPOS geo1 beijing 1) 1) "116.40740185976028442" 2) "39.90420012463194865"
地理坐标最常见的应用场景是根据给定位置(通常是用户当前位置),指定一个范围(XX公里),返回在该范围内的地理坐标。比如点评应用最常见的离我最近的饭馆等等。
这个需求可以用以下命令实现:
GEORADIUS 将返回给定坐标(longitude latitude)为圆心,指定距离(radius)为半径范围内的地理坐标。其中 WITHDIST 参数可以同时返回距离信息。COUNT 参数可以用于分页查询,返回前 N 个坐标。ASC DESC 可以用于按距离远近升序或降序排列后返回。STORE 参数可以将结果存储为 KEY-VALUE 结构。
需要注意的是,GEORADIUS 命令已经于 Radis 6.2 版本开始弃用,因此,从新版本开始应当使用以下命令:
GEOSEARCH 与 GEORADIUS 不同的是,除了可以指定以圆心为半径的方式检索外,还可以以某个点作为矩形中心,指定矩形的长宽的方式划定局域进行检索。BYRADIUS 参数指定以圆形区域检索,BYBOX 参数指定以矩形区域检索。其它参数与 GEORADIUS 类似,不再重复说明。
导入店铺地理信息
编写测试用例将店铺的地理位置信息导入 Redis:
/**
* 导入店铺的地理位置信息到 Redis
*/
public void testImportGeoData() {
// 检查是否已经存在旧数据
Set<String> keys = stringRedisTemplate.keys(SHOP_GEO_KEY + "*");
if (keys != null && (!keys.isEmpty())) {
stringRedisTemplate.delete(keys);
}
// 获取店铺信息
List<Shop> shops = shopService.list();
// 将店铺按照类型分组
Map<Long, List<Shop>> groupShops = shops.stream().collect(Collectors.groupingBy(Shop::getTypeId));
// 按照分组导入 Redis
GeoOperations<String, String> opsForGeo = stringRedisTemplate.opsForGeo();
for (Map.Entry<Long, List<Shop>> entry : groupShops.entrySet()) {
Long typeId = entry.getKey();
List<Shop> subShops = entry.getValue();
final String key = SHOP_GEO_KEY + typeId;
for (Shop shop : subShops) {
opsForGeo.add(key, new Point(shop.getX(), shop.getY()), shop.getId().toString());
}
}
}
因为示例的店铺数据很少,因此采用一次性查询所有店铺信息并导入 Redis 的方式,如果数据量大,可能需要分批查询和导入。
这里使用 Stream 流和 Collectors.groupingBy 收集器进行分组,代码更为简洁。
在这个示例应用中,前端需要按照店铺类型(美食/KTV等)查询离用户最近的商铺,因此这里需要将商铺先按照类型分组,再添加到 GEO 中。
再上边的示例中,每个商铺都会调用一次 GEOADD 进行添加,效率比较低,可以改为批量添加:
List<RedisGeoCommands.GeoLocation<String>> locations = new ArrayList<>(subShops.size());
for (Shop shop : subShops) {
locations.add(new RedisGeoCommands.GeoLocation<>(shop.getId().toString(), new Point(shop.getX(), shop.getY())));
}
opsForGeo.add(key, locations);
附近商户
查询附近商户的前端调用为:
http://localhost:8080/api/shop/of/type?&typeId=1¤t=1&x=120.149993&y=30.334229
其中包含以下参数:
-
typeId 上铺类型
-
current 用于分页(当前页码)
-
x,y 用户的经纬度信息(这里前端写死)
示例项目的 Redis 客户端版本较低,不支持 GEOSEARCH 调用,因此这里排除 Spring 自带的 Redis 客户端,改为指定版本的 Lettuce 客户端和 spring-data-redis API:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<exclusions>
<exclusion>
<artifactId>lettuce-core</artifactId>
<groupId>io.lettuce</groupId>
</exclusion>
<exclusion>
<artifactId>spring-data-redis</artifactId>
<groupId>org.springframework.data</groupId>
</exclusion>
</exclusions>
</dependency>
<!-- https://mvnrepository.com/artifact/io.lettuce/lettuce-core -->
<dependency>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
<version>6.1.6.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-redis</artifactId>
<version>2.6.2</version>
</dependency>
可以在 Idea 中安装一个 Maven Helper 插件,可以更方便的管理 Maven 依赖,比如排除某个依赖。
实现处理 GEO 信息的内部类:
// ...
public interface IShopService extends IService<Shop> {
// ...
/**
* 商铺地理位置信息
*/
class RedisShopGeo {
private final String key;
private final GeoOperations<String, String> opsForGeo;
public RedisShopGeo(StringRedisTemplate stringRedisTemplate, long typeId) {
this.key = SHOP_GEO_KEY + typeId;
opsForGeo = stringRedisTemplate.opsForGeo();
}
/**
* 分页获取商铺位置信息(按由近到远排列)
*
* @param x 当前位置的经度
* @param y 当前位置的纬度
* @param distance 查询距离范围
* @param start 分页起始位置
* @param limit 返回数据最大条数
* @return 商铺位置信息列表
*/
public List<GeoResult<RedisGeoCommands.GeoLocation<Long>>> searchShops(double x, double y, Distance distance, long start, int limit) {
GeoReference<String> reference = new GeoReference.GeoCoordinateReference<>(x, y);
RedisGeoCommands.GeoSearchCommandArgs args = RedisGeoCommands.GeoSearchCommandArgs.newGeoSearchArgs();
args.limit(start + limit);
args.sort(Sort.Direction.ASC); // 按距离升序排列
args.includeDistance(); // 返回值包含距离信息
GeoResults<RedisGeoCommands.GeoLocation<String>> geoResults = opsForGeo.search(key, reference, distance, args);
// 逻辑分页
List<GeoResult<RedisGeoCommands.GeoLocation<String>>> results = geoResults.getContent();
if (results.size() <= start) {
return Collections.emptyList();
}
// 只取最后 limit 个商铺位置信息
List<GeoResult<RedisGeoCommands.GeoLocation<String>>> collect = results.stream()
.skip(start)
.collect(Collectors.toList());
// 将 String 类型的 shopId 转换为 long
List<GeoResult<RedisGeoCommands.GeoLocation<Long>>> longResults = new ArrayList<>(collect.size());
for (GeoResult<RedisGeoCommands.GeoLocation<String>> result : collect) {
RedisGeoCommands.GeoLocation<Long> content = new RedisGeoCommands.GeoLocation<>(Long.valueOf(result.getContent().getName()), result.getContent().getPoint());
GeoResult<RedisGeoCommands.GeoLocation<Long>> longResult = new GeoResult<>(content, result.getDistance());
longResults.add(longResult);
}
return longResults;
}
}
}
代码最后的部分是为了将 String 类型的商铺 id 转换为 Long 类型,这部分不是必须的,这里是考虑到内部类本身是负责处理业务层和 Redis 调用之间的桥梁,因此业务层使用的实际类型和 Redis 使用的 String 类型转换都应当在这里完成。
GEOSEARCH 命令只支持一个 LIMIT 参数,即只能指定返回前 N 个元素,不能真正做到分页那样返回从 START 到 END 的元素。因此这里是在 Java 代码中逻辑分页,即获取前 N 个元素后删除掉一部分元素作为分页结果。
GEOSEARCH 调用返回的结构比较复杂,这里解释一下:
GeoResults:GEOSEARCH 查询到的结果
-
content:GeoResult 组成的列表
-
content:GeoLocation 类型
-
name:geo标识,这里是商铺id
-
point:坐标的经纬度信息
-
-
distance:与查询时给定参考坐标的直线距离
-
服务层实现:
@Override
public Result queryShopsByType(Integer typeId, Integer current, Double x, Double y) {
if (x == null || y == null) {
// 根据类型分页查询
Page<Shop> page = query()
.eq("type_id", typeId)
.page(new Page<>(current, SystemConstants.DEFAULT_PAGE_SIZE));
// 返回数据
return Result.ok(page.getRecords());
}
// 按照地理坐标远近返回商铺信息
final int pageSize = 5;
final int start = (current - 1) * pageSize;
RedisShopGeo redisShopGeo = new RedisShopGeo(stringRedisTemplate, typeId);
// 默认查询 10km 范围内的商铺
final Distance distance = new Distance(10, RedisGeoCommands.DistanceUnit.KILOMETERS);
List<GeoResult<RedisGeoCommands.GeoLocation<Long>>> geoResults = redisShopGeo.searchShops(x, y, distance, start, pageSize);
// 查询商铺详情
List<Long> shopIds = geoResults.stream().map(gr -> gr.getContent().getName()).collect(Collectors.toList());
String strIds = shopIds.stream().map(Object::toString).collect(Collectors.joining(","));
String lastSql = String.format("order by field(id,%s)", strIds);
List<Shop> shops = this.query().in("id", shopIds)
.last(lastSql).list();
Map<Long, Shop> shopMap = new HashMap<>();
for (Shop shop : shops) {
shopMap.put(shop.getId(), shop);
}
// 遍历 geo 查询结果,将距离信息填充到店铺详情
for (GeoResult<RedisGeoCommands.GeoLocation<Long>> geoResult : geoResults) {
// 获取店铺id
Long shopId = geoResult.getContent().getName();
// 获取距离信息
Distance distance1 = geoResult.getDistance();
// 填充距离信息(以米为单位)
double meters = distance1.in(RedisGeoCommands.DistanceUnit.METERS).getValue();
shopMap.get(shopId).setDistance(meters);
}
return Result.ok(shops);
}
签到
可以使用31个比特位存储某个用户某一月的签到记录:
这样做的好处是最大限度节省空间。对应的,在 Redis 中有一种数据结构 BitMap,对应这种 bit 位数据结构。
SETBIT
命令可以设置 BitMap 的指定 bit 位:
示例:
127.0.0.1:6379> SETBIT bm1 3 1 (integer) 0
用图形化客户端查看:
这里的偏移量(offset)是3,所以第四个bit位被设置为1,其它没有被设置过的 bit 位默认为 0。
注意,需要确保查看值的方式是 Binary,否则看到的可能是乱码。
可以用GETBIT
命令查看 BitMap 的指定 bit 位的值:
示例:
127.0.0.1:6379> GETBIT bm1 3 (integer) 1 127.0.0.1:6379> GETBIT bm1 2 (integer) 0
可以用BITCOUNT
命令统计 BitMap 有多少位 bit 位为1:
示例:
127.0.0.1:6379> BITCOUNT bm1 (integer) 1
可以用BITPOS
命令获取第一个是 1 或 0 的比特位的偏移量:
示例:
127.0.0.1:6379> BITPOS bm1 1 (integer) 3
BITFIELD
命令有很多用途,但通常用它来获取整个 BitMap 的值:
示例:
127.0.0.1:6379> BITFIELD bm1 GET u1 0 1) (integer) 0 127.0.0.1:6379> BITFIELD bm1 GET u2 0 1) (integer) 0 127.0.0.1:6379> BITFIELD bm1 GET u3 0 1) (integer) 0 127.0.0.1:6379> BITFIELD bm1 GET u4 0 1) (integer) 1 127.0.0.1:6379> BITFIELD bm1 GET u5 0 1) (integer) 2
该命令返回的结果是十进制。type 参数指定返回值的格式,比如 u1 表示无符号长度为1的值(i表示有符号)。这里 BitMap 实际上是00010000
,因此 u4 返回的是0001
的十进制表示,也就是1,而 u5 返回的是00010
,也就是2。
封装签到相关功能:
// ...
public class RedisSign {
private final ValueOperations<String, String> opsForValue;
private final StringRedisTemplate stringRedisTemplate;
public RedisSign(StringRedisTemplate stringRedisTemplate) {
opsForValue = stringRedisTemplate.opsForValue();
this.stringRedisTemplate = stringRedisTemplate;
}
/**
* 签到
*
* @param userId 用户id
* @param date 日期
* @return 成功/失败
*/
public boolean sign(long userId, LocalDate date) {
final String key = getKey(userId, date);
// 判断给定日期是所在月份的第几天
int dayOfMonth = date.getDayOfMonth();
Boolean result = opsForValue.setBit(key, dayOfMonth - 1, true);
return BooleanUtil.isTrue(result);
}
/**
* 返回用于记录单个用户指定月的签到记录的 key,类似于 user:sign:1001:200501
*
* @param userId 用户id
* @param date 日期
* @return key
*/
private String getKey(long userId, LocalDate date) {
YearMonth yearMonth = YearMonth.of(date.getYear(), date.getMonth());
return getKey(userId, yearMonth);
}
private String getKey(long userId, YearMonth yearMonth) {
String monthStr = yearMonth.format(DateTimeFormatter.ofPattern("yyyyMM"));
return String.format("%s%d:%s", SIGN_KEY, userId, monthStr);
}
/**
* 用户是否在某一天签到过
*
* @param userId 用户id
* @param date 指定日期
* @return 是否签到过
*/
public boolean isSigned(long userId, LocalDate date) {
String key = getKey(userId, date);
// 判断记录签到的 BitMap 是否存在
Long count = stringRedisTemplate.countExistingKeys(Collections.singletonList(key));
if (count == null || count <= 0) {
// 不存在,视作没有签到过
return false;
}
Boolean bit = opsForValue.getBit(key, date.getDayOfMonth() - 1);
return BooleanUtil.isTrue(bit);
}
/**
* 获取用户的月度签到记录
*
* @param userId 用户id
* @param yearMonth 指定月
* @return 月度签到记录
*/
public Map<LocalDate, Boolean> getSignRecord(long userId, YearMonth yearMonth) {
// 判断指定月份有多少天
int lengthOfMonth = yearMonth.lengthOfMonth();
// 获取签到信息
final String key = getKey(userId, yearMonth);
// 读取无符号正月数据,比如 u31
BitFieldSubCommands.BitFieldType type = BitFieldSubCommands.BitFieldType.unsigned(lengthOfMonth);
BitFieldSubCommands subCommands = BitFieldSubCommands.create().get(type).valueAt(0);
List<Long> longList = opsForValue.bitField(key, subCommands);
// 月度签到记录
Map<LocalDate, Boolean> record = new HashMap<>();
// 初始化
for (int i = 1; i <= lengthOfMonth; i++) {
LocalDate date = LocalDate.of(yearMonth.getYear(), yearMonth.getMonth(), i);
record.put(date, Boolean.FALSE);
}
if (longList == null || longList.isEmpty()) {
return record;
}
Long decimal = longList.get(0);
if (decimal == null || decimal == 0) {
return record;
}
// 按bit位从低到高遍历
for (int i = lengthOfMonth; i >= 1; i--) {
if ((decimal & 1) == 1) {
// 当前bit位是1,说明当天签到了
LocalDate date = LocalDate.of(yearMonth.getYear(), yearMonth.getMonth(), i);
record.put(date, Boolean.TRUE);
}
// 十进制数右移一位
decimal = decimal >>> 1;
}
return record;
}
/**
* 返回用户已经连续签到的天数
*
* @param userId 用户 id
* @return 已经连续签到的天数
*/
public int getContinueSignDays(long userId) {
// 获取当月签到记录
final LocalDate now = LocalDate.now();
Map<LocalDate, Boolean> signRecord = this.getSignRecord(userId, YearMonth.of(now.getYear(), now.getMonth()));
// 从当前天向前遍历统计
int signedDays = 0;
LocalDate currentDate = now;
do {
Month currentMonth = currentDate.getMonth();
do {
Boolean isSigned = signRecord.get(currentDate);
if (BooleanUtil.isTrue(isSigned)) {
signedDays++;
} else {
break;
}
currentDate = currentDate.minusDays(1);
}
while (currentDate.getMonth().equals(currentMonth));
if (currentMonth.equals(currentDate.getMonth())){
// 连续签到没有跨月
break;
}
// 连续签到跨月,重新获取所在月的签到记录
signRecord = this.getSignRecord(userId, YearMonth.of(currentDate.getYear(), currentDate.getMonth()));
}
while (true);
return signedDays;
}
}
spring-data-redis 的 API 并没有类似 OpsForBitMap 这样的操作集合,因为 Redis 的 BitMap 类型底层用 String 实现,因此相关操作在 spring-data-redis 中被定义在 OpsForValue 中。
调用示例见。
UV 统计
有时候我们需要对网站的网络访问做统计,通常包含两个维度:
-
UV:全称Unique Visitor,也叫独立访客量,是指通过互联网访问、浏览这个网页的自然人。1天内同一个用户多次访问该网站,只记录1次。
-
PV:全称Page View,也叫页面访问量或点击量,用户每访问网站的一个页面,记录1次PV,用户多次打开页面,则记录多次PV。往往用来衡量网站的流量。
因为 UV 的统计需要记录用户是否当天已经访问过,如果用户量巨大(10w+),将这种记录存储就会消耗大量内存。可以利用 Redis 的 HyperLogLog 来记录这些信息并完成统计。
HyperLogLog
Hyperloglog(HLL)是从Loglog算法派生的概率算法,用于确定非常大的集合的基数,而不需要存储其所有值。相关算法原理大家可以参考:https://juejin.cn/post/6844903785744056333#heading-0 Redis中的HLL是基于string结构实现的,单个HLL的内存永远小于16kb,内存占用低的令人发指!作为代价,其测量结果是概率性的,有小于0.81%的误差。不过对于UV统计来说,这完全可以忽略。
HyperLogLog 主要包含以下命令:
-
PFADD 添加指定元素
-
PFCOUNT 统计 HyperLogLog 中的(不重复的)元素个数
-
PFMERGE 将多个 HyperLogLog 进行合并
编写一个测试用例插入 100w 条数据:
@Test
public void testHyperLogLog() {
// 模拟100w条数据的插入
final int ALL_COUNT = 1000000; // 总插入数据数
final int COUNT = 1000; // 单次插入数据数
final String KEY = "test:hll2";
int userId = 1;
do {
// 分批插入,每次插入1000条
String[] values = new String[COUNT];
for (int i = 0; i < COUNT; i++) {
values[i] = "user" + userId;
userId++;
}
stringRedisTemplate.opsForHyperLogLog().add(KEY, values);
}
while (userId <= ALL_COUNT);
// 打印结果
Long size = stringRedisTemplate.opsForHyperLogLog().size(KEY);
log.info(size);
}
实际的执行结果并不是 100w,有 1% 左右的误差。
对比插入前后的 Redis 内存占用情况:
可以看到内存占用是很少的。
实现
利用 HyperLogLog 实现 UV 统计:
// ...
public class RedisUV {
private final HyperLogLogOperations<String, String> opsForHyperLogLog;
private final StringRedisTemplate stringRedisTemplate;
public RedisUV(StringRedisTemplate stringRedisTemplate) {
opsForHyperLogLog = stringRedisTemplate.opsForHyperLogLog();
this.stringRedisTemplate = stringRedisTemplate;
}
/**
* 记录一次用户请求
*
* @param userId 用户id
*/
public void recordUserCall(long userId) {
// 获取当天记录 uv 的 HyperLogLog 的key
final String KEY = getKey(LocalDateTime.now().toLocalDate());
opsForHyperLogLog.add(KEY, Long.toString(userId));
}
/**
* 获取指定日期对应的 HyperLogLog 的 key
*
* @param date 日期
* @return key
*/
private String getKey(LocalDate date) {
String dateStr = date.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
return UV_KEY + dateStr;
}
/**
* 获取指定日期的 uv
*
* @param date 日期
* @return uv
*/
public long getUV(LocalDate date) {
final String KEY = getKey(date);
// 不需要考虑染回值是 null 的情况,缺少 key 时自动返回 0
return opsForHyperLogLog.size(KEY);
}
/**
* 统计指定月份的 uv 之和
*
* @param yearMonth 指定月份
* @return uv 之和
*/
public long getUV(YearMonth yearMonth) {
// 利用模糊查询获取相关 HyperLogLog 的 key
final String PATTERN = UV_KEY + yearMonth.format(DateTimeFormatter.ofPattern("yyyy:MM")) + ":*";
Set<String> keys = stringRedisTemplate.keys(PATTERN);
if (keys == null || keys.isEmpty()) {
return 0;
}
// 合计
long allSize = 0;
for (String key : keys) {
Long size = opsForHyperLogLog.size(key);
allSize += size;
}
return allSize;
}
}
调用示例可以看。
本文的完整示例代码可以从获取。
The End.
文章评论