MySQL
索引
为什么选B+树而不是B树或者红黑树
B+ Tree 特点: 只有叶子节点存储数据,非叶子节点只存索引,每个叶子节点均有两个指针,形成双向链表,这使得每个节点能容纳更多索引,降低树的高度(通常 3-4 层即可支撑千万级数据)。
相较于在非叶子节点也存储数据的B树和红黑树,B+树单个节点的数据量更小,相同磁盘IO可以查询到更多节点
InnoDB表为什么必须有主键
InnoDB基于聚簇索引组织,而聚簇索引依赖主键,因此InnoDB表必须有主键,并且一张表只能有一个聚簇索引,而主键的选择优先级为
- 如果有主键,默认会使用主键作为聚簇索引的索引键(key)
- 如果没有定义主键:InnoDB 会检查是否有唯一非空索引(Unique Not Null),如果有,则将其作为聚集索引
- 如果连唯一索引都没有,InnoDB 会在后台自动生成一个 6 字节的隐式自增 ID(RowID)作为聚集索引
为什么推荐使用“自增整型”做主键
性能维度(页分裂): B+ Tree 维护索引是有序的。自增主键每次插入都在树的末尾,属于顺序写,页利用率高。而 UUID 是随机的,会导致频繁的页分裂(Page Split),造成大量磁盘 I/O 和内存碎片。
空间维度: 所有的二级索引(非聚集索引)的叶子节点都存储主键值。如果主键很长(如 UUID 字符串),会导致所有二级索引膨胀,消耗更多内存和磁盘空间。
为什么二级索引不直接存磁盘地址,而要存主键值
为了保证数据维护的灵活性。如果存储磁盘地址,一旦主键索引发生页分裂导致行数据发生物理移动,所有的二级索引都必须跟着更新地址。而存储主键值,二级索引无需任何修改
对于联合索引(a, b),在执行 select * from table where a > 1 and b = 2 语句的时候,a和b字段谁用到索引
只有字段 a 能用到联合索引,字段 b 无法通过这个联合索引被有效过滤
- 遍历联合索引
(a, b),找到所有a > 1的索引条目(这一步用到了索引) - 对于这些条目,取出对应的主键值,回表(访问聚簇索引)获取整行数据
- 在内存中过滤出
b = 2的数据(这一步是「回表后过滤」,没用到索引)
因为在筛选出a>1的数据后,b在该数据中并非有序
什么时候适合/不适合建立索引
- 适合建立索引
字段的选择性高
覆盖索引 :如果一个索引包含了查询语句中所有的返回字段(包括 WHERE、SELECT、GROUP BY、 ORDER BY 中的字段),MySQL 就直接从二级索引返回数据。避免了回表操作,效率提升数倍
- 不适合建立索引
表中数据量较小,因为数据量较少时,全表查询可能更快
频繁更新的字段: 每次更新都需要维护 B+ 树的有序性(涉及页分裂/合并),性能损耗大
不出现在 WHERE 后的字段
count(*)为什么快
count(*)=count(1)>count(主键字段)>count(字段)
count(1)不会读取记录中的任何字段值,每读取一条记录count值就加一
count(*)和count(1)一样,但MySQL对其进行了优化,如果没有二级索引,则用主键索引进行统计,有多个二级索引则使用key_len最小的二级索引进行扫描
而count(字段)则会进行全表扫描,因此效率最差
如何对count(*)的情况优化
在千万级大表上,COUNT(*) 也是全表扫描,非常耗时
- show table status 或者 explain进行估算
- 用额外一张表或者redis保存计数,但增和删除操作时,需要额外维护这个计数表
- 交互设计改为“加载更多”,不展示总页数
为什么 LIMIT 1000000, 10 会很慢?
执行原理: MySQL 并不是直接跳过前 100 万行,而是全量扫描前 1000010 行,然后丢弃前 100 万行,只返回最后 10 行。
资源消耗: 如果查询涉及非覆盖索引,MySQL 需要进行 100 万次“回表” 操作,这会产生大量的随机 I/O,导致查询耗时从毫秒级飙升至秒级。
如何优化
- 针对主键索引
elect * from page where id >=(select id from page order by id limit 6000000, 1) order by id limit 10;
先执行子查询select id from page order by id limit 6000000, 1,此时获取前6000000+1条数据,最后只保留最后一条数据的id,之后sql语句变成select * from page where id >=(6000000) order by id limit 10;走一次主键索引
- 非主键索引
select * from page t1, (select id from page order by user_name limit 6000000, 100) t2 WHERE t1.id = t2.id;
通过select id from page order by user_name limit 6000000, 100先走user_name的非主键索引取出id,不需要回表,最后只保留后100个id,通过主键索引获取100条数据
总结: 先用查询字段+limit走二级索引筛选出所需数据的id,最后根据id走主键索引,避免回表操作
事务
事务四大特性ACID
原子性(Atomicity): 事务是最小执行单位,要么全成功要么全失败。依靠 undo log(回滚日志)。
一致性(Consistency): 事务执行前后,数据库从一个合法状态转换到另一个合法状态(如转账总额不变)。一致性是目的,由原子性、隔离性、持久性共同保证。
隔离性(Isolation): 多个并发事务之间相互不干扰。依靠 锁机制 和 MVCC(多版本并发控制)。
持久性(Durability): 事务一旦提交,修改就是永久的。依靠 redo log(重做日志)。
并发事务引发的问题
脏读(Dirty Read): 事务 A 读到了事务 B 未提交的数据
不可重复读(Non-repeatable Read): 事务 A 内部两次读取同一行,结果不同(中间事务 B 修改并提交了)
幻读(Phantom Read): 事务 A 内部两次按条件查询,记录行数不同(中间事务 B 插入或删除了数据)
四种隔离级别
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 实现方式 |
|---|---|---|---|---|
| 读未提交 (RU) | 允许 | 允许 | 允许 | 几乎不加锁 |
| 读已提交 (RC) | 禁止 | 允许 | 允许 | MVCC:每次快照读生成新的 ReadView |
| 可重复读 (RR) | 禁止 | 禁止 | 禁止(很大程度上) | MVCC:事务启动后第一次快照读生成 ReadView |
| 串行化 (S) | 禁止 | 禁止 | 禁止 | 强制加锁,性能最差 |
MVCC 是如何实现可重复读的
A. 隐藏字段
每行记录除了你定义的列,还有两个关键隐藏字段:
DB_TRX_ID:最近修改该行的事务 ID。DB_ROLL_PTR:指向 undo log 中该行旧版本的指针。
B. ReadView(读视图)
当事务执行“快照读”(普通 SELECT)时,会生成一个 ReadView,包含:
-
creator_trx_id:创建该 Read View 的事务的事务 id -
m_ids:当前活跃(未提交)的事务 ID 列表 -
min_trx_id:活跃列表中的最小值 -
max_trx_id:系统将要分配给下一个事务的 ID 值
C. 可见性算法
- 如果记录的
TRX_ID(最近修改该行的事务ID) <min_trx_id:说明该版本在快照创建前已提交,可见。 - 如果
TRX_ID>max_trx_id:说明该版本在快照创建后才开启,不可见。 - 如果在两者之间:判断
TRX_ID是否在m_ids列表中。在则说明未提交,不可见;不在则说明已提交,可见。
可重复读RR级别下如何解决幻读?
快照读(普通 SELECT): 通过 MVCC 解决。由于 ReadView 在事务开启后第一次查询时就固定了,后续查询结果一致,自然看不到别人新插的数据。
当前读(SELECT FOR UPDATE / UPDATE / DELETE): 通过 Next-Key Lock(记录锁+间隙锁) 解决。锁住记录本身以及记录之间的“间隙”,防止其他事务在间隙中插入新数据。
可重复读RR级别下出现幻读的情况
- 事务中途由快照读升级为当前读
- 事务由快照读后通过UPDATE操作其他事务新增并提交的数据
如何彻底避免幻读
在事务开始后就使用当前读加锁
为什么 MySQL 不干脆让所有 SELECT 都加间隙锁,彻底解决幻读
是性能与一致性的权衡(Trade-off)。如果所有读都加锁,数据库就退化成了串行化(Serializable),并发性能会大幅下降。MySQL RR 级别的设计初衷是让‘读-写’尽量不冲突,只在用户明确要求一致性(当前读)时才牺牲性能去加锁
在读提交RC隔离级别下,MVCC 为什么不能解决不可重复读
因为在RC级别下,事务中每一次执行普通 SELECT 查询时,都会重新生成一个新的 ReadView。这意味着如果两次查询之间有其他事务提交了修改,第二次查询生成的 ReadView 就能看到最新的数据,从而导致两次结果不一致
锁
InnoDB 的行锁是加在记录上的吗?如果没有索引会发生什么?
InnoDB 的行锁是通过给索引上的索引项加锁来实现的,而不是针对记录本身。
没有索引的后果: 如果查询没有命中索引,MySQL 无法使用行锁,会直接升级为对全表记录加锁(类似于表锁),这会严重降低并发性能
既然有了行锁,为什么还需要意向锁(IS/IX)
快速判断表里是否有记录被加锁
解决效率问题: 当事务想要申请表级锁(如 ALTER TABLE)时,如果没有意向锁,它必须一行行检查表里是否有行被锁住,效率极低。
既然间隙锁会阻塞插入,为什么多个事务同时往同一个间隙插入不同数据时不会冲突?
因为插入时会持有一种特殊的间隙锁插入意向锁。
特性: 多个事务在同一间隙插入不同记录时,只要位置不冲突,插入意向锁之间是互相兼容的,这大大提升了并发插入性能。
什么是 AUTO-INC 锁?
当一个事务执行插入操作时,它会持有一个表级别的 AUTO-INC 锁,直到这条 SQL 语句(注意:不是整个事务)执行结束,以保证生成的自增 ID 是连续且唯一的。
自增锁有哪些模式?它们对性能有什么影响?
| 模式 | 名称 | 描述 | 优点/缺点 |
|---|---|---|---|
| 0 | Traditional (传统) | 所有的插入都用表级 AUTO-INC 锁。 | 简单但并发性能最差。 |
| 1 | Consecutive (连续) | 对于简单插入(能预知行数)用轻量级锁(mutex),即申请到主键就释放;对于批量插入(如 INSERT...SELECT)用 AUTO-INC 锁。 |
MySQL 5.7 默认值。兼顾性能和安全性。 |
| 2 | Interleaved (交错) | 所有插入都用轻量级锁,不使用表级 AUTO-INC 锁。 | MySQL 8.0 默认值。性能最高,但会导致自增 ID 在并发插入下不连续。 |
但在使用 Statement-based Replication(基于语句的复制) 时,模式 2 可能会导致主从数据不一致(因为 ID 顺序可能不同)。因此,使用模式 2 必须配合 Row-based Replication(基于行的复制)
自增 ID 为什么会不连续?
场景 1:事务回滚。事务 A 申请了 ID=1,但事务 A 回滚了,ID=1 不会被回收。
场景 2:唯一索引冲突。插入时申请了 ID=2,但因为其他字段唯一键冲突失败,ID=2 也会丢失。
场景 3:批量插入优化。为了提高效率,MySQL 批量插入时会申请多个 ID,如果没用完,剩下的也会被浪费。
请简述死锁产生的必要条件,并给出一个 MySQL 中的典型案例
必要条件: 互斥、占有且等待、不可抢占、循环等待(面试重点)
经典案例: 两个事务(A 和 B)分别持有资源 1 和 2 的锁,同时又去申请对方手中的锁
MySQL 发现死锁后会一直卡住吗?它有什么自我保护机制?
死锁检测(Wait-for Graph): 默认开启(innodb_deadlock_detect)。InnoDB 会维护一个锁等待图,一旦发现回路,立即主动回滚。
回滚策略: 检测到死锁后,InnoDB 通常会选择回滚持有最少行级排他锁的事务(代价最小原则)。
超时机制: innodb_lock_wait_timeout。如果关闭了死锁检测,事务在超过设定时间后会自动放弃并回滚。
日志
undo log的作用
事务回滚: 事务异常时,靠它恢复到初始状态。
MVCC: 作为版本链的载体,让不同事务看到不同版本的数据。
为什么需要 redo log
保证事务的持久性和崩溃恢复
如果每次修改都直接写磁盘(随机 IO),效率太低。MySQL 采用 WAL 技术(Write-Ahead Logging),先写日志,再找机会刷磁盘。 将写操作从随机写变成了顺序写
redo log 的“环形写”结构是怎样的
固定大小: Redo Log 的空间是有限的(由 innodb_log_file_size 决定),是一组文件循环写入。
两个关键指针:
- write pos: 当前记录的位置,边写边后移。
- checkpoint: 当前要擦除的位置(即已经刷入磁盘的数据位置)。
阻塞风险: 当 write pos 追上 checkpoint 时,说明日志满了。此时 MySQL 会阻塞更新操作,强制刷脏页,这在面试中常被问及性能抖动的原因。
redo log 是直接写磁盘吗
事务执行过程中,redo log 先写入内存中的 redo log buffer。
并在以下场景会刷盘:
- MySQL正常关闭
- log buffer 空间占用达到一半时。
- 事务提交时(根据参数配置)。
- 每隔 1 秒的后台定时任务。
- redo log文件满(checkpoint 检查点)
redo log刷盘策略(innodb_flush_log_at_trx_commit)
0: 每秒写入并刷盘。性能最高,但宕机会丢 1 秒数据。
1 (默认): 每次事务提交都强制刷盘。最安全,持久性最强,性能损耗最大。
2: 每次事务提交写入操作系统的 Page Cache,每秒再刷盘。兼顾性能和安全(操作系统不挂,数据就不丢)。
Binlog 与 Redo Log 的本质区别
层次不同:Binlog 是 MySQL Server 层实现的,所有引擎都能用;Redo Log 是 InnoDB 引擎特有的。
类型不同:Binlog 是逻辑日志,记录的是 SQL 语句或行数据变更;Redo Log 是物理日志,记录的是“在某个数据页上做了什么修改”。
写入方式不同:Binlog 是追加写,文件写满会切换到下一个,不会覆盖;Redo Log 是循环写,空间固定,会覆盖旧记录。
功能不同:Binlog 用于数据备份、主从复制;Redo Log 用于 Crash-safe(崩溃恢复)。
Binlog 的三种格式(Statement, Row, Mixed)
Statement (基于语句):记录原始 SQL。
- 优点:日志量小,节省 IO。
- 缺点:可能导致主从不一致(例如使用了
UUID()、NOW()等动态函数)。
Row (基于行):记录每一行变更后的具体值。
- 优点:逻辑最严谨,完全保证主从一致。
- 缺点:日志量大(如一个
UPDATE影响百万行,日志会爆满)。
Mixed (混合模式):普通语句用 Statement,可能引起不一致的语句用 Row。
面试建议:现在主流方案(包括 MySQL 8.0)默认且推荐使用 Row 格式,配合 binlog_row_image=minimal 来优化日志量。
Binlog 的写入时机与刷盘策略
写入过程:事务执行过程中,先把日志写到线程私有的 binlog cache,事务提交时再统一写入文件。
参数 sync_binlog:
0:由操作系统决定何时刷盘(性能最高,宕机会丢数据)。1:每次事务提交都刷盘(最安全,MySQL 默认值)。N:每 N 个事务提交后刷盘。
什么是“二阶段提交”?
事务提交后,redo log 和 binlog 都要持久化到磁盘, 需要两阶段提交来保证redo log和binlog的一致性
流程描述: 写入 Redo Log(Prepare 状态) -> 写入 Binlog -> 提交 Redo Log(Commit 状态)。
异常场景分析: 如果在写完 Binlog 后宕机,重启后由于发现 Redo Log 处于 Prepare 状态但 Binlog 已完整写入,MySQL 会判定该事务有效,从而保证主从一致。
什么是两阶段提交中的“组提交” (Group Commit)?
为了解决频繁刷盘(fsync)导致的性能下降,MySQL 引入了组提交:
原理:将多个并发事务的 Binlog 刷盘操作合并为一次磁盘 IO。
效果:极大提升了高并发下的 TPS。
redo log的组提交
当多个事务并发执行时,它们的 Redo Log 都会先写到 redo log buffer 中。
当 Leader 事务准备刷 Binlog 之前,MySQL 会顺便把 redo log buffer 中已经准备好的记录一并刷入磁盘。
这样,后续的所有事务在 commit 时,发现自己的 Redo Log 物理记录其实早就在上一次 fsync 时被“顺风车”带入磁盘了。
如何提高组提交的效果 /缓解IO压力
- 提高组提交效果
binlog_group_commit_sync_delay: 设置延迟多少微秒才调用 fsync。故意等一等,为了能收集到更多事务一起刷盘。
binlog_group_commit_sync_no_delay_count: 如果等待的事务数达到了这个值,不等延迟时间结束,直接刷盘。
- 缓解IO压力
提高组提交效果
sync_binlog设置大于1的值,积累N个事务才提交
innodb_flush_log_at_trx_commit设为2,每次事务提交写入page size,每秒再写入磁盘
Redis
Redis作为单线程为什么快
常说的“单线程”,是指 Redis 的网络 IO 和键值对读写指令是由一个主线程完成的。但在后台,Redis 还有专门处理关闭文件、AOF 刷盘、释放大块内存等任务的辅助线程。
绝大部分请求是纯内存操作 :内存读写比磁盘快了数万倍,
IO 多路复用 (Epoll) : 使用 Epoll(在 Linux 下)多路复用技术。通过内核监听所有连接的事件,当某个连接有数据传过来时,内核会通知 Redis 去处理。
避免了多线程的竞争开销:单线程反而让逻辑变得简单,没有死锁,没有竞争
数据类型
Redis的数据类型和底层实现
| 业务数据类型 | 旧版本(Redis 6.0 之前) | 新版本(Redis 7.0 及以后) | 核心变化原因 |
|---|---|---|---|
| String | SDS (Simple Dynamic String) | SDS (Simple Dynamic String) | 无本质变化 |
| List | Ziplist (压缩列表) + 双向链表 | Quicklist (内部节点由 Ziplist 换成了 Listpack) | 彻底消除 Ziplist 的级联更新风险。 |
| Hash | Ziplist 或 Hashtable | Listpack 或 Hashtable | 同样是为了解决级联更新,并提升内存利用率。 |
| Set | Intset (整数集合) 或 Hashtable | Intset 或 Hashtable | 基本保持一致。 |
| ZSet | Ziplist 或 Skiplist (跳表) | Listpack 或 Skiplist | 兼顾跳表的范围查询能力与 Listpack 的内存紧凑性。 |
| Stream | Rax (前缀树) + Ziplist | Rax (前缀树) + Listpack | 提升消息队列数据的存储稳定性。 |
String为什么不用C语言字符串
O(1) 获取长度:C 需要遍历,SDS 记录了 len。
二进制安全:SDS 以长度判断结束,而非 \0,可以存图片或序列化对象。
杜绝缓冲区溢出:修改前会检查空间,不足则自动扩容。
在 Redis 7.0 中,ziplist 被全面废弃,替换为 listpack(紧凑列表)
为什么要替换?
ziplist存在级联更新(Cascading Updates)问题。由于ziplist记录了前一个节点的长度,如果一个节点长度变化,可能导致后续所有节点都要重新分配空间,性能抖动剧烈。
Listpack 的改进:
- 每个节点不再记录前一个节点的长度,而是记录当前节点的长度。
- 节点之间互不影响,彻底消除了级联更新。
如果不通过平衡操作,跳表怎么保证不会退化成普通链表?
Redis 跳表采用随机概率函数。
机制:当插入新节点时,程序会生成一个随机数。
- 有 \(1/4\) 的概率将该节点提升到 Level 2。
- 如果有 Level 2,则再有 \(1/4\) 的概率提升到 Level 3,以此类推。
Redis 限制:跳表最高层数限制为 64 层。这种随机化机制在统计学上能保证索引结构的平衡性。
为什么 Zset 的实现用跳表而不用平衡树(如 AVL树、红黑树等)?
从内存占用上来比较,跳表比平衡树更灵活一些
在做范围查找的时候,跳表比平衡树操作要简单
从算法实现难度上来比较,跳表比平衡树要简单得多
为什么 Redis 的 Hash 在数据量小时用 ZipList,大了转成哈希表?
内存与性能的权衡:ZipList 是连续内存,没有指针开销,极端节省空间;但查询是 O(N)。
阈值切换:当元素数量超过 512 个或单个元素大于 64 字节(默认),为了保证 O(1) 的查询速度,必须转为 哈希表。
什么是“渐进式 Rehash”?
场景: 当 Hash 表需要扩容时,如果一次性迁移百万个 Key 会导致 Redis 卡死。
过程:
- 同时维持两个哈希表
ht[0]和ht[1]。 - 每次对该 Hash 执行增删改查时,顺带迁移一个索引桶的数据。
- 此外还有定时任务辅助迁移。
注意: 迁移期间,新增数据只写 ht[1],查询会先查 ht[0] 再查 ht[1]。
持久化
RDB 执行 bgsave 时,Redis 还能处理写请求吗?
能 ,底层原理利用操作系统的 fork() 和 Copy-On-Write (写时复制) 技术。
fork时,子进程和主进程共享相同的物理内存页。- 如果主进程要修改某个内存页,操作系统会为该页创建一个副本,主进程修改副本,而子进程继续读取原始页数据并写入 RDB。
- 追问陷阱:如果写请求非常多,内存占用会翻倍吗?
- 是的,极端情况下(所有页都被修改),内存占用会接近原先的 2 倍。
AOF 重写(Rewrite)期间,新进入的写指令会丢失吗?
**不会 ** ,Redis 设置了一个 AOF 重写缓冲区。
- 子进程开始重写内存快照
- 主进程接收到的新写命令会同时写入“原 AOF 缓冲区”和“重写缓冲区”(避免重写期间宕机,因此需要写入原AOF缓冲区)
- 子进程重写完成后,主进程将“重写缓冲区”内容追加到新 AOF 文件,最后原子替换旧文件
如果 RDB 文件和 AOF 文件同时存在,Redis 重启时加载哪一个?
答案:优先加载 AOF
原因:AOF 保存的数据通常比 RDB 更完整。
如何优化 AOF 的性能?
策略选择:使用 everysec 平衡性能与安全。
重写触发:合理设置 auto-aof-rewrite-percentage(如 100%)和 auto-aof-rewrite-min-size(如 64MB),避免频繁重写。
Redis持久化三大方案
RDB (Redis Database) 快照
在指定的时间间隔内,将内存中的全量数据以二进制压缩文件的形式(dump.rdb)写入磁盘。
- 触发机制:
save 900 1(900秒内有1次修改则触发)或手动执行bgsave。 - 核心原理:Copy-On-Write (COW)。主进程
fork出一个子进程,子进程负责将快照写入磁盘,主进程继续处理命令。 - 优点:文件紧凑,恢复速度极快,适合全量备份。
- 缺点:实时性差,宕机会丢失最后一次快照后的所有数据;
fork大内存进程时可能导致瞬间卡顿。
AOF (Append Only File) 日志
将每一次写命令追加到日志文件(appendonly.aof)中。
- 刷盘策略 (appendfsync):
always:每次写都刷盘(安全但极慢)。everysec:默认/推荐。每秒刷盘一次(性能与安全的折中)。no:交给操作系统决定(不可控)。
- AOF 重写 (Rewrite):当文件过大时,根据内存现状重新生成一份最精简的命令集。
- 优点:数据安全性高,最多丢失 1 秒数据。
- 缺点:文件比 RDB 大得多,恢复速度慢。
混合持久化 (Redis 4.0+)
- 原理:AOF 重写时,将当前内存数据以 RDB 格式写入开头,后续的写命令以 AOF 格式追加在末尾。
- 意义:结合了 RDB 的快速恢复和 AOF 的数据完整性。这是目前主流的配置方案。
功能
Redis 实现的是标准 LRU 吗?
不是。标准的 LRU 需要维护一个双向链表,内存开销大且移动频繁。Redis 使用的是近似 LRU:
- 在
RedisObject中记录一个lru时间戳。 - 随机采样(默认 5 个 Key),从中挑选最久没被访问的那个淘汰。
- 新版本变化:Redis 3.0 以后引入了“淘汰候选池”,让采样后的淘汰效果更接近真实 LRU。
为什么有了 LRU 还需要 LFU?
LRU 只看最后一次访问时间。如果一个 Key 长期不被访问访问,但在刚好要淘汰前被点了一下,LRU 就不会删它。而 LFU (Least Frequently Used) 会记录访问频次,能更准确地识别哪些是真正的“热点数据”。
如果线上的 Redis 内存突然暴涨,你该如何排查?
检查过期策略:是否有大量设置了过期时间的 Key 但因为不被访问且定期删除没抽到,导致堆积
大 Key 排查:使用 redis-cli --bigkeys 寻找占用内存巨大的 Key
淘汰策略配置:检查 maxmemory-policy 是否设置成了 noeviction,导致内存只增不减
主从模式下,从库是如何处理过期 Key 的?
从库不主动删除过期 Key。
- 为了保证主从一致性,当主库删除一个过期 Key 后,会显式地向从库发送一条
DEL命令。 - 在 Redis 3.2 之前,从库读取过期 Key 可能读到数据,3.2 之后,从库在读取前会检查过期时间,如果已过期则返回空,但物理删除仍等主库指令。
Redis 的内存淘汰和过期策略
过期策略:
- 惰性删除:访问 Key 时才判断是否过期,过期则删除。
- 定期删除:每隔一段时间随机检查一部分 Key 是否过期。
内存淘汰策略:当内存超出 maxmemory 时触发,常用 allkeys-lru(淘汰所有 Key 中最近最少使用的)或 volatile-lru(仅淘汰设置了过期时间的 Key)
| 策略类别 | 具体策略 | 描述 |
|---|---|---|
| 不淘汰 | noeviction |
(默认) 不删除数据,直接报错,仅允许读操作。 |
| LRU (最近最少使用) | allkeys-lru / volatile-lru |
淘汰最久没被访问的数据。 |
| LFU (最不经常使用) | allkeys-lfu / volatile-lfu |
淘汰访问频率最低的数据。 |
| 随机淘汰 | allkeys-random / volatile-random |
随机挑选 Key 丢弃。 |
| 过期时间优先 | volatile-ttl |
挑选离过期时间最近(TTL 最小)的 Key 淘汰。 |
Redis 分布式锁如何保证原子性?
多参数命令:使用 SET ... NX PX ... 将加锁和设置过期合并
Lua 脚本:Redis 执行 Lua 脚本是原子的,适合处理“判断-删除”或“判断-增加次数”的多步逻辑
集群模式下,主从切换会导致锁丢失吗?(重点!)
主库加锁成功,还没同步到从库,主库挂了,从库升级为主库,新主库没有这把锁
- 方案 A:Redlock(红锁)。向 5 个独立的 Redis 节点申请锁,超过半数(3个)成功才算成功。
- 现实反思:Redlock 实现复杂且开销大
- 方案 B:如果业务要求绝对一致性(如金融),建议改用 Zookeeper(强一致性 CP 模型)。如果追求高性能,Redis 偶尔的丢失通过业务幂等性来兜底。
如何实现锁的公平性?
Redisson 提供了 getFairLock。它底层通过 Redis 的 List 结构和 ZSet 结构维护了一个等待队列,严格按照申请顺序分配锁。
如果秒杀场景下,Redis 分布式锁导致性能太慢怎么办?”
分段锁思想(类似 LongAdder)。
- 将一个大库存(如 1000 个)拆分成 10 个小库存(每段 100 个)。
stock_lock_1,stock_lock_2...- 不同用户请求路由到不同的分段锁上,将竞争压力分散。
为什么需要看门狗?不设过期时间行不行?
如果不设过期时间:如果业务机器在加锁后突然宕机(或进程被 kill -9),这把锁将永远残留在 Redis 中,产生死锁。
如果设了固定过期时间:由于业务执行时间是不确定的(可能涉及大文件处理、慢查询、GC 抖动),如果过期时间设短了,业务没跑完锁就释放了,会导致并发安全失效。
看门狗的工作原理是什么?/如何续期
默认配置:Redisson 默认的锁有效期(Lock Watchdog Timeout)是 30 秒。
核心步骤:
- 触发条件:客户端加锁成功后(且未指定
leaseTime释放时间),会开启一个后台定时任务。 - 续期时机:每隔 10 秒(即
timeout / 3)检查一次。 - 续期动作:如果业务线程还持有锁,看门狗会向 Redis 发送一段 Lua 脚本,将锁的过期时间重新重置为 30 秒。
- 循环往复:只要业务没执行完,这个过程就会一直持续。
- 销毁:当业务执行完毕显式释放锁,或者客户端进程宕机,定时任务停止,锁会在 30 秒内自然过期。
为什么我加了锁,看门狗却没生效/如果执行 lock(10, TimeUnit.SECONDS),看门狗会起作用吗?”
不会生效
原理:在 Redisson 中,如果手动指定了 leaseTime(释放时间),Redisson 会认为你已经明确知道业务最长跑多久,它就不会再启动看门狗。只有在不传参数或传 -1 时,才会启动自动续期。
业务机器如果宕机了,看门狗会导致锁死吗?
不会
原理:看门狗是在客户端(应用端)开启的后台线程。如果应用机器宕机了,看门狗线程也会随之消失,不再发送续期指令,Redis 中的锁在达到当前的 TTL(剩余 30 秒以内)后会自动过期删除
如果业务代码进入死循环了,看门狗会一直续期吗?
会。
风险:如果业务逻辑写得有问题(比如 while(true)),看门狗会认为任务还在跑,从而无限期续期下去,导致这把锁永远无法被其他线程获取
优化建议:在实际开发中,依然建议通过监控系统关注长事务,或者在非常特殊的场景下设置一个“强制最大持有时间”
看门狗是为每一个锁都开一个线程吗?
不是。
原理:Redisson 内部使用了一个 HashedWheelTimer(时间轮算法)来管理所有的续期任务,多个锁可以共用一套调度逻辑,不会因为加锁多而导致线程数爆炸
高可用
Redis的部署方案
单机、主从、哨兵、集群
说说主从复制的核心流程
主从复制主要分为三个阶段:建立连接、全量同步、增量同步。
阶段一:建立连接
- 从库执行
replicaof <master-ip> <master-port>。 - 从库向主库发送
psync命令(包含runID和offset)。 - 主库校验后,决定是进行 全量 还是 增量 同步。
阶段二:全量同步(RDB 传输)
当从库第一次连接主库,或者从库断开时间太长导致缓冲区溢出时触发。
- 生成快照:主库执行
bgsave生成 RDB 文件。 - 发送 RDB:主库将 RDB 发送给从库,从库清空旧数据并加载。
- 发送增量命令:在生成和发送 RDB 期间,主库会将新收到的写命令缓存到 复制缓冲区
replication buffer中,并在 RDB 加载完后发给从库。
阶段三:增量同步(基于命令传播)
正常运行期间,主库每执行一个写命令,就会同步发送给从库,保证数据一致。
如果主从连接由于网络波动断开了 10 秒,重连后必须重新传一遍 RDB 吗?
不需要。Redis 2.8 以后引入了 psync 机制,利用以下三个组件实现增量回传:
- Replication ID (runID):主库的唯一标识。如果重连后 runID 没变,说明还是同一个主库
- 复制偏移量 (offset):主库和从库各自维护一个偏移量,代表同步到了哪里
- 复制积压缓冲区 (repl_backlog_buffer):主库维护的一个环形缓冲区
- 逻辑:断开重连后,主库对比 offset,如果丢失的数据还在这个环形缓冲区内,就只发增量数据;如果被覆盖了,则只能全量同步
主从同步时,主库/从库是阻塞的吗?
主库不阻塞,主库通过 bgsave 派生子进程生成 RDB,且在传输 RDB 的同时依然能处理写请求。
从库阻塞,从库在加载 RDB 时,为了保证数据一致性,会清空旧数据并阻塞读写请求
主从复制会导致数据丢失吗?
会。
异步复制丢失:主库刚写完还没发给从库就挂了,此时哨兵切主,这笔数据丢失。
脑裂问题:旧主库没挂但失联了,继续接收写请求,等网络恢复后降级为从库,清空自己,导致失联期间的数据全部丢失。
解决方案:配置 min-replicas-to-write 1,要求至少有 1 个从库确认收到才允许写。
为什么从库也会读到过期数据?
异步删除:Redis 的过期删除是在主库进行的,主库删了才发 DEL 给从库。由于网络延迟,从库可能稍晚才删
版本差异:在 Redis 3.2 之前,从库不会主动判断过期。3.2 之后,从库虽然不主动删,但会进行逻辑判断,发现过期就返回 null
如果多个从库同时重启,会集体向主库请求 RDB,可能把主库带宽占满,如何避免
采用级联架构(主 \(\rightarrow\) 从 \(\rightarrow\) 从),减轻主库压力
把环形缓冲区repl_backlog_buffer 调大(如 256MB 或 1GB),防止网络抖动导致的频繁全量同步
哨兵的工作原理(三步走)
第一步:判断主库下线
- 主观下线 (SDOWN): 单个哨兵发现主库心跳超时,认为它挂了。
- 客观下线 (ODOWN): 哨兵向其他哨兵询问,如果超过 Quorum(法定人数) 的哨兵都认为主库挂了,则正式判定为客观下线。
第二步:选举哨兵 Leader
哨兵们会选出一个“领头人”来执行具体的切换任务。
- 规则: 先到先得。每个发现主库下线的哨兵都会向别人发投票请求,获得半数以上票数的哨兵成为 Leader。
第三步:选出新主库
哨兵 Leader 会在从库中按以下顺序筛选:
- 优先级: 选
replica-priority配置最高(值越小优先级越高)的。 - 复制偏移量: 选
offset最大的(说明数据最完整)。 - RunID: 如果以上都一样,选 RunID 最小的。
哨兵模式下,为什么建议至少部署 3 个节点?
防止误判: 单个节点容易受网络抖动影响误判
选举需要: 哨兵 Leader 的选举需要“半数以上”通过。2 个节点时,如果挂了 1 个,剩下的 1 个无法达到 2 的半数(即 2 票),导致无法选举,系统失效。
什么是“脑裂”?哨兵如何解决它?
由于网络分区,主库与哨兵失联,但与部分客户端还能连。哨兵选了新主库,此时系统中有两个“主”。当网络恢复,旧主库降级为从库并清空数据进行同步,导致失联期间客户端写入旧主的数据全部丢失。
解决方案: 配置 Redis 的以下两个参数:
min-slaves-to-write x:至少有 x个从库在连接min-slaves-max-lag t:从库同步延迟不超过t 秒- 逻辑: 如果不满足这些条件,主库会拒绝写入。这样即便发生脑裂,旧主库因为收不到从库确认也会停止写服务,保护了数据一致性
客户端是如何感知到主库切换的?
每个哨兵节点提供发布者/订阅者机制,客户端订阅哨兵的 +switch-master 频道,一旦发生切换,哨兵会发布新主库地址,客户端收到通知后,自动刷新本地的连接池
为什么集群下哈希槽的数量是 16384 (16k)?
心跳包大小问题:集群节点间会频繁交换状态信息。16384 个槽位的位图占 2KB,而如果是 65536 个(\(2^{16}\))则占 8KB。在大型集群中,这会严重浪费网络带宽。
集群规模限制:官方建议集群节点不要超过 1000 个。对于 1000 个节点,16k 个槽位足够让每个节点分到约 160 个槽位,扩展性绰绰有余。
什么是“哈希标签 (Hash Tag)”?
Redis 集群不支持跨节点的批量操作(如 MSET)。如果两个 Key 分到了不同节点,操作会失败
通过 Hash Tag 可以强行让不同的 Key 落到同一个槽位
做法:在 Key 中使用花括号 {}。例如 {user1001}:name 和 {user1001}:order。Redis 只会对 {} 里的内容进行哈希计算。这样只要 {} 内的内容相同,它们就一定会落在同一个槽位上
集群模式下,客户端请求是如何重定向的?
MOVED 重定向:当节点发现 Key 不归自己管,返回 MOVED slot ip:port。客户端通常会自动缓存这个映射表,下次直接找对的人。
ASK 重定向:发生在扩容/缩容(数据迁移)期间。表示数据正在迁移中,请临时去新节点查一下,但不更新本地缓存。
集群在线扩容时,业务会中断吗?
不会中断
原理:使用 redis-cli 的 reshard 命令。它会将某些槽位从旧节点迁移到新节点
过程:迁移是按 Key 为单位进行的。迁移过程中,原节点依然处理请求,如果找不到 Key,则引导客户端去新节点找(即上述的 ASK 重定向)
缓存
缓存穿透 (Cache Penetration)
现象:查询一个根本不存在的数据(如 ID = -1)。缓存查不到,请求直接打到数据库,数据库也查不到。如果有恶意攻击,数据库会瞬间崩溃
解决方案:
- 缓存空对象:查询结果为空也存入 Redis(设短过期时间)简单但浪费内存
- 布隆过滤器 (Bloom Filter): 在查询 Redis 前先过一层布隆过滤器,它能判定“数据一定不存在”或“可能存在”。如果判定不存在,直接拒绝请求
- Bloomfilter+缓存空对象:通过布隆过滤器先拦截一部分请求,对于不存在但误判的情况则缓存空对象,避免只采用方案1造成过多空间浪费
缓存击穿 (Cache Breakdown)
现象:一个热点 Key(如秒杀商品)突然过期。此时海量并发请求瞬时击穿缓存,全部涌向数据库。
解决方案:
- 逻辑过期:给热点数据设置一个逻辑上的过期字段,物理不过期。后台线程异步刷新
- 互斥锁 (Mutex Lock):只允许一个请求去查库并重建缓存,其他请求等待(使用
setnx)
缓存雪崩 (Cache Avalanche)
现象:大量 Key 同时过期,或者 Redis 宕机。导致数据库压力激增,甚至引发连锁宕机。
解决方案:
- 过期时间随机化:在基础 TTL 上增加 1-5 分钟的随机值,防止集体失效。
- 高可用集群:部署 Redis Cluster 或 Sentinel 避免单点故障。
- 多级缓存:增加本地缓存(Guava/Caffeine)作为备选。
缓存双写一致性 (Consistency) /先更新数据库,还是先删除缓存?
先更新库,再删缓存:(目前业界主流方案,Cache Aside Pattern)。虽有极小概率出现不一致,但由于“写库”慢于“回写缓存”,概率极低。
进阶优化(针对极致一致性):
-
延迟双删:更新库后,先删一次缓存,睡几百毫秒再删一次。确保能清理掉“更新期间”产生的脏缓存。
-
监听 Binlog 异步删除:使用 Canal 等中间件订阅 MySQL Binlog,当库变更时,异步通知 Redis 删除缓存。这种方式解耦最好,可靠性最高。
JUC
JMM 的核心结构
JMM 规定了所有的变量都存储在 主内存(Main Memory) 中,每条线程还有自己的 工作内存(Working Memory)
工作内存:存储了该线程使用到的变量的主内存副本
规则:线程不能直接读写主内存,必须先在工作内存中完成,再同步回主内存
JMM 的三大特性
可见性 (Visibility)
- 定义:当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。
- 保证方式:
volatile、synchronized、final。
原子性 (Atomicity)
- 定义:一个或多个操作,要么全部执行且不被干扰,要么都不执行。
- 保证方式:
synchronized、JUC 的各种Lock、原子类(CAS)。 - 注意:
volatile不能保证原子性(如i++)。
有序性 (Ordering)
- 定义:在本线程内观察,所有操作都是有序的;在一个线程观察另一个线程,所有操作都是无序的(因为指令重排)。
- 保证方式:
volatile(禁止重排)、synchronized。
线程创建的四种方式
继承 Thread 类
实现 Runnable 接口
实现 Callable 接口 + FutureTask(JUC 核心)
使用线程池(Executor 框架)(JUC 核心/生产实践)
| 维度 | Runnable | Callable | Thread | 线程池 (Pool) |
|---|---|---|---|---|
| 返回值 | 无 | 有 (Future获取) | 无 | 提交任务时,submit 能提交 Callable 有返回值而 execute 只能提交 Runnable |
| 异常处理 | 只能内部处理 | 可向上抛出异常 | 只能内部处理 | submit能通过 Future 捕获任务执行中的异常,execute 异常会直接抛出 |
| 耦合度 | 较低(接口实现) | 较低(接口实现) | 高(单继承限制) | 最低(任务与执行分离) |
| 资源利用 | 频繁创建/销毁 | 频繁创建/销毁 | 频繁创建/销毁 | 资源复用,性能最优 |
CompletableFuture和Future 的区别
| 特性 | Future (Java 5) | CompletableFuture (Java 8) |
|---|---|---|
| 获取结果 | 主动阻塞。必须调用 get() 阻塞等待,或者轮询。 |
被动回调。支持 thenAccept 等回调函数,结果出来自动执行。 |
| 链式调用 | 不支持。无法把多个任务串联起来(A完了执行B)。 | 支持。提供 thenApply, thenCompose 等流式 API。 |
| 组合能力 | 不支持。很难实现“两个任务都完成再执行第三个”。 | 强大。支持 allOf, anyOf 等多任务组合操作。 |
| 异常处理 | 很麻烦。只能在 get() 时通过 try-catch 捕获。 |
优雅。提供 exceptionally, handle 等专门处理异常的方法。 |
| 手动完成 | 不支持。一旦开始只能等它结束。 | 支持。可以手动调用 complete(value) 强制结束并返回值。 |
直接调用 run() 方法能启动线程吗/同一个线程对象可以执行两次 start() 吗
start():启动一个新线程,进入就绪(Runnable)状态,由 JVM 调用底层的 start0() 本地方法。
run():只是一个普通的方法调用。如果你直接调 run(),它会在当前线程(通常是 main 线程)里执行,起不到并发效果。
线程状态由 threadStatus 变量维护,一旦不为 0(NEW 状态),再次调用start()会抛出 IllegalThreadStateException
为什么生产环境禁止使用 Executors.newFixedThreadPool?
这些便捷方法底层用的是 LinkedBlockingQueue,它的默认容量是 Integer.MAX_VALUE。
如果任务堆积过多,会导致内存溢出(OOM)。
正确做法: 必须通过 new ThreadPoolExecutor(...) 手动指定核心线程、队列长度和拒绝策略。
FutureTask 是如何拿到返回值的?
它内部维护了一个状态位(NEW, COMPLETING, NORMAL 等)。
当 call() 没执行完时,调用 get() 的线程会被放入一个等待队列并挂起。
执行完后,结果存入 outcome 变量,并唤醒等待队列里的线程。
你有几种方式可以让线程执行完后返回结果?
一是实现 Callable 接口配合 FutureTask;
二是在线程池中使用 submit() 方法提交任务,返回一个 Future 对象。
从新建到销毁,线程有哪些状态?
NEW (新建):还没调 start()。
RUNNABLE (就绪/运行):Java 把操作系统中的就绪和运行统称为 RUNNABLE。
BLOCKED (阻塞):等待进入 synchronized 临界区。
WAITING (等待):调了 wait() 或 join(),需要别人唤醒。
TIMED_WAITING (限时等待):调了 sleep(1000) 或 wait(1000)。
TERMINATED (终止):运行结束。

sleep 与 wait 的区别
| 维度 | Thread.sleep() | Object.wait() |
|---|---|---|
| 所属类 | 属于 Thread 静态方法 |
属于 Object 实例方法 |
| 释放锁 | 不释放锁 | 释放锁,进入等待池 |
| 使用限制 | 任何地方都能用 | 必须在 synchronized 代码块内 |
| 唤醒条件 | 时间到自动苏醒 | 需 notify 唤醒或时间到 |
虚假唤醒/为什么 wait 必须放在 while 循环里,而不是 if 里
错误写法: if (condition) { wait(); }
正确写法: while (condition) { wait(); }
核心原因: 线程被唤醒(通过 notify 或意外打断)后,会从 wait() 处继续执行。如果是 if,它直接向下运行,此时条件可能已经又被其他线程改掉了;如果是 while,它会再次检查条件,不满足则继续等待。
如何优雅地停止一个线程?
使用 中断机制(Interrupt)
正常循环逻辑:通过 Thread.currentThread().isInterrupted() 检查中断标志位,如果为 true 则跳出循环,清理资源。
阻塞逻辑(sleep/wait):如果线程在 sleep 时被打断,它无法检查标志位,会抛出 InterruptedException,此时中断标志位会被清除,必须在 catch 块中再次手动调用 interrupt() 恢复标志位。
| 方法 | 适用场景 | 注意事项 |
|---|---|---|
| 循环检测标志位 | 简单无阻塞的逻辑 | 确保标志位使用 volatile 或通过锁保证可见性 |
| 中断机制 | 可中断的阻塞操作 | 正确处理 InterruptedException 并恢复中断标志 |
| Future.cancel() | 线程池管理任务 | 需要线程池任务支持中断处理机制 |
| 资源关闭 | 不可中断的阻塞操作(如Sockets) | 显式关闭资源触发异常,结合中断状态判断回滚 |
为什么 sleep 是 Thread 的静态方法,而 wait 是 Object 的方法?
因为 Java 的锁是对象级别的,每个对象头里都有一个监视器(Monitor)。wait 是为了释放这个锁,所以它必须由对象来操作;而 sleep 只是让当前线程暂缓执行,不涉及任何对象锁的操作
可以用 Lock/Condition 替代 wait/notify 吗?
可以。Condition 的 await/signal 机制更强大,它可以创建多个等待集(例如一个队列可以有两个 Condition:notFull 和 notEmpty),从而实现定向唤醒,效率比 notifyAll 高得多
如何实现 3 个线程循环打印 A、B、C?
可以通过 ReentrantLock 配合三个 Condition(aCondition, bCondition, cCondition)来定向唤醒下一个线程
ThreadLocal 会导致内存泄漏吗?
会。如果线程不结束(如在线程池中),且没有手动调用 remove(),其内部的 Entry(由于 Key 是弱引用但 Value 是强引用)会导致 Value 无法被回收
线程间通信方式
- Object 监视器模式(wait/notify)
wait() / notify() / notifyAll():线程 A 调用 wait() 进入等待池(WaitSet)并释放锁;线程 B 执行完后调用 notifyAll() 唤醒等待池中的线程,让它们去竞争锁。
join():主线程调用 threadA.join(),主线程会阻塞,直到 threadA 执行完毕。底层其实就是调用了 wait()。
- Lock 与 Condition
Condition (await/signal):通过 lock.newCondition() 创建。它允许一个锁对应多个“等待条件”。
可以定向唤醒。比如在生产消费者模式中,我可以只唤醒“消费者”而不唤醒“生产者”,效率更高。
- volatile
当一个变量被声明为 volatile 时,它会保证对该变量的写操作会立即刷新到主内存中 ,而读操作会从主内存中读取最新的值
- JUC 同步工具类
CountDownLatch:一个或多个线程等待其他 N 个线程(倒计时)。
CyclicBarrier:多个线程互相等待
Semaphore:信号量,控制同时访问的线程数。
有哪些常见的锁
synchronized关键字- JUC 显式锁(
Lock接口)
ReentrantLock
可中断:lockInterruptibly() 允许在等待锁时响应中断。
可以设置超时:tryLock(time) 拿不到锁就返回,不一直死等。
公平与非公平:默认是非公平锁(性能好),也可以通过构造函数设为公平锁(按排队顺序)。
多条件变量:配合 Condition 可以实现定向唤醒。
ReadWriteLock
读读共享,写写互斥
| 锁的概念 | 解释 |
|---|---|
| 乐观锁 vs 悲观锁 | 乐观锁假定没竞争(用 CAS 实现);悲观锁假定一定有竞争(用 synchronized/Lock)。 |
| 自旋锁 (SpinLock) | 线程不挂起,循环尝试获取锁。减少线程上下文切换,但消耗 CPU。 |
| 公平锁 vs 非公平锁 | 公平锁先来后到;非公平锁允许“插队”(谁抢到是谁的,吞吐量更高)。 |
| 可重入锁 | 同一个线程可以多次获得同一把锁。 |
| 死锁 (Deadlock) | 两个线程互相持有对方需要的资源,都在等对方释放。 |
非公平锁吞吐量为什么比公平锁大
非公平锁吞吐量大,是因为它利用了线程被唤醒的阻塞缝隙。它允许活跃线程直接获取锁,避免了频繁的 CPU 上下文切换。可能会导致排队线程‘饥饿’
synchronized 工作原理
使用synchronized之后,会在编译之后在同步的代码块前后加上monitorenter和monitorexit字节码指令,他依赖操作系统底层互斥锁实现。他的作用主要就是实现原子性操作和解决共享变量的内存可见性问题。
执行monitorenter指令时会尝试获取对象锁,如果对象没有被锁定或者已经获得了锁,锁的计数器+1。此时其他竞争锁的线程则会进入等待队列中。执行monitorexit指令时则会把计数器-1,当计数器值为0时,则锁释放,处于等待队列中的线程再继续竞争锁。
synchronized 锁升级过程
无锁:初始状态
偏向锁:认为锁只有一个人用。在对象头(Mark Word)里记录线程 ID,只需对比 ID,无需 CAS
轻量级锁:多个线程发生竞争,线程通过 CAS 自旋 尝试获取锁,在用户态完成,不涉及线程切换,不挂起 CPU
重量级锁:竞争激烈,自旋失败,升级为重量级锁。线程被挂起,由用户态转换到内核态 ,进入操作系统调度
ReentrantLock 底层原理
ReentrantLock 是基于 Java 编写的,其核心是 AQS (AbstractQueuedSynchronizer)。
- 核心组件
state 变量:一个 volatile 修饰的整数,代表锁的状态(0 空闲,>0 已加锁)。
CAS 操作:利用 Unsafe 类的原子指令,尝试修改 state 的值。
CLH 队列:一个双向链表,当线程抢锁失败时,会被封装成 Node 放入队列末尾并挂起
- 加锁逻辑
公平锁:加锁前先看队列里有没有人在排队,有就排在后面。
非公平锁(默认):不管队列,先尝试 CAS 抢一下。抢不到再进队列。
- 相比 synchronized 多出的功能
等待可中断:lockInterruptibly() 可以响应中断,不至于死等。
公平性选择:可以设置公平锁或非公平锁。
超时机制:tryLock(timeout)。
多条件变量:Condition 对象可以实现定向唤醒,而 synchronized 只能通过 notifyAll 唤醒所有。
JVM对Synchornized的优化
-
锁升级
-
锁消除 (Lock Elimination)
如果在局部方法里用 StringBuffer,JVM 会加锁吗?
JIT 编译器通过 逃逸分析 (Escape Analysis) 发现某个对象只会被当前线程访问(不会逃逸到外部),那么即便代码里写了 synchronized,编译器也会直接把锁删掉。
- 锁粗化 (Lock Coarsening)
循环里一直调 synchronized 方法会发生什么?
如果 JVM 发现一系列连续的操作都在对同一个对象反复加锁解锁(比如在循环体里),它会把锁的范围扩大到整个操作序列外部,只加一次锁,减少频繁加解锁的开销。
- 自旋锁与自适应自旋 (Adaptive Spinning)
轻量级锁阶段,线程不会立即阻塞,而是先“原地转圈”等一会儿。
JVM 会根据该锁上一次自旋是否成功来决定这次自旋多久。如果上次很快拿到了锁,这次就多转几圈;如果上次总失败,这次可能直接阻塞。
AQS 的核心架构
AQS 充分利用了 volatile 的可见性和 CAS 的原子性。通过 自旋 + 阻塞 的结合,在保证线程安全的前提下,最大程度减少了内核态切换的开销。采用模版方法模式,让子类只需实现 tryAcquire 等少量方法就能定制复杂的同步逻辑
State(同步状态):一个 volatile int 变量。
0 代表空闲,1 代表被占用(如果是可重入锁,数值代表重入次数)。
CAS(原子抢锁):线程通过 CAS 指令尝试修改 state 的值。成功即获取锁。
CHL 队列(双向链表):抢锁失败的线程会被封装成 Node 节点,放入这个双向队列中阻塞等待。
- 常用工具类
| 工具类 | AQS 状态 (State) 的含义 | 模式 |
|---|---|---|
| ReentrantLock | 锁被重入的次数 | 独占 |
| Semaphore | 剩余可用的许可数量 | 共享 |
| CountDownLatch | 剩余需要等待的线程数 | 共享 |
| ReadWriteLock | 高 16 位为读锁计数,低 16 位为写锁计数 | 独占 + 共享 |
ThreadLocal 为什么会发生内存泄漏?
Entry 的 Key 是弱引用,但 Value 是强引用。如果 ThreadLocal 对象没有外部强引用了,Key 会被 GC 回收,导致 Entry 中出现 Key 为 null 但 Value 还在的情况
后果:如果线程不结束(比如在线程池中复用),这些 null Key 的 Value 就会一直占用内存,无法被回收
解决方案:每次使用完 ThreadLocal,务必调用 remove() 方法手动清理
ThreadLocal底层原理/ThreadLocal 的副本存在哪
ThreadLocalMap:每个线程(Thread 对象)内部都有一个名为 threadLocals 的成员变量,其类型是 ThreadLocal.ThreadLocalMap。
Key-Value 结构:ThreadLocalMap 内部维护了一个 Entry 数组。
- Key:是
ThreadLocal对象的弱引用(WeakReference<ThreadLocal<?>>)。 - Value:是线程持有的具体变量副本(强引用)。
线程池参数
public ThreadPoolExecutor(int corePoolSize, // 1. 核心线程数int maximumPoolSize, // 2. 最大线程数long keepAliveTime, // 3. 非核心线程生存时间TimeUnit unit, // 4. 时间单位BlockingQueue<Runnable> workQueue, // 5. 阻塞队列ThreadFactory threadFactory, // 6. 线程工厂(给线程起名、设置优先级等)RejectedExecutionHandler handler // 7. 拒绝策略(队列满且线程数达到最大时)
)
线程池的执行流程是怎样的?
任务进来,先看核心线程数满没满。没满则创建线程执行。
核心线程满了,看阻塞队列满没满。没满则放入队列等待。
队列也满了,看最大线程数满没满。没满则创建非核心线程执行。
最大线程数也满了,触发拒绝策略。
拒绝策略有哪些?
AbortPolicy(默认):直接抛异常
CallerRunsPolicy:谁提交的任务谁执行(主线程自己去跑,减缓提交速度)
DiscardPolicy:直接丢弃
DiscardOldestPolicy:丢弃队列里最老的,尝试重新提交
线程池状态
RUNNING:接收新任务,处理队列任务。
SHUTDOWN:不接新任务,但处理队列任务。
STOP:不接新任务,不理队列任务,中断正在执行的任务。
TIDYING:所有任务已终止,线程数为 0。
TERMINATED:terminated() 方法执行完毕。
线程池种类有哪些
- FixedThreadPool (固定线程数量线程池)
特点:核心线程数等于最大线程数,没有救急线程。
风险:使用无界队列 LinkedBlockingQueue,任务堆积过多会导致 OOM (内存溢出)。
- CachedThreadPool (可缓存线程池)
特点:核心线程数为 0,最大线程数为 Integer.MAX_VALUE。每来一个任务,如果没有空闲线程就创建一个。线程空闲 60 秒后自动回收。
场景:适用于任务执行时间短、且频繁发生的异步小任务。
风险:线程数无上限,瞬时高并发下会 创建大量线程导致系统卡死。
- SingleThreadExecutor (单线程化线程池)
特点:池子里只有一个线程,保证所有任务按照 FIFO(先进先出)的顺序执行。
场景:适用于需要保证任务执行顺序,且不需要并发的场景。
- ScheduledThreadPool (定时任务线程池)
特点:可以执行周期性任务或延迟任务。
场景:替代传统的 Timer 类,用于业务中的定时刷新、心跳检查等。
JVM
JVM 内存区域作用与溢出
| 内存区域 | 线程共享 | 作用描述 | 是否溢出/发生溢出 | 异常类型 |
|---|---|---|---|---|
| 堆 (Heap) | 是 | 对象存储中心。存放几乎所有的对象实例、数组,以及字符串常量池。 | 是,内存泄漏、超大对象分配、死循环创建对象。 | OOM: Java heap space |
| 元空间 (Metaspace) | 是 | 存放类元数据、运行时常量池、字段与方法数据。 | 是,动态代理生成类过多(CGLib/代理)、JSP 加载过多。 | OOM: Metaspace |
| 虚拟机栈 | 否 | 方法执行模型。每调用一个方法创建一个栈帧,存储局部变量、操作数栈等。 | 是,无限递归、局部变量表过大、创建线程过多。 | StackOverflowError 或 OOM |
| 本地方法栈 | 否 | Native 服务。为 JVM 使用到的本地 (C/C++) 方法服务。 | 是,本地方法调用深度过深。 | StackOverflowError 或 OOM |
| 程序计数器 | 否 | 行号指示器。记录当前线程执行到的字节码位置。 | 否,占用极小,逻辑上不涉及溢出。 | 无 |
| 直接内存 | 是 | 堆外数据交换。NIO 使用,减少数据在内核空间与用户空间的拷贝。 | 是,Netty 或 NIO 申请了大量堆外内存且未释放。 | OOM: Direct buffer memory |
JVM中堆和栈的区别是什么
管理方式:栈是自动分配和释放的;堆是通过申请并由 GC 释放的
空间大小:栈通常较小(默认 1M 左右);堆非常大
内容:栈存局部变量、方法调用;堆存对象实例
可见性:栈是线程私有的;堆是线程共享的。
局部变量都在栈上吗?
不一定。
如果局部变量是基本类型,它的值存在栈中
如果局部变量是引用类型(Object),栈中存的是该对象的地址引用,而真正的对象实例存在堆中
一个对象在内存里长什么样
对象头 (Header):包含 Mark Word(锁状态、哈希码、GC 分代年龄)和类型指针
实例数据 (Instance Data):对象真正存储的有效信息
对齐填充 (Padding):为了保证对象大小是 8 字节的整数倍(CPU 读取优化)
对象在内存中是怎么创建的?
比如 User user = new User();
类加载检查:检查 User 类是否加载(方法区)。
分配内存:在堆中划分一块空间(大小由类决定)。
初始化零值:保证成员变量有默认值。
设置对象头:记录哈希码、GC 分代年龄、锁状态等。
执行构造方法 (<init>):按代码逻辑初始化属性。
堆的物理组成(分代架构)
- 新生代 (Young Generation)
占据堆的约 1/3 空间。内部又细分为:
Eden 区:绝大多数对象刚创建时存放的地方。
Survivor 0 (S0/From) 区:上一次 GC 后幸存的对象存放处。
Survivor 1 (S1/To) 区:备用幸存区。
比例:默认比例是 Eden:S0:S1 = 8:1:1。
- 老年代 (Old Generation)
占据堆的约 2/3 空间。存放生命周期长的对象,或者新生代放不下的超大对象
递归调用太深为什么会报 StackOverflow 而不是方法区溢出?
因为递归不断产生新的栈帧(在栈里),而方法区的指令代码始终只有那一份。所以消耗的是栈空间,而不是方法区空间。
四种引用类型
| 引用类型 | 回收时机 | 生存时间 | 典型场景 |
|---|---|---|---|
| 强引用 (Strong) | 永远不会被自动回收 | 只要引用在,对象就在 | 普通变量赋值(最常用) |
| 软引用 (Soft) | 内存不足时回收 | 内存充足能存活,内存不足被清除 | 缓存(如图片缓存) |
| 弱引用 (Weak) | 下一次 GC 时必定回收 | 只能存活到下一次垃圾回收前 | ThreadLocal的key |
| 虚引用 (Phantom) | 随时可能被回收 | 无法通过它获取对象实例 | 管理直接内存(如堆外内存释放) |
内存溢出(OOM)和内存泄漏(Memory Leak)
- 内存溢出 (Out Of Memory, OOM)
定义:程序申请内存时,JVM 没有足够的内存空间供其使用。
原因:
创建了大量对象,且这些对象是强引用(无法回收),或者堆内存设置过小。
递归过深或死循环调用。
- 内存泄漏 (Memory Leak)
定义:程序中已分配的内存由于某种原因未释放,导致这部分内存无法被 GC 回收,最终可能导致 OOM。
原因:
静态集合类:如 static HashMap,长生命周期的集合持有短生命周期对象的引用。
未关闭的资源:数据库连接、网络连接(Socket)、IO 流等。
ThreadLocal 误用:线程池环境下,用完不 remove(),导致 Value 随线程一直存在。
如何排查生产环境的 OOM?
获取 Dump 文件:
参数设置:-XX:+HeapDumpOnOutOfMemoryError(崩溃时自动导出)。
手动导出:jmap -dump:format=b,file=heap.hprof <pid>。
使用分析工具:使用 MAT (Memory Analyzer Tool) 或 JProfiler 打开 Dump 文件。
定位嫌疑对象:
查看 Histogram,看哪个类产生的实例最多。
查看 Dominator Tree,看哪个对象占用的空间最大。
查找引用链:通过 GCRoots 溯源,看为什么这个对象没有被回收,从而定位到源代码。
判断垃圾的方法有哪些
引用计数法:每个对象有一个计数器,被引用 \(+1\),失效 \(-1\)。当计数器为0时,表示对象不再被任何变量引用,可以被回收 。缺点:无法解决循环引用问题(A 引用 B,B 引用 A,两者都没人用了但计数不为 0)。
可达性分析(主流):从一组被称为 GC Roots 的根对象开始向下搜索,搜索过的路径称为引用链。如果一个对象到 GC Roots 没有任何引用链相连,则证明此对象不可用。
哪些可以作为 GC Roots?
虚拟机栈(栈帧中的局部变量表)中引用的对象。
方法区中类静态属性和常量引用的对象。
本地方法栈中 JNI(Native 方法)引用的对象。
垃圾收集(回收)算法
| 算法 | 原理 | 优点 | 缺点 | 应用场景 |
|---|---|---|---|---|
| 标记-清除 | 标记垃圾,直接原地回收 | 速度快 | 效率一般,内存碎片多 | 老年代 |
| 标记-复制 | 内存分两块,把存活的拷到另一块 | 无碎片,效率高 | 浪费一半内存 | 新生代 (Eden/Survivor) |
| 标记-整理 | 标记存活对象,全部向一端移动 | 无碎片,利用率高 | 移动对象耗时大 | 老年代 |
垃圾回收器 CMS 和 G1的区别
| 特性 | CMS | G1 |
|---|---|---|
| 垃圾回收算法 | 标记-清除 (有碎片) | 复制算法 (Region 间) + 标记-整理 (无碎片) |
| 可预测性 | 无法预测停顿时间, 以最小的停顿时间为目标 | 可预测停顿 (设置 -XX:MaxGCPauseMillis) |
| 适用场景 | 4GB - 6GB 左右的堆 | 6GB 以上的大内存 |
| 回收目标 | 只回收老年代 | 整个堆(年轻代 + 老年代部分区域) |
| 回收过程 | 初始标记(STW)、并发标记、重新标记(STW)、并发清除 | 初始标记(STW)、并发标记、最终标记(STW)、筛选回收(STW) |
什么情况下使用CMS,什么情况使用G1?
- CMS适用场景:
低延迟需求:适用于对停顿时间要求敏感的应用程序。
老生代收集:主要针对老年代的垃圾回收。
碎片化管理:容易出现内存碎片,可能需要定期进行Full GC来压缩内存空间。
- G1适用场景:
大堆内存:适用于需要管理大内存堆的场景,能够有效处理数GB以上的堆内存。
对内存碎片敏感:G1通过紧凑整理来减少内存碎片,降低了碎片化对性能的影响。
比较平衡的性能:G1在提供较低停顿时间的同时,也保持了相对较高的吞吐量。
什么是 Stop The World (STW)?
GC 过程中,为了保证引用关系的准确性,必须暂停所有的用户线程。这种全局停顿就叫 STW。优化 GC 的核心目标就是减少 STW 的时长和频率。
minorGC、majorGC、fullGC及触发时机
| GC 类型 | 回收范围 (Scope) | 算法重点 | 停顿时间 (STW) |
|---|---|---|---|
| Minor GC (Young GC) | 仅 新生代 (Eden + S0 + S1) | 标记-复制算法 | 极短 (毫秒级) |
| Major GC (Old GC) | 仅 老年代 | 标记-清除 / 标记-整理 | 较长 (是 Minor 的 10 倍以上) |
| Full GC | 整个堆 (新生代+老年代) + 方法区 (元空间) | 深度清理 | 最长,对系统影响最大 |
- Minor GC 的触发时机
Eden 区满:这是最直接、最唯一的触发点。
注意:Survivor 区满不会触发 Minor GC,而是由 Eden 满带动 Survivor 进行清理。
- Major GC 的触发时机
老年代空间不足:当老年代无法容纳新晋升的对象或大对象,或者老年代空间不足时
- Full GC 的触发时机 (高频考点)
老年代空间不足:晋升的对象大小超过了老年代剩余空间。
方法区 (元空间) 空间不足:加载了太多类,导致元空间达到阈值。
显式调用 System.gc():代码中手动触发(虽然不保证立即执行,但通常会引发 Full GC)。
- 频繁GC的处理方法
| 场景 | 推荐操作 |
|---|---|
| Minor GC 频繁 | 适当调大新生代内存 (-Xmn)。 |
| Full GC 频繁 | 检查内存泄漏,或调大老年代/元空间。 |
| Major GC 耗时太长 | 考虑更换收集器,比如G1 |
GC只会对堆进行GC吗?
堆 (Heap):GC 的核心区,回收对象实例。
方法区 (Method Area):GC 的边缘区,回收废弃常量和无用的类。
栈与计数器:无 GC 区,随线程自动销毁。
JavaSE
概念
Java 是“值传递”还是“引用传递”?
Java 只有值传递
传递基本类型:传递的是值的副本
传递对象:传递的是引用的地址副本。在方法内修改对象属性会影响原对象,但如果给引用重新赋值(obj = new Object()),原对象不会改变
数据类型
基本数据类型
| 分类 | 类型 | 字节 (Byte) | 位数 (Bit) | 默认值 |
|---|---|---|---|---|
| 整数 | byte | 1 | 8 | 0 |
| short | 2 | 16 | 0 | |
| int (默认) | 4 | 32 | 0 | |
| long | 8 | 64 | 0L | |
| 浮点数 | float | 4 | 32 | 0.0f |
| double (默认) | 8 | 64 | 0.0d | |
| 字符 | char | 2 | 16 | '\u0000' |
| 布尔 | boolean | 1* (见后文) | - | false |
浮点数的默认类型为double,如果声明float型要在末尾加上f或F
整数的默认类型为int,声明Long型在末尾加上l或者L
八种基本数据类型的包装类:除了char的是Character、int类型的是Integer,其他都是首字母大写
char类型是无符号的,不能为负,所以是0开始的
金融项目中如何表示金额?
使用 BigDecimal
因为有些小数使用double会丢失精度
什么是自动装箱与自动拆箱?
装箱是将基本类型变为包装类(int -> Integer),底层调用 valueOf();拆箱是相反过程,底层调用 intValue()。
什么时候用基本类型,什么时候用包装类?
POJO 类(如数据库实体类)成员变量、RPC 方法参数建议使用包装类(因为 null 可以表示无数据)
局部变量建议使用基本类型(效率高,省内存)。
Integer 的缓存池了解吗?
Integer 内部维护了一个缓存池(默认 -128 到 127)。创建这个范围内的整数对象时,不会每次都生成新的对象实列,而是复用缓存中现有对象,通过 valueOf() 调用直接返回缓存对象。
short s1 = 1; s1 = s1 + 1; 有错吗?s1 += 1; 呢?
s1 = s1 + 1 会报错,因为 1 是 int 型,运算结果会自动提升为 int,不能直接赋给 short。
而 s1 += 1 是正确的,因为 += 运算符隐式包含了强制类型转换。
面向对象
面向对象四大特性
封装、继承、多态、抽象
重载(Overload)和重写(Override)的区别?
重载:同名方法,参数列表不同。发生在编译期(静态绑定)。
重写:子类覆盖父类方法。发生在运行期(动态绑定)。
坑点:构造器可以重载,但不能重写。
面向对象设计原则 (SOLID) 了解吗?
单一职责 (SRP):一个类只做一件事。
开闭原则 (OCP):对扩展开放,对修改关闭(核心)。
里氏替换 (LSP):子类可以透明替换父类。
接口隔离 (ISP):不应强迫客户端依赖它不用的接口。
依赖倒置 (DIP):高层模块不应该依赖低层模块。
this() 和 super() 能同时出现在一个构造函数里吗?
不能。Java 规定 this() 或 super() 必须放在构造函数的第一行。如果你写了两个,必然有一个不在第一行。此外,构造器的目的是初始化,如果同时允许,会导致父类部分被重复初始化或初始化顺序混乱。
什么时候用抽象类,什么时候用接口?
用抽象类:如果你有一组对象,它们具有很强的同源性,且需要共享代码。例如:Bird(鸟)作为抽象类,具体的 Eagle 和 Penguin 继承它。
用接口:如果你想定义一种通用的行为规范,且这些对象可能完全不是同类。例如:Flyable(会飞的)作为接口,Bird(鸟)可以实现它,Airplane(飞机)也可以实现它。
既然抽象类不能实例化,为什么它还有构造方法?
抽象类的构造方法是给子类准备的。当子类实例化时,必须先初始化父类(调用 super())。抽象类通常存放子类共有的属性,这些属性需要在构造器中初始化。
普通类、抽象类和接口的区别
| 特性 | 普通类 (Class) | 抽象类 (Abstract Class) | 接口 (Interface) |
|---|---|---|---|
| 关键字 | class |
abstract class |
interface |
| 实例化 | 可以直接 new |
不可以 new |
不可以 new |
| 成员变量 | 无限制 | 无限制 | 只能是静态常量 (public static final) |
| 方法实现 | 必须全部实现 | 可以有抽象方法,也可以有普通方法 | JDK 8+ 前只能有抽象方法,8 以后支持 default/static |
| 构造方法 | 有 | 有(给子类初始化用的) | 没有 |
| 继承/实现 | 单继承 | 单继承 | 多实现 |
| 设计逻辑 | 对现实实体的描述 | 模板设计(is-a) | 行为契约/功能扩展(has-a / like-a) |
接口里面可以定义哪些方法?
- 抽象方法 (Abstract Methods)
默认修饰符为 public abstract,只有声明,没有方法体。定义行为契约,强制子类实现。
- 默认方法 (Default Methods) —— Java 8 引入
使用 default 关键字修饰,有方法体。
解决接口升级问题。在不破坏已有实现类(不强迫它们重写新方法)的前提下,向接口添加新功能。
如果一个类实现了两个接口,它们有相同的默认方法怎么办?(答:子类必须重写该方法来解决冲突)。
- 静态方法 (Static Methods) —— Java 8 引入
使用 static 关键字修饰,有方法体。
只能通过接口名直接调用(InterfaceName.staticMethod()),不能通过实现类对象调用。
作为工具方法,与接口逻辑紧密绑定。
- 私有方法 (Private Methods) —— Java 9 引入
使用 private 关键字修饰,可以有方法体。
主要用于抽取默认方法中的重复逻辑。私有方法不能被子类访问或重写,仅供接口内部使用,保证了代码的复用性和封装性。
非静态内部类和静态内部类的区别?
| 特性 | 非静态内部类 (Inner Class) | 静态内部类 (Static Nested Class) |
|---|---|---|
| 关键字 | 无 static |
有 static |
| 对外部类的依赖 | 强依赖:必须先有外部类对象,才能创建内部类对象。 | 弱依赖:不依赖外部类实例,可独立创建。 |
| 持有引用 | 隐式持有外部类对象的引用 (Outer.this)。 |
不持有外部类对象的引用。 |
| 访问外部成员 | 可直接访问外部类所有成员(包括私有、静态)。 | 只能访问外部类的静态成员。 |
| 成员定义 | 不能定义静态变量或静态方法。 | 可以定义各种静态或非静态成员。 |
| 实例化方式 | outer.new Inner() |
new Outer.StaticInner() |
非静态内部类依赖外部类的实列,静态内部类类似于独立的类,不依赖外部类实列
为什么非静态内部类容易导致内存泄漏?
因为非静态内部类隐式持有外部类对象的强引用。
典型场景:如果在外部类中定义了一个非静态内部类(如 Handler 或 TimerTask),并将这个内部类对象传给了一个长生命周期的线程或单例。即使外部类不再使用,但由于内部类还被持有,且内部类又拽着外部类的引用,导致外部类无法被 GC 回收,从而发生内存泄漏。
解决方案:将内部类改为静态内部类,如果需要访问外部类成员,通过 WeakReference(弱引用)传入。
静态内部类和普通外部类有什么区别?
本质上没太大区别。静态内部类只是恰好写在另一个类的花括号里。
优势:它能更好地封装代码(只给特定的外部类用),并且它可以访问外部类的 private static 成员,而普通的外部类做不到这一点。
静态变量和实例变量的区别?
内存位置:静态变量属于类,存放在方法区(元空间);实例变量属于对象,存放在堆中。
生命周期:静态变量随类加载而生,随类卸载而死;实例变量随对象销毁而死。
为什么静态方法不能调用非静态成员?
静态方法在类加载时就存在了,此时可能还没有任何实例对象。没有对象,就无法访问属于对象的非静态成员。
关键字
final 修饰引用类型时,内容能变吗?
地址不能变,但内容能变。例如 final List list = new ArrayList();,你不能给 list 重新赋值,但可以执行 list.add("item")
拷贝
浅拷贝和深拷贝
浅拷贝 (Shallow Copy)
- 基本类型:复制的是具体的值。
- 引用类型:复制的是内存地址。这意味着新旧对象指向的是同一个内部对象。如果其中一个修改了内部对象,另一个也会受影响。
深拷贝 (Deep Copy)
- 原理:创建一个新对象,对于原对象中的引用类型,会递归地创建全新的实例。
- 结果:新旧对象完全独立,互不干扰。修改其中任何一个都不会影响另一个。
如何实现拷贝?
实现 Cloneable 接口(默认是浅拷贝)
这是 Java 原生提供的机制,通过重写 Object 类的 clone() 方法实现。
- 注意:
Object.clone()默认执行的是浅拷贝。如果需要深拷贝,你必须在clone()方法里手动调用内部引用成员的clone()。
构造函数 / 工厂方法(手动深拷贝)
通过 new 一个新对象,并将原对象的字段逐一赋值。如果字段是引用类型,则继续 new。
序列化机制(最省心的深拷贝)
将对象序列化为字节流,再反序列化回来。这种方式会自动处理所有嵌套的引用关系,实现真正的深拷贝。
- 可以使用 Java 原生的
Serializable,或者 JSON 工具类(如 Jackson, Fastjson)。
泛型
什么是类型擦除?
Java 泛型只存在于编译阶段。代码编译成字节码后,所有的泛型信息都会被擦除。
List<String>和List<Integer>在运行时的 Class 对象其实是同一个。- 擦除后,泛型参数会被替换成它们的原始类型(如果没有指定边界,就是
Object;如果有extends,就是边界类型)。
对象
创建对象的方式
使用 new 关键字:最标准的方式。
使用 Class 类的 newInstance()(或 Constructor 类的 newInstance()):反射。
使用 clone():通过深/浅拷贝复制已有对象,不调用构造函数。
使用反序列化:从文件或网络中恢复对象,不调用构造函数。
反射
反射的四个核心类
Class:提供类名、包、构造器、方法、属性等信息。
Field:类的成员变量。
Method:类的方法。
Constructor:类的构造器。
反射有哪些优缺点?
优点:
- 灵活性高:可以在运行时动态加载类、创建对象,不需要在编译时确定。
- 解耦:是框架(Spring AOP/IOC)实现自动注入、动态代理的基础。
缺点:
- 性能开销:反射涉及动态类型解析,JVM 无法进行某些编译优化。
- 安全问题:可以无视访问修饰符(通过
setAccessible(true)),破坏封装性。
获取 Class 对象的三种方式?
Object.getClass():对象已经在手。
类名.class:最安全、性能最高(编译时确定)。
Class.forName("全限定类名"):最常用,动态从字符串加载。
为什么反射慢?
检查开销:反射需要频繁进行安全检查(访问权限)。
方法解析:需要通过方法名在类的方法表中反复查找。
参数处理:涉及变长参数的数组包装、基本类型的装箱拆箱。
反射出现的场景
JDBC 加载驱动:Class.forName("com.mysql.cj.jdbc.Driver")。
Spring IOC:通过配置文件或注解获取类名,反射创建 Bean 实例并注入依赖。
动态代理:JDK 动态代理利用反射在运行时生成代理类。
注解处理:很多框架通过反射获取类或方法上的注解,从而执行特定逻辑(如 @Test)。
注解
注解的原理是什么?它到底是怎么生效的?
注解本质上是一个特殊的接口(继承自 java.lang.annotation.Annotation)。
生效机制:
- 编译期处理:编译器或插件(如 Lombok)扫描注解并生成代码。
- 运行期处理:通过 反射 机制读取
RUNTIME类型的注解。当程序运行时,JVM 为注解生成一个代理对象,通过这个对象读取注解中定义的参数。
注解的分类
标准注解(内置在 JDK 中)
@Override:检查方法重写。@Deprecated:标记过时元素。@SuppressWarnings:压制编译警告。
元注解(用于修饰注解的注解)
-
@Target:注解可以用在哪里(类、方法、字段等)。 -
@Retention:注解的生命周期,以下三个可选值- SOURCE:只留在源码中,编译成
.class文件后就没了(如@Override)。 - CLASS:留在字节码文件中,但 JVM 加载类时会忽略。这是默认行为。
- RUNTIME:不仅留在字节码中,JVM 运行期间也可以读取。只有这类注解才能通过“反射”获取。
- SOURCE:只留在源码中,编译成
-
@Documented:是否生成在 JavaDoc 中。 -
@Inherited:子类是否可以继承父类的这个注解。
如何自定义一个注解?
使用 @interface 关键字。
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface MyLog {String value() default "No Log"; // 定义参数
}
异常
Java 异常体系结构
ava 中所有的异常都继承自 java.lang.Throwable 类。
- Error(错误):JVM 无法处理的严重问题(如
StackOverflowError、OutOfMemoryError)。程序不该尝试捕获这些错误,通常会导致 JVM 直接崩溃。 - Exception(异常):程序本身可以处理的问题。它又分为两大类:
- Checked Exception(非运行时异常/编译时异常):编译器强制要求处理的(如
IOException、SQLException)。如果不try-catch或throws,代码编译不通过。 - RuntimeException(运行时异常):编译器不强制要求处理(如
NullPointerException、ArrayIndexOutOfBoundsException)。这类异常通常是逻辑错误,应该通过代码优化来避免。
- Checked Exception(非运行时异常/编译时异常):编译器强制要求处理的(如
异常处理的方法
try-catch-finally
try块:用于包裹可能出现异常的代码。catch块:用于捕获并处理特定类型的异常。可以有多个catch块,但子类异常必须放在父类异常前面。finally块:无论是否发生异常,代码都会执行(除非JVM直接结束,比如System.exit(0))。通常用于释放资源(如关闭数据库连接)。
throws
当前方法中不想处理异常,可以使用 throws 将异常向上抛出,交给调用者处理。
- 非运行异常 (Checked Exception):必须显式地
throws声明,否则编译不通过。 - 运行时异常 (Runtime Exception):可以不声明,但抛出后如果没人接,线程会终止。
throw
在程序运行过程中,根据业务逻辑手动抛出一个异常对象。
- 场景:比如校验入参不合法时:
if (age < 0) throw new IllegalArgumentException("年龄不能为负数");
Object
== 和 equals() 的区别?
==:
- 基本类型:比较值。
- 引用类型:比较内存地址。
equals():
- 默认比较地址。
- 被
String、Integer等重写后比较的是内容。
String、StringBuffer、StringBuilder的区别和联系
| 特性 | String | StringBuffer | StringBuilder |
|---|---|---|---|
| 可变性 | 不可变(Immutable) | 可变(Mutable) | 可变(Mutable) |
| 线程安全 | 线程安全(因为不可变) | 线程安全(方法加了 synchronized) |
线程不安全 |
| 性能 | 低(频繁修改会产生大量临时对象) | 中(有同步锁开销) | 高(无锁,速度最快) |
| 引入版本 | JDK 1.0 | JDK 1.0 | JDK 1.5 |
在 Java 中,String 的底层是一个被 final 修饰的字节数组(Java 9 以后是 byte[],之前是 char[])。StringBuffer 与 StringBuilder 则没有 final 修饰
序列化
怎么把一个对象从一个jvm转移到另一个jvm?
Java 原生序列化(最基础)
使用中间件(Redis / MQ)
RPC 框架(Dubbo / gRPC / Feign)
使用共享数据库或缓存
如果父类没实现序列化,子类实现了,会发生什么?
子类可以正常序列化。
但父类的属性会丢失,除非父类有一个无参构造函数。反序列化时,JVM 会调用父类的无参构造器来初始化父类成员。
设计模型
单例模式 (Singleton Pattern)
确保一个类只有一个实例,并提供全局访问点。
- 实现方式:
- 饿汉式:类加载时就创建。优点是线程安全,缺点是可能造成资源浪费。
- 懒汉式(双重检查锁 DCL):需要时才创建。
为什么要用 volatile 修饰 DCL 中的实例变量?
- 防止指令重排。
new对象分为:分配内存、初始化、指向地址三步。没有volatile,线程可能拿到一个还未初始化的“半成品”对象。
如何防范反射或序列化破坏单例?
- 使用 枚举 (Enum) 实现单例。JVM 保证了枚举不能被反射创建,且序列化机制也不同。
工厂模式 (Factory Pattern)
定义一个创建对象的接口,让子类决定实例化哪一个类。
- JDK 中的应用:
Calendar.getInstance()NumberFormat.getInstance()
Q:简单工厂、工厂方法和抽象工厂的区别?
- 简单工厂通过一个类创建所有产品,违法开闭原则
- 工厂方法让子类创建特定产品,一个产品一个工厂,遵循开闭原则
- 抽象工厂一个工厂创建相关的一族产品,增加新的产品族符合开闭原则,在某一族新增产品违背开闭原则
适配器模式(AdapterPattern)
使接口不兼容的对象互相合作
惰性加载模式 (Lazy Loading Pattern)
不提前做不需要做的工作。将对象的初始化、资源的分配或数据的获取推迟到绝对必要的那一刻。
对象适配器、接口适配器和类适配器区别
- 对象适配器一般实现目标接口,并在内部包含一个被适配者实列,适配器在内部将用户请求委托给被适配者
- 接口适配器使用抽象的适配器类继承接口,空写接口的方法,客户端继承抽象适配器并重写关心的方法
- 类适配器一般通过多重继承实现
桥接模式 (Bridge Pattern)
将抽象和实现分离,使两者独立变化
假设某个对象有两种属性,每个属性分别有m和n种,如果用继承需要m*n个子类
用桥接则是m+n个类
建造者模式 (Builder Pattern)
通过内部静态类builder,能够分步骤创建复杂对象
责任链模式 (Chain of Responsibility Pattern)
将请求沿着一条由多个处理者组成的链进行发送。收到请求后,每个处理者均可对请求进行处理,或者将其传递给链上的下一个处理者。
装饰器模式 (Decorator Pattern)
在不修改原有类代码,且不使用继承重写的情况下,给一个对象扩展额外功能
策略模式 (Strategy Pattern)
定义一系列算法,把它们一个个封装起来,并且使它们可以相互替换。
- JDK 中的应用:
Arrays.sort()时传入的Comparator接口。
Q:策略模式有什么好处?
- 消除了代码中大量的
if-else或switch语句,符合“开闭原则”。
IO
Java三种I/O
BIO (Blocking I/O) - 同步阻塞
- 特点:一线程一连接。如果没数据,线程就一直死等。
- 场景:连接数较少且固定的架构。
- 设计模式:大量使用了 装饰器模式(如
BufferedInputStream包装FileInputStream)。
NIO (Non-blocking I/O) - 同步非阻塞 (重点)
- 特点:基于 Channel(通道)、Buffer(缓冲区) 和 Selector(选择器)。
- 核心:一个线程可以管理多个通道(多路复用)。
- 场景:高并发、连接数多但连接时间短的场景(如聊天服务器、Netty)。
AIO (Asynchronous I/O) - 异步非阻塞
- 特点:操作系统完成后通知线程。
- 场景:连接数多且连接时间长的架构。由于 Linux 对 AIO 支持不够成熟,目前主流仍使用 NIO。
Java集合
集合体系结构
List(有序、可重复):ArrayList、LinkedList、Vector。
Set(无序、唯一):HashSet、LinkedHashSet、TreeSet。
Queue(队列):PriorityQueue、Deque。
Map(键值对):HashMap、LinkedHashMap、TreeMap、ConcurrentHashMap。
HashMap 的底层原理(Java 8+)?
结构:数组 + 链表 + 红黑树(jdk8之前没有)
过程:计算 Key 的 hashCode,经过扰动函数得到 Hash 值,再通过 (n-1) & hash 计算下标。
树化:当链表长度大于 8 且数组长度大于 64 时,链表转为红黑树,以优化查找效率(从 O(n)降到 O(log n)。
HashMap 的扩容机制?
触发条件:当前元素个数超过 容量 * 加载因子(默认 \(16*0.75 = 12\))。
过程:创建一个 2 倍容量的新数组。Java 8 优化了迁移逻辑:对象要么留在原位,要么移动到 原位置 + 旧容量 的位置,避免了 Java 7 中的死循环问题(因为 7 是头插法,8 是尾插法)。
为什么 HashMap 的容量必须是 2 的幂次方?:为了能用位运算 (n-1) & hash 代替取模运算 %,效率更高,且能使分布更均匀。
HashMap链表发生转换后为什么不用平衡二叉树?
红黑树是弱平衡的(或者说近似平衡)。它通过 5 条规则保证最长路径不超过最短路径的 2 倍。
优点:虽然查询速度略慢于 AVL 树(但也依然是 \(O(\log n)\)),但在插入和删除时,由于平衡要求没那么严苛,旋转次数显著减少。
哪些集合是线程安全的?
CopyOnWriteArrayList:写时复制,适合读多写少。
ConcurrentHashMap:Java 8 放弃了分段锁(Segment),改用 CAS + synchronized,锁的粒度细化到每个哈希桶的头节点。
List和数组转换
List转数组:list.toArray(new T[0])
数组转List:new ArrayList<>(Arrays.asList(array))
Arrays.asList() 转换基本类型数组问题
如果传入 int[],Arrays.asList 会把它识别为一个单一的对象,而不是多个元素。
结果:会得到一个 List<int[]>,其 size() 是 1。
对策:使用包装类型 Integer[] 或者使用 Java 8 的流:Arrays.stream(intArray).boxed().collect(Collectors.toList())。
如何对map进行快速遍历?
entrySet() + for-each
for (Map.Entry<String, String> entry : map.entrySet()) {System.out.println("Key: " + entry.getKey() + ", Value: " + entry.getValue());
}
forEach + Lambda
map.forEach((key, value) -> {System.out.println("Key: " + key + ", Value: " + value);
});
Stream API
Map<String, Integer> filteredMap = map.entrySet().stream() .filter(entry -> entry.getValue() > 1) .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
HashMap的put(key,val)和get(key)过程
put(K key, V value) 的执行过程
- 计算 Hash 值: 调用 Key 的
hashCode()方法,并经过 扰动函数(将高 16 位与低 16 位进行异或运算),目的是让 Hash 值分布更均匀,减少碰撞。 - 判断数组是否为空: 如果底层
table数组为空或长度为 0,先调用resize()方法进行初始化。 - 计算索引位置: 使用公式
(n - 1) & hash计算该 Key 应该存放在数组的哪个“桶”(bucket)中。 - 处理碰撞(三种情况):
- 无碰撞:该位置没有元素,直接创建新节点放入。
- 覆盖:该位置存在元素且 Key 完全相同(
==或equals),直接覆盖旧的 Value。 - 有碰撞:该位置已存在元素但 Key 不同:
- 红黑树节点:如果当前桶已经是
TreeNode,直接在树中插入。 - 链表节点:在链表尾部插入。插入后如果链表长度 ≥ 8 且 数组长度 ≥ 64,则将链表转为红黑树。
- 红黑树节点:如果当前桶已经是
- 记录修改次数:
modCount++(用于 fail-fast 机制)。 - 判断扩容: 如果当前元素个数(size)超过了阈值(
capacity * loadFactor),调用resize()进行扩容。
get(Object key) 的执行过程
- 计算 Hash 值:同样先通过扰动函数算出 Key 的 Hash 值。
- 定位索引:通过
(n - 1) & hash找到对应的数组下标。 - 检查首个节点: 如果桶位第一个节点的 Key 就匹配(
hash相等且equals返回 true),直接返回该 Value。 - 遍历查找: 如果第一个没匹配上,判断后续结构:
- 如果是 红黑树:调用
getTreeNode(hash, key)在 O(logn) 时间内找到。 - 如果是 链表:通过
next指针循环遍历,直到找到匹配的 Key 或遍历到末尾。
- 如果是 红黑树:调用
- 返回结果:找到则返回 Value,没找到则返回
null。
Spring
Spring框架
IOC的实现机制
反射 (Reflection)
Spring 并不直接 new 对象,而是读取配置(XML 或注解如 @Service),获取类的全限定名,然后通过 Java 反射机制 在运行时动态地创建实例。
Class.forName("com.xxx.UserService").newInstance();
工厂模式 (Factory Pattern)
Spring 提供了一个巨大的工厂BeanFactory(及其高级实现 ApplicationContext),来对所有的 Bean 进行统一生产、配置和管理。
容器 (Container)
Spring 维护着一套内部 Map(单例池)来存放这些创建好的对象。当你需要一个对象时,Spring 会从 Map 中找到并注入给你,而不是让你自己去创建。
AOP 的实现机制
AOP 的核心目标是在不修改源代码的情况下增强功能(如加日志、加事务)。它在底层通过动态代理技术来实现。
根据目标对象的不同,Spring 会自动选择以下两种代理方式之一:
JDK 动态代理(基于接口)
- 原理:利用
java.lang.reflect.Proxy类。它要求目标对象必须实现至少一个接口。 - 过程:JVM 在内存中动态生成一个代理类,这个代理类和目标类实现同样的接口。
- 优点:JDK 原生支持,效率高,代码轻量。
CGLIB 代理(基于继承)
- 原理:利用开源的字节码处理框架 ASM。如果目标对象没有实现接口,Spring 就会改用 CGLIB。
- 过程:它会在内存中构建一个目标类的子类,并重写父类的方法来实现增强。
- 限制:因为是基于继承,所以被
final修饰的类或方法无法被代理。
什么是依赖注入
依赖注入是实现 IoC 的最主流手段。IoC 是一个概念,而 DI 是让这个概念落地的动作。
动作过程:当容器(IoC Container)创建了一个对象后,它发现这个对象依赖于另一个对象,于是容器会自动把依赖的对象“注入”进去。
注入方式:
- 构造器注入(Spring 推荐)。
- Setter 注入。
- 字段注入(即在属性上加
@Autowired)。
AOP 的应用场景
- 声明式事务管理(最经典):通过
@Transactional注解,由 AOP 在方法开始前开启事务,结束后提交或回滚。 - 权限校验:在执行 Controller 方法前,切面统一拦截请求,校验 User Token 或权限标识。
- 统一日志记录:记录方法的入参、出参、执行时间及异常信息,用于监控和审计。
spring是如何解决循环依赖的?
Spring 默认只能解决 单例(Singleton) 模式下的 Setter 注入(或属性注入) 引起的循环依赖。构造器注入和多例时setter方法的循环依赖是无法解决的。
Spring 在 DefaultSingletonBeanRegistry 类中定义了三个 Map 类型的缓存来存放不同阶段的 Bean:
| 缓存层级 | 名称 | 存放内容 | 作用 |
|---|---|---|---|
| 第一级缓存 | singletonObjects |
完全初始化好的成品 Bean | 供应用程序直接使用。 |
| 第二级缓存 | earlySingletonObjects |
半成品的 Bean(尚未填充属性) | 用于检测并解决普通的循环依赖。 |
| 第三级缓存 | singletonFactories |
Bean 工厂(ObjectFactory) | 核心:用于处理存在 AOP 代理时的循环依赖。 |
如果A和B循环依赖,Spring通过以下操作解决循环依赖问题
实例化 A:Spring 开始创建 A,通过反射实例化 A(仅创建对象,未注入依赖、未初始化);将其对应的工厂对象 ObjectFactory存入 第三级缓存。
属性填充 A:发现 A 需要注入 B。
寻找 B:Spring 去容器找 B,发现没有,开始创建 B。
实例化 B:将 B 对应的 ObjectFactory 放入 第三级缓存。
属性填充 B:发现 B 需要注入 A。
寻找 A:Spring 依次查缓存:
- 一级、二级都没有。
- 在 第三级缓存 找到了 A 的工厂。
- 执行工厂方法,获取 A 的引用(如果是 AOP,此处返回的是代理对象),并把 A 存入 第二级缓存,同时删除第三级缓存。
完成 B:B 成功拿到 A 的引用,完成属性填充和初始化,进入 第一级缓存。
完成 A:返回到 A 的创建流程,A 拿到 B 的引用,完成初始化,进入 第一级缓存。
为什么需要第三级缓存,如果只有两级缓存行不行?
如果只有一级缓存:半成品和成品混在一起,多线程下可能拿到没初始化完的对象,直接崩掉。
如果只有二层缓存:可以解决普通的循环依赖。但是无法处理 AOP(代理对象)。
- 因为 AOP 代理通常是在初始化之后发生的。如果没有第三级缓存的工厂机制,B 注入的可能是 A 的原始对象,而最终 A 经过 AOP 变成了一个代理对象。这样 B 持有的 A 和容器里的 A 就不是同一个对象了!
- 第三级缓存的作用:就是为了判断这个 Bean 是否需要 AOP。如果需要,提前把代理对象造出来给对方用,保证全局唯一。
构造器循环依赖为什么不能解决?
因为构造器注入发生在“实例化”阶段。三级缓存的前提是对象已经“实例化”但还没“初始化”。构造器还没执行完,连“半成品”都造不出来,自然没法放进缓存。
Spring的事务什么情况下会失效?
内部方法调用
-
场景:在同一个类中,方法 A 调用方法 B,且方法 B 上标记了
@Transactional。 -
原因:Spring 事务是通过代理对象(Proxy)执行的。同一个类内部调用,相当于
this.B(),它是直接调用目标对象的方法,绕过了代理对象,切面逻辑(开启事务)也就无法生效。 -
解决:将方法 B 移到另一个 Service;或通过
AopContext.currentProxy()获取当前代理对象再调用。
方法修饰符不是 public
-
场景:在
private、protected或package-private方法上加@Transactional。 -
原因:Spring 的
TransactionInterceptor(事务拦截器)在设计时就规定:只对public方法进行事务增强。虽然 CGLIB 可以代理非 private 方法,但 Spring 事务检查器会直接忽略。 -
解决:将方法修改为
public。
Bean 没有被 Spring 容器管理
- 场景:在类上漏掉了
@Service、@Component等注解。
- 原因:Spring 无法扫描到这个类,自然无法为其创建 AOP 代理。
- 解决:确保类上已添加 Spring 核心注解,并能被扫描。
异常被内部“吞掉”
- 场景:在方法内使用了
try-catch块捕获了异常,且没有在catch块中重新抛出。
- 原因:Spring 事务的回滚触发条件是检测到有未捕获的异常(
RuntimeException和Error及其子类)抛出到切面。如果你自己处理了异常且不再向上抛出,Spring 会认为方法执行成功,从而正常提交。 - 解决:在
catch块中手动抛出RuntimeException,或者手动回滚:TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
SpringMVC
Spring MVC 的核心执行流程
DispatcherServlet(前端控制器):所有请求的第一站,负责统一调度。
HandlerMapping(处理器映射器):根据 URL 找到对应的 Controller(处理器)和具体方法。
HandlerAdapter(处理器适配器):由于 Controller 形式多样,适配器负责调用具体方法并处理参数绑定。
Controller(控制器):程序员编写业务逻辑的地方,执行完后返回一个 ModelAndView。
ViewResolver(视图解析器):将逻辑视图名解析为真正的物理视图,并进行渲染。
SpringBoot
Spring Boot 相比 Spring 的优势
约定优于配置 (Convention Over Configuration)
在传统 Spring 中,你需要写大量的 XML 或 Java 配置类来声明 Bean、配置数据源、开启事务等。
Spring Boot 提供了大量的自动化配置,并且预设了大量的默认值。
起步依赖 (Starters)
在传统 Spring 中,由于 Jar 包版本冲突(Jar Hell),需要手动编写 Maven 依赖并对齐版本。SpringBoot的starters本质上是一个 Maven 依赖组合。它没有代码逻辑,只是定义了该功能所需的所有依赖项。
Spring Boot提供了一系列 Starter。比如 spring-boot-starter-web,你只要引这一个,它内部会自动帮你关联好 Spring MVC、Tomcat、Jackson、Validation 等所有必要的包,且版本都是经过官方兼容性测试的
MyBatis
#{} 和 ${}的区别?
#{}:是 预编译处理(Prepared Statement)。它会将参数替换为 ?,并自动处理引号和转义。能有效防止 SQL 注入。
${}:是 字符串替换。直接将变量拼接到 SQL 中。由于存在 SQL 注入风险,通常只用于传入表名或 ORDER BY 的字段名。
消息队列
为什么要用消息队列?
解耦:生产者只负责发消息,不需要知道谁来消费。新增一个下游系统,不需要修改生产者的代码。
异步:不需要同步等待耗时的逻辑(如注册后发短信、发邮件),直接返回响应,提升用户体验。
削峰填谷:在秒杀等瞬间高并发场景下,MQ 像个蓄水池,先把请求存起来,下游系统根据自己的处理能力匀速消费,防止系统被冲垮。
如何处理重复消费?(幂等性问题)
原因:网络抖动导致 ACK 丢失,MQ 认为没消费成功,触发重新投递。
解法:业务端实现幂等。
- 使用数据库唯一索引。
- 使用 Redis 记录
msgId。 - 状态机判断(如订单状态:如果是“已支付”,就不再处理支付成功的消息)。
如何保证消息不丢失?(可靠性传输)
生产者阶段:开启确认机制(如 RabbitMQ 的 confirm 模式),确保消息到达 MQ。
MQ 存储阶段:开启持久化。如果是集群,使用镜像队列或副本机制,确保主节点宕机数据不丢。
消费者阶段:关闭自动 ACK,改为手动确认。只有业务逻辑处理完了,才告诉 MQ “我删掉了”。
如何保证消息的顺序性?
要保证顺序,必须保证 “一个 Queue 对应一个 Consumer”。
Kafka/RocketMQ:通过 sharding key 将相关联的消息(如同一订单的创建、支付)发往同一个 Partition/Queue。
消息积压了怎么办?
- 临时扩容:增加消费者(Consumer)的数量。
- 根本解决:检查消费者代码是否有死循环、查询数据库是否慢 SQL 导致处理变慢,是否可以批量处理消息。
分布式事务中,MQ 如何保证数据一致性?
使用 事务消息。流程是:先发半消息(Half Message),执行本地事务,如果成功则提交(Commit)消息,让消费者可见;如果失败则回滚
RocketMQ
如何解决消息重复消费
RocketMQ 并不在内部保证幂等(因为会极大地降低吞吐量)。
解法:必须在业务端实现。
- 使用数据库唯一索引。
- 使用 Redis setnx 记录消息 ID。
- 乐观锁版本号判断。
RocketMQ消息顺序怎么保证
生产者
- 实现机制:使用 消息队列选择器(MessageQueueSelector)。
- 操作方法:在发送消息时,传入一个
Sharding Key(如订单 ID)。通过哈希算法,将具有相同 Key 的消息定向发送到同一个物理队列中。
消费者
- 锁定队列:当 Consumer 注册为顺序监听时,它会向 Broker 申请锁定该队列。在一个消费者组内,一个队列同一时间只能被一个消费者线程持有。
- 串行处理:
MessageListenerOrderly内部会保证对同一个队列的消息进行加锁,确保线程池中的线程是一个接一个地处理消息,而不是并发处理。
kafka
为什么 Kafka 这么快?
顺序写磁盘:磁盘随机读写很慢,但顺序追加(Append-only)的速度堪比内存。
零拷贝 (Zero-Copy):利用 Linux 的 sendfile 系统调用,数据直接从内核页缓存发送到网卡,跳过了用户态的两次拷贝。
页缓存 (Page Cache):Kafka 尽量利用操作系统的内存来缓存数据,减少真实的磁盘 I/O。
批量操作 (Batching):消息不是一条条发的,而是积攒成一个批次(Batch)统一压缩和传输。
计算机网络
应用层
http和https区别
| 特性 | HTTP | HTTPS |
|---|---|---|
| 安全性 | 明文传输,无加密 | 密文传输,SSL/TLS 加密 |
| 身份证明 | 无 | 有(通过 CA 证书) |
| 默认端口 | 80 | 443 |
| 性能消耗 | 低(无需加解密) | 稍高(需要 CPU 进行计算) |
| 成本 | 免费 | 证书通常需要购买 |
| 建立连接 | TCP三次握手 | TCP三次握手+SSL/TLS握手 |
https建立连接
在建立连接阶段用非对称加密安全地交换一个随机密钥,一旦交换成功,后续通讯全部改用对称加密
第一阶段:TCP 三次握手(开路)
在进行任何 HTTPS 通信前,浏览器必须先与服务器的 443 端口建立连接。
- SYN:客户端发送连接请求。
- SYN-ACK:服务端确认请求并同意连接。
- ACK:客户端确认收到服务端的同意。 此时成功建立连接,但传输的内容还是透明的。
第二阶段:TLS 握手(核心加密流程)
这是 HTTPS 最关键的一步。以目前最广泛使用的 TLS 1.2 为例:
① 客户端发起请求 (Client Hello)
浏览器向服务器发送:支持的 TLS 版本、 密码套件列表(加密算法组合)、客户端随机数 (Random_C)
② 服务端响应 (Server Hello)
服务器返回:确认使用的 TLS 版本和加密算法、服务端随机数 (Random_S)、服务器数字证书(包含服务器公钥和权威机构签名)。
③ 客户端验证证书与交换密钥
- 证书校验:浏览器验证证书的合法性(是否过期、是否权威机构颁发、域名是否对得上)。
- 生成预主密钥 (Pre-Master Secret):浏览器在本地生成第三个随机数 (Random_3),并用证书里的服务器公钥对其加密。
- 发送加密信息:将加密后的随机数发送给服务器。
④ 服务端解密
服务器用自己的私钥解密,得到 随机数 (Random_3)。
⑤ 最终会话密钥生成
此时,双方手中都握有三个随机数:Random_C + Random_S + Random_3。 双方通过同样的算法,将这三个随机数计算成同一个会话密钥 (Session Key)。
为什么要三个随机数? 增加随机性,防止密钥被破解或重放攻击。
⑥ 握手结束
双方互发一段加密测试信息(Finished),确认加解密功能正常。后续所有的业务数据传输都使用刚才生成的会话密钥进行对称加密。
总结:客户端主动商议TLS版本和加密算法并生成第一个随机数;服务端确认TLS版本和加密算法,发送证书和第二个随机数;客户端确认证书合法并通过证书中公钥加密第三个随机数并发送;服务端获得第三个随机数,双方使用相同加密算法,将三个随机数计算成相同的会话密钥
http进行TCP连接中断的原因
正常的业务逻辑中断:由客户端或服务器根据 HTTP 协议约定主动发起的关闭
超时中断:http长时间没有请求和响应
异常中断:接收方没有收到TCP响应ACK,发送方重传达到最大限制
DNS解析流程
DNS默认端口是53,当你在浏览器输入 www.example.com 时,幕后发生了以下步骤:
- 浏览器缓存 & 系统缓存:先看浏览器自己记没记住,再看操作系统的
hosts文件。 - 本地域名服务器 (LDNS):如果本地没有,就去问你的 ISP(电信、联通)提供的服务器。
- 根域名服务器 (Root DNS):LDNS 问根服务器:“我知道
.com在哪吗?”。根服务器回复:“我不知道具体的,但我可以告诉你.com顶级域服务器的地址。” - 顶级域名服务器 (TLD DNS):LDNS 问
.com服务器:“example.com在哪?”。回复:“去问example.com的权威服务器。” - 权威域名服务器 (Authoritative DNS):LDNS 问权威服务器:“
www对应的 IP 是多少?”。回复:“是1.2.3.4”。 - 返回结果:LDNS 把结果给浏览器,并自己缓存一份。
hosts文件就是把域名和IP地址绑定在一起,常用于测试还没有正式上线的服务器
DNS底层使用UDP(无连接、不可靠但是快且开销小)
http/https如何保存会话状态
HTTP 和 HTTPS 都是无状态的
① Cookie(客户端保存)
这是最基础的机制。服务器在响应头中加入 Set-Cookie,浏览器会自动将其保存。下次请求同一域名时,浏览器会自动在请求头中带上这些 Cookie。
- 优点:简单,不占用服务器内存。
- 缺点:不安全(容易被截获或伪造),存储空间小(4KB限制)。
② Session(服务端保存)
这是传统的 Web 开发模式。
- 用户登录成功后,服务器在内存或数据库中创建一个 Session 对象,并生成一个唯一的 Session ID。
- 服务器通过 Cookie 将 Session ID 发给浏览器。
- 浏览器后续请求都会带上这个 ID,服务器根据 ID 找到对应的 Session 数据。
- 挑战:在分布式场景下,需要做 Session 共享(通常使用 Redis 统一存储)。
③ Token / JWT(无状态的“有状态”实现)
这是目前主流的令牌机制,常用于前后端分离和移动端。
- 服务器验证身份后,签发一个加密的 JSON Web Token (JWT) 发给客户端。
- 客户端通常存放在
localStorage或Header中。
- 优点:真正的分布式友好,服务器完全不需要存储会话状态。
如果浏览器禁用了 Cookie,Session 还能用吗?
默认不能,但有替代方案。可以通过 URL 重写(将 Session ID 作为参数拼在 URL 后面,如 ;jsessionid=xxx)来传递状态。
localStorage和Cookie的区别
cookie适合客户端服务器间传递数据,localStorage适合同一域名下不同页面间共享数据
| 特性 | Cookie | localStorage |
|---|---|---|
| 数据有效期 | 可设置过期时间(Expires/Max-Age),默认关掉浏览器失效。 | 永久保存,除非手动删除。 |
| 存储大小 | 极小,每个域名约 4KB。 | 较大,每个域名约 5MB。 |
| 与服务器交互 | 每次 HTTP 请求都会自动携带在 Header 中,浪费带宽。 | 仅保存在客户端,不参与服务器通信。 |
| 安全性 | 容易受到 CSRF 攻击;可设置 HttpOnly 防止 XSS 读取。 |
容易受到 XSS 提取数据;没有 HttpOnly 保护。 |
JWT 令牌为什么能解决集群部署
传统的登录机制是基于 Session 的,而不同服务器是不会共享会话状态的
而JWT令牌通过在令牌中包含所有必要的身份验证和会话信息,使得服务器无需存储会话信息。当用户进行登录认证后,服务器将生成一个JWT令牌并返回给客户端。客户端在后续的请求中携带该令牌,服务器可以通过对令牌进行验证和解析来获取用户身份和权限信息,而无需访问共享的会话存储。
JWT 的缺点在于一旦签发,在有效期内无法撤回(除非引入黑名单机制)。如果用户修改了密码或被封号,他手里的 JWT 依然有效。
JWT 令牌如果泄露了,怎么解决
刷新令牌:当检测到令牌泄露时,可以主动刷新令牌,即重新生成一个新的令牌,并将旧令牌标记为失效状态。
使用黑名单:服务器可以维护一个令牌的黑名单,将泄露的令牌添加到黑名单中。在接收到令牌时,先检查令牌是否在黑名单中,如果在则拒绝操作。
既然有 HTTP (REST),为什么还要 RPC?
性能更高:HTTP 包含大量的 Header(如 User-Agent, Cookie 等),冗余信息多。RPC(如 gRPC, Dubbo)通常使用二进制序列化(Protobuf),体积更小,速度快 5~10 倍。
强类型约束:RPC 通常有接口定义文件(如 .proto),在编译阶段就能发现错误。而 HTTP 调用往往是传 JSON 字符串,字段写错了只能在运行时报错。
服务治理:成熟的 RPC 框架自带服务发现、负载均衡、熔断、降级等功能,适合大规模微服务集群。
HTTP长连接与WebSocket有什么区别
| 特性 | HTTP 长连接 (Keep-Alive) | WebSocket |
|---|---|---|
| 通信模式 | 单向(请求-响应)。 | 双向(全双工)。 |
| 主动权 | 只有客户端能发起。 | 双方都可以主动发送。 |
| 协议标识 | http:// 或 https:// |
ws:// 或 wss:// |
| 数据量 | 每次都有完整的 Header,开销大。 | 建立连接后,数据帧非常小。 |
| 应用场景 | 普通网页浏览、静态资源下载。 | 聊天室、实时游戏、股票行情、协同编辑(如飞书文档)。 |
Nginx有哪些负载均衡算法?
nginx位于应用层
轮询:按照顺序依次将请求分配给后端服务器。这种算法最简单,但是也无法处理某个节点变慢或者客户端操作有连续性的情况。
加权轮询:按照权重分配请求给后端服务器,权重越高的服务器获得更多的请求。适用于后端服务器性能不同的场景
IP哈希:根据客户端IP地址的哈希值来确定分配请求的后端服务器。可以解决Session共享问题
通用哈希:允许根据自定义的变量(如:URL、请求参数、Cookie)进行 Hash,提高灵活性
最短响应时间:按照后端服务器的响应时间来分配请求,响应时间短的优先分配。
传输层
TCP三次握手
AI
Agent
Agent和LLM区别
Agent具备自主规划、工具调用和闭环能力
LLM本身只能思考
Agent的核心组件
LLM、工具、记忆和规划模块
WorkFlow、Agent和Tools区别
- Tools:按特定格式暴露给LLM的函数,本身没有决策能力
- Agent:由 LLM 在运行时动态决策
- Workflow:预定义的固定流程,步骤、顺序、分支都写死,硬编码,节点可以是任意的LLM调用、Tools或Agent
Agent设计范式
| 范式 | 特点 | 适合场景 | 成本 | 最佳场景 |
|---|---|---|---|---|
| ReAct | 边想边做 | 步骤少,步骤间独立,不需要全局规划 | 低 | 工具调用、实时问答 |
| Plan-and-Solve | 先规划后做 | 任务复杂,步骤间有依赖,需要全局视角 | 中 | 长任务、流水线 |
| Self-Reflection | 做完再优化 | 质量要求高,容错率低,需要自我优化 | 高 | 高准确率、代码生成 |
| Agentic Workflow | 骨架固定 + 智能节点 | 整体可控,局部灵活 | 中 | 企业级复杂业务 |
Agent的推理模式
- ReAct 推理(思考 + 行动循环)
核心:Thought → Action → Observation 循环,边思考边调用工具。
特点:动态决策、适合不确定任务、最通用;无全局规划,容易绕路。
适用:实时联网搜索、日常工具问答、简单任务拆解。
- CoT 链式推理(思维链)
核心:不调用工具,纯 LLM 内部分步推理,一步步拆解逻辑。
特点:纯逻辑推演、无外部依赖;不能用工具、复杂任务容易断链。
适用:数学计算、逻辑推理、文案梳理、纯问答。
- Plan-Execute 规划执行推理
核心:先一次性生成完整全局计划 → 再按步骤依次执行。
特点:有全局视角、流程可控、可复盘;固定计划难适配中途变化。
适用:长链路任务、项目规划、多步骤复杂业务。
Multi-Agent
- 驱动原因:上下文窗口的硬上限;单agent做多个任务时的注意力分散、子任务可以并行
CoT、ToT和GoT
- CoT 思维链 Chain of Thought
核心:线性串行推理,一步一步往下想,解决跳步执行
- ToT 思维树 Tree of Thought
核心:多分支并行推理,每层生成多个思路,择优向下探索,解决走错纠正
- GoT 思维图 Graph of Thought
核心:网状连通推理,节点可互相连接、融合、交叉推导,解决不同路径的中间结论复用
RAG
Embedding算法
静态词向量(Word2Vec/GloVe/FastText)
局限性:静态,不管上下文如何变化,同一个词只有一个固定向量。中心词和上下文之间互相预测
上下文相关向量(ELMo/BERT)
局限性:要比较两个句子的相似度,必须把两个句子拼接在一起来判断,查询是需要把查询和每个候选chunk拼在一起
句子级对比学习(SBERT/SimCSE/BGE)
将句子独立向量化,通过相似度进行比较
局限性:精度不如cross-encoder
向量数据库
索引算法
HNSW
多层有向网络图,相近向量互相连边;检索从顶层快速跳转往下找最近邻
优点:速度快,召回率高
缺点:内存消耗大
IVF
先聚类(K-Means)分出聚类中心。先找最近几个聚类,只在聚类内部比对,不用全量扫
优点:内存占用小
缺点:精度不如HNSW
核心能力
Metadata过滤
通过metadata字段,检索时加过滤条件
实时更新
支持在线写入,更新新数据时不需要停服重建
与关键词检索融合
部分数据库支持向量检索+BM25关键词检索做混合召回
选型
| 数据库 | 部署方式 | 适合规模 | 混合检索 | 主要优势 | 主要劣势 |
|---|---|---|---|---|---|
| Chroma | 本地 / Client-Server / 云 | 中小规模 | 是(支持 BM25/SPLADE) | 零配置上手极快,生态集成好 | 超大规模稳定性待验证 |
| Qdrant | 自托管 / 云(支持分布式) | 中大规模(亿级) | 是 | 性能好,API 简洁,Rust 高性能 | 超大规模需调优 |
| Milvus | 自托管(分布式) | 大规模(亿级) | 是 | 可水平扩展 | 部署运维复杂 |
| Pinecone | 全托管云服务 | 中大规模 | 是 | 无需运维 | 费用高,数据出境 |
| pgvector | PostgreSQL 插件 | 中小规模 | 是(配合全文检索) | 无需新组件,可 JOIN 业务数据 | 性能弱于专用向量库 |
| Elasticsearch | 自托管 / 云(分布式) | 中大规模 | 是(原生支持 BM25+HNSW 混合检索) | 全文检索能力强,生态成熟,支持复杂过滤 | 向量性能弱于专用向量库,资源占用高 |
关键词查询
代表是BM25,通过给文档打分进行检索,打分因素为:
词频(TF):查询词在文档中出现的次数
逆文档频率(IDF):查询词在所有文档中出现的稀有程度
Query rewrite
| 方法 | 解决的核心问题 | 额外开销 | 适合场景 |
|---|---|---|---|
| 直接改写 | 口语化、指代不清、上下文丢失 | 1 次 LLM 调用 | 多轮对话场景 |
| HyDE | 问题和文档文体风格差异大 | 1 次 LLM 调用 | 专业知识库、文体差异明显 |
| Step-back | 具体问题需要背景知识辅助 | 1 次 LLM 调用 | 技术文档、需要原理支撑的场景 |
| 多 Query 扩展 | 单一角度覆盖不全 | N 次 LLM 调用 | 答案涉及多个维度的复杂问题 |
多路召回RRF算法
核心思路:不用原始分数,只用排序位置来融合,公式:
\(\text{RRF}(d) = \sum_{r \in R} \frac{1}{k + \text{rank}_r(d)}\)
- R:各路召回结果
- \(\text{rank}_r(d)\):文档 d 在第 r 路的排名(从 1 开始)
- k:平滑系数(常用 60),避免低排名项权重过高
RAG检索优化
索引优化:小块检索、大块使用
- Parent-Child Chunking(父子切割)
优点:兼顾细粒度精准召回和粗粒度完整上下文
缺点:需要维护父子 Chunk 关联关系,存储翻倍,架构稍复杂
- 摘要索引(Summary Index)
让LLM对文档提取摘要,并用提炼的摘要做检索,用原文做生成
优点:解决内容松散、口语化、多分支的文档召回检索
缺点:依赖 LLM 生成摘要的质量,生成过程有额外成本;摘要可能丢失细节,导致召回的段落和问题不匹配
- 多粒度分层索引
不同问题用不同粒度的索引检索
优点:覆盖更多用户问题类型,宽泛问题用大块避免信息不全,细节问题用小块提升精准度
缺点:维护成本高,需要建多套索引,存储翻倍;需要额外的问题分类逻辑
查询优化
与Query rewrite一致
召回优化
多路召回
重排序
Rerank使用Cross-encoder结构,把query+chunk拼成一对输入,整体评估相关性
| 层次 | 解决的核心问题 | 推荐程度 |
|---|---|---|
| 索引优化(Parent-Child) | 检索粒度 vs 上下文完整性的矛盾 | 推荐,效果稳定 |
| 查询优化(Multi-Query / HyDE) | 用户提问和知识库表达不对齐 | 视场景,提问质量差时必做 |
| 多路召回(向量 + BM25) | 单路检索漏召 | 推荐,低成本高收益 |
| Rerank 精排 | 粗召精度不足 | 强烈推荐,提升精度最直接的手段 |
RAG范式
| 范式 | 核心创新 | 适合场景 | 工程复杂度 |
|---|---|---|---|
| Advanced RAG | Query 改写 + Rerank | 大多数生产场景 | 低 |
| Self-RAG | LLM 自主决策检索 | 问题类型多样、部分不需要检索 | 高(需特殊训练模型) |
| CRAG | 检索质量差时降级网络搜索 | 知识库覆盖不全的场景 | 中 |
| GraphRAG | 社区发现 + 层次摘要,支持全局查询 | 实体关系复杂、需要全局理解的场景 | 高(图构建 + 摘要成本大) |
| Agentic RAG | 多轮动态检索 | 复杂多步骤问题 | 中(Agent 框架) |
如何规避RAG中大模型的幻觉
- 使用prompt约束模型只能使用资料内的信息进行回答
- 检索分数较差时,拒绝回答
- 答案生成后,逐条检查是否捏造
- 强制LLM输出带上来源编号
如何量化RAG效果
检索层评估
- Hit@K:所有查询中,前
k个结果至少包含 1 个相关文档的比例
100 个查询中,90 个在 Top10 里有相关文档,Hit@10 = 0.9
- Mean Reciprocal Rank(MRR):所有查询中,第一个相关文档出现位置的倒数平均值
3 个查询的第一个相关文档位置分别是 1、2、3,MRR = (1/1 + 1/2 + 1/3)/3 ≈ 0.61
生成层评估
| 指标名称 | 核心定义 | 作用 / 衡量目标 | 目标值 |
|---|---|---|---|
| Faithfulness(忠实度) | 答案中的每句话,是否都能在检索到的 chunk 中找到依据 | 衡量幻觉程度,防止生成无来源的编造内容 | > 0.8 |
| Answer Relevancy(答案相关性) | 答案是否直接回答用户提出的问题,不跑题 | 衡量答案与用户意图的匹配度,区分 “内容正确但无关” 的情况 | > 0.8 |
| Context Recall(上下文召回率) | 回答问题所需的信息,有多少比例被检索结果覆盖 | 衡量检索层是否漏掉关键信息,需标准答案作为参照 | > 0.7 |
| Context Precision(上下文精确率) | 检索结果中,有用内容的排名是否靠前 | 衡量检索结果的排序质量,确保相关内容排在前面,避免无关信息干扰 | - |
总结
| 指标 | 属于哪层 | 衡量什么 | 低了说明什么 |
|---|---|---|---|
| Hit@K | 检索层 | 正确 chunk 是否被召回 | Embedding 或 Chunking 有问题 |
| MRR | 检索层 | 正确 chunk 的排名是否靠前 | Rerank 效果差 |
| Context Recall | 生成层输入 | 检索内容覆盖了多少正确信息 | 多路召回不足 |
| Context Precision | 生成层输入 | 检索内容里噪音多不多 | Rerank 没过滤掉无关内容 |
| Faithfulness | 生成层 | 答案有没有幻觉 | Prompt 约束不足或检索质量差 |
| Answer Relevancy | 生成层 | 答案和问题相不相关 | Prompt 写法问题 |
| 踩率 / 转人工率 | 线上 | 用户实际满意度 | 整体系统效果,综合反映 |
RAG更新
| 方案 | 延迟 | 实现复杂度 | 适用场景 |
|---|---|---|---|
| 定时轮询 | 分钟 - 小时级 | 低 | 文档更新频率低,实时性要求不高 |
| Webhook 触发 | 秒级 | 中 | 数据源支持 Webhook,如 Confluence、Notion |
| 消息队列 | 秒级 | 中高 | 大规模、高并发更新,生产环境首选 |
| 全量重建 | 分钟 - 小时级 | 低 | 文档量小,或知识库结构大改,不推荐常用 |
什么是RAG
RAG端到端流程
离线数据处理(构建知识库)
这个阶段的任务是把各种格式的内部数据,转换成机器能理解的数学向量,并存入数据库。
数据抽取与解析 (Parsing)
- 动作:将 PDF、Word、HTML、Markdown 甚至数据库中的原始数据,提取为纯文本。
- 难点:处理复杂的文档结构(如剥离页眉页脚、解析表格内容、保留段落层级)。
文本切分 (Chunking)
- 动作:因为大模型的上下文窗口(Context Window)有限,且向量模型对长文本的表征能力偏弱,需要将长文档切分成小块(Chunk)。
- 策略:
- 固定长度切分:例如每 500 个字符一块。
- 滑动窗口切分(Overlap):为了防止一句话被硬生生切断,相邻的 Chunk 之间会保留一段重叠区域(例如重叠 50 个字符)。
向量化 (Embedding)
- 动作:调用 Embedding 模型(如 OpenAI 的
text-embedding-3或开源的BGE),将每一个文本块转换成高维的浮点数数组(向量)。 - 意义:在这串数字中,语义相近的句子,它们在多维空间中的几何距离也会非常近。
向量入库 (Storage)
- 动作:将生成的向量,连同它的原始文本(Payload/Metadata),一起存入向量数据库。
- 选型落地:在企业级后端架构中,如果已经在使用关系型数据库管理订单或商品数据,通常会直接引入 pgvector 这类扩展插件。它允许你用标准 SQL 语句同时查询结构化业务数据和非结构化向量数据,极大降低了运维和架构复杂度。
在线检索与生成(回答用户问题)
当用户在聊天框里敲下问题时,这个实时管道就开始运作了。
问题向量化 (Query Embedding)
- 动作:用户输入问题(例如:“退换货政策是什么?”),系统使用与离线阶段完全相同的 Embedding 模型,将用户的 query 也转换成一个向量。
向量检索 (Vector Search)
- 动作:拿着 query 的向量,去向量数据库(如 pgvector)中进行相似度计算。
- 算法:最常用的是余弦相似度 (Cosine Similarity) 或内积 (Inner Product)。数据库会找出与问题在多维空间中距离最近的 Top-K 个文档块。
上下文组装 (Prompt Engineering)
-
动作:将检索出来的这 \(K\) 个文档片段,与用户的原始问题一起,塞进一个预设的 Prompt 模板中。
-
模板示例:
“你是一个专业的客服助手。请仅根据以下参考资料回答用户的问题。如果资料中没有答案,请回答‘我不知道’。
参考资料:[检索到的片段 1]、[检索到的片段 2]...
用户问题:[退换货政策是什么?]”
大模型生成 (Generation)
- 动作:将组装好的巨大 Prompt 发送给 LLM。由于此时 LLM 已经“读”过了正确的背景资料,它就能生成准确、无幻觉的回答。
在 RAG 架构中,为什么要引入重排序(Reranking)环节?
多源召回融合需要统一排序
实际 RAG 常混合多路召回:向量检索、BM25 关键词检索等等
平衡检索效率与效果
初检索:用粗粒度、高吞吐方式快速召回几百条
重排序:用更强模型精排 top 几十条
既保证速度,又保证最终送入 LLM 的上下文质量。
弥补“双塔模型(Bi-encoder)”的语义硬伤
用户问题和初始文档是分开进行向量化后计算相似度,重排序会将检索结果和问题拼接在一起进行再次判断
文本切割有哪些方法
固定大小切分 (Fixed-size Chunking)
这是最简单粗暴、也是计算成本最低的方法。直接按照字符数(或 Token 数)进行“一刀切”。
- 优点:实现极简,计算速度快,不需要复杂的 NLP 算法。
- 缺点:毫无语义感知。经常会把一个完整的长句或一个不可分割的专业术语拦腰截断。
基于标点符号的切分 (Sentence/Punctuation Chunking)
为了解决固定切分“破坏句子结构”的问题,这种方法利用自然语言的规律进行切分。
- 原理:优先按照句号
。、问号?、换行符\n进行切分,确保切出来的每一块都是完整的句子。如果一个句子实在太长,再退化为按字符数切分。 - 优点:保持了单个句子的语义完整性。
- 缺点:依然无法感知段落级别的上下文关系(比如把“起因”和“结果”切到了不同的块里)。
递归字符切分 (Recursive Character Chunking)
这是目前 LangChain 和 Spring AI 官方最推荐、应用最广泛 的切分器(如 RecursiveCharacterTextSplitter)。
- 原理:它提供了一个分隔符的“优先级列表”,例如
["\n\n", "\n", " ", ""]。- 首先尝试用
\n\n(段落)切分。 - 如果切出来的块还是大于设定的最大长度(比如 500),它就“退一步”,用优先级稍低的
\n(单行)对这个超大的块继续切。 - 如果还是太大,就用空格
切,最后逼不得已才用空字符""强行切断。
- 首先尝试用
- 优点:在“保持段落语义完整”和“控制块大小”之间找到了最佳平衡。
特定结构感知切分 (Document/Structure-aware Chunking)
这种方法抛弃了纯文本视角,直接利用文档原有的排版结构。
- Markdown 切分:根据
# 一级标题、## 二级标题来切。 - HTML/XML 切分:根据
<div>、<p>、<table>标签来切分。 - 代码切分:针对 Java 或 Python 代码,利用抽象语法树(AST)按照类、方法(函数)进行切分,保证代码逻辑不被破坏。
- 业务场景:在零售电商的商品详情页处理中,利用这种切分可以将商品的“规格参数表”、“售后保障条款”完美剥离开来,检索精度极高。
语义切分 (Semantic Chunking)
这是目前最前沿的切分思路:用大模型(或 Embedding 模型)来决定从哪里下刀
- 原理:先把文章切成最基础的单句,使用 Embedding 模型把每一句变成向量。计算相邻两句之间的“余弦相似度距离”,如果发现第 3 句和第 4 句的距离突然拉大(相似度骤降),说明这里发生了“话题转换”,系统就会在这里切一刀。
- 优点:切分结果极其符合人类直觉,每一块包含的都是一个独立、完整的语义主题。
- 缺点:切分阶段就需要大量调用 Embedding 计算,耗时且费钱(Token 消耗大)。
知道哪些Embedding模型
BGE/Jina系列
商业API
如何评估一个 RAG 系统的好坏(评价指标有哪些)
检索阶段
Recall@k(召回率):前 k 条召回结果中,包含真实相关文档的比例。
Precision@k(精确率):前 k 条里真正相关的比例。
生成阶段
BLEU:衡量回答与标准答案的相似度。
Skill
skill的好在哪,有什么不同
工具调用
LLM如何学会调用外部工具
监督微调 SFT
- 数据:大量完整工具调用对话样本,每条包含:
- System Prompt:工具列表(名称、描述、参数 JSON Schema);
- 用户问题;
- 模型输出:结构化调用(如
<tool_call>{"name":"get_weather","args":{"city":"北京"}}</tool_call>); - 工具返回结果;
- 模型最终答案。
- 目标:让模型模仿学会三件事:
- 何时切到工具调用(vs 直接回答);
- 输出合法 JSON / 格式;
- 正确填参数。
RLHF(人类反馈强化学习)
- 解决 SFT 问题:模型乱调用、不该调也调;
- 流程:
- 人类标注:对 “调用 / 不调用”“参数对错” 打分;
- 训练奖励模型 RM:给不同调用策略打分;
- PPO 强化学习:让模型学会边界感—— 能直接回答就不调,必须外部数据才调。
MCP组成结构
| 组成部分 | 核心职责 |
|---|---|
| MCP Client(客户端) | 发起请求、协商能力、调用服务端能力、拼接上下文给 LLM |
| MCP Server(服务端) | 提供工具、资源、提示模板,执行实际业务逻辑 |
| Transport(传输层) | 提供通信通道,标准支持 stdio / Streamable HTTP(SSE) |
| Protocol(协议层) | 基于 JSON-RPC 2.0定义消息格式、握手、请求响应规范 |
MCP Server三大能力
| 能力模块 | 作用说明 |
|---|---|
| Tools 工具 | 提供外部可调用函数、API、业务操作能力 |
| Resources 资源 | 提供文档、文件、配置、知识库等上下文数据源 |
| Prompts 提示模板 | 预置可复用的 Prompt 模版,供客户端直接调用 |
WebSocket和SSE
| 维度 | SSE(Server-Sent Events) | WebSocket |
|---|---|---|
| 底层协议 | 基于标准 HTTP | 独立 TCP 协议 |
| 通信方向 | 单向:服务端 → 客户端 | 全双工双向通信 |
| 连接性质 | 长连接,单次握手持续推送 | 长连接,一次握手永久通道 |
| 数据格式 | 纯文本 | 文本、二进制都支持 |
| 自动重连 | 浏览器原生自动重连 | 需业务手动实现重连、心跳 |
| 头部开销 | 每次有 HTTP 头开销 | 握手后无多余头部,开销极低 |
| 跨域 | 原生支持 | 需要服务端配置允许跨域 |
| 浏览器 API | 原生 EventSource |
原生 WebSocket |
| 适用场景 | AI 流式输出、日志推送、进度条、消息通知 | 在线聊天、实时对战、协同编辑、高频双向交互 |
| 不适合场景 | 客户端向服务端发消息 | 简单单向推送、想省开发量 |
适用场景
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| LLM 流式文字输出(ChatGPT 风格) | SSE | 单向推送够用,轻量,HTTP 原生支持,运维简单 |
| 多轮对话(用户发消息 + 模型回复) | SSE + 普通 POST | 用户发消息走 POST,模型回复走 SSE,解耦简单 |
| 需要用户中途打断模型输出 | WebSocket | 需要客户端在流式输出中途主动发消息 |
| 多人协同(多用户同时编辑、实时同步) | WebSocket | 频繁双向消息,SSE + POST 的双通道架构太繁琐 |
| 实时语音对话 | WebRTC | 音频流需要 UDP + 低延迟,WebSocket 的 TCP 重传是瓶颈 |
| MCP 远程 Server | Streamable HTTP(内部仍用 SSE 做流式) | MCP 2025-03-26 规范从早期的 HTTP+SSE 双端点升级为单端点的 Streamable HTTP,保留了代理穿透友好的特性 |
WebSocket和WebRTC
| 维度 | WebSocket | WebRTC |
|---|---|---|
| 底层协议 | TCP | UDP |
| 连接模式 | 客户端 <-> 服务器 | P2P 直连(可降级为 TURN 中转) |
| 延迟 | 50-500ms(受 TCP 重传影响) | 50-150ms(UDP 不重传) |
| 丢包处理 | 强制重传,后续数据等待 | 丢包隐藏,插值填补,不阻塞 |
| 音视频支持 | 无,需自行实现全套处理链路 | 原生支持(编解码、AEC、NS、AGC、ABR) |
| 连接建立 | 简单,HTTP Upgrade 即可 | 复杂,需要信令交换 + ICE NAT 穿透 |
| 适合场景 | 文字 / 数据实时双向通信 | 实时音视频通话 |
| 实现复杂度 | 低 | 高(信令服务器、STUN/TURN 服务器都要自建或使用云服务)**** |
jdk25新特性
构造函数能「先检查再初始化」
允许在调用 super() 或 this() 前写校验逻辑(如参数检查),如果参数不合法直接抛出异常。
Scoped Values替代ThreadLocal
线程间共享不可变数据,自动随作用域销毁,避免内存泄漏(ThreadLocal的祖传坑),虚拟线程场景更友好。
紧凑的对象头
压缩对象头(从128位→64位),减少小对象内存占用,微服务、高频创建对象的场景更省内存。
