知识获取分享平台

知识获取与分享平台

知识社区App,支持发布知识、点赞/收藏、关注取关、首页Feed展示、AI生成摘要等等。项目各模块进行了充分详细的设计以满足高并发和高可用需求

一、认证系统

开发JWT双令牌认证系统,采用RS256签名 + Redis刷新令牌白名单,实现15分钟访问令牌 + 7天刷新令牌的安全会话管理,支持即时令牌撤销,兼顾高安全和高性能。

1.1 有状态身份验证和无状态身份验证

  • 有状态身份验证:服务器端保存用户的会话状态信息,当用户访问时会携带上会话标识(如sessionid),服务器查找是否有这个会话来判断是否放行
  • 无状态身份验证:服务器端不保存用户的会话状态信息,用户登录时,服务器使用私钥加密生成一个token返回给客户端,用户访问时带上这个token,若能用私钥解密,则允许访问。

1.2 什么是双令牌机制

服务器返回两个token给客户端,一个access token(AT)用于访问,一个refresh token(RT)用于刷新access token。

Access Token Refresh Token
持续时间 短,15min 长,7d
用途 用于客户端访问 用于刷新AT
是否无状态 否,需要将RT存储在Redis中,通过查询Redis中是否有对应RT来判断RT是否过期,这样的好处是能够方便强制踢人下线,保障了安全管控能力

所以本项目实现的是一个半无状态的双token机制,兼顾高安全与高性能。

1.3 token保存在什么地方

JWT Token 存放在 HTTP 请求头的Authorization 字段中,格式为:

1
Authorization: Bearer Header.Payload.Signature

客户端在登录成功后,会将服务端返回的 token 保存在:

  • localStorage / sessionStorage (本项目中使用此种保存方法)

    localStorage 是一种浏览器提供的客户端存储机制,允许开发者在用户的浏览器中以键值对的形式存储数据。与 sessionStorage 不同,localStorage 中的数据可以长期保留,直到手动删除。

  • Cookie (如果使用 HttpOnly Cookie)

  • 内存变量 (如 Redux/Vuex 状态)

每次发送请求时,客户端需要手动在请求头中加上这个 token。流程如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
1. 用户在前端输入账号密码

2. 前端发送给后端 (Spring Boot)

3. 后端验证成功,生成 JWT Token,返回给前端

4. 前端把 Token 存到 localStorage

5. 以后每次请求,前端从 localStorage 读取 Token

6. 把 Token 放在 HTTP Header 中发给后端

7. 后端用 @AuthenticationPrincipal 自动解析 Token

1.4 token过期验证流程

场景1:token正常

1
2
3
4
5
6
7
前端:
└─ ⚠️ 每 60 秒检查:Date.now() < expiresAt - 5000 → 是 → 不刷新
└─ 发送请求,携带 token

后端:
└─ JwtDecoder 验证:exp > 当前时间 → 通过 ✓
└─ Controller 正常处理

场景2:token快过期(还有几秒)

1
2
3
4
5
6
7
8
9
10
11
12
前端:
└─ ⚠️ 检查:Date.now() >= expiresAt - 5000 → 快过期了!
└─ 自动调用 /api/v1/auth/token/refresh

后端:
└─ 验证 refresh token7天有效期)
└─ 生成新的 access token(新的15分钟)
└─ 返回给前端

前端:
└─ 保存新 token 到 localStorage
└─ 后续请求使用新 token

场景3:token已过期(前端没刷新成功)

1
2
3
4
5
6
7
8
9
10
11
前端:
└─ 发送请求,携带过期 token

后端:
└─ JwtDecoder 验证:exp < 当前时间 → 过期!
└─ 抛出 JwtException
└─ Spring Security 返回 401 Unauthorized

前端:
└─ 收到 401 错误
└─ 可以尝试刷新 token,或跳转到登录页

1.5 token使用什么加密算法?token里包含什么信息?

本项目中的Jwt采用RSA-256非对称加密算法,原理如下:

密钥类型 作用 谁持有
私钥 (private.pem) 签名 Token(加密)
生成:header.payload.signature
只有后端服务器持有
公钥 (public.pem) 验证 Token(解密)
检查:signature 是否匹配 header + payload
可以公开,任何人都能验证

一个完整的Jwt长这样:

1
2
[Header].[Payload].[Signature]
这三部分用 . 分隔:

Header(头部) - 算法信息

1
2
3
4
5
{
"alg": "RS256", // 使用 RSA-256 算法
"typ": "JWT", // 类型是 JWT
"kid": "xxx-key" // 密钥 ID(用于密钥轮换)
}

Payload(负载) - 实际数据

1
2
3
4
5
6
7
8
9
10
{
"iss": "xxx", // 签发者(issuer)
"iat": 1706169600, // 签发时间(issued at)
"exp": 1706170500, // 过期时间(expires at)
"sub": "123", // 主题(subject,通常是用户ID)
"jti": "uuid-1234", // JWT ID(唯一标识)
"token_type": "access", // Token 类型(access 或 refresh)
"uid": 123, // 用户 ID(自定义声明)
"nickname": "张三" // 用户昵称(自定义声明)
}

Signature(签名) - 防篡改

1
2
3
4
签名 = RSA_SIGN(
Base64Url(header) + "." + Base64Url(payload),
private.pem // 私钥
)

1.6 Jwt验证完整流程

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
1. 接收 Token
前端发送:Authorization: Bearer eyJhbGc...

2. 分解 Token
header.payload.signature

3. ⚠️ 用公钥验证签名【关键步骤】
┌─────────────────────────────┐
│ 用 public.pem 解密 signature │
│ 得到:hash1 │
└─────────────────────────────┘

┌─────────────────────────────┐
│ 重新计算 header + payload │
│ 的哈希值:hash2 │
└─────────────────────────────┘

┌─────────────────────────────┐
│ 对比:hash1 == hash2 ? │
│ - 相等 → 签名有效,未被篡改 │
│ - 不等 → 签名无效,拒绝! │
└─────────────────────────────┘

4. 验证声明(Claims)
✅ exp(过期时间)> 当前时间?
✅ iss(签发者)== "xxx"
✅ Token 格式正确?

5. 提取用户信息
从 payload 中取出 uid、nickname 等

6. 传递给 Controller
@AuthenticationPrincipal Jwt jwt

二、计数系统

计数系统:笔记维度(点赞收藏)与用户维度(关注取关) 以 Redis 作为底层存储系统,采用定制化 Redis SDS 二进制紧凑计数,使用 Lua 脚本进行原子更新,并实现了采样一致性校验与自愈重建。

2.1 为什么要单独把计数模块抽出来,有什么好处?

计数类数据(浏览量、点赞数、收藏数、关注数、粉丝数等)有几个典型特征:

  • 读写极高频:一个热门用户/热门作品,计数每秒都在变。
  • 更新粒度小且随机:每次只改一个数,还可能集中打在少数热点 Key 上。
  • 对“绝对强一致”要求没那么死:10001 和 9999 对用户来说差别不大,比起“页面加载很慢/操作失败”,用户更在乎“流畅”。

如果计数都落在 MySQL:

  • 热点行会频繁 UPDATE,行锁、redo log 压力巨大,很容易撑爆。
  • 想做分库分表,计数又和业务数据纠缠在一起,拆分困难。
  • 任意一个计数维度新增或变更,都要改表结构,DDL 成本很高。

所以:把计数抽出成一个独立的“计数服务 + Redis 存储”,是更合理的架构拆分。

2.2 选择Redis作为底层存储系统可靠吗?

  1. 可以使用 RDB + AOF 混合持久化的方式,将数据持久化到本地;AOF开启策略everysec,理论上最多丢失1s的数据
  2. 如果担心redis宕机,可以使用 多副本 + 主从 的模式,确保一台redis服务器宕机了,另一台能立马顶上
  3. 对于 RDB + AOF 可能丢失的1s的数据,也可能与数据库事实表出现不一致,可以通过执行定时对账任务,和事实表进行对账来修正(本项目的:采样一致性校验与自愈重建)
  4. 在极端情况下,比如硬盘损坏,也可以通过和事实表定期对账,来找回丢失的数据 / 修正错误的数据

2.3 为什么采用自定义的二进制Redis SDS来计数,讲一下存储计数的实现细节

对于作品维度,有点赞数、收藏数;对于用户维度,有关注数、粉丝数、作品数、获赞数、获收藏数,乍一看来,好像是使用Hash来存储更加合适:

1
2
3
4
5
key: 作品id		field: like/fav		value: likeNum/favNum

key: userId
field: followCount, fanCount, workCount, likeCount, favCount
value: 100, 50, 1, 100, 20

但是随着业务规模上涨,用户数、作品数增加,如果每个用户/作品都在Redis里维护一个这样的Hash结构,先不谈数据多少,元数据的字段名就占了很多重复的空间,浪费内存

我们设计的紧凑计数:

1
2
3
key: userId
value: 一个连续的二进制块
[offset0: followCount][offset1: fanCount][offset2: workCount][offset3: likeCount][offset4: favCount]

没有字段名,只存值,节省空间;寻找对应的数据只需:“起始地址 + 类型偏移”即可。我们为每一个key分配了20字节的value,每个value分成5段,每段4字节存储对应数值。

2.4 采样一致性校验和自愈重建是怎么做的?

每隔300s,在查询Redis SDS中数量的时候,去对比和数据库中事实表的实际值,如果不一致,就触发重建(用户关系每隔300s检查是否一致;而点赞系统每次获取数量都判断最终计数结构是否完整,只有结构不完整才会重建)

采样 = 不是每次请求都校验,而是按一定频率抽样检查

  1. 限流机制:使用 setIfAbsent (相当于 SETNX) 实现分布式锁,过期时间300s,保证300s内只有一个线程能够校验并重建
    1. 如果 300 秒内第一次访问:key 不存在,设置成功 → doCheck = true,执行校验
    2. 如果 300 秒内已校验过:key 已存在 → doCheck = false,跳过校验
  2. 校验内容:对比 Redis SDS 中的关注/粉丝数与数据库实际值
  3. 修复机制:发现不一致时,触发 rebuildAllCounters() 全量重建

为什么需要采样校验?

  • 性能考虑:每次查询都去数据库校验会严重影响性能

  • 最终一致性:通过定期采样保证缓存与数据库的最终一致性

  • 自动修复:自动检测并修复可能出现的数据不一致问题

三、用户关系系统

实现关注功能,采用一主多从+事件驱动模型。粉丝表,计数系统,列表缓存都作为关注表的伪从。关注事件发生时,在同一事务中插入关注表和 Outbox 表,使用 Canal 订阅 Outbox 表的 binlog,并将变更事件发布到 Kafka 异步更新其他数据源。

3.1 用户关系系统是怎么实现的?什么是“一主多从+事件驱动”?

当用户A关注用户B时,以下多张表会发生更新:

  1. 关注表(MySQL)需要新增:A -> B
  2. 粉丝表(MySQL)需要新增:B -> A
  3. A关注数 + 1,B粉丝数 + 1(Redis)
  4. A关注列表缓存、B粉丝列表缓存更新(Redis)

这么多操作,如果有一个发生错误,就会导致数据不一致的情况。为了解决一致性的问题,可能会想到,使用分布式事务,将这多个任务都放在一个事务里,就能解决多张表之间的一致性问题。

但是,随之而来的新问题,多个任务放在同一个事务里,会导致性能变差,特别是对于热点用户,后续如果有新的任务需要添加,性能只会越来越差。而且,多个任务放在同一个事务里,如果有一个组件挂了,整体就会一起挂,耦合性变得非常高。

而在我们的用户关系系统里,当用户A关注了用户B后,用户B的粉丝表、用户A的关注数、用户B的粉丝数等这些都不需要非常强的一致性,只需要保证最终一致即可。于是我们将关注表作为主表,其他作为从表,我们只关注主表是否成功,主表成功就算业务成功,下游任务可以容忍慢一点,甚至短暂挂掉,后续只要通过重建措施来保证最终一致性即可

性质 是否必须一致
following(关注表) 唯一真相 必须准确
follower(粉丝表) 粉丝投影表 可延时,但必须可修正
计数(Redis SDS) 聚合投影 可延时,可修正
列表缓存(ZSet / Caffeine) 性能投影 可延时,可修正

我们通过一个新的表:outbox表,来记录following主表的事件,一个事务里只需要完成following表 + outbox表的更新即可。然后通过Canal去订阅outbox表的binlog,再通过Kafka去异步消费outbox表中的事件以更新下游的follower表、Redis计数、缓存(kafka带上userId作为key,保证一个用户的所有事件都进入同一个分区,确保顺序消费)。这就是“一主多从 + 事件驱动”。

3.2 这种模式有什么好处?

  1. 高可扩展性:在没有使用outbox表之前,如果想在下游添加新的任务,需要放在一个事务里,拖垮性能。使用outbox表之后,新来的任务只需要新增一个消费者去订阅Kafka事件,就能够无限扩展下游服务。
  2. 高可用性:在没有outbox表时,如果不把所有任务放在一个事务里,那么follower表只要有一次失败,那就永远不一致;Redis计数也是同理。添加outbox + canal + kafka之后:
    • kafka具有重试机制,同时消息会持久化到本地,保证了不会丢消息
    • 下游任务可以通过outbox表,进行重建
    • 由于我们保证了following是强一致的,即使下游Redis计数任务有不一致,也可以通过每日自动比对following聚合值与Redis计数是否一致,并自动修复,来保证计数任务的一致性

3.3 讲一下Canal的原理

canal的工作原理就是把自己伪装成MySQL slave,模拟MySQL slave的交互协议向MySQL Mater发送 dump协议,MySQL mater收到canal发送过来的dump请求,开始推送bin log给canal,然后canal解析bin log,再发送到存储目的地,比如MySQL,Kafka,Elastic Search等等。

3.4 讲一下Outbox设计模式

Outbox模式主要用于解决在微服务架构中,数据库更新与消息发送之间可能出现的不一致性问题。其基本思想是:

  1. 创建Outbox表:在数据库中增加一个专用的Outbox表,用于存储待发送的事件或消息。
  2. 原子性操作:在同一个数据库事务中,先更新业务相关的数据库表(如订单表),然后将事件记录插入到Outbox表中。这样可以确保这两个操作要么同时成功,要么同时失败,保证数据的一致性。
  3. 异步发送:一个独立的进程(如消息中继或调度器)定期查询Outbox表(本项目中的Canal),将待发送的消息发布到外部消息代理(如Kafka、RabbitMQ等)。在消息成功发送后,更新Outbox表中对应消息的状态

3.5 用户关系系统中如何保障消息的幂等性?

  1. Canal每次拉取outbox表中一批未确认的消息,只有消息解析成功并被成功发送到Kafka,才会ack这一批消息,保证了Canal中的幂等
  2. Kafka也采用手动提交offset的方式,只有消息都被成功消费了,才会提交
  3. 而对于消费者中每一个用户关注事件,通过Redis分布式锁(10min的setnx),来实现一个用户关注事件只被消费一次,保证了幂等性

3.6 用户关系系统如何实现重建?

采样校验:查询关注数/粉丝数等Redis计数时,使用 Redis 锁限流,每用户 300s 触发一次,去和数据库中的事实表做比较,不一致则重建。

3.7 Outbox表的结构?Outbox表binlog中哪些信息可以被Canal监控并发送消息?

Outbox表结构如下:

1
2
3
4
5
6
7
8
9
10
11
CREATE TABLE IF NOT EXISTS outbox (
id BIGINT UNSIGNED NOT NULL,
aggregate_type VARCHAR(64) NOT NULL,
aggregate_id BIGINT UNSIGNED NULL,
type VARCHAR(64) NOT NULL,
payload JSON NOT NULL,
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
KEY ix_outbox_agg (aggregate_type, aggregate_id),
KEY ix_outbox_ct (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

其中payload字段为如下Json:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* 构造关系事件。
*
* @param type 事件类型
* @param fromUserId 触发方用户ID
* @param toUserId 目标方用户ID
* @param id 关系记录ID,可为空
*/
public record RelationEvent(
String type,
Long fromUserId,
Long toUserId,
Long id) {
}

Canal通过监控outbox表中,事件类型为INSERTUPDATE的数据行,并将payload字段发送给kafka,这个字段包含了完整的业务信息。最终发送到kafka的消息结构:

1
2
3
4
5
6
7
8
9
{
"table": "outbox",
"type": "INSERT/UPDATE",
"data": [
{
"payload": "{type: FollowCreated, fromUserId: 123, toUserId: 456}"
}
]
}

四、点赞系统

采用异步写+写聚合的形式应对高并发写场景。采用分片位图的结构高效实现幂等和判重。读取遇到异常或缺失时,基于位图做按需重建,保证最终一致。

4.1 点赞系统是怎么设计的?

点赞系统存在如下“矛盾”:

  • 一方面,用户操作后需要“即时生效”的交互反馈(如点击点赞后立即显示“已点赞”状态),这要求用户维度的状态具备强一致性;
  • 另一方面,实体的总计数(如点赞数)对实时性要求较低,允许秒级最终一致。

于是,点赞系统也效仿用户关系系统,采用“一主多从 + 事件驱动”的设计。记录用户是否点赞/收藏的位图bitmap(key为作品id)作为主表,需要强一致性;而作品点赞数、收藏数等这些不需要非常精确的计数作为从表,通过消费事件来同步(异步写),保证最终一致性。只要确保主表bitmap正确,即使计数有不一致,也可以通过正确的bitmap进行重建。

4.2 什么是“写聚合”

如果每发生一次点赞操作,更新位图bitmap的同时,直接去更新最终计数存储(Redis SDS),会导致形成高频次、细粒度的写请求,特别是对于热点帖子,它们的计数键会成为热点行,集中占用存储节点的资源。单条行为的计数更新数据量极小,但高频请求会占用大量网络带宽;同时,存储层需要处理海量零散写请求,硬件资源利用率低,导致成本浪费。

所以需要一个中间层,去将 同一实体(同一帖子)、同一指标(点赞/收藏)在同一时间窗口内的多次增量更新聚合为一次批量更新,减少最终存储层的写次数,这就是“写聚合”。具体通过Redis的一个Hash结构实现,架构图如下:

点赞操作流程

  1. 用户发起点赞操作,去更新位图(这一步需要强一致),然后即时返回点赞结果

  2. 位图更新成功后,产出事件供kafka去消费(更新点赞数、触发点赞数缓存失效),以作品id为key,保证同一个作品的所有事件都进入同一个分区,保证顺序消费

  3. kafka消费事件时,并不是直接写入最终存储层的自定义SDS,而是先写入一个Redis Hash聚合桶里,聚合桶结构如下:

    1
    2
    3
    key: 作品id + 指标(点赞 or 收藏) + 时间窗口
    field: 点赞、收藏
    value: 这段时间窗口累积的点赞数、这段时间窗口累积的收藏数

    这样就实现了:大量更新操作不会直接达到最终的计数存储SDS处,而是在中间的Hash聚合桶累计一段时间,再批量直接写入最终存储,将多次写入变为一次写入;同时,时间窗口的划分,也能够保证对一个作品的大量点赞操作不会打到同一个聚合桶的key上

  4. 最后,再设计一个定时任务,每隔1s去读取聚合桶里累积的点赞数/收藏数,批量写入最终计数,写入成功后再删除已刷写的聚合字段,防止重复计算

4.3 什么是分片位图?为什么需要分片?

当前用户量少的情况下,一个作品的所有点赞用户通过一个位图bitmap来记录是可以的;但是随着用户量的增加,如果还用一个位图bitmap来记录,那么这个key就会非常大,对这个key的操作也会变得非常重,所以需要将一个作品的key分成多个,本项目中设计每个分片位数为32K位(即可以记录32K = 32768个用户的点赞数据),即每个分片4KB;通过usedId / 32768得到该用户的点赞数据在哪个分片,再通过userId % 32768得到该用户的点赞数据在该分片的哪一位

分片的好处:

  • 避免bigkey
  • 统计总点赞数时,可以对多个分片并行执行 BITCOUNT 再汇总
  • 某些分片冷了,可以整体迁移到冷实例/冷存储
  • 用户量级上去,只需要增加分片数或调整映射规则,无需推倒重来

4.4 点赞系统是如何实现重建的?

对需要重建点赞/收藏数的内容,列出该内容的所有分片位图键,对每个位图bitmap执行BITCOUNT统计1的数量,求和即为真实点赞/收藏数量,再写回对应的最终计数存储。(注意:重建过程需要加锁,重建结束删除对应的过时聚合字段)

4.5 点赞系统是如何保障幂等性的?

  1. 位图层面、事件生产的幂等

    使用Lua脚本,只有位图对应位发生改变时,才会产生事件;否则不产生任何事件,也就不会多算点赞/收藏数

    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
    /**
    * 位图状态切换:仅在状态变化时返回成功,并产出增量事件。
    * @param etype 实体类型
    * @param eid 实体 ID
    * @param uid 用户 ID
    * @param metric 指标名称(like/fav)
    * @param idx 指标索引(用于 SDS 固定结构定位)
    * @param add 是否置位(true=添加,false=移除)
    */
    private boolean toggle(String etype, String eid, long uid, String metric, int idx, boolean add) {
    // 固定分片定位:按用户ID映射到 chunk 与分片内 bit 偏移,避免单键膨胀与热点
    long chunk = BitmapShard.chunkOf(uid);
    // 分片内位偏移
    long bit = BitmapShard.bitOf(uid);
    String bmKey = CounterKeys.bitmapKey(metric, etype, eid, chunk);
    List<String> keys = List.of(bmKey);
    List<String> args = List.of(String.valueOf(bit), add ? "add" : "remove");
    Long changed = redis.execute(toggleScript, keys, args.toArray());
    boolean ok = changed == 1L;
    if (ok) {
    int delta = add ? 1 : -1;
    // 产出计数事件(异步聚合),分区按实体维度保证同实体事件顺序
    eventProducer.publish(CounterEvent.of(etype, eid, metric, idx, uid, delta));
    // 本地事件:触发缓存失效/旁路更新等快速路径
    eventPublisher.publishEvent(CounterEvent.of(etype, eid, metric, idx, uid, delta));
    }
    return ok;
    }
  2. 聚合刷写的幂等

    1
    2
    3
    4
    5
    6
    7
    redis.execute(incrScript, List.of(cntKey),
    String.valueOf(CounterSchema.SCHEMA_LEN),
    String.valueOf(CounterSchema.FIELD_SIZE),
    String.valueOf(idx),
    String.valueOf(delta));
    // 成功后删除该字段,避免重复加算
    redis.opsForHash().delete(aggKey, field);
    • 事件先写入聚合桶(Hash)

    • 定时任务(每1秒)将增量折叠到SDS

    • 刷写成功后立即删除聚合字段

    • 即使定时任务多次执行,每个增量只会被刷写一次

  3. 重建场景的幂等

    • 使用Redisson分布式锁保证只有一个线程重建,这个过程中其他获取锁失败的线程降级返回0(启用Redisson的看门狗机制去自动给锁续期,防止BITCOUNT统计没统计完就释放锁,因为这个执行多个分片的BITCOUNT可能是很耗时的操作)
    • 重建完成后删除对应的聚合字段,避免 位图重建值 + 聚合增量 被重复计算

4.6 使用位图判断点赞与否不会存在内存浪费的问题吗?

用户量很小时不会产生稀疏问题,后续可更换为稀疏位图,比如 roaring bitmap

4.7 一次点赞会访问三次Redis(bitmap+hash+sds),这是不是冗余的?为什么不直接存储在sds里?

并不冗余。

  • 第一次访问Redis的bitmap,用于记录该用户是否点赞了该作品,用于实时展示“已点赞”的状态;然后生成一个kafka消息,用于后续点赞数更新
  • 第二次访问Redis的hash,用于记录当前时间窗口内,该作品点赞数的增量(防止某个作品的总点赞数sds被不停访问更新,特别是对于热门作品,导致redis负载不均衡)
  • 第三次访问Redis的sds,用于将hash中的增量点赞数刷写到总点赞数中去

4.8 对于点赞业务,如果消息还没被消费,那么如果在前端页面上刷新一下页面,这个点赞会消失吗?

不会消失,因为当用户点赞之后,触发作品对应的bitmap更新,将该用户对应的bitmap位设置为1,表示用户“已点赞”,之后才发出消息去更新点赞数,所以即使刷新页面,用户已点赞的状态不会消失

但是如果此时消息还没被消费,那么点赞数的更新可能会有延迟,但这是在能够接受的范围内的

五、Feed信息流

采用三级缓存架构且设计了缓存一致性策略,本地 Caffeine + Redis 页面缓存 + Redis 片段缓存。自定义 hotkey 探测机制,基于热点检测按层级延长缓存时长,叠加随机抖动抗雪崩。并设置单飞锁(single-flight)避免同一页并发回源风暴

5.1 介绍一下项目中用到的三级缓存

三级缓存用来存放一个页面里的所有推文数据:

  • L2 本地Caffeine缓存:存放完整页面数据,命中成本最低,适合最热的公共页
  • L1 Redis页面缓存:存放页面“骨架”,即当前页面文章的id列表,命中后用它来快速拼装页面
  • L0 Redis片段缓存:存放每篇文章的小碎片(作者、封面、标题、计数等),用来按id拼装完整条目

获取页面数据的流程是:

  • 优先读 L2:本地命中则直接返回完整页面,延迟最小。
  • L2 未命中则读 L1:拿到页面骨架(ID 列表),随后按 ID 批量读取 L0 的条目碎片与计数碎片;缺片则进行最小化补全。
  • L1 未命中则回源数据库:用单次查询拉取页面数据,同时并行批量获取计数;把结果写回 L0/L1,并把完整响应写入 L2,最后返回给用户。

5.2 为什么Redis要分成页面缓存和碎片缓存?不能像Caffeine一样存储完整页面数据吗?

如果Redis存储完整页面数据,虽然一次读取就能够返回所有数据,但是一个key包含整个页面的所有数据,容易产生bigkey问题;此外,页面内任何一篇文章更新(点赞数更新、内容更新),都会导致整个页面的缓存失效;而且,倘若当前页面和其他页面有同一篇文章,这篇文章的内容也没法复用

相比之下,将Redis缓存分成页面缓存、片段缓存,当文章内容发生更新时,只需要使文章对应的那份片段缓存失效即可,无需将整页缓存失效,在最小范围内进行改动;若多个页面都包含同一篇文章,那么它们都能够复用同一篇文章的片段缓存,提高了空间利用率

5.3 如何保证Caffeine、Redis、MySQL的一致性?

采用缓存双删策略:

  • 更新前先删除 Redis 和 Caffeine 缓存
  • 执行 MySQL 更新
  • 延迟后(默认200ms)再次删除,清除更新 MySQL 期间可能写入的旧数据

5.4 项目中的hotkey探测是怎么设计的?

本项目中的hotkey探测以滑动窗口为核心,为每个 Redis key 维护一个整型计数数组,数组的每个元素对应滑动窗口内的一个 “时间切片”,用于存储该时间段内的页面访问次数

滑动窗口核心参数

  • 滑动窗口总时长 window-seconds :统计热度的时间范围(如 60 秒,即仅统计最近 60 秒的访问量);
  • 分段粒度 segment-seconds :每个时间切片的时长(如 10 秒,即把 60 秒窗口划分为 6 个连续切片);
  • 分段数量 segments :滑动窗口包含的时间切片总数,由公式 segments = window-seconds / segment-seconds 计算得出(示例中 60s/10s=6,即数组长度为 6)。
参数名称 含义 默认值 调参建议
window-seconds 滑动窗口总时长 60 秒 流量波动大的场景可缩短至 30 秒(快速响应热点变化);流量稳定的场景可延长至 120 秒(更准确统计热度)
segment-seconds 分段粒度(轮转频率) 10 秒 确保 segments 在 6-12 之间:若 window-seconds=60 秒,segment-seconds 可在 5-10 秒之间调整;若 window-seconds=120 秒,segment-seconds 可在 10-20 秒之间调整

热度统计与轮转机制

  • 全局指针管理:通过一个原子类型 AtomInteger 的变量 current,记录当前处于哪个时间窗口
  • 窗口轮转机制:启用一个定时任务,每隔10s(分段粒度决定)去执行 current++,超出 segments 则归零,并将该索引对应的数组元素清空(该机制天然实现了热度自动降级
  • 热度计算:对数组求和,结果即为当前时间窗口内,该 Redis key 的热度
  • 计数逻辑:每访问一个key,找到该key对应的数组 arr,执行 arr[current]++

热度分级与TTL动态扩展

根据每个 Redis key 的热度,划分为三个等级:

参数名称 含义 默认值 调参建议
level-low 低 / 中热度分界阈值 50 参考日常 QPS:若平均单页 QPS 为 10,可设为 30;若平均单页 QPS 为 50,可设为 100
level-medium 中 / 高热分界阈值 200 高热阈值建议为低热阈值的 3-5 倍,避免等级分布过于集中
level-high 高热上限阈值(可选) 500 用于告警(如高热页面占比超 30% 需关注),不影响分级逻辑

每次访问某个key时,都需要reset该key的TTL,如果该key的热度较高,则需在baseTTL的基础上,再加上对应的扩展TTL,同时再加上随机抖动时间防止缓存雪崩。

参数名称 含义 默认值 调参建议
extend-medium-seconds 中热度扩展 TTL 60 秒 初始可设为基础 TTL 的 1-2 倍,观察命中率变化;若命中率提升不明显,可适当增大
extend-high-seconds 高热扩展 TTL 120 秒 不超过 window-seconds 的 2 倍,避免缓存过度停留导致数据陈旧
jitter-percent 随机抖动比例 ±5% 流量峰值高的场景可增大至 ±10%,进一步分散失效时间

5.5 项目中的single-flight有什么作用?

当本地缓存Caffeine、Redis缓存都失效时,需要到MySQL数据库查询,如果大量请求直接打到MySQL性能会非常差,这就是所谓的缓存击穿single-flight主要思想就是只允许一个线程获取锁,到MySQL中去取数据并回源到Caffeine和Redis,这个过程中的其他线程都需要等待;直到完成了回源重建工作,其他线程才能获取锁,这时候缓存中已有数据,无需查询MySQL就可返回。

5.6 项目有对缓存雪崩、缓存击穿、缓存穿透做预防和处理吗?

  • 缓存穿透:缓存空值,对“不存在内容”的查询直接写入一个短 TTL 的 NULL 值,下次请求命中后不再打数据库
  • 缓存雪崩:设置缓存过期时间时,加上一个随机抖动值,防止大量key同时过期;同时还采用自定义的hotkey探测,动态延长热点key的TTL
  • 缓存击穿:采用single-flight锁,热点key过期时只允许一个线程操作数据库查询数据并执行重建工作

5.7 为什么选用Caffeine?Caffeine解决了guava的哪些痛点?

核心算法:从 LRU 到 W-TinyLFU

  • Guava 的痛点(LRU): Guava 使用的是经典的 LRU (Least Recently Used) 算法。LRU 在应对突发流量(稀疏流量)时表现很差。例如,如果有一波冷数据瞬间被大量访问,LRU 会把真正的热数据挤出缓存,导致缓存命中率骤降。
  • Caffeine 的解决(W-TinyLFU): Caffeine 使用了 W-TinyLFU 算法。它结合了 LFU(频率)和 LRU(新鲜度)的优点:
    • 它用极小的内存空间(类似布隆过滤器的 Count-Min Sketch)记录数据的访问频率。
    • 当新数据进来时,它会对比新数据和待淘汰数据的“频率”,如果新数据访问频率更高才准入。
    • W-TinyLFU算法原理

并发性能:由“段”到“条”

  • Guava 的痛点(Segment): Guava 的设计思路类似于 ConcurrentHashMap(Java 7 之前),通过分段锁 (Segment) 来减少竞争。但在高并发下,对同一个段的访问依然会有锁竞争。此外,Guava 在读取数据时也会执行一些清理维护操作,这进一步加剧了竞争。
  • Caffeine 的解决(无锁化/异步): Caffeine 借鉴了数据库写前日志(WAL)的思想。
    • 读取操作: 几乎是无锁的,它将读取记录放入一个环形缓冲区(Ring Buffer)中。
    • 维护操作: 将缓存的清理、写入、统计等繁重任务交给独立的线程池(ForkJoinPool)异步执行。

六、AI问答系统

开发 RAG 知识问答系统,实现用户调用接口→索引检查→向量检索→Prompt 构造→大模型流式生成的全流程,通过合理分块、幂等删除保持单一版本、预索引减少首次提问等待时间等,显著提升用户围绕单篇知文的智能问答效率与准确性。

6.1 向量检索用的是哪个数据库?为什么选择它?索引维度是多少?召回率是多少?

Elasticsearch

  • Elasticsearch 是成熟的分布式搜索引擎,支持向量检索的同时也支持传统的全文检索

  • Spring AI 原生支持:Spring AI 框架提供了开箱即用的 Elasticsearch Vector Store 集成

索引维度是1536 维,embedding使用的是阿里云 DashScope 的 text-embedding-v4 模型;回答采用deepseek模型

采用了宽召回策略来提高召回率:

  • 采用 fetchK = max(topK × 3, 20) 的宽召回策略,即召回 3 倍目标数量的候选,再进行后过滤

  • 通过 metadata.postId 进行服务端过滤,避免跨帖子污染

6.2 “合理分块”的策略是什么? 块大小如何确定?块之间有重叠吗?如何处理跨块的语义信息?

先按 Markdown 标题(以 # 开头的行)进行段落划分,保持逻辑结构的完整性;再对每个段落进行进一步切分,每个chunk <= 800字符,避免单个 chunk 过长,同时块之间保留100个字符的重叠,确保跨块的关键信息不会被切断。

6.3 “幂等删除保持单一版本”具体怎么实现?如果用户编辑了知文,旧版本的向量如何清理?如何避免检索到过期内容?

项目采用指纹检测 + 先删后写的策略来保持单一版本:

  • 先检测文章的指纹,如果未发生变化则跳过重建
  • 否则先删除所有旧版本向量
  • 再重新写入新版本向量,并携带新的指纹信息

6.4 “预索引”是在什么时候实现的?

  • 发布文章成功后触发一次预索引
  • 回答时检查文章是否发生变化,若发生变化则生成新索引

6.5 检索的原理是什么?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* 语义检索上下文:
* - 先进行宽召回(fetchK ≥ 3×topK,至少 20)提高召回率
* - 再按 metadata.postId 做服务端过滤,避免跨帖子污染
*/
private List<String> searchContexts(String postId, String query, int topK) {
int fetchK = Math.max(topK * 3, 20); // 宽召回:扩大初始检索集合
List<Document> docs = vectorStore.similaritySearch(
SearchRequest.builder().query(query).topK(fetchK).build() // 语义相似检索
);
List<String> out = new ArrayList<>(topK);
for (Document d : docs) {
Object pid = d.getMetadata().get("postId");
if (pid != null && postId.equals(String.valueOf(pid))) { // 仅保留当前帖子对应的切片
String txt = d.getText();
if (txt != null && !txt.isEmpty()) {
out.add(txt);
if (out.size() >= topK) break; // 只取前 topK 个上下文
}
}
}
return out;
}

基于向量距离的 kNN/ANN 搜索

  1. Embedding(向量化): 系统首先会调用 Embedding Model,将输入的自然语言 query 转换成一个1536维向量。
  2. Distance Calculation(距离计算): 拿着这个查询向量,到 vectorStore(向量数据库)中去计算它与库中存储的 Document 向量之间的距离(通常使用余弦相似度 Cosine 或 欧氏距离 L2)。
  3. Ranking(排序): 找出距离最近(相似度最高)的 fetchK 个文档返回。

知识获取分享平台
http://example.com/2026/01/05/知识获取分享平台/
作者
Kon4tsu
发布于
2026年1月5日
许可协议