# 仿大众点评_群众点评 **Repository Path**: wkling/Dianping ## Basic Information - **Project Name**: 仿大众点评_群众点评 - **Description**: 技术难点主要在于Redis缓存、缓存刷新、缓存击穿与缓存穿透解决、分布式锁等 - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2025-08-26 - **Last Updated**: 2026-02-28 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README ## 备注 项目目录: ![image-20250814184600315](C:\Users\wangKunlin\AppData\Roaming\Typora\typora-user-images\image-20250814184600315.png) 配置: - 前端:localhost:8080 - 后端:localhost:8081 参考CSDN:[黑马点评项目学习笔记(15w字详解,堪称史上最详细,欢迎收藏)-CSDN博客](https://blog.csdn.net/qq_66345100/article/details/131986713) 启动: - 启动前端:`start nginx`,重新加载`nginx -s reload` - 启动Redis:启动Redis目录下的`start.bat` - 启动mysql - 启动后端:Idea直接启动项目 ## 技术点 - Redis缓存 - 缓存刷新机制(基于interceptor实现) - 缓存一致性(缓存更新策略,超时剔除与主动更新) - Redis全局唯一自增 - 缓存击穿与缓存穿透解决方法(逻辑过期或互斥锁、缓存空字符串) - 超卖问题-添加乐观锁 - 一人一单—spring事务注解、代理对象、方法加锁 - Redis基于`setnx`实现分布式锁,锁误删问题、锁原子性操作问题 - Redssion分布式锁框架 - Redis消息队列 - Lua脚本实现事务原子化 - Redis SortedSet实现点赞用户排名 - Feed流的滚动查询方式,基于SortedSet ## 学习经历 2025年08月15日 12:11:42: ​ 问题:前端跑在8080端口,后端跑在8081端口,请求的URL也是8080端口 ​ 原因:配置了前端代理,把所有8080/api的请求转发到8081。![image-20250815121157867](C:\Users\wangKunlin\AppData\Roaming\Typora\typora-user-images\image-20250815121157867.png) 2025年08月15日 21:34:08: 问题:执行 `LambdaQueryWrapper.eq(User::getPhone, phone)`时报错`Caused by: java.lang.reflect.InaccessibleObjectException: ` 原因:Java 17 的模块系统限制了反射访问 `java.lang.invoke`包,而 MyBatis-Plus 的 Lambda 表达式解析依赖此**反射**机制。降级java8解决。 2025年08月16日 12:13:56: 判空逻辑优化:`shopJson.isEmpty()`不能这样用,因为如果shopJson是空就会有异常,用`StrUtil.isBlank(shopJson)` 2025年08月16日 12:17:54: ​ 手动new拦截器会导致自动注入失效: ```java interceptorRegistry.addInterceptor(new LoginInterceptor()); interceptorRegistry.addInterceptor(new RefreshTokenInterceptor()); ``` 后果:拦截器内部的 `@Autowired`注解(如 `StringRedisTemplate`)**不会生效**,导致 `NullPointerException` | **操作** | **结果** | | :----------------------: | :----------------------------------------------------------: | | `new LoginInterceptor()` | 绕过Spring容器,导致: 1. `@Autowired`失效 2. AOP代理失效 3. 生命周期不受控 | | `@Autowired`注入 | Spring自动处理依赖,保证: 1. 依赖注入有效 2. 单例管理 3. 代理增强 | ​ 2025年08月17日 19:08:59: Java中的Boolean和boolean不一样: | 特性 | `boolean`(基本类型) | `Boolean`(包装类) | | :-----------------: | :-----------------------: | :------------------------------: | | **类型** | 基本数据类型(Primitive) | 包装类(Wrapper Class) | | **默认值** | `false` | `null` | | **存储位置** | 栈内存(Stack) | 堆内存(Heap) | | **是否支持 `null`** | ❌ 不能为 `null` | ✅ 可以为 `null` | | **用途** | 一般逻辑判断 | 需要对象化的场景(如集合、泛型) | 2025年08月18日 12:51:53: 普通优惠券与大额优惠券,有两张表来维护,为什么不写在一张表里? | 字段 | 优惠券 (`tb_voucher`) | 秒杀券 (`tb_seckill_voucher`) | | :---------------: | :-------------------: | :---------------------------: | | 标题 | ✅ | ❌(复用优惠券的标题) | | 折扣规则 | ✅ | ❌ | | 库存 | ❌ | ✅ | | 秒杀开始/结束时间 | ❌ | ✅ | | 限购数量 | ❌ | ✅ | ​ 原因有很多: - 合并后,查询普通优惠券时会加载无用的秒杀字段(如 `stock`),浪费 I/O 资源。 - 违反了Java开发的单一职责原则, 2025年08月22日 22:34:55: 偶现前端发送的请求挂起,没有响应标头, 解决方法:可能是Redis导致的问题,暂时不清楚 2025年08月22日 22:59:58: 实现异步秒杀优化,Redis有数据更新,数据库没有数据更新, ![image-20250822230122726](C:\Users\wangKunlin\AppData\Roaming\Typora\typora-user-images\image-20250822230122726.png) ​ 解决方法:应该是触发了事务回滚,用try catch可以发现问题,是因为实现下列代码的时候,一开始没有set orderId,也没有警告。 ``` long orderId = redisIDWorker.nextId("order"); voucherOrder.setId(orderId); flag = this.save(voucherOrder); ``` ## 项目技术栈 - spring boot - Redis - MySQL - vue - ngnix - mybatis-plus ## 短信登陆 - 基于Session实现登录 - 集群的session共享问题 - 基于Redis实现共享session登录 流程: 1. 提交手机号—前端校验—点击发送验证码 2. 后端生成验证码,保存验证码到Session/Redis中 3. 前端点击登录,发送请求 4. 后端校验前端的验证码,前端通过cookie/url字段捎带上验证码,后端在session/Redis中基于token检验用户的验证码 5. 验证码通过后,如果不存在用户就创建,存在就将用户信息存到Session/Redis里(Redis中,结构为{phone: code}) 6. 全程有一个拦截器`LoginInterceptor`,拦截器默认排除/user/login请求,当访问其他url请求的时候,拦截器启动,并判断用户的登录状态(同样基于Session或Redis) ### 基于Session实现登录 - 生成验证码;localhost:8080/api/user/code?phone=13505980073 - 短信验证码登录;localhost:8080/api/user/login - 登录验证拦截;使用filter或Interceptor,结合threadLocal,将用户信息保存到ThreadLocal中,这样的好处是可以分发各种登录验证请求,而不需要一个一个具体实现登录业务,同时threadLocal可以使用多线程保存每个controller独立的用户信息。 **集群的session共享问题** 多台Tomcat并不共享session存储空间,当请求切换到不同tomcat服务时导致数据丢失的问题。 所以需要用**一台新的服务器跑Redis,存缓存,用Redis缓存来解决Session共享问题**。 ### 基于Redis实现共享session登录(更好) 需要注意的点: - stringRedisTemplate需要保证K-V都是string类型。 - radis需要设置有效期,**有效期刷新可以在拦截器中实现。** - Redis的数据结构采用HashMap,因为短信验证涉及多个字段(phone、验证码、用户id等)如果用String,String的value需要Json化,更麻烦 ### 双层拦截器实现 **为什么需要双层拦截器** 单拦截器的问题在于,单login拦截器会不拦截一些页面(如首页,登录页等),如果用户一直在这些页面停留,不会触发redis缓存刷新,那用户用着用着就发现Redis过期了。用第二个拦截器专门实现Redis刷新。 **springboot中如何实现拦截器** - 写拦截器类,类需要实现`HandlerInterceptor`接口,在类上要带上@Component注解,把他注册为Bean; 拦截器有3个生命周期: - **`preHandle`,在 控制器方法执行前被调用(路由匹配成功后)** - **`postHandle`,在 控制器方法执行后调用** - **`afterCompletion`**,在 **整个请求处理完成后**(包括视图渲染或异常处理)调用,**必定执行** - 在mvcConfig(实现Spring的`WebMvcConfigurer`)中,重写`addInterceptors`接口,添加先前实现的拦截器类。 具体实现: 1. 第一层是刷新拦截器,获取token,用`String token = request.getHeader("authorization");`常见的身份验证Token会通过 `Authorization`头部传递。 2. Redis里面找一下这个token有value吗,找不到说明没有,直接response(401)。 3. 找的到说明存在,把信息存在**`ThreadLocal`**中,并且刷新token的有效期。 4. 第二层拦截器较为简单,只需要拦截URL+二次鉴权就行了。 **拦截器的执行顺序** ![image-20250816003620649](C:\Users\wangKunlin\AppData\Roaming\Typora\typora-user-images\image-20250816003620649.png) ## 商户查询缓存 ![image-20250816111532248](C:\Users\wangKunlin\AppData\Roaming\Typora\typora-user-images\image-20250816111532248.png) ### 添加Redis缓存 参考以下代码,service里面先查缓存,没有就查数据库,然后添加到缓存里面 ```cpp public Result queryById(Long id) { // log.info("enter shopServiceImpl"); String key = CACHE_SHOP_KEY + id; String shopJson = stringRedisTemplate.opsForValue().get(key); // 判断是否存在 存在则返回 if(StrUtil.isNotBlank(shopJson)){ log.info("shop exist in Redis"); Shop shop = JSONUtil.toBean(shopJson, Shop.class); return Result.ok(shop); } //不存在就查数据库 Shop shop = this.getById(id); if(shop == null){ log.info("不存在商户, id:" + id); return Result.fail("不存在商户"); } log.info("查询到shop"); //存在就把他添加到Redis缓存里 并返回 stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES); return Result.ok(shop); } ``` ### 缓存更新策略(一致性) 缓存更新的实现一般是删掉缓存,然后再添加一个新的,比更新缓存更高效。 缓存与数据库的操作更新顺序,优先执行数据库更新(原则是优先执行速度慢的) ![image-20250817111155402](C:\Users\wangKunlin\AppData\Roaming\Typora\typora-user-images\image-20250817111155402.png) ![image-20250817112647452](C:\Users\wangKunlin\AppData\Roaming\Typora\typora-user-images\image-20250817112647452.png) ```cpp public Result updateShopById(Shop shop) { // 两步骤 1更新数据库 2删除缓存 // 参数校验, 略 // 1、更新数据库中的店铺数据 boolean f = this.updateById(shop); if (!f) { // 缓存更新失败,抛出异常,事务回滚 throw new RuntimeException("数据库更新失败"); } // 2、删除缓存 f = stringRedisTemplate.delete(CACHE_SHOP_KEY + shop.getId()); if (!f) { // 缓存删除失败,抛出异常,事务回滚 throw new RuntimeException("缓存删除失败"); } return Result.ok(shop); } ``` ### 缓存穿透Cache Penetration **缓存穿透**是指客户端请求的数据在缓存中和数据库中都不存在,这样缓存永远不会生效,这些请求都会打到数据库,造成数据库压力巨大。 **常见的解决方案有两种:** 1. 缓存空对象 优点:实现简单,维护方便 缺点:额外的内存消耗 可能造成短期的不一致 2. 布隆过滤 优点:内存占用较少,没有多余key 缺点:实现复杂 存在误判可能(布隆过滤器说存在数据,实际上不一定存在) ![image-20250817120753977](C:\Users\wangKunlin\AppData\Roaming\Typora\typora-user-images\image-20250817120753977.png) ```Java if(shopJson != null && shopJson.equals("")){ // 说明 返回的shopJson是一个用以解决缓存穿透的""字符串 return null; } ``` ### 缓存雪崩 **缓存雪崩**是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。 **解决方案:** 给不同的Key的TTL添加随机值 利用Redis集群提高服务的可用性 给缓存业务添加降级限流策略 给业务添加多级缓存 ### 缓存击穿**Cache Breakdown** **缓存击穿问题**也叫热点Key问题,就是一个被**高并发访问**并且**缓存重建业务较复杂**的key突然失效了,无数的请求访问会在瞬间给数据库带来巨大的冲击。 **常见的解决方案有两种:** 逻辑过期(设置缓存TTL永不过期,缓存数据里面加个逻辑TTL,拿数据的时候判断逻辑TTL有没有过期) 互斥锁(加锁访问数据库) ![image-20250817122924342](C:\Users\wangKunlin\AppData\Roaming\Typora\typora-user-images\image-20250817122924342.png) ![image-20250817185537248](C:\Users\wangKunlin\AppData\Roaming\Typora\typora-user-images\image-20250817185537248.png) **加锁操作基于Redis的`setnx`命令** ![image-20250817185816090](C:\Users\wangKunlin\AppData\Roaming\Typora\typora-user-images\image-20250817185816090.png) ```java public Shop queryWithMutex(Long id) { String key = CACHE_SHOP_KEY + id; String shopJson = stringRedisTemplate.opsForValue().get(key); // 判断是否存在 存在则返回 if(StrUtil.isNotBlank(shopJson)){ log.info("shop exist in Redis"); Shop shop = JSONUtil.toBean(shopJson, Shop.class); return shop; } if(shopJson != null && shopJson.equals("")){ // 说明 返回的shopJson是一个用以解决缓存穿透的""字符串 return null; } // 解决缓存击穿 String mutexKey = "lock:shop:" + id; Shop shop = null; int spinCount = 0; final int maxSpins = 20; // 最大自旋次数 final long spinInterval = 50; // 自旋间隔时间(ms) try { // 自旋锁尝试拿锁 while (!tryLock(mutexKey)) { if (++spinCount > maxSpins) { log.warn("获取锁失败,已达到最大自旋次数: {}", maxSpins); return null; // 或者可以返回旧数据/降级处理 } Thread.sleep(spinInterval); // 短暂休眠后继续尝试 } // 拿锁成功 shop = this.getById(id); if(shop == null){ log.info("不存在商户, id:" + id); // 添加空值null进入Redis 解决缓存穿透问题 stringRedisTemplate.opsForValue().set(key, "", CACHE_NULL_TTL, TimeUnit.MINUTES); return null; } log.info("查询到shop"); //存在就把他添加到Redis缓存里 并返回 stringRedisTemplate.opsForValue().set(key, JSONUtil.toJsonStr(shop), CACHE_SHOP_TTL, TimeUnit.MINUTES); } catch (InterruptedException e) { throw new RuntimeException(e); } finally { unlock(mutexKey); } return shop; } private boolean tryLock(String key){ // 基于Redis的setnex实现互斥锁 Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(key, "1", 10, TimeUnit.SECONDS); return BooleanUtil.isTrue(flag); } private boolean unlock(String key){ Boolean flag = stringRedisTemplate.delete(key); return BooleanUtil.isTrue(flag); } ``` **逻辑过期:** ![image-20250817214913579](C:\Users\wangKunlin\AppData\Roaming\Typora\typora-user-images\image-20250817214913579.png) ## Redis在秒杀场景下的应用 ### 优惠券秒杀—Redis实现全局唯一ID **为什么要实现全局唯一ID?** 自增ID有些局限性:安全隐私问题,自增太规律的话,可能会被人推测出一些敏感信息;不方便分布式储存,一般来说,数据库的表,超过500万行就要考虑分表分库了。 本项目采用的全局ID实现方式是:**时间戳**+**序列号**+**数据库自增** ![image-20250818115151976](C:\Users\wangKunlin\AppData\Roaming\Typora\typora-user-images\image-20250818115151976.png) ```c public long nextId(String keyPrefix){ // 1、生成时间戳 LocalDateTime now = LocalDateTime.now(); long nowSecond = now.toEpochSecond(ZoneOffset.UTC); long timestamp = nowSecond - BEGIN_TIMESTAMP; // 2、生成序列号 // 以当天的时间戳为key,防止一直自增下去导致超时,这样每天的极限都是 2^{31} String date = now.format(DateTimeFormatter.ofPattern("yyyyMMdd")); Long count = stringRedisTemplate.opsForValue().increment(ID_PREFIX + keyPrefix + ":" + date); // 3、拼接并返回 位运算 // key是不变的 订单号是基于timestamp和Redis自增拼接的 return timestamp << COUNT_BITS | count; } ``` ### 优惠券秒杀—下单功能 ![image-20250819114316732](C:\Users\wangKunlin\AppData\Roaming\Typora\typora-user-images\image-20250819114316732.png) #### 超卖问题—添加乐观锁解决 悲观锁性能比较差,不适合高并发场景 ![image-20250819123101942](C:\Users\wangKunlin\AppData\Roaming\Typora\typora-user-images\image-20250819123101942.png) ![image-20250819124543057](C:\Users\wangKunlin\AppData\Roaming\Typora\typora-user-images\image-20250819124543057.png) **CAS法不加版本号,在set数据库的时候,再查一次数据,看看是否有变化,不变就说明没人改过,修改即可** ![image-20250819130901362](C:\Users\wangKunlin\AppData\Roaming\Typora\typora-user-images\image-20250819130901362.png) ```cpp // 添加乐观锁 CAS法 boolean success = seckillVoucherService.update().setSql("stock = stock - 1").gt("stock", 0).eq("voucher_id", voucherId).update(); ``` #### 实现一人一单—spring事务注解、代理对象、方法加锁 实现一人一单功能,可以基于userId对每个用户进行加锁判断。 普通的加锁,并不能杜绝在分布式集群下的并发安全问题,因为集群服务器的JVM都是不一样的,各自维护了不同的锁监视器,两个集群下的线程都能同时拿到属于自己JVM下的锁。 ![image-20250819213706411](C:\Users\wangKunlin\AppData\Roaming\Typora\typora-user-images\image-20250819213706411.png) ### Redis分布式锁 **分布式锁:**满足分布式系统或集群模式下多进程可见并且互斥的锁。 使用Redis实现分布式锁,基于Redis的`setnx`命令。 **`KEY_PREFIX + name`是整个Redis集群共通的**。只要所有JVM节点连接到同一个Redis集群(如哨兵模式、Cluster模式),这个key会在整个集群中唯一存在。 **`threadId`是每个JVM进程内部分配的**,`Thread.currentThread().getId()`是JVM内部的线程ID,**不同JVM的线程ID可能重复**。 例如,JVM1的线程1与JVM的线程2,会有竞态条件,使用Redis分布式锁,整个集群的`KEY_PREFIX + name`是同一个,线程2不能抢线程1的锁。同时,线程2由于自己的`String threadId = ID_PREFIX + Thread.currentThread().getId() + "";`与线程1的不同,所以线程2也不能释放线程1的获取的锁, ```java /** * 获取锁 * * @param timeoutSec 超时时间 * @return */ @Override public boolean tryLock(long timeoutSec) { String threadId = ID_PREFIX + Thread.currentThread().getId() + ""; // SET lock:name id EX timeoutSec NX Boolean result = stringRedisTemplate.opsForValue() .setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS); return Boolean.TRUE.equals(result); } ``` #### 锁误删问题 线程1拿锁,执行的时候阻塞了,然后锁超时自动释放了,让其他线程执行,其他线程拿锁执行后,线程1执行完毕,自己释放锁了。解决方法是释放锁之前先校验这个锁属不属于自己拿的。 ![image-20250819231415772](C:\Users\wangKunlin\AppData\Roaming\Typora\typora-user-images\image-20250819231415772.png) #### 锁原子性操作问题 判断锁是否是自己的到释放锁这一过程并不是原子性的,可能会出现判断完了后JVM跑去阻塞线程,然后进行垃圾回收,导致超时释放锁。解决方法需要确保判断到释放的过程是原子性。 ![image-20250819231441224](C:\Users\wangKunlin\AppData\Roaming\Typora\typora-user-images\image-20250819231441224.png) 使用lua脚本。 ```Java // static包裹的静态代码块 作用是在类初始化的时候调用一次 private static final DefaultRedisScript UNLOCK_SCRIPT; static { UNLOCK_SCRIPT = new DefaultRedisScript<>(); UNLOCK_SCRIPT.setLocation(new ClassPathResource("lua/unlock.lua")); UNLOCK_SCRIPT.setResultType(Long.class); } // 放锁 放锁利用Lua脚本原子化操作 public void unlock() { // 执行lua脚本 // stringRedisTemplate.execute第二个参数是一个集合 stringRedisTemplate.execute( UNLOCK_SCRIPT, Collections.singletonList(KEY_PREFIX + name), ID_PREFIX + Thread.currentThread().getId() ); } ``` ### Redisson [Redis分布式锁-这一篇全了解(Redisson实现分布式锁完美方案)_redisson分布式锁实现-CSDN博客](https://blog.csdn.net/asd051377305/article/details/108384490#:~:text=前言在某些场景中,多个进程必须以互斥的方式独占共享资源,这时用分布式锁是最直接有效的。) Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务,**其中就包含了各种分布式锁的实现。** ### 秒杀优化—异步秒杀 #### 基于JVM的阻塞队列实现 ![image-20250820223332113](C:\Users\wangKunlin\AppData\Roaming\Typora\typora-user-images\image-20250820223332113.png) 将前期的快速判断相关的逻辑,通过lua脚本原子化操作。 ![image-20250820224225877](C:\Users\wangKunlin\AppData\Roaming\Typora\typora-user-images\image-20250820224225877.png) ![image-20250822170935879](C:\Users\wangKunlin\AppData\Roaming\Typora\typora-user-images\image-20250822170935879.png) #### 基于Redis消息队列实现 JVM阻塞队列是基于内存的,掉电的话阻塞队列里的任务全消失了,而且jvm有内存限制。 消息队列优势: - 不受内存限制 - 安全,可持久化。消费者需要针对消息进行确认 ![image-20250823174102749](C:\Users\wangKunlin\AppData\Roaming\Typora\typora-user-images\image-20250823174102749.png) Redis提供了三种不同的方式来实现消息队列: - list结构:基于List结构模拟消息队列 - PubSub:基本的点对点消息模型 - Stream:比较完善的消息队列模型 ## 业务功能:达人探店 ### 点赞排名功能 基于Redis的**SortedSet**结构实现, `zadd key value score`,添加value的score `zscore key value`,获取value的score,没有就返回不存在 可以实现排序集合。 ## 业务功能:好友关注 ### Feed流 关注推送也叫做Feed流,直译为投喂。为用户持续的提供“沉浸式”的体验,通过无限下拉刷新获取新的信息。 Feed流产品有两种常见模式: **Timeline**:不做内容筛选,简单的按照内容发布时间排序,常用于好友或关注。例如朋友圈 优点:信息全面,不会有缺失。并且实现也相对简单 缺点:信息噪音较多,用户不一定感兴趣,内容获取效率低 **智能排序**:利用智能算法屏蔽掉违规的、用户不感兴趣的内容。推送用户感兴趣信息来吸引用户 优点:投喂用户感兴趣信息,用户粘度很高,容易沉迷 缺点:如果算法不精准,可能起到反作用 本例中的个人页面,是基于关注的好友来做Feed流,因此采用Timeline的模式。该模式的实现方案有三种: ①拉模式 ②推模式 ③推拉结合 ![image-20250824224600600](C:\Users\wangKunlin\AppData\Roaming\Typora\typora-user-images\image-20250824224600600.png) ![image-20250825164848678](C:\Users\wangKunlin\AppData\Roaming\Typora\typora-user-images\image-20250825164848678.png) Feed流无法采用传统分页查询:Feed流中的数据会不断更新,所以数据的角标也在变化,因此不能采用传统的分页模式。 Feed流实现滚动查询,使用SortedSet ## 业务功能:附件商户 ### GEO数据结构 本章较难 ![image-20250825230522757](C:\Users\wangKunlin\AppData\Roaming\Typora\typora-user-images\image-20250825230522757.png) ### 业务功能:签到 用bitmap来实现低空间占用签到统计。 ![image-20250826201853511](C:\Users\wangKunlin\AppData\Roaming\Typora\typora-user-images\image-20250826201853511.png) ## UV统计 UV:Unique Visitor,也叫独立访客量,是指通过互联网访问、浏览这个网页的自然人。1天内同一个用户多次访问该网站,只记录1次。 PV:全称Page View,也叫页面访问量或点击量,用户每访问网站的一个页面,记录1次PV,用户多次打开页面,则记录多次PV。往往用来衡量网站的流量。 **UV统计在服务端做会比较麻烦,因为要判断该用户是否已经统计过了,需要将统计过的用户信息保存。但是如果每个访问的用户都保存到Redis中,数据量会非常恐怖。** Redis中的HLL是基于string结构实现的,**单个HLL的内存永远小于16kb。**作为代价,其测量结果是概率性的,有小于0.81%的误差。