Redis 除了五种常见数据类型:String、List、Hash、Set、ZSet,还有一些不常用的数据类型,如:BitMap
、Geo
、HyperLogLog
等等,它们在各自的领域为大数据量的统计
BitMap 计算,可以应用于任何大数据场景下的二值计算,比如:是否登录、是否在线、是否签到、用户性别状态、IP黑名单、是否VIP用户统计 等等场景
BitMap (位图)的底层数据结构使用的是String类型的的 SDS 数据结构来保存。因为一个字节 8 个 bit 位,为了有效的将字节的 8 个 bit 都利用到位,使用数组模式存储
并且每个 bit 都使用二值状态表示,要么 0,要么 1
所以,BitMap 是通过一个 bit 位来表示某个元素对应的值或者状态, 它的结构如下,key 对应元素本身;offset即是偏移量,固定整型,一般存数组下表或者唯一值;value存储的是二值(要么0要么1),一般用来表示状态,如性别、是否登录、是否打卡等
从上面可以看出这边使用一个字节表示 1 行,每 1 行存储 8 个 bit,就是可以存储 8 个状态位,极大的提高了空间利用。这也是 BitMap 的优势
我们可以使用很少的字节,存储大量的在线状态、打卡标记等状态信息,非常有效果
# 设置
SETBIT key offset value
# 获取
GETBit key offset
# 统计
BITCOUNT key [start end]
注意:参数 start、end 表示字节!!
如:现在存储一个用户的在线状态。用户ID 5、9 都在线:
可以看出用户ID为 5 和 9 被打上 1 的标志,代表在线状态,其他未设置值默认为 0,是离线状态
我们存储了 bit 位,其实目的还是为了高效的计算,而不是简单的状态记录。
而在实际的应用场景中,他主要解决如下几个类型的需求:
这种场景最常见,因为值只能是 1 或者 0,所以 所有的二值状态的,所有存在是否对照关系的场景都可以使用。
如:在线(1) 离线(0),打卡(1) 未打卡(0),登录(1) 未登录(0),群聊消息已阅(1) 未阅(0) 等等
我们以用户 离线/在线 为例子,看看如何使用 Bitmap 在海量的用户数据之中判断某个用户是否是在线状态
假设我们使用一个 online_statu 来作为 key,用来存储 用户登录后的状态集合,而用户的 ID 则为 offset,online 的状态就用 1 表示,offline 的状态就用 0 表示
SETBIT online_statu 1024 1
GETBIT online_statu 1024
SETBIT online_statu 1024 0
空间上的有效利用,1亿 人的状态存储只需要 100000000/8/1024/1024 = 11.92 M,简单的数据结构也保证了性能上的优势
基于上面的讨论,我们可以总结出一个预评估公式,根据实际的数据量获取存储空间:( offset / 8 / 1024 / 1024 ) M
固定周期可能是年/月/周,按照不同维度,可能有 365,31,7的bit位的统计周期。
假设这时候我们如果对于某个用户(如1024)全年的签到情况做统计,可以这么设计:
签到则执行对应代码。如:1024用户在2022 年的第1天和最后一天如果有签到,那就是:
# 22年第一天
SETBIT sign_1024_2022 0 1
# 22年最后一天
SETBIT sign_1024_2022 364 1
BITCOUNT sign_1024_2022
那如果你想限定范围了怎么办,比如原来设计的是一年的统计。但是你想获得某个月第一次打卡的数据,这时候就要使用 BITPOS 了
BITPOS key value [start] [end]
注意:参数 start、end 表示字节!!
返回位图中第一个值为 bit 的二进制位的位置。
在默认情况下, 命令将检测整个位图, 但用户也可以通过可选的 start 参数和 end 参数指定要检测的范围
实现签到接口,将当前用户当天签到信息保存到 Redis 中
思路分析:
我们可以把 年和月 作为BitMap的key,然后保存到一个BitMap中,每次签到就到对应的位上把数字从0 变为1,只要是1,就代表是这一天签到了,反之咋没有签到
实现签到接口,将当前用户当天签到信息保存至 Redis 中:
@RestController
@RequestMapping("/sign")
public class SignController {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@GetMapping("/sign")
public String sign(Integer userId) {
LocalDateTime now = LocalDateTime.now();
String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
String key = "sign:" + userId + keySuffix;
int dayOfMonth = now.getDayOfMonth();
redisTemplate.opsForValue().setBit(key, dayOfMonth - 1, true);
return "sign";
}
}
连续签到天数:从最后一次签到开始向前统计,直到遇到第一次未签到为止,计算总的签到次数,就是连续签到天数
逻辑分析:获得当前这个月的最后一次签到数据,定义一个计数器,然后不停的向前统计,直到获得第一个非0的数字即可,每得到一个非0的数字计数器+1,直到遍历完所有的数据,就可以获得当前月的签到总天数了
问题一:如何得到本月到今天为止的所有签到数据?
BITFIELD key GET u[dayOfMonth] 0
假设今天是7号,那么我们就可以从当前月的第一天开始,获得到当前这一天的位数,是7号,那么就是7位,去拿这段时间的数据,就能拿到所有的数据了,那么这7天里边签到了多少次呢?统计有多少个1即可
问题二:如何从后向前遍历每个Bit位?
注意:bitMap返回的数据是10进制,哪假如说返回一个数字8,那么我哪儿知道到底哪些是0,哪些是1呢?
我们只需要让得到的10进制数字和1做与运算就可以了,因为1只有遇见1 才是1,其他数字都是0 ,我们把签到结果和1进行与操作,每与一次,就把签到结果向右移动一位,依次类推,我们就能完成逐个遍历的效果了
@GetMapping("/signCount")
public String signCount(Integer userId) {
LocalDateTime now = LocalDateTime.now();
String keySuffix = now.format(DateTimeFormatter.ofPattern(":yyyyMM"));
String key = "sign:" + userId + keySuffix;
int dayOfMonth = now.getDayOfMonth();
List<Long> result = redisTemplate.opsForValue().bitField(key, BitFieldSubCommands.create()
.get(BitFieldSubCommands.BitFieldType.unsigned(dayOfMonth)).valueAt(0));
if (CollectionUtils.isEmpty(result)) {
return "";
}
// 十进制结果
Long aLong = result.get(0);
if (Objects.isNull(aLong)) {
return "";
}
int count = 0;
while (true) {
if ((aLong & 1) == 0) {
// 如果为 0,签到结束
break;
} else {
count++;
}
aLong >>>= 1;
}
return count + "";
}
缓存穿透:发起了一个数据库不存在的,redis里边也不存在的数据,通常你可以把他看成一个攻击
解决方案:
所以我们如何解决呢?
可以将数据库的数据,所对应的 id 写入到一个list集合中,当用户过来访问的时候,我们直接去判断 list 中是否包含当前的要查询的数据,如果说用户要查询的 id 数据并不在 list 集合中,则直接返回,如果 list 中包含对应查询的 id 数据,则说明不是一次缓存穿透数据,则直接放行
现在的问题是:主键其实并没有那么短,而是很长的一个 主键,随着时间的积累,数量会变得庞大
所以如果采用以上方案,这个 list 也会很大,我们可以使用 bitmap 来减少 list 的存储空间
我们可以把 list 数据抽象成一个非常大的 bitmap,我们不再使用 list,而是将 db 中的 id 数据利用哈希思想,比如:
id 求余 bitmap 长度 :id % bitmap.size = 算出当前这个 id 对应应该落在 bitmap 的哪个索引上,然后将这个值从 0 变成 1,然后当用户来查询数据时,此时已经没有了 list,让用户用他查询的id去用相同的哈希算法, 算出来当前这个 id 应当落在 bitmap 的哪一位,然后判断这一位是 0,还是 1,如果是 0 则表明这一位上的数据一定不存在,采用这种方式来处理,需要重点考虑一个事情,就是误差率,所谓的误差率就是指当发生哈希冲突的时候,产生的误差
Geo 在 坐标记录、位置计算、距离计算上的能力,以及在地图业务中的应用场景
Location Based Services,记作 LBS,基于用户的地理位置数据定位展开的服务,广泛应用于地图类(百度地图、高德地图)、电商团购类(美团、饿了么)软件。它常见的使用场景有:
Redis 的 GEO 特性在 Redis 3.2 版本就有了, 这个功能主要是用于存储用户地理位置信息,并对这些信息进行操作。
Redis 的 GEO 数据结构常见的命令:
GEOADD key longitude latitude member [longitude latitude member ...]
longitude latitude member
分别指给定的空间元素:维度、精度、名称
,这些数据会以有序集合的形式存储在给定的键里面GEOADD food:location 115.775632 39.483256 "东北饺子馆" 114.081569 39.692756 "兰州拉面"
GEOPOS key member [member ...]
GEOPOS food:location 东北饺子馆 兰州拉面 NonExisting
GEODIST key member1 member2 [unit]
GEODIST food:location 东北饺子馆 兰州拉面
GEORADIUS key longitude latitude radius m|km|ft|mi [WITHCOORD] [WITHDIST] [WITHHASH] [ASC|DESC] [COUNT count]
key longitude latitude
:是前置条件,给定的经纬度信息,以及我要搜索的keyradius
:距离半径,指的搜索的范围m|km|ft|mi
:为给定的距离单位,有 米、千米、英尺、英里 4种[WITHCOORD] [WITHDIST] [WITHHASH]
:为返回的信息类型
ASC|DESC
:可选参数,按照距离升序或者降序排列,即 由近到远(asc) 还是 由远到近(desc)COUNT count
:取数数量,避免获取到太多的信息,返回太多信息如果需要获取 距离本人位置10公里半径内由近到远的美食店排序,按km单位计算,返回值带上距离信息,并只取前100个的信息,代码如下:
GEORADIUS food:location 115.791331 39.5120003 10 km WITHDIST ASC COUNT 100
GEORADIUSBYMEMBER key member radius
已知兰州拉面和东北饺子馆的距离是6.1公里,根据兰州拉面获取10公里范围内的距离的美食店,可以获取到东北饺子馆和自己的位置:
GEORADIUSBYMEMBER food:location "兰州拉面" 100 km WITHDIST
GEOHASH key [member [member ...]]
zrem key member [member ...]
1、定义测试商铺数据文件 shop.txt:
0f8207fd52344b348584f82d7ffef389 淮南牛肉汤 15.361239 20.115126 五星 9.7
c0304660e5be494eaff45ce26fcb9bf9 华莱士.鸡肉汉堡 13.361239 21.115126 四星 8.9
a998f4386fa34e16ba9ccf3f448bab7b 驴肉火烧 18.361239 24.115126 五星 9.5
76e50c6b464740bc888a226687961d0a 谷香煎饼 29.361239 24.115126 五星 9.0
1e84ace9b8c6492db416d6abd982e60d 老王鲜肉饼.砂锅 52.361239 40.115126 五星 9.0
3c9557c45a9f4e51ac3bd3ac39052622 麦多馅饼 51.361239 42.115126 三星 7.8
4a5771f48a4f4c61ba1c0b992989af86 张亮麻辣烫 78.361239 67.115126 五星 9.4
6c1b322c2f2546f4a286f745b3b800c3 农家大烤盘饭 80.361239 -67.115126 五星 9.0
2d577e6196414148a7809a469ded51c0 沙野轻食 -80.361239 -67.115126 三星 7.6
588fa28618b147fa87a904a541e7833b 卷饼王.炸串 70.361239 67.115126 五星 9.6
8247ba41fc2942f5b29e91cd42a7b422 凉皮先生.肉夹馍 29.361239 80.115126 五星 9.6
00de5559ecfc4c419b4e6adef8bffee6 火炉火韩式拌饭 12.361239 10.115126 五星 9.9
29d18fc219ed4ad09bdaf2fe806f796f 南城.黄焖鸡米饭 72.361239 50.115126 三星 7.9
770c8f0bbbb44f259d58d1e5b350fbd4 李大姐水饺 52.361239 42.115126 三星 7.9
b3d5dd8773e6475b9bb16ad25f876afb 田老师烤肉 52.469669 42.225196 三星 7.4
d112f7be99c24142b422633cdf15461b 老家炒饼 52.362239 42.145126 四星 8.4
8d59cae232da485d9cb77f6c6060c929 地摊烤冷面 52.398239 42.416526 四星 8.7
5ade01f108ba4ba884a8ec1d37bdf9bb 卤汁拌饭 83.361239 68.115126 四星 8.0
96f462d9a20f40419f18a0f4936ad099 人民公社大饭菜 20.361239 10.115126 四星 8.8
6b0b5955c6b8444ca49a5a2ba39ab49b 炸串王铁板烧 34.361239 20.115126 五星 9.8
2、定义操作商铺 Mapper
@Data
public class Shop {
private String id;
// 名称
private String name;
// 经度
private BigDecimal accuracy;
// 纬度
private BigDecimal latitude;
// 店铺星级
private String star;
// 评分
private BigDecimal score;
}
public class ShopMapper {
private final static List<Shop> SHOP_LIST;
static {
SHOP_LIST = new ArrayList<>();
BufferedReader reader;
try {
reader = new BufferedReader(new FileReader("D:\\workspace\\sb-redis\\src\\main\\resources\\shop.txt"));
String line;
do {
line = reader.readLine();
if (!StringUtils.isEmpty(line)){
String[] split = line.split(" ");
Shop shop = new Shop();
shop.setId(split[0]);
shop.setName(split[1]);
shop.setAccuracy(new BigDecimal(split[2]));
shop.setLatitude(new BigDecimal(split[3]));
shop.setStar(split[4]);
shop.setScore(new BigDecimal(split[5]));
SHOP_LIST.add(shop);
}
} while (line != null);
reader.close();
} catch (IOException e) {
e.printStackTrace();
}
}
public static List<Shop> getData(){
return SHOP_LIST;
}
public static Map<String, Shop> getDataMap(){
return SHOP_LIST.stream().collect(Collectors.toMap(Shop::getId, obj -> obj));
}
}
3、定义 GEO工具类
@Component
public class GeoUtil {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
/**
* 添加成员
*
* @param key
* @param lon 经度
* @param lat 纬度
* @param member 成员
* @return
*/
public Long geoAdd(String key, double lon, double lat, String member){
return redisTemplate.opsForGeo().add(key, new Point(lon, lat), member);
}
/**
* 获取两个成员的距离
*
* @param key
* @param member1
* @param member2
* @return
*/
public Distance geoDist(String key, String member1, String member2){
return redisTemplate.opsForGeo().distance(key, member1, member2);
}
/**
* 获取两个成员的距离
*
* @param key
* @param member1
* @param member2
* @param metric 度规(枚举)(km、m)
* @return
*/
public Distance geoDist(String key, String member1, String member2, Metrics metric){
return redisTemplate.opsForGeo().distance(key, member1, member2, metric);
}
/**
* 获取成员经纬度
*
* @param key
* @param members
* @return
*/
public List<Point> geoPos(String key, String... members){
return redisTemplate.opsForGeo().position(key, members);
}
/**
* 获取某个成员附近(距离范围内)的成员
*
* @param key
* @param member 成员
* @param v 距离
* @param metric 度规(枚举)(km、m)
* @return
*/
public List<Object> geoRadiusByMember(String key, String member, double v, Metrics metric){
GeoResults<RedisGeoCommands.GeoLocation<Object>> geoResults = redisTemplate.opsForGeo().radius(key, member, new Distance(v, metric));
List<Object> result = new ArrayList<>();
for(GeoResult<RedisGeoCommands.GeoLocation<Object>> geoResult :geoResults.getContent()){
result.add(geoResult.getContent().getName());
}
return result;
}
/**
* 获取某个成员附近(距离范围内)的成员
*
* @param key
* @param member 成员
* @param v 距离
* @param metric 度规(枚举)(km、m)
* @param args
* 示例:RedisGeoCommands.GeoRadiusCommandArgs.newGeoRadiusArgs().includeCoordinates().includeDistance().limit(1).sortAscending();
* includeCoordinates:结果包含坐标,includeDistance:结果包含距离,limit:返回数量:sort...:排序
* @return GeoResults
* geoResult.getContent().getName() 元素名称
* geoResult.getContent().getPoint() 元素坐标
* geoResult.getDistance() 元素距离
*/
public GeoResults<RedisGeoCommands.GeoLocation<Object>> geoRadiusByMember(String key, String member, double v, Metrics metric, RedisGeoCommands.GeoRadiusCommandArgs args){
return redisTemplate.opsForGeo().radius(key, member, new Distance(v, metric), args);
}
/**
* 删除
*
* @author zzc
* @date 2023/9/9 16:26
* @param key
* @param members
* @return java.lang.Long
*/
public Long geoDelete(String key, Object... members) {
return redisTemplate.opsForGeo().remove(key, members);
}
}
4、调用接口
@RestController
@RequestMapping("/ego")
public class GeoController {
@Autowired
private GeoUtil geoUtil;
private final static String SHOP_KEY = "shop:location";
// 同步到 Redis
@GetMapping("/async")
public String async() {
List<Shop> shops = ShopMapper.getData();
shops.forEach(shop -> geoUtil.geoAdd(SHOP_KEY, shop.getAccuracy().doubleValue(), shop.getLatitude().doubleValue(), shop.getId()));
return "async";
}
@PostMapping("/listShopsByLocation")
public List<ShopVo> listShopsByLocation(@RequestBody UserVo userVo) {
List<ShopVo> shopVos = new ArrayList<>();
// 1.添加
geoUtil.geoAdd(SHOP_KEY, userVo.getAccuracy().doubleValue(), userVo.getLatitude().doubleValue(), userVo.getId());
// 2.寻找用户附近的门店
List<Object> shopIds = geoUtil.geoRadiusByMember(SHOP_KEY, userVo.getId(), 100, Metrics.KILOMETERS);
Map<String, Shop> shopMap = ShopMapper.getDataMap();
for (Object shopId : shopIds) {
String tempShopId = (String)shopId;
if (tempShopId.equals(userVo.getId())) {
continue;
}
Shop shop = shopMap.get(tempShopId);
ShopVo shopVo = new ShopVo();
BeanUtils.copyProperties(shop, shopVo);
// 两点的距离
double distance = geoUtil.geoDist(SHOP_KEY, userVo.getId(), tempShopId, Metrics.KILOMETERS).getValue();
// 保留 1 位小数
distance = new BigDecimal(distance).setScale(1, RoundingMode.DOWN).doubleValue();
shopVo.setDistance(distance);
shopVos.add(shopVo);
}
// 删除用户数据
geoUtil.geoDelete(SHOP_KEY, userVo.getId());
return shopVos.stream().sorted(Comparator.comparingDouble(ShopVo::getDistance)).collect(Collectors.toList());
}
}
/ego/async
接口:将数据同步到 Redis/ego/listShopsByLocation
:查询某个用户附近的商铺查询参数:
@Data
public class UserVo {
// id
private String id;
// 经度
private BigDecimal accuracy;
// 纬度
private BigDecimal latitude;
}
测试数据:
{
"id": "1111111",
"accuracy": 80.361239,
"latitude": 67.115126
}
返回参数:
@Data
public class ShopVo {
// id
private String id;
// 名称
private String name;
/**
* 店铺星级
*/
private String star;
/**
* 评分
*/
private BigDecimal score;
/**
* 距离
*/
private Double distance;
}
【案例实战】SpringBoot整合Redis的GEO实现查找附近门店功能
HyperLogLog 主要用于Redis 的基数统计,它的数据结构专门设计用来做数据合并和计算,并能节省大量的空间。
基数计数( cardinality counting) 通常用来统计一个集合中不重复的元素个数。例如:统计某个网站的UV、PV或者网站搜索的的关键词数量
应用场景:很多计数类场景,比如:每日注册 IP 数、每日访问 IP 数、页面实时访问数 PV、访问用户数 UV 等
因为主要的目标高效、巨量地进行计数,所以对存储的数据的内容并不关系。也就是说它只能用于统计数量,没办法知道具体的统计对象的内容
如果我们使用普通集合,也能够实现对巨量数据的存储和统计么,但是存储量会大很多,性能也比较差。
以百度搜索为例,如果要做百度指数的计算,针对来访IP进行统计。那么如果每天 有 1000 万 IP,一个 IP 占位 15 字节,那么 1000 万个 IP 就是 143M:
10,000,000 * 15 /(1024 * 1024) = 143.05 M
如果使用 HyperLogLog ,那么在 Redis 中每个键占用的内容都是 12K,理论上能够存储 2^64 个值,即18446744073709551616,这个数是巨量,Java 中 long 类型也只能计算到 2^62 。
无论存储何值,它一个基于基数估算的算法HyperLogLog Counting(简称HLLC),使用少量固定的内存去存储并识别集合中的唯一元素。
HLLC采用了分桶平均的思想来消减误差,在Redis中, 有 16384 个桶 。而 HyperLogLog 的标准偏差公式是 1.04 / sqrt(m),m 为桶的个数。所以
1.04 / sqrt(16384) = 1.04 / 128 = 0.008125
所以这个计数的估算,是一个带有 0.81% 标准偏差的近似值。
Redis 的 HyperLogLog 数据结构常见的命令:
PFADD key element [element ...]
PFADD baidu:ip_address "192.168.0.1" "192.168.0.2" "192.168.0.3"
PFCOUNT key [key ...]
PFMERGE destkey sourcekey [sourcekey ...]