最近在准备实习,找一些烂大街经典项目练练手.
苍穹外卖
一个项目通常包含公共类(常量,工具以及异常)部分以及实体类部分
此外还有service,controller,mapper(repository)层以及一些配置类,拦截器等
Jwt登录验证
- 用户登录请求
客户端(通常是浏览器或App)发送包含用户名和密码的登录请求到后端。
- 服务端验证身份
后端接收请求,验证用户名和密码是否正确:
- 正确:生成 JWT,返回给客户端
- 错误:返回认证失败响应
- 服务端生成 JWT
服务端使用 密钥 对 payload 进行签名,生成一个完整的 token:1
2
3bashCopyEditeyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9. # Header
eyJ1c2VySWQiOjEyMywidXNlcm5hbWUiOiJ0b20iLCJleHAiOjE3MTM1NjgwMDB9. # Payload
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c # Signature
服务端此后 不再保存用户状态,所有认证信息都由 token 自带。
- 客户端保存 JWT
客户端收到 token 后,通常将其存储在:
localStorage
/sessionStorage
- cookie(慎用,需设置
HttpOnly
和Secure
)
- 客户端携带 JWT 访问资源
客户端每次请求受保护的资源时,在请求头中携带 token:1
Authorization: Bearer <token>
- 服务端验证 JWT
- 服务端提取 token,验证签名是否合法、是否过期。
- 若合法,解析 payload,拿到
userId
等信息,并执行业务逻辑。
🔐 JWT 的结构
JWT 是一个由三部分组成的字符串,用 .
分隔:
- Header(头部)
描述签名的算法及类型,通常是这样的:1
2
3
4jsonCopyEdit{
"alg": "HS256",
"typ": "JWT"
}
- Payload(有效载荷)
存放业务数据,不应包含敏感信息,因为它是明文的。常见字段:
字段 | 含义 |
---|---|
sub | 主题(Subject) |
exp | 过期时间(Expiration Time) |
iat | 签发时间(Issued At) |
userId | 自定义字段,通常是用户唯一标识 |
roles | 自定义字段,表示用户权限角色 |
示例:1
2
3
4
5jsonCopyEdit{
"userId": 123,
"username": "tom",
"exp": 1713568000
}
- Signature(签名)
由 header 和 payload 使用密钥 secret
签名生成,用于防篡改。1
2
3
4javaCopyEditHMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret
)
🧾 JWT 优点
- 无需在服务端存储 Session,实现 无状态认证
- 可跨服务、跨域使用(适合微服务)
- 自带用户信息,减少查库压力
- 易扩展,可加入权限、组织、平台等字段
⚠️ 安全建议
- token 不要放敏感信息(明文可读)
- 设置合理的 过期时间
- 通过
HTTPS
传输,防止中间人攻击 - 使用
HttpOnly + Secure
的 cookie 保存(如 SSR)
接口文档
开放接口规范有Swagger(springfox)和OpenAPI(目前常用).
可以使用springdoc-openapi或Knife4j工具通过添加注解生成规范
springdoc/springdoc-openapi: Library for OpenAPI 3 with spring-boot
新增员工
增加mapper的插入语句增加员工信息,注意插入错误处理.
以及通过interceptor,threadlocal存储登录信息.
分页查询员工
利用mybatis的pagehelper插件,其通过拦截执行的查询语句修改其中的LIMIT返回结果.首先设置页大小和需要查询的页.
PageHelper.startPage(pageNum, pageSize)
用于设置当前页码和每页条数,必须在执行查询语句之前调用。1
2
3PageHelper.startPage(1, 10); // 第1页,每页10条
List<User> users = userMapper.selectAll();
PageInfo<User> pageInfo = new PageInfo<>(users);
PageHelper.offsetPage(offset, limit)
按偏移量方式分页,适合流式加载等场景。1
2PageHelper.offsetPage(20, 10); // 跳过前20条,查询10条
List<User> users = userMapper.selectAll();
然后在mapper中的sql语句中直接写查询条件,返回Page结果.
问题/注意点 | 说明 |
---|---|
startPage 必须紧跟查询语句 | 否则分页不起作用(建议不要有中间处理逻辑) |
不支持多线程共享分页上下文 | 每次分页只作用于当前线程 |
Page<T>
:继承自 ArrayList<T>
,直接包含结果数据 + 分页信息;
PageInfo<T>
:是一个额外封装类,包含分页信息(适合返回给前端);
POJO中日期序列化
在 Spring Boot 项目中,如果你使用的是 Jackson(Spring Boot 默认的 JSON 序列化库),可以通过配置 ObjectMapper
或 application.yml
来自定义 LocalDateTime
/ LocalDate
/ LocalTime
的序列化格式。
✅ 方法一:在全局 ObjectMapper
中注册时间模块(推荐)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
public class JacksonConfig {
public ObjectMapper objectMapper() {
ObjectMapper mapper = new ObjectMapper();
JavaTimeModule javaTimeModule = new JavaTimeModule();
// LocalDateTime
javaTimeModule.addSerializer(LocalDateTime.class,
new LocalDateTimeSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
javaTimeModule.addDeserializer(LocalDateTime.class,
new LocalDateTimeDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
// LocalDate
javaTimeModule.addSerializer(LocalDate.class,
new LocalDateSerializer(DateTimeFormatter.ofPattern("yyyy-MM-dd")));
javaTimeModule.addDeserializer(LocalDate.class,
new LocalDateDeserializer(DateTimeFormatter.ofPattern("yyyy-MM-dd")));
// LocalTime
javaTimeModule.addSerializer(LocalTime.class,
new LocalTimeSerializer(DateTimeFormatter.ofPattern("HH:mm:ss")));
javaTimeModule.addDeserializer(LocalTime.class,
new LocalTimeDeserializer(DateTimeFormatter.ofPattern("HH:mm:ss")));
mapper.registerModule(javaTimeModule);
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); // 防止序列化为时间戳
return mapper;
}
}
✅ 方法二:使用 @JsonFormat
注解在字段上局部配置
适合只对个别字段格式化时使用:1
2
3
4
5
6
7
8
9
public class MyDto {
private LocalDateTime createdTime;
private LocalDate date;
}
黑马点评
缓存作用: 降低后端负载,提升读写速度
开发成本和维护一致性问题
Redis学习
Jedis guide (Java) | Docs1
2
3
4
5<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>5.2.0</version>
</dependency>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
38public class JedisTest {
private Jedis jedis;
public void setUp() {
// 初始化 Jedis 连接
jedis = new Jedis("localhost", 6379); // 假设 Redis 服务运行在本地,默认端口为 6379
System.out.println("Connected to Redis");
// 清空 Redis 数据库,确保测试环境干净
jedis.flushAll();
}
// 在每个测试方法执行之后运行
public void tearDown() {
// 关闭 Jedis 连接
if (jedis != null) {
jedis.close();
System.out.println("Disconnected from Redis");
}
}
public void testString() {
String result = jedis.set("name","proanimer");
System.out.println("result = " + result);
String name = jedis.get("name");
System.out.println("name = " + name);
}
public void testHash() {
jedis.hset("user:1","name","proanimer");
jedis.hset("user:2", "age", "24");
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18public class JedisConnectionFactory {
private static final JedisPool jedisPool;
static {
//配置连接池
JedisPoolConfig jedisPoolConfig = new JedisPoolConfig();
jedisPoolConfig.setMaxIdle(10);
jedisPoolConfig.setMaxTotal(10);
jedisPoolConfig.setMinIdle(0);
jedisPoolConfig.setMaxWait(Duration.of(10, ChronoUnit.SECONDS));
// 创建连接池
jedisPool = new JedisPool(jedisPoolConfig,"127.0.0.1",6379);
}
public static Jedis getJedis() {
return jedisPool.getResource();
}
}
Spring Data Redis
序列化器
在使用 Spring Data Redis 时,序列化器(Serializer)用于将 Java 对象转换为适合存储在 Redis 中的格式(如字节数组),并在从 Redis 读取数据时将其反序列化回 Java 对象。选择合适的序列化器对于确保数据正确性以及优化性能非常重要。默认序列化器是JDK序列化器.
1. JdkSerializationRedisSerializer
- 描述:这是默认的序列化器,使用 Java 的序列化机制来处理对象。
- 优点:支持任意类型的 Java 对象。
- 缺点:生成的数据较大,效率较低,并且只有在同一 JVM 环境下才能正确反序列化。
1 |
|
2. StringRedisSerializer
- 描述:专门用于字符串的序列化器,能够高效地处理字符串类型的数据。
- 优点:简单、快速,适用于大多数键值对场景。
- 缺点:仅限于字符串类型的数据。
1 |
|
3. GenericJackson2JsonRedisSerializer
- 描述:使用 Jackson 库将对象序列化为 JSON 格式。
- 优点:易于阅读和调试,支持复杂对象结构。
- 缺点:相对于其他二进制格式(如 Protocol Buffers),JSON 的体积更大,解析速度较慢。
1 |
|
4. Jackson2JsonRedisSerializer
- 描述:类似于
GenericJackson2JsonRedisSerializer
,但它允许你指定序列化的具体类型。 - 优点:可以更精确地控制序列化过程。
- 缺点:需要提前知道序列化对象的确切类型。
1 |
|
5. OxmSerializer
- 描述:用于 XML 数据的序列化/反序列化。
- 优点:适用于需要以 XML 格式存储数据的场景。
- 缺点:XML 数据通常比 JSON 更大,处理速度也较慢。
1 |
|
6. 自定义序列化器
根据业务需求,你也可以实现自己的序列化器,只需要实现 RedisSerializer<T>
接口即可。
示例代码:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23public class CustomRedisSerializer implements RedisSerializer<MyCustomType> {
public byte[] serialize(MyCustomType t) throws SerializationException {
// 实现序列化逻辑
return new byte[0];
}
public MyCustomType deserialize(byte[] bytes) throws SerializationException {
// 实现反序列化逻辑
return null;
}
}
// 在配置中使用自定义序列化器
public RedisTemplate<String, MyCustomType> customRedisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, MyCustomType> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
template.setValueSerializer(new CustomRedisSerializer());
return template;
}
基于Session的登陆
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
public Result sendCode(String phone, HttpSession session) {
// 1.校验
/* String phoneRegex = "^1[3-9]\\d{9}$";
if (!phone.matches(phoneRegex)) {
return Result.fail("手机号格式错误");
}*/
boolean phoneInvalid = RegexUtils.isPhoneInvalid(phone);
// 2.如果不符合
if (phoneInvalid) {
return Result.fail("手机号格式错误");
}
// 3.符合,生成验证码
String code = RandomUtil.randomNumbers(6);
// 4.保存验证码到session
session.setAttribute("code", code);
// 5.发送验证码
log.debug(StrUtil.format("发送验证码成功,验证码:{}", code));
// 返回ok
return Result.ok();
}
public Result login(LoginFormDTO loginForm, HttpSession session) {
// 1.校验手机号和验证码
String phone = loginForm.getPhone();
boolean phoneInvalid = RegexUtils.isPhoneInvalid(phone);
if (phoneInvalid) {
return Result.fail("手机号格式错误");
}
String code = loginForm.getCode();
String codeInSession = (String) session.getAttribute("code");
if (!code.equals(codeInSession)) {
return Result.fail("验证码错误");
}
// 2.查询用户
User user = query().eq("phone", phone).one();
if (user == null) {
// 不存在 创建用户
user = createUserWithPhone(phone);
}
// 3.保存用户信息到session
session.setAttribute("user", BeanUtil.copyProperties(user, UserDTO.class));
return Result.ok();
}
private User createUserWithPhone(String phone) {
User user = new User();
user.setPhone(phone);
user.setNickName(USER_NICK_NAME_PREFIX + RandomUtil.randomNumbers(8));
save(user);
return user;
}
集群的session共享问题
基于Redis的短信登陆
优化拦截器
原本的拦截器只拦截需要权限的controller,但是如果已经有cookie的用户只访问不需要权限的controller就不会更新redis.
商户查询缓存
缓存更新策略
策略 | 读操作 | 写操作 | 优点 | 缺点 | 适用场景 |
---|---|---|---|---|---|
Cache Aside | 先查缓存,再查数据库 | 更新数据库后删除缓存 | 简单、灵活、一致性较好 | 存在短暂不一致、未命中时性能较差 | 数据读多写少、一致性要求不高 |
Read/Write Through | 缓存负责未命中处理 | 缓存负责同步到数据库 | 透明性好、一致性好 | 复杂性高、可能成为性能瓶颈 | 数据一致性要求高 |
Write Behind Caching | 先查缓存,再查数据库 | 异步批量写回数据库 | 写性能高、吞吐量大 | 数据丢失风险、一致性差 | 数据写多读少、一致性要求低 |
名称 | 触发场景 | 结果 | 常见解决方案 |
---|---|---|---|
缓存穿透 | 请求的数据本就不存在(DB 也无) | 每次请求都打到数据库 | 缓存空值、布隆过滤器 |
缓存击穿 | 某个热点 key 恰好过期了 | 大量请求同时访问 DB,瞬时压力大 | 加互斥锁、热点预热 |
缓存雪崩 | 大量 key 在同一时间过期 | 缓存失效,数据库压力激增 | 加随机过期时间、限流、降级 |
布隆过滤器是基于一个 bit 数组 + 多个 哈希函数:
- 初始创建一个很大的 bit 数组(如 1 亿位,全是 0)。
- 插入元素时,用多个哈希函数对元素哈希,得到多个下标位置,把这些位置设为 1。
- 查询时,对待查元素用相同的哈希函数求下标:
- 若所有对应 bit 位都是 1 → 可能存在
- 有任意一个 bit 是 0 → 一定不存在
优惠券秒杀
全局ID生成器,在分布式系统下用来生成全局唯一ID的工具.
满足:唯一性,高可用,高性能,递增性,安全性.
悲观锁 乐观锁
乐观锁的关键是判断之前查询得到的数据是否被修改过.常见方式:
- 版本号法
给数据添加版本号,每次更新的时候查询数据对应的版本,如果版本号跟之前的不同则表明更新过了.
- CAS法
一人一单,使用悲观锁,加锁. 但在分布式系统下,多个实例下进程不相干,无法进行线程同步,需要实现分布式锁.
分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁
基于Redis的分布式锁
使用setnx
注意如果出现业务耗时超过key的ttl,导致其他线程拿到锁,在删除锁时检查value是否一致。
redis lua脚本
Redisson可重入锁
可重试/更新超时时间
所以利用redis缓存作分布式锁的需要核心解决的可重入和超时重试机制.
主从一致性问题
一、单机多实例(适合开发和测试环境)
配置不同的实例端口,多个配置文件启动多个实例.
二、多机集群,在每台服务器上创建一个 Redis 配置文件(如 redis-cluster.conf
),并添加以下内容:1
2
3
4
5port 6379
cluster-enabled yes
cluster-config-file nodes.conf
cluster-node-timeout 5000
appendonly yes
cluster-enabled yes
:启用集群模式。cluster-config-file nodes.conf
:指定集群节点配置文件。cluster-node-timeout 5000
:设置节点超时时间(毫秒)
Redis消息队列
基于list数据结构
Redis列表是简单的字符串列表,按照插入顺序排序。你可以添加一个元素到列表的头部(左边)或者尾部(右边)一个列表最多可以包含 2^32^ -1个元素。主要利用BRPOP
移除列表元素,如果列表没有元素会阻塞列表直到等待超时或发现可弹出元素为止. 同时通过LPUSH
添加值.
pubsub 点对点消息消息模型
Redis 发布订阅 (pub/sub) 是一种消息通信模式:发送者 (pub) 发送消息,订阅者 (sub) 接收消息。
Redis 客户端可以订阅任意数量的频道。
Stream
Redis Stream 是 Redis 5.0 版本新增加的数据结构。
Redis Stream 主要用于消息队列(MQ,Message Queue),Redis 本身是有一个 Redis 发布订阅 (pub/sub) 来实现消息队列的功能,但它有个缺点就是消息无法持久化,如果出现网络断开、Redis 宕机等,消息就会被丢弃。
简单来说发布订阅 (pub/sub) 可以分发消息,但无法记录历史消息。
而 Redis Stream 提供了消息的持久化和主备复制功能,可以让任何客户端访问任何时刻的数据,并且能记住每一个客户端的访问位置,还能保证消息不丢失。
- Stream: 在 Redis 中,一个 Stream 就是一个追加日志类型的键值对集合。
- Entry: 每个流中的元素称为 Entry 或者 Message,由唯一标识符(ID)和数据字段组成。
- Consumer Group: 允许不同的消费者组从同一个流中读取消息,每个组可以独立地跟踪自己已经消费的消息位置。
- ID: 每条消息都有一个唯一的 ID,格式为
<timestamp>-<sequence>
,其中时间戳是消息添加时的时间,序列号用于区分同一毫秒内添加的消息。
基于Stream的消息队列-消费者组
给消费者分类,消息漏读,消息确认避免消息丢失.
命令 | 作用 |
---|---|
XADD | 添加消息到 Stream |
XRANGE / XREVRANGE | 范围读取消息(正/反向) |
XREAD | 阻塞或非阻塞读取消息 |
XGROUP CREATE | 创建消费者组 |
XREADGROUP | 按消费者组读取消息 |
XACK | 确认消息已处理 |
XPENDING | 查看待处理(未 ack)消息 |
XDEL | 删除指定消息 |
XTRIM | 裁剪旧消息,控制 Stream 大小 |
XLEN | 获取 Stream 长度 |
XINFO | 获取 Stream / Consumer 详细信息 |
创建组
1 | bash |
创建名为
mygroup
的消费者组,$
从最新消息开始消费,MKSTREAM
可自动创建 Stream。
读取消息
1 | bash |
>
表示读取尚未分配的消息(新消息)。
消息确认
1 | XACK mystream mygroup 1686900000000-0 |
查看未确认消息
1 | XPENDING mystream mygroup |
命令 | 说明 |
---|---|
XINFO STREAM mystream | 查看 stream 本体信息 |
XINFO GROUPS mystream | 查看所有消费者组信息 |
XINFO CONSUMERS mystream mygroup | 查看某个消费者组中各个消费者状态 |
消费者组中的多消费者争抢消息体现在
在 Redis Stream 中使用 消费者组(Consumer Group) 时,有个关键的机制是:
➤ 同一个消费者组内,一个消息只会被分配给一个消费者处理。
这意味着:
如果消费者 A 已经读取并 ack(确认)了一条消息,那么:
- 同组内的消费者 B 是 无法再读取这条消息 的。
- 除非你专门指定消息 ID 重新读取(例如用
XREADGROUP
+ 指定 ID)。
如果你希望 消费者 B 能“读到之前被其他消费者已处理的消息”,你必须显式指定 ID,并该消息未被 ack或使用
XPENDING
查找。✅如何让消费者读取“历史消息”?
场景 1:消息还未被 ack(pending)
可以通过
XPENDING
+XCLAIM
把消息“抢过来”:1
2
3
4
5
6
7
8
9
10
11javaCopyEditPendingMessages pending = stringRedisTemplate.opsForStream()
.pending("mystream", "mygroup", Range.unbounded(), 10);
for (PendingMessage message : pending) {
// 把未 ack 的消息交给当前消费者
List<MapRecord<String, Object, Object>> records = stringRedisTemplate.opsForStream().claim(
Consumer.from("mygroup", "consumer2"),
Duration.ofSeconds(5),
message.getId()
);
}场景 2:消息已被 ack,想重复读取
Redis 默认设计下是不会让你“重复消费”被 ack 的消息的,但你可以手动读取它(不是 group 模式):
1
2
3// 不使用消费者组,直接用 XREAD + ID
List<MapRecord<String, Object, Object>> records = stringRedisTemplate.opsForStream()
.read(StreamOffset.fromStart("mystream")); // 或者用具体 ID也可以用
XRANGE
来精确读取:1
2
3// 获取某条历史消息
stringRedisTemplate.opsForStream()
.range("mystream", Range.closed("1682390889639-0", "1682390889639-0"));| 场景 | 是否能重新读取 |
| —————————————————- | ——————————————— |
| 消费者组内,消息已被 ack | ❌(除非用非 group 方式手动读) |
| 消费者组内,消息未被 ack(pending) | ✅(可以用 XCLAIM 抢回来) |
| 想让多个消费者都能读一条消息 | ❌(组内不支持;需非 group 读) |
达人探店
点赞功能
点赞排行榜
好友关注
写两个接口,一个查看是否关注,另一个进行关注或取关.
关注的数据表设计为user_id和follower_id. 为一个关注记录
共同关注
在新增关注时添加缓存,同时利用redis中的set交集操作在缓存中得到共同关注
关注推送
通过推模式,通过分页滚动读取关注用户发布的博客数据. 用户发布博客时将博客id加入关注自己的粉丝的收件箱, 使用sorted set,以时间戳为score(即推模式)
关键是利用最新的时间戳去拿最新的博客id,同时利用偏移量滤去相同的时间戳(默认不会重复). 如果考虑发布时间重复,也可以在存储score时在时间戳基础上加一个随机值避免score重复.
附近商户
Redis GEO 主要用于存储地理位置信息,并对存储的信息进行操作,该功能在 Redis 3.2 版本新增。
Redis GEO 操作方法有:
- geoadd:添加地理位置的坐标。
- geopos:获取地理位置的坐标。
- geodist:计算两个位置之间的距离。
- georadius:根据用户给定的经纬度坐标来获取指定范围内的地理位置集合。
- georadiusbymember:根据储存在位置集合里面的某个地点获取指定范围内的地理位置集合。
- geohash:返回一个或多个位置对象的 geohash 值。
geoadd 用于存储指定的地理空间位置,可以将一个或多个经度(longitude)、纬度(latitude)、位置名称(member)添加到指定的 key 中。
geoadd 语法格式如下:1
GEOADD key longitude latitude member [longitude latitude member ...]
- m :米,默认单位。
- km :千米。
- mi :英里。
- ft :英尺。
- WITHDIST: 在返回位置元素的同时, 将位置元素与中心之间的距离也一并返回。
- WITHCOORD: 将位置元素的经度和纬度也一并返回。
- WITHHASH: 以 52 位有符号整数的形式, 返回位置元素经过原始 geohash 编码的有序集合分值。 这个选项主要用于底层应用或者调试, 实际中的作用并不大。
- COUNT 限定返回的记录数。
- ASC: 查找结果根据距离从近到远排序。
- DESC: 查找结果根据从远到近排序。
用户签到
1
2
3
4
5
6
7
8
9
10LocalDateTime now = LocalDateTime.now();
int dayOfYear = now.getDayOfYear();
int year = now.getYear();
String key = SIGN_KEY + UserHolder.getUser().getId()+ ":" + year;
Boolean signSuccess = stringRedisTemplate.opsForValue().setBit(key, dayOfYear-1, true);
if (BooleanUtil.isTrue(signSuccess)) {
return Result.ok();
}else{
return Result.fail("签到失败");
}
签到统计
使用bitfield
查询一个范围内的二进制返回十进制数据.
UV统计 HyperLogLog
Redis HyperLogLog 是用来做基数统计的算法,HyperLogLog 的优点是,在输入元素的数量或者体积非常非常大时,计算基数所需的空间总是固定 的、并且是很小的。
在 Redis 里面,每个 HyperLogLog 键只需要花费 12 KB 内存,就可以计算接近 2^64 个不同元素的基 数。这和计算基数时,元素越多耗费内存就越多的集合形成鲜明对比。
但是,因为 HyperLogLog 只会根据输入元素来计算基数,而不会储存输入元素本身,所以 HyperLogLog 不能像集合那样,返回输入的各个元素。