一、mybatis动态SQL解析流程
编译期解析为 SqlNode 树 → 运行期 OGNL 求值递归拼接成包含 #{} 的SQL → 替换 #{} 为 ? → 交给 JDBC PreparedStatement 执行
【启动阶段 — 一次性】
XML文件读取
→ XMLMapperBuilder 解析 <select> 节点
→ XMLScriptBuilder 解析动态标签 → SqlNode树
→ DynamicSqlSource
→ MappedStatement 注册到 Configuration
【运行阶段 — 每次调用】
Mapper接口方法调用
→ MapperProxy 动态代理
→ SqlSession.selectList()
→ CachingExecutor (二级缓存)
→ BaseExecutor (一级缓存)
→ DynamicSqlSource.getBoundSql() ← OGNL求值,SQL拼接
→ StatementHandler.prepare() ← PreparedStatement
→ ParameterHandler ← 设置 ?
→ JDBC执行
→ ResultSetHandler ← 映射结果
阶段一 XML 解析 — 构建 SqlNode 树
阶段二 动态代理
阶段三 动态SQL拼接
阶段四 Executor — 缓存检查与执行分发
阶段五 StatementHandler — 准备 Statement
阶段六: ParameterHandler — 设置 ? 参数
阶段七:JDBC 执行 → ResultSetHandler 映射结果
完整执行时序(以本 SQL 为例)
调用: mapper.getApproveList("zhangsan", param)
│
▼ [MapperProxy]
invoke() → MapperMethod.execute()
参数处理: {"approver":"zhangsan", "param":QueryParam{transStatus:1,start:0,pageSize:20}}
│
▼ [DefaultSqlSession]
selectList("xxx.getApproveList", paramMap)
│
▼ [CachingExecutor]
query() → 查二级缓存 → 未命中
│
▼ [BaseExecutor]
query() → 查一级缓存 → 未命中 → queryFromDatabase()
│
▼ [DynamicSqlSource.getBoundSql()]
DynamicContext 初始化(绑定 paramMap)
MixedSqlNode.apply():
- 追加 "select id,... from wm_ad_billing_transfer_approve"
- WhereSqlNode → FilteredDynamicContext:
IfSqlNode#1: "approver != null and approver != ''" = true → 追加
IfSqlNode#2: fromTransDate != null = false → 跳过
IfSqlNode#3: transType != null = false → 跳过
IfSqlNode#4: inflowId != null = false → 跳过
IfSqlNode#5: outflowId != null = false → 跳过
IfSqlNode#6: transStatus > 0 = true → 追加
IfSqlNode#7: acctId > 0 = false → 跳过
applyAll(): 去掉开头AND,加 WHERE
- 追加 "order by id desc limit #{param.start},#{param.pageSize}"
context.getSql() = "select...WHERE approver=#{approver} and...trans_status=#{param.transStatus}...limit ?,?"
SqlSourceBuilder.parse(): #{} → ?,生成 parameterMappings[4]
返回 BoundSql
│
▼ [SimpleExecutor.doQuery()]
newStatementHandler() → PreparedStatementHandler(含插件代理)
│
▼ [PreparedStatementHandler.prepare()]
connection.prepareStatement("select...WHERE approver=? and...limit ?,?")
→ MySQL预编译
│
▼ [DefaultParameterHandler.setParameters()]
ps.setString(1, "zhangsan")
ps.setInt(2, 1)
ps.setInt(3, 0)
ps.setInt(4, 20)
│
▼ [PreparedStatement.execute()]
发送给 MySQL,获取 ResultSet
│
▼ [DefaultResultSetHandler.handleResultSets()]
逐行: ResultSet → new WmAdBillingTransferApprove()
rs.getString("trans_no") → setTransNo("TRN...")
rs.getLong("amount_cent") → setAmountCent(10000L)
...
返回 List<WmAdBillingTransferApprove>
│
▼ 写入一级缓存(localCache)
│
▼ 返回给调用方
如果是注解方式呢?
如果是注解方式,也分为动态(包含标签)和静态SQL,最终都会生成SqlSource
二、mysql是否需要分表
分表 or 分库分表
如果数据规模并不是特别大,并且QPS,数据库连接数,磁盘IO不是瓶颈,瓶颈仅是因为数据量大导致查询变慢 -> 仅分表
如果不仅数据量大,且数据库性能成为瓶颈:高QPS,数据库连接数不够,磁盘IO成为瓶颈 -> 分库分表
1. 分库分表数量的黄金法则
推荐:库数 × 表数 = 2^n(方便后续扩容)
常见配置:
- 中小规模:1库 × 16表(纯分表)
- 中等规模:4库 × 16表(共 64 张表)
- 大规模:16库 × 16表(共 256 张表)
- 超大规模:32库 × 32表(共 1024 张表)
2. 分片键的选择原则
// 好的分片键特征:
// 1. 查询中必然出现(避免全分片扫描)
// 2. 数据分布均匀(避免热点)
// 3. 不会变更(分片键变更代价极高)
// 商家ID为分片键
int shardIndex = Math.abs(poiId.hashCode()) % 256;
int dbIndex = shardIndex / 16;
int tableIndex = shardIndex % 16;
3. 分片键的键的计算原则
举一个具体的例子,有4个库,每个库有128表,路由规则如下
4个库,每库128张表,共 4 × 128 = 512 张逻辑分片
dbRule: account_id % 512 / 128 → 得到 0~3,决定去哪个库
tbRule: account_id % 512 % 128 → 得到 0~127,决定去哪张表
如果要分表可能会带来很多问题,如
- 分页查询变得困难,不能直接用LIMIT 1, 200。可能需要Hash策略让数据集中在同一张表,或则使用ES,或者多张表查询后聚合(可能存在问题)
- 使用事务时可能需要引入分布式事务(XA,TCC)或者使用mysql消息表+MQ实现最终一致性
- 数据迁移也是一个问题,需要做增量数据与存量数据迁移,还需做迁移前后数据diff
这里需要注意下Query Cache 和 Buffer Pool 是完全独立的两个缓存!
Query Cache是缓存的查询结果,Buffer Pool缓存的是磁盘数据页(16KB原始数据块)
所以关闭query_cache_type=OFF缓存开关后,Buffer Pool也不会受影响
三、分布式ID
实际应用举例
对于百度 UidGenerator实现
四、HBase与Doris对比
HBase与mysql对比,mysql的优点是
- 支持复杂查询
- 支持事务
- 运维成本低
- 数据量较低时mysql更快
五、MySQL中B+树叶子节点是双向链表的原因
B+树在插入节点时通过分裂来保证树的平衡,保证所有叶子结点的深度相同。双向链表主要是方便范围查询,节点内部是单向链表,节点之间是双向链表
六、Redis中的跳表
本质是在链表的基础上,增加了索引节点,通过二分法提升定位速度
七、Tomcat与Netty架构对比
两者是如何实现连接数卡控的
- Tomcat超过最大连接数时,会阻塞Accptor线程
- Netty可通过MaxConnextionsHandler来讲channel.close(会给客户端发FIN,关闭连接)
listen_fd与client_fd
listen_fd是专门用于监听连接的fd,client_fd代表的客户端请求的fd,每次新建立连接后调用accpet(3)就能获取client_fd,将client_fd注册在epoll_fd上。
Redis 进程的文件描述符表:
fd 0 → stdin
fd 1 → stdout
fd 2 → stderr
fd 3 → listen_fd (绑定 0.0.0.0:6379,监听连接请求)
fd 4 → epoll_fd (epoll 实例本身)
fd 5 → client_fd (192.168.1.1:6379 ↔ 10.0.0.1:52341)
fd 6 → client_fd (192.168.1.1:6379 ↔ 10.0.0.2:48921)
fd 7 → client_fd (192.168.1.1:6379 ↔ 10.0.0.1:52342)
...
epoll 红黑树:
├── fd 3 (listen_fd) 监听 EPOLLIN
├── fd 5 (client A) 监听 EPOLLIN
├── fd 6 (client B) 监听 EPOLLIN
└── fd 7 (client C) 监听 EPOLLIN
Linux内核分发到不同client_fd流程:
客户端A 发送数据到 192.168.1.1:6379
│
▼
【内核网络协议栈】
1. 网卡收到数据帧
2. IP 层解析目标 IP
3. TCP 层根据四元组 (src_ip, src_port, dst_ip, dst_port)
找到对应的 socket(即 client_fd5 对应的内核 socket 结构)
4. 数据写入 client_fd5 的【内核接收缓冲区】
5. 标记 fd5 可读
6. 通知 epoll:fd5 就绪 → 加入就绪链表
│
▼
【Redis 主线程】
epoll_wait() 返回,拿到 fd5 就绪事件
read(fd5, buf) → 从内核缓冲区复制数据到 Redis 的 querybuf
执行命令...
fd和Socket的关系
fd 是一个整数(int) → 是进程访问内核资源的"句柄" → 可以引用:普通文件、目录、管道、socket、epoll实例、定时器...
fd 是用户空间访问内核 socket 的整数索引,就像停车位编号;socket 是内核里真正存储连接状态、收发缓冲区的数据结构,就像停车位里的车
┌─────────────────────────────────────────────────────────────┐
│ 用户空间(Redis 进程) │
│ │
│ fd = 5 (只是一个整数,进程内唯一) │
└──────────────────────┬──────────────────────────────────────┘
│ 系统调用(read/write/send/recv)
▼
┌─────────────────────────────────────────────────────────────┐
│ 内核空间 │
│ │
│ 进程文件描述符表(每个进程一张) │
│ ┌────┬──────────────────┐ │
│ │ fd │ 指针 │ │
│ ├────┼──────────────────┤ │
│ │ 0 │ → file(stdin) │ │
│ │ 1 │ → file(stdout) │ │
│ │ 2 │ → file(stderr) │ │
│ │ 3 │ → file ──────────┼──▶ socket 结构体 │
│ │ 5 │ → file ──────────┼──▶ socket 结构体 │
│ └────┴──────────────────┘ ├── 四元组 (src/dst ip:port) │
│ ├── 内核接收缓冲区 sk_rcvbuf │
│ ├── 内核发送缓冲区 sk_sndbuf │
│ ├── TCP 状态机 │
│ └── epoll 等待队列 │
└─────────────────────────────────────────────────────────────┘
八、Redis内存淘汰策略
- volatile-lru
- volatile-random
- volatile-lfu
- volatile-ttl
- allkeys-lru
- allkeys-random
- allkeys-lfu
- no-evication
九、Redis批量操作都有哪些
- Redis事务(多个命令会挨个提交)
- 批量操作(MGT等,会一次性提交(Redis Cluster除外)是原子操作)
- pipeline(会一次性提交(Redis Cluster除外)不是原子操作)
- LUA(脚本,是原子操作(Redis Cluster除外))
十、Redis热点Key,大key
热点key指的是,某个key在集群某个节点上短时间内被高频访问,导致节点负载过高
解决方案
- 允许从节点可读
- 使用本地缓存
- Key分片(一个Key分成多个Key存在不同节点,写的时候需要都写一遍,且可能会导致不一致问题)
- 热点Key自动探测
当探测到时热点key时,主动上报给服务端,服务端会开启推送,将最新内容推送给客户端,客户端会更新本地缓存,达到解除热点key的作用\
redis大key处理
- 异步删除大key(UNLINK命令)
- 大key拆分成小key
十一、缓存穿透
定义:大量请求非法不存在的key,缓存中没有,DB中也没有,给DB造成压力
解决办法:
- 入参校验过滤
- 布隆过滤器(布隆过滤器不支持删除! 这是工程落地的最大障碍)
- 限流针对用户或IP进行限流(记录恶意请求IP)
十二、缓存击穿
定义:热点key可能由于过期原因,缓存中没有,导致大量请求打在了DB上,给DB造成压力
解决办法:
- 针对热点数据提前进行预热,保证高并发期间不会过期
- 使用SingleFlight合并请求,减少对DB的查询
缓存击穿防护
没命中缓存,可以加分布式锁+singlFlight(查同一个key,可以将多个请求合并,只有一个请求去查DB其他请求等待Cache就绪)
public List<Long> getFollowing(long userId) {
String cacheKey = "following:" + userId;
String lockKey = "lock:following:" + userId;
String channel = "channel:following:" + userId;
// 1. 读缓存
List<Long> cached = redis.zrange(cacheKey, 0, -1);
if (cached != null && !cached.isEmpty()) return cached;
// 2. 尝试抢锁
if (redisLock.tryLock(lockKey, 5, TimeUnit.SECONDS)) {
try {
cached = redis.zrange(cacheKey, 0, -1);
if (cached != null && !cached.isEmpty()) return cached;
List<Long> dbResult = followingDao.query(userId);
redis.zadd(cacheKey, dbResult, 3600);
// 3. 通知所有等待者:缓存已就绪
redis.publish(channel, "ready");
return dbResult;
} finally {
redisLock.unlock(lockKey);
}
}
// 4. 没抢到锁:订阅通知,等缓存重建
return subscribeAndWait(cacheKey, channel);
}
private List<Long> subscribeAndWait(String cacheKey, String channel) {
CountDownLatch latch = new CountDownLatch(1);
redis.subscribe(channel, message -> latch.countDown());
try {
boolean signaled = latch.await(3, TimeUnit.SECONDS);
if (signaled) {
return redis.zrange(cacheKey, 0, -1); // 缓存已就绪
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// 超时降级
return followingDao.query(userId);
}
还可以使用ConcurrentHashMap+Future对象来实现SingleFlight(但仅限于单机)
// 核心数据结构:key → 正在进行的 Future
private final ConcurrentHashMap<String, CompletableFuture<List<Long>>> inflightMap
= new ConcurrentHashMap<>();
public List<Long> getFollowing(long userId) {
String cacheKey = "following:" + userId;
// 读缓存
List<Long> cached = getFromRedis(cacheKey);
if (cached != null) return cached;
// Singleflight:合并对同一 key 的并发请求
CompletableFuture<List<Long>> future = inflightMap.computeIfAbsent(
cacheKey,
key -> CompletableFuture
.supplyAsync(() -> {
// 只有第一个请求会执行这里
List<Long> result = followingDao.query(userId);
redis.zadd(cacheKey, result, 3600);
return result;
}, dbExecutor)
.whenComplete((result, ex) -> {
// 完成后从 map 中移除,下次重新触发
inflightMap.remove(cacheKey);
})
);
try {
// 所有并发请求共享同一个 Future,等待同一个结果
return future.get(3, TimeUnit.SECONDS);
} catch (TimeoutException e) {
log.error("Singleflight 超时, key={}", cacheKey);
return Collections.emptyList(); // 降级返回空
}
}
十三、缓存雪崩
雪崩有两种根因:
1、大量key在同一时间过期
2、Redis节点宕机
解决办法:
对于同一时间过期问题
- 避免大量key设置同一过期时间
- 使用本地缓存做兜底,然后读取到DB后更新Redis缓存,DB压力及时下降
对于Redis节点宕机,主要是Redis高可用问题
- 使用Redis哨兵/集群(目前主流使用集群),实现主从同步,故障探测,主动故障转移
- 服务需要做熔断降级(如当Redis节点宕机,请求异常后降级读本地缓存或读DB(最好限流))
一般来说本地缓存是L1,Redis缓存是L2,DB是L3。本地缓存一致性无法做到强一致性,通常设置很短的TTL,另外也可以通过异步方式来更新本地缓存。
十二、常见限流算法
-
固定窗口限流
- 如只允许10点1分至10点2分有200个请求(限制数量)
- 优点:实现简单
- 缺点:无法应对激增流量,窗口是固定的,请求不均匀的时候拦截不住
-
滑动窗口限流
- 将窗口划分为多个小窗口,每次请求来都看的是上一个窗口的数量
- 优点:相比固定窗口算法计算更准确
- 缺点:不够平滑
-
漏桶算法
- 请求装在一定容积的桶内,消费端以固定速率来消费
- 优点:可以达到平滑限流,计算准确
- 缺点:无法调整消费速率
-
令牌桶算法
- 生产令牌装在一定容积的桶内,请求过来时需获取令牌来处理
- 优点:平滑限流且可控制令牌生成速率
- 缺点:实现较为复杂
分布式限流
常见是使用Redis的LUA脚本来实现令牌桶的生产和获取
mt使用Token服务来实现,可以批量获取令牌,减少IO
十三、限流,降级,熔断的区别
十四、Redis Cluster
Redis集群可以分为代理模式与去中心化模式
- 代理模式常见有TwemProxy与Codis,依赖代理层管理连接与节点路由
- 去中心化模式则没有代理层,在Client缓存Slot信息进行路由,node节点本身也是哨兵节点,每个节点中都存储了所有Slot对应节点信息
Redis 主从节点同步方式
| 触发时机 | 同步方式 | 流程 | 备注 |
|---|---|---|---|
| 从节点第一次连接主节点/断开重连 | 全量同步 | 从节点 --psync-->主节点,进行全量同步 | 主节点接受到psync后会fork子进程来bgsave RDB文件,同时将增量请求存储在Replication Buffer中。RDB发送完成后,将Replication Buffer中的命令发送给子节点 |
| 正常流程 | 增量同步 | 主节点写入后主动推送给从节点 | 主节点将增量数据同步至从节点用的是主线程,但是却是【异步】的,当前时间循环主线程只负责将数据写入backlog,写入output_buffer,后续下次事件循环时主线程读取output_buffer中数据发送给从节点。 |
主从数据同步使用的是后台线程吗
Redis6.0之后支持开启多线程,但只是用于Socket读写,所以还是主线程负责命令读写,主从同步操作的。同理,从节点也是主线程通过事件循环完成的命令复制写入的。
backlog作用
backlog是一个环形缓冲区,主节点会记一个已写入的offset,从节点会记一个已同步的offset,如果发生断连,可通过从节点的offset判断是否还在环形缓冲区中确定使用增量同步还是全量同步。
Redis故障转移
完整时序图
时间线:
T0: Master-1 宕机
T1: 其他节点发送 PING,超时无响应
T15s: Master-0 将 Master-1 标记 PFAIL
T15s: 通过 Gossip 传播 PFAIL 信息
T16s: 多数 Master 确认 → 标记 FAIL,广播 FAIL 消息
T16s: Slave-1A 收到 FAIL,计算延迟 = 500 + 0(rank=0) = 500ms
T16s: Slave-1B 收到 FAIL,计算延迟 = 500 + 1000(rank=1) = 1500ms
T16.5s: Slave-1A 发起选举,广播投票请求
T16.5s: Master-0/2/3 回复投票
T16.5s: Slave-1A 获得多数票,晋升为 Master
整个故障转移时间 ≈ 15~30秒
其他某个主节点通过Gossip协议进行主观下线(ping-pong),其他多数节点也确认下线后客观下线。然后从slave节点中选举,其他主节点选举新Master。
新Master节点与旧Master节点的差异数据如何找回
一般会采用DB重建缓存,如果是分布式锁的话可以考虑使用RedLock
LRU 与 LFU 的区别详解
LRU维护了一个有序链表,每次访问就把元素放在头部
十五、Kafka架构
Kafka 架构详解 —— 消息发送、存储、消费全流程
Kafka幂等Idempotent
Producer ID(PID):
每个 Producer 实例启动时,向 Broker 申请一个唯一 PID
PID 在 Producer 重启后会变化(重启会申请新 PID)
Sequence Number(序列号):
每个<PID, TopicPartition> 对应一个单调递增的序列号
Producer 每发一条消息,序列号 +1
去重流程
ProducerBroker
│ │
│ PID=1001, SeqNum=0, 消息A│
│─────────────────────────────────→│写入成功,记录 {PID=1001, SeqNum=0}
│ │
│ ←网络故障,ACK 丢失 │
│ │
│ 重试:PID=1001, SeqNum=0, 消息A │
│─────────────────────────────────→ │ 发现 SeqNum=0 已存在!
│ │ 返回 DuplicateSequenceException
│← ACK(但 Broker 内部去重,不重复写) │
结果:消息 A 在 Broker 中只有一条!
十六、什么是Kafka ReBalance
ReBalance(重平衡)是消费者发生变化时(新增,减少或分区数量变化)分区进行重新分配,导致整个消费组停止消费。
解决方案:
- 参数调优(如降低每次拉取数量,防止超时),防止误触
- 使用CooperativeStickyAssignor或StickyAssignor重平衡,只停止涉及变动的Partition就行
StickyAssignor是什么?
是Kafka分区分配策略,有四种策略
- RangeAssignor(范围分配,旧默认)
- RoundRobinAssignor(轮询分配)
- StickyAssignor(粘性分配)
* 优点:停顿后分区迁移量小了
* 缺点:但停顿本身没有消除STW
- CooperativeStickyAssignor(增量协作粘性分配)
* 优点:只有少数分区短暂停止,大部分分区正常消费
如果新增订阅TOPIC会对原来消费者有影响吗?
这是最常见的场景,例如你修改了代码,让消费者额外订阅 topic-B,然后滚动重启。
用 EAGER 协议(RangeAssignor / RoundRobinAssignor / StickyAssignor)
每次有消费者重启,都会触发一次完整的 Stop-the-World Rebalance:
滚动重启过程(3个实例):
第1次:C3 重启
↓
Rebalance #1 触发
→ C1、C2 立刻暂停消费所有分区 ⛔
→ 等待 C3 重新加入
→ 重新分配所有分区
→ C1、C2、C3 恢复消费 ✅
第2次:C2 重启
↓
Rebalance #2 触发
→ C1、C3 立刻暂停消费所有分区 ⛔
→ 等待 C2 重新加入
→ 再次重新分配所有分区
→ 恢复消费 ✅
第3次:C1 重启
↓
Rebalance #3 触发(同上)
痛点:3个实例滚动重启,触发 3 次全量 Rebalance,每次都全组停止消费!
用 COOPERATIVE 协议(CooperativeStickyAssignor)✅ 推荐
增量式 Rebalance,未被迁移的分区继续消费,不停机:
初始分配(只有 topic-A):
C1 → topic-A P0, P1
C2 → topic-A P2
C3 → topic-A P3
C3 重启,新增 topic-B 订阅:
第一轮协商:
→ 识别需要迁移的分区(topic-B 的分区,因为之前没人消费)
→ topic-A 的分区继续正常消费 ✅
第二轮分配:
C1 → topic-A P0, P1, topic-B P0 (只有 topic-B P0 是新的)
C2 → topic-A P2, topic-B P1 (只有 topic-B P1 是新的)
C3 → topic-A P3 (保持原有)
✅ topic-A 的消费从未中断!
新增Topic分区与新增消费的区别
十七、什么是Kafka Group Coordinator?
Coordinator是负责管理消费者组的角色,本质是其中一个Broker。
Group Coordinator 负责什么?
- 维护 Consumer Group 成员列表
- 接收 Consumer 心跳,判断存活
- 触发和主持 Rebalance 流程(具体分区分配是有Leader Consumer完成的)
- 接收并持久化 Offset 提交(每个消费者?是的)
- 响应 Offset 查询(Consumer 重启时读取上次进度)(每个消费者?)
Offset 持久化:怎么存的?
Group Coordinator 将 Offset 持久化到一个特殊的内置 Topic: __consumer_offsets
存储格式:
Key(唯一标识一条 offset 记录):
group.id + topic + partition
例:("payment-group", "order-events", 1)
Value(offset 的具体内容):
{
offset: 43, // 下次从哪里开始消费
leaderEpoch: 2, // 分区 Leader 版本号(防脑裂)
metadata: "custom-info", // 可选的自定义元数据(通常为空)
commitTimestamp: 1712345678000, // 提交时间
expireTimestamp: -1 // 过期时间(新版本已废弃)
}
Kafka的其他用途
- 大数据实时处理:由于其高吞吐量和低延迟的特性,Kafka常被用于实时数据流处理场景,作为数据源接入层,对接各种数据处理框架如Apache Storm、Spark Streaming或Flink,用于实时分析和处理海量数据流。
- 日志聚合与传输:Kafka能够高效收集应用程序日志,作为集中式日志系统,为日志分析和监控提供数据源,支持如ELK Stack(Elasticsearch, Logstash, Kibana)的日志处理流程。
十八、为什么CMS相比G1更容易引起FULL GC?
- CMS使用【标记-清除】算法,造成内存碎片化,出现大对象时无法分配内存
- CMS老年代清理触发时机是静态阈值,要么不清理,要么就清理整个老年代,可能出现清理速度 < 晋升速度,导致FULL GC
- G1是动态预测,会提前使用Mixed G1 GC清理一些 Old Region(垃圾最多的Region)
G1是如何清理老年代的
G1是通过MIXED GC来清理老年代的,当老年代空间到达阈值时,会触发MIXED GC。
如果老年代未来得及清理完成,会触发FULL GC,JDK8会使用Serial单线程完成FULL GC。
哪个GC吞吐量高?
Parallel GC,但如果FULL GC会完全暂停所有应用线程,用多线程并行完成,该过程可能时间会很长,不可预测。
为什么Parallel GC吞吐量高,但不推荐呢?
因为Parallel GC是以吞吐量第一的,在堆大的情况下可能会造成明显的延迟(长时间的STW),比较适合离线数据处理系统。
而G1 GC能够实现停顿时间可配置,能降低长时间STW的可能性,适合延迟要求较低,要求稳定的系统。
G1 GC YoungGC , Old GC, FULL GC的触发时机,执行过程
触发时机
YoungGC:当前 Young Region(Eden + Survivor)的总数量 达到 G1 动态计算的上限时触发 Young GC
Old GC: 老年代占整个堆的比例 超过 InitiatingHeapOccupancyPercent(默认 45%),随后先并发标记,然后再进行Mixed GC
FULL GC:
-
并发标记跟不上分配速度(最常见) Concurrent Mode Failure: 并发标记还没完成,老年代已经满了
-
Mixed GC 时 Evacuation 失败 复制存活对象时,找不到足够的空闲 Region (Evacuation Failure / To-Space Exhausted)
-
显式调用 System.gc()(除非 -XX:+ExplicitGCInvokesConcurrent)
-
Humongous 对象(大对象)分配失败 大于 Region 大小 50% 的对象分配到连续 Old Region 分配不下时触发
-
元空间(Metaspace)不足
执行过程
YoungGC:并发清理,会STW
Mixed GC: 老年代是并发标记,清理和新生代一起
FULL GC:单线程(JDK8)STW清理
G1 什么时候进入老年代
- 年龄到达一定阈值
- Young GC后S Region没有足够空间,会直接进入老年代
- 年轻代中同意年龄的占有内存大于总内存的50%
- 大对象直接分配到老年代(G1 特有机制:当对象大小 超过 Region 大小的 50% 时,会被识别为 Humongous 对象,直接分配到连续的 Humongous Region(逻辑上属于老年代管理范畴))
G1是如何控制 STW 时间的
- 设置停顿时间目标
-XX:MaxGCPauseMillis=200 # 默认 200ms,G1 会据此动态调整 CSet 大小
2. 动态调整Eden大小 G1 会自动增减 Eden Region 的数量来控制 GC 频率和单次停顿时间之间的平衡。
ZGC vs G1 vs CMS 优缺点详析
ZGC 优点
- 极低延迟:停顿时间与堆大小无关,始终在 ms 级以下
- 支持超大堆:可轻松管理 TB 级内存
- 无内存碎片:并发整理
- 无需 Remember Set:染色指针替代,减少内存开销
ZGC 缺点
- 不支持压缩指针:每个对象指针多占 8 字节,内存利用率低
- 吞吐量略低:读屏障有额外开销
- JDK 版本要求高:JDK 15+ 才 GA,JDK 21 才引入分代 ZGC
- 调优参数少:目前可调项不如 G1 丰富
- 不支持 32 位:仅限 64 位系统
十九、Kafka中Zookeeper与KRaft区别
| Zookeeper | KRaft | |
|---|---|---|
| 架构,存储 | 元数据存在 ZooKeeper 节点上Controller 从 ZK 读取,再广播给所有 Broker→ 存在"先更新ZK,再同步Broker"的两阶段延迟 | 元数据存储在内置的 __cluster_metadata Topic(Raft Log)中Controller 直接写 Raft Log,Broker 订阅并追赶→ 单一数据源,无需两阶段同步 |
| Controller选举 | 所有 Broker 抢占 ZK 的 /controller 临时节点依赖 ZK 的分布式锁能力Controller 宕机 → 重新抢锁 → 选举耗时较长(秒级) | Controller 节点之间用 Raft 协议选主Leader 宕机 → Raft 自动选出新 Leader 选举更快(毫秒级),无需外部依赖 |
| 支持Partition | 支持20w左右原因:当Partition数激增 -> ZK节点激增 -> Controller故障转移时间变长(每次都全量读ZK节点) -> 运维风险高 | 支持百万原因:固定时间会存储快照,增量存在Log中,故障转移快 |
二十、Kafka事务
Kafka事务旨在实现跨Topic/Partition写入的原子操作,流程如下:
Step 1: initTransactions()
Producer ──findCoordinator──► Broker
Coordinator 分配 PID,并检查历史事务是否需要恢复
Step 2: beginTransaction()
仅在 Producer 本地标记,不通知 Broker
Step 3: send() 发送消息
Producer ──send msg──► Partition A
Producer ──addPartitionToTxn──► Coordinator(登记涉及的分区)
Coordinator 将 <transactional.id, partitions> 写入 __transaction_state
Step 4: commitTransaction() 两阶段提交
┌─────── Phase 1: Prepare ───────┐
│ Producer ──PrepareCommit──► Coordinator
│ Coordinator 将状态写为 PrepareCommit(持久化)
└────────────────────────────────┘
┌─────── Phase 2: Commit ────────┐
│ Coordinator ──WriteTxnMarker──► 所有涉及的 Partition
│ 各 Partition 收到 COMMIT 标记后,消息对消费者可见
│ Coordinator 将状态更新为 CompleteCommit
└────────────────────────────────┘
Step 5: abortTransaction() 回滚
与 Commit 类似,写入 ABORT 标记,消息对消费者不可见
二十一、AQS中Condition的执行流程
Condition的实现是在AQS的内部类ConditionObject,ConditionObject内部有一个单向队列,存储了等待被唤醒的节点。
AQS 同步队列(CLH 双向链表) Condition 等待队列(单向链表)
┌─────────────────────────────┐ ┌──────────────────────────┐
│ HEAD ←→ Node ←→ Node ←→ TAIL│ │ firstWaiter → Node → ... → lastWaiter │
│ (竞争锁的线程在这里排队) │ │ (调用 await() 的线程在这里等待) │
└─────────────────────────────┘ └──────────────────────────┘
await方法流程如下
调用 await()
│
▼
创建 Node(CONDITION) 加入 Condition 等待队列
│
▼
fullyRelease() 完全释放锁 → 唤醒 AQS 同步队列中的下一个线程
│
▼
LockSupport.park() 挂起当前线程
│
▼ (被 signal() 唤醒)
isOnSyncQueue() = true → 退出循环
│
▼
acquireQueued() 重新竞争锁(在 AQS 同步队列中排队)
│
▼
获得锁,从 await() 返回
signal方法流程如下
调用 signal()(调用者必须持有锁)
│
▼
取出 Condition 等待队列头节点
│
▼
CAS: node.waitStatus: CONDITION → 0
│
▼
enq(): 将 node 加入 AQS 同步队列尾部
│
▼
设置前驱节点 waitStatus = SIGNAL
│
▼
(节点现在在 AQS 同步队列中等待获取锁)
│
▼ (调用者最终 unlock() 释放锁)
LockSupport.unpark(node.thread) 唤醒等待线程
│
▼
等待线程从 await() 中的 park() 返回,重新竞争锁
AQS中CLH队列为什么使用双向队列
-
节点取消时方便找到前驱结点
-
当节点release时,需要从后往前遍历找到未cancel的节点
关键问题:next指针不是实时可靠的先看 AQS 的入队代码(
addWaiter+enq):// 入队的核心步骤,顺序不可忽视 node.prev = pred; // ① 先设置 prev 指针 if (compareAndSetTail(pred, node)) { // ② CAS 将 node 设为新 tail pred.next = node; // ③ 最后才设置前驱的 next 指针 return node; }关键时序问题:② 和 ③ 之间存在空窗期!
时间轴: Thread-A 执行入队: ① node.prev = pred ✅ prev 指针已建立 ② CAS(tail = node) ✅ tail 已更新,node 对其他线程可见 === 此处 Thread-B 执行 release === ③ pred.next = node ❌ next 指针还未建立!在 ② 完成、③ 未完成的瞬间:
- 从 tail 向前找(用 prev):链路完整,可以找到 node ✅
- 从当前节点向后找(用 next):
pred.next还是 null,node 对next遍历不可见 ❌
二十二、syncronized的执行流程
syncronized的本质:对象监视器(Monitor),两者的关系:
Java Object
┌────────────────────────────┐
│ 对象头(Mark Word) │ ← 存储 Monitor 指针、锁状态、hashcode、GC信息
│ 类型指针(Klass Pointer) │
│ 实例数据 │
└────────────────────────────┘
│
│ Mark Word 中存储指向 Monitor 的指针
▼
┌─────────────────────────────────────┐
│ ObjectMonitor(C++) │
│ │
│ _owner → 持有锁的线程 │
│ _count → 锁重入次数 │
│ _WaitSet → wait() 的等待队列 │ ← 单向链表
│ _EntryList → 竞争锁的阻塞队列 │ ← 双向链表
│ _cxq → 竞争锁的入口队列 │ ← 单向链表(栈结构)
│ _waiters → wait() 等待线程数 │
└─────────────────────────────────────┘
三个队列的作用
_cxq(Contention Queue,竞争队列)
└── 新来竞争锁的线程,以栈的方式 LIFO 压入
_EntryList(等待获取锁的队列)
└── 从 _cxq 转移过来的,或 notify 后从 _WaitSet 转移过来的线程
_WaitSet(等待队列)
└── 调用 wait() 的线程在这里等待,是双向循环链表
加锁(锁升级)过程
对象刚创建
│
▼
┌─────────────────────────────────────────────────────────┐
│ 无锁状态(Unlocked) │
│ Mark Word: [hashcode(25bit) | age(4bit) | 0 | 01] │
└─────────────────────────────────────────────────────────┘
│ 第一个线程访问 synchronized
▼
┌─────────────────────────────────────────────────────────┐
│ 偏向锁(Biased Lock) │
│ Mark Word: [ThreadID(54bit) | epoch | age | 1 | 01] │
│ 适合:只有一个线程反复进入同步块(无竞争) │
│ 代价:几乎为零(只是 CAS 记录线程ID) │
└─────────────────────────────────────────────────────────┘
│ 第二个线程来竞争(偏向撤销)
▼
┌─────────────────────────────────────────────────────────┐
│ 轻量级锁(Lightweight Lock) │
│ Mark Word: [指向栈帧中 Lock Record 的指针 | 00] │
│ 适合:两个线程交替进入,无真正并发竞争 │
│ 代价:CAS 自旋(避免线程切换) │
└─────────────────────────────────────────────────────────┘
│ 自旋失败,或等待线程超过阈值
▼
┌─────────────────────────────────────────────────────────┐
│ 重量级锁(Heavyweight Lock / Monitor Lock) │
│ Mark Word: [指向 ObjectMonitor 的指针 | 10] │
│ 适合:真正存在并发竞争 │
│ 代价:线程挂起/唤醒,涉及内核态切换(最慢) │
└─────────────────────────────────────────────────────────┘
wait执行过程
线程 T 调用 obj.wait()
│
▼
① 检查当前线程是否持有 obj 的 Monitor
└── 未持有 → 抛出 IllegalMonitorStateException
│
▼
② 创建 ObjectWaiter 节点(TState = TS_WAIT)
│
▼
③ 将 ObjectWaiter 加入 _WaitSet(双向循环链表的尾部)
│
▼
④ 保存并清零 _recursions(记录重入深度,便于后续恢复)
│
▼
⑤ 将 _owner 置为 null(完全释放锁!)
│
▼
⑥ 唤醒 _EntryList 或 _cxq 中的一个等待线程(让别人来拿锁)
│
▼
⑦ 调用 park() 挂起自身(进入 WAITING 状态)
│
▼ (被 notify/notifyAll 或超时唤醒后)
⑧ 线程被 unpark() 唤醒
│
▼
⑨ 重新竞争 Monitor
│
▼
⑩ 获得锁后,恢复 _recursions,从 wait() 返回
notify执行过程
线程 T 调用 obj.notify()
│
▼
① 检查当前线程是否持有 Monitor(未持有抛 IllegalMonitorStateException)
│
▼
② 从 _WaitSet 中取出第一个节点(队列头)
│
▼
③ 将该节点的 TState 改为 TS_ENTER(即将竞争锁)
│
▼
④ 根据策略(Policy)决定节点去向:
├── Policy=0:放入 _EntryList 头部(默认)
├── Policy=1:放入 _EntryList 尾部
├── Policy=2:放入 _cxq 头部(LIFO)
└── Policy=3:放入 _cxq 尾部
│
▼
⑤ 注意:notify() 此时【并不立即唤醒被通知的线程】!
被通知线程还是 park 状态,直到当前线程 unlock() 后才会竞争锁
│
▼
⑥ 当前线程执行完 synchronized 块 → monitorexit → 释放锁
│
▼
⑦ exit() 方法扫描 _EntryList/_cxq,unpark() 一个线程
│
▼
⑧ 被通知线程被唤醒,重新竞争 Monitor
│
▼
⑨ 竞争成功 → 恢复 _recursions → 从 wait() 返回
二十三、为什么要用线程上下文类加载器
核心问题在于:用哪个类加载器取决于当前这个类是用哪个类加载器(this.getClass().getClassLoader()),且双亲委派模型只会向上找并不会向下找,所以需要通过线程上线文类加载器来获取当前App的类加载器
二十四、关于红黑树,AVL,跳表,B+树的选择
Q1: 为什么MySQL用B+树而不用红黑树?
红黑树在数据量大时树高会达到 log₂(n),比如1000万行需要约23层,每次查询23次磁盘I/O。B+树每节点可存1170个指针,3层就能存2200万行,只需3次I/O。
Q2: 为什么Redis用跳表而不用B+树?
Redis是纯内存操作,不需要考虑磁盘I/O,B+树减少I/O的优势消失。跳表实现更简单,并发修改更容易(不需要整树rebalance),范围查询也高效。
Q3: 为什么HashMap的链表要在长度>8时转红黑树?
链表查询是O(n),当长度较小时O(n)和O(log n)差距不大,且红黑树节点占用内存更多。长度>8时查询性能差距明显,转红黑树值得付出空间代价。
Q4: AVL树和红黑树如何选择?
读多写少选AVL(查询更快);读写均衡选红黑树(综合性能更好);需要范围查询且数据在磁盘选B+树;内存中需要范围查询且并发高选跳表。
即使如此,红黑树写入性能比AVL好,工程使用更多
二十五、Redis中Hash扩容机制
Redis中HashTable的扩容机制是【渐进式扩容】,主要是为了避免ReHash阻塞主线程。在每次执行命令的时候就捎带进行扩容,将旧HashTable内容同步到新的HashTable当中去。此外,在读写时的策略如下
二十六、MySQL主从同步流程
MySQL主节点在写入后会主动请求从节点同步Binlog,从节点会先写入Relay Log,后续再进行SQL回放。
客户端写 → Master Binlog → Slave IO Thread → Relay Log → SQL Thread → Slave 数据库
↑
网络传输(异步/半同步/全同步)
交互方式
初始状态,从节点会与主节点建立连接。当主节点写完Binlog,会通知Master BinlogDump线程,将BinlogDump线程会将Binlog Event推送给从节点
主从复制数据的方式
-
同步方式
-
异步方式
-
半同步/无损复制
- 无损复制是对半同步的优化(半同步会在未收到从库的ACK前就提交事务,而无损复制会在收到从库的ACK再提交事务)
GTID
GTID是全局事务ID,用于主从同步定位数据已同步的位置
主从如何选举
主从同步延迟升高,如何排查
-
查看是否有大事务,导致同步时间较长
-- 查找主库上的大事务(执行时间超过10秒) SELECT * FROM information_schema.INNODB_TRX WHERE TIME_TO_SEC(TIMEDIFF(NOW(), trx_started)) > 10; -- 查看 Binlog 中的大事务 mysqlbinlog mysql-bin.000023 | grep -A 5 "# at" -- 典型场景: -- 一条 UPDATE 更新了 100万行数据 -- 产生巨大的 Binlog(ROW模式下每行都记录前后镜像) -- 从库 SQL Thread 需要串行执行这100万行的回放 -
是否主库并发写入量变大,可能是从库性能问题,看下CPU与磁盘写入情况可以考虑开启并发复制(Mysql5.7+)
二十七、高可用网关系统设计
-
Nginx接入(多机房部署,崩溃后可切换)
-
API网关
- 支持限流(令牌桶),熔断(监听下游接口失败率),降级(熔断后兜底方案,如兜底配置)
- 支持多支付渠道
- 身份校验
- 参数加密
- 支付模块路由
二十八、IO多路复用select,poll,epoll的区别
二十九、MySQL迁移分库分表如何保证一致性
- 增量数据不能上来就只写新库,如果有问题会丢失数据!
- 可以先写旧库,通过Binlog异步同步至新库(或者可以通过异步方式在旁路完成写辛苦,不影响原来写入流程),读旧库,持续对比新旧一致性
- 再开始双写,读旧库,还要支持新库熔断降级,读旧库,持续对比新旧一致性
- 继续双写,灰度读新库,持续对比新旧一致性
- 继续双写,全量读新库,持续对比新旧一致性
- 停止写旧库,全量使用新库
- 存量数据可以分批次写入新库
三十、MySQL联合索引命中索引问题
分析以下情况的MySQL索引使用情况:
create table myTest (a string, b int, c int, KEY a(a,b,c));
(1).select * from myTest where c=3 and a="test" and b=5;
(2).select * from myTest where a="test" and b>5 and c=6;
(3).select * from myTest where a="test" order by b asc;
(4).select * from myTest order by a asc, b desc;
(5).select a,b,c from myTest where a like "te%" and b=5 and c=3;
- 最左前缀:必须从索引第一列开始使用,中间不能跳列
- 范围查询截断:
>、<、BETWEEN、LIKE "xx%"之后的列索引失效 - 排序方向一致性:ORDER BY 中所有列方向必须与索引方向一致(MySQL 8.0 支持降序索引可解)
- 覆盖索引优化:SELECT 的列都在索引中时,无需回表,性能最优
- 优化器会自动调整条件顺序:WHERE 中字段顺序不影响索引命中(如第①题)
另外,filesort并不一定会造成慢查,如果数据量不大是在内存里进行排序,如果数据量过大会溢出到硬盘进行排序,此时速度会大幅下降
三十一、关于数据库设计问题
举例:设计一个点赞功能,支持获取点赞数,点赞记录,最近点赞的人
实现方案:MySQL或Redis或MySQL+Redis
-
MySQL:表设计,谁在什么时候给哪个资源(文章或评论点赞)
- 考虑索引设计,唯一索引去重
- 数据量大可选择呢分库分表或者考虑数据归档(如果分页查询需求较多)
-
Redis:数据结构选型,ZSET即可去重,也可以进行排序,获取范围数据
- 存储优化:可以使用INCR来存储数量
- 数据冷热分离:3个月前的数据可以从Redis删除,MySQL持久化
- 海量数据可进行分片:如按照时间分片,like:id:date
- 为降低Redis压力可选择本地缓存
三十二、关于自动拆箱装箱问题
在进行算数运算,==比较时会进行自动拆箱装箱
Integer a = 1, b = 2, c = 3;
Long g = 3L;
System.out.println(c == (a + b)); // true ← a+b 触发拆箱,int 比较
System.out.println(c.equals(a + b)); // true ← a+b 装箱为 Integer(3)
System.out.println(g == (a + b)); // true ← 数值提升为 long,3L == 3L
System.out.println(g.equals(a + b)); // false ← equals 不做类型转换,Long != Integer
System.out.println(g.equals(a + g)); // false ← a+g 提升为 long,装箱为 Long(3L),但 3!=4
自动类型提升
坑点
byte a = 10, b = 20;
// byte c = a + b; // ❌ 编译错误!a+b 结果是 int
int c = a + b; // ✅ 必须用 int 接收
byte a = 10, b = 11;
byte c = ++a; // ✅ 可以编译
byte d = a + 1; // ❌ 编译错误
byte e = a + b; // ❌ 编译错误
byte d = 10 + 1; // ✅ 可以编译
System.out.println(c == b); // ✅ 输出 true
System.out.println(d == b); // ✅ 输出 true
为什么 byte c = 10 + 1 又合法?
因为 10 + 1 是编译期常量表达式,编译器在编译时就能算出结果是 11,确认在 byte 范围内(-128~127),所以允许。而 a + 1 中 a 是变量,编译器无法在编译期确定其值,因此不允许隐式转型。
三十四、脑裂
什么是脑裂
当节点状态异常,或者发生了网络隔离,从节点选出了一个新的Master节点,但旧Master并没有下线,同时有两个Master在执行写入,导致数据不一致。
如何避免脑裂
-
奇数节点部署(3、5、7)
-
Quorum机制:多数派才能写入(如果主节点发现连接的从节点少于多数,就停止写入)
-
Fencing机制:主动隔离旧Master(关闭主节点)
三十四、MySQL死锁
MySQL死锁出现的case
| 场景 | 流程 | 原因 | 加锁类型 |
|---|---|---|---|
| 两个事务更新顺序相反 | 行锁加锁顺序有交叉导致 | 只会加行锁 | |
| 并发批量写入 | 行锁加锁顺序有交叉导致 | 只会加行锁 | |
| 间隙锁与插入意向锁冲突 | 间隙锁阻塞插入,但间隙锁可以有多个事务拥有,形成互相等待 | 间隙锁插入意向锁 | |
| 并发INSERT ON DUPLICATE KEY UPDATE 死锁 | INSERT冲突加S锁,再尝试升级X锁,并发导致互相等待释放S锁 | S锁升级X锁 |
MySQL间隙锁触发条件
触发条件:范围查询 / 非唯一索引查询 + 加锁操作
即使用select ... for update
使用update,delete操作会加Next-Key Lock吗?
可能会,UPDATE / DELETE 的加锁逻辑 = 先按 SELECT ... FOR UPDATE 的规则加锁,再执行修改。所以加锁的场景和上面一样。
所以建议尽量用主键/唯一索引等值查询,可以退化为纯行锁,并发度最高
三十五、热点账户如何应对
热点账户指同时间并发进行入账或出账,业内常见方案有:
- 顺序记账(台账方式),只记账不扣减余额(适用于B端商家,可按结算周期进行结算)
- 账户分桶,将热点账户分为多个子账户,写入时做Hash分片,避免同时写入
- 通过负载均衡将同一账户的写入请求路由至同一个服务节点,存储在内存队列中,聚合后再进行处理
- C端用户一般没有热点问题,可以使用乐观锁
三十六、Kafka与主流MQ对比
为什么Kafka高吞吐,可回放
三者都是顺序写,但是
- RabbitMQ在处理每条ACK时会找到消息(产生随机IO)更新消息状态,在高并发下会有大量随机IO,此外RabbitMQ是推送模式的,有专门的进程负责消息的push,在量大的时候内存会有瓶颈
- RocketMQ也是顺序写CommitLog,但因为所有topic的消息写在一起,所以需要借助ConsumeQueue来定位topic位置,相比Kafka多了一次IO(消费时会使用mmap提高随机读取速度,但RocketMQ有TAG过滤逻辑,因此不会使用sendfile),但写入时比Kafka快
- Kafka是批量提交,在消费时批量消费后只提交一个offset并不会产生额外IO(读取index时会使用mmap)所以消费快,另外使用sendfile零拷贝,减少拷贝时间,另外批量拉取也会提高吞吐
Kafka吞吐量大的核心原因:
- 存储:顺序写log文件(虽然会写.index文件但并不是每次写log都会写.index文件,而是积累到一定数量后写),相比之下RocketMQ每次都会写CommitLog与ConsumeQueue(异步),存在写放大问题。
- 批处理:Kafka在生产或消费时都会进行批量处理,并进行压缩,提高批处理大小,RocketMQ也支持批量,但批量大小相对较小。
- 异步刷盘:Kafka消息持久化是异步刷盘,消息会先落到PageCache中,后续再刷盘,大量消费可能会直接命中缓存不会产生磁盘IO。相比之下,RocketMQ刷盘频率更高。
- 内存占用少:Kafka只负责对消息存储和消费并无其他业务加工逻辑,内存占用较少。RocketMQ还支持tag过滤,延迟队列,重试队列等。
为什么RabbitMQ低延迟
能低延迟是因为:
-
每个 Queue 是一个 Erlang 轻量进程 Erlang 调度器:抢占式,微秒级进程切换 消息 Queue 在 Erlang 进程间传递,几乎是内存拷贝速度
-
PUSH模式,消息到了就推给消费者
但存在以下问题:
-
内存优先 → 内存耗尽时性能断崖式下跌(flow control 触发) Producer → Broker 内存使用超过 40% → 触发流控 → Producer 被阻塞 延迟从 μs 级突变为秒级,非常不稳定
-
Push 模型 → Consumer 处理不过来时,消息堆积在Broker → Broker 内存爆了→ 开始 Page Out 到磁盘 → 性能急剧下降
-
不适合大消息/高吞吐持续场景 → 吞吐一旦上去,内存/GC压力让延迟变得不可预测
三者缺点对比
为什么Kafka经常被用于日志采集
1.高吞吐
日志特点:
- 消息小(平均 200B ~ 2KB)
- 数量极多(百台机器,每台每秒产生数千条)
- 瞬间洪峰(早高峰、营销活动期间)
Kafka 的批量聚合对小消息特别友好:
1000条 × 500B = 500KB,批量压缩后≈ 100KB
一次网络请求 + 一次磁盘追加 = 搞定 1000 条消息
RocketMQ 的 ConsumeQueue 分发对小消息不友好:
1000 条消息 → 1000 次 ConsumeQueue 写入
ConsumeQueue 固定 20字节/条,小消息的"消息体/索引"比例低
写放大代价相对更高
2.可回溯
可按照时间进行回溯,但这个RocketMQ也能支持
3.开箱即用的Connect生态
Kafka Connect 是一个标准化的数据集成框架,有 200+ 开箱即用的 Connector:
日志采集侧(Source Connector):
Filebeat → Kafka(官方支持,最成熟)
Fluentd → Kafka(k8s 环境标配)
Flume → Kafka(Hadoop 生态标配)
Logstash → Kafka(ELK 栈核心组件)
日志消费侧(Sink Connector):
Kafka → Elasticsearch(官方 ES Connector,自动管理 index)
Kafka → HDFS(Confluent HDFS Connector,支持 Parquet/Avro)
Kafka → S3/OSS(云存储 Connector)
Kafka → ClickHouse(实时分析)
Kafka → Doris(Apache Doris Connector)
4.Kafka Streams —— 日志实时处理
三十七、线程池设计
1000万条短信1小时发完 — 线程池设计与并发原理深度解析
三十八、线程死锁后可能出现的现象
- 相关接口无响应
- CPU使用率下降
- BLOCKED状态的线程数增加
- 进程没有崩溃,但没有响应
如何预防:
- 按固定顺序加锁
- 加锁时设置超时时间
- 使用更高层的工具如ConcurrentHashMap等
有什么检测死锁的工具吗
jstack(最常用)
# 查找 Java 进程 PID
jps -l
# 打印线程堆栈,自动检测死锁
jstack <pid>
# 输出到文件分析
jstack <pid> > thread_dump.txt
输出示例
Found one Java-level deadlock:
=============================
"Thread-1":
waiting to lock monitor 0x... (object 0x..., a java.lang.Object),
which is held by "Thread-0"
"Thread-0":
waiting to lock monitor 0x... (object 0x..., a java.lang.Object),
which is held by "Thread-1"
Arthas(阿里开源,生产推荐)
# 启动 Arthas
java -jar arthas-boot.jar <pid>
# 查看线程,检测死锁
thread -b
thread -b 直接找出阻塞其他线程最多的那个线程,非常适合快速排查生产问题。
三十九、什么是虚拟内存
虚拟内存是操作系统提供的一种内存管理机制,它让每个进程都"以为"自己独占一段连续的、巨大的地址空间,而实际上这些地址会被映射到物理内存(RAM)或磁盘上的交换空间(Swap)。
核心概念
- 地址空间抽象
- 每个进程有独立的虚拟地址空间(如64位系统上理论可达 128TB)
- 进程看到的地址是"虚拟地址",CPU 通过 MMU(内存管理单元) 将其翻译为物理地址
- 分页(Paging)
- 虚拟内存和物理内存都被切分成固定大小的块,称为页(Page),通常4KB
- 操作系统维护页表(Page Table),记录虚拟页→ 物理页的映射关系
- 缺页中断(Page Fault)
- 进程访问一个虚拟地址,对应的物理页不在内存中时触发
- OS 从磁盘 Swap 区加载该页到内存,然后继续执行
虚拟内存的作用
共享内存与零拷贝中内存映射的关系
传统拷贝与零拷贝对比
传统方式(4次拷贝):
磁盘 ──[DMA]──► 内核页缓存 ──[CPU]──► 用户缓冲区 ──[CPU]──► Socket缓冲区 ──[DMA]──► 网卡↑不可省↑可省 ↑可省 ↑不可省
mmap + write(3次拷贝):
磁盘 ──[DMA]──► 内核页缓存 ──────────────────[CPU]──► Socket缓冲区 ──[DMA]──► 网卡省掉了内核→用户态的那次CPU拷贝
sendfile(2次拷贝,真正零CPU拷贝):
磁盘 ──[DMA]──► 内核页缓存 ─────────────────────────────────────────[DMA]──► 网卡内核内部只传描述符,完全无CPU拷贝
四十、HashMap为什么用红黑树
为什么用红黑树不用其他数据结构
-
BST,可能退化为链表
-
AVL,严格平衡,但旋转次数多
-
B树/B+树,树矮,但是每个节点存储内容较多(一个节点中存储多个key,和子节点,需要进行二分查找比较,比较次数较多),在内存环境中优势不明显
-
红黑树,可能发生旋转(但次数较少),但在内存中速度很快,且不每个节点只需要比较一次
-
跳表,索引层数不确定,可能会增加内存开销,但适合范围查询(ZSET)
- 调表的时间复杂度是O(log n) 期望,有退化风险
跳表查找之所以能达到 O(log n),依赖一个前提:高层的节点数约是低层的一半,形成类似二分的索引结构。但这个"每层节点数减半"的理想结构,是靠随机数来维持的,不是强制保证的。
- 调表的时间复杂度是O(log n) 期望,有退化风险
四十一、MySQL中COUNT(*)执行过程
如果没有where条件
SELECT COUNT(*) FROM orders;
优化器会:
-
找出表上所有索引
-
选择叶子节点数据量最小的那棵索引树
- 这里说的最小指的是叶子节点(Page)数量最少,行数是固定的,但每个字段大小不一样,不同索引占用的Page数量不同,选取Page数量最少的索引树来遍历时最快的
-
从头到尾扫描该索引的所有叶子节点
-
累加可见行数
不同 COUNT写法的差异
加了 WHERE 条件的情况
可能会命中索引
-- 场景1:WHERE 条件命中索引的range
SELECT COUNT(*) FROM orders WHERE status = 1;
-- type=ref或 range,只扫描部分索引,不是全量扫描
-- 场景2:WHERE 条件无索引
SELECT COUNT(*) FROM orders WHERE remark LIKE '%退款%';
-- type=ALL,全表扫描,最慢
-- 场景3:有索引但区分度低
SELECT COUNT(*) FROM orders WHERE status IN (0, 1, 2);
-- 优化器可能直接走全索引扫描,因为几乎命中所有行
四十二、Kafka消息重试实现
在创建TOPIC时,会创建origin_topic与重试origin_topic_retry_level_1,origin_topic_retry_level_2。当消费重试时,投递到重试的topic中去,进行重试。
死信队列,经过多次重试失败后投递到死信队列中,消费失败后会再次投递到死信队列,再提交offset,达到一直重试的效果。
四十三、消息已读如何实现
方案一、写扩散
为每个用户创建一个收件箱,发一条消息会写1000条收件箱,但更新收件箱消息状态时每次只用更新一条。
方案二、读扩散
消息只存一份,新增游标表来记录用户在某个会话中已读取的最大seq
会话序列号seq是单调递增的
在同一会话中,seq代表消息的顺序,简单实现可用Redis的INCR来实现,保证不重复,递增。
但Redis节点挂了,需要考虑重新恢复seq:
- 可考虑读取DB中max_seq + 1作为Redis初始值
四十四、Spring中AOP的实现
以@Async简单举例来看,主要是依赖于BeanPostProcessor(BPP)接口,Spring会先注册BPP,然后在创建业务Bean的时候会挨个走BPP。而代理的逻辑就是在BeanPostProcessor.postProcessAfterInitialization方法内实现的,返回的Bean就是代理对象会替代掉原有Bean。
1. Spring 启动 refresh()
2. registerBeanPostProcessors:
- AsyncAnnotationBeanPostProcessor 被实例化并注册
- 此时业务 Bean 还没创建
3. finishBeanFactoryInitialization:
- 开始创建业务 Bean(如 MyService)
- initializeBean 时,遍历所有已注册的 BPP
- 调用 AsyncAnnotationBeanPostProcessor.postProcessAfterInitialization
- 判断 isEligible → 发现有 @Async 方法 → 创建代理
- 返回代理对象,替换原始 MyService- 代理对象被存入 singletonObjects
可能存在的问题\
四十五、Redisson如何实现分布式锁
LUA + HashTable
1. 加锁 Lua 脚本
-- KEYS[1] = 锁的 key(如 "myLock")
-- ARGV[1] = 锁的过期时间(毫秒,如 30000ms)
-- ARGV[2] = 唯一标识(clientId:threadId,如 "uuid:1")
if (redis.call('exists', KEYS[1]) == 0) then
-- 锁不存在,创建 hash,设置重入次数为 1
redis.call('hincrby', KEYS[1], ARGV[2], 1);
-- 设置过期时间
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
-- 锁已存在,且是当前线程持有(可重入)
redis.call('hincrby', KEYS[1], ARGV[2], 1);
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
-- 锁被其他线程持有,返回剩余过期时间(毫秒)
return redis.call('pttl', KEYS[1]);
2.解锁 Lua 脚本
-- KEYS[1] = 锁的 key
-- KEYS[2] = 发布订阅的 channel("redisson_lock__channel:{myLock}")
-- ARGV[1] = 解锁消息(通知等待线程锁已释放)
-- ARGV[2] = 锁的过期时间(续期用)
-- ARGV[3] = 唯一标识(clientId:threadId)
-- 当前线程不持有锁,直接返回 nil(非法解锁)
if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then
return nil;
end;
-- 计数器 -1
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);
if (counter > 0) then
-- 还有重入,刷新过期时间
redis.call('pexpire', KEYS[1], ARGV[2]);
return 0;
else
-- 计数器为 0,删除锁
redis.call('del', KEYS[1]);
-- 发布解锁消息,唤醒等待的线程
redis.call('publish', KEYS[2], ARGV[1]);
return 1;
end;
四十六、ConcurrentHashMap加锁,计数,扩容
1.加锁
加锁采用的策略是若桶位置上没有元素尝试用CAS,CAS失败或者桶位置上有元素采用syncronized同步锁
2.计数
计数放弃了使用AtomicLong(避免高并发下竞争导致的空旋,CPU资源浪费),使用Cell分片
单一热点变量 → 分散到多个 Cell
线程1 → Cell[0] +1
线程2 → Cell[1] +1
线程3 → Cell[2] +1
线程4 → Cell[3] +1
线程5 → baseCount +1 (Cell 未初始化时用这个)
最终 size = baseCount + Cell[0] + Cell[1] + Cell[2] + Cell[3]
3.扩容
在扩容期间,调用多个线程(请求线程)分配扩容任务,完成扩容
时刻0:size 达到阈值 12(16 * 0.75)
线程A 触发扩容,sizeCtl = -2(标识戳 + 1个线程)
创建 nextTable[32],transferIndex = 16
时刻1:线程A 领取 [8,15],开始迁移
bucket[15] 迁移完 → 放 ForwardingNode(hash=MOVED)
bucket[14] 迁移完 → 放 ForwardingNode
...
时刻2:线程B 执行 put,发现 sizeCtl < 0
→ 调用 helpTransfer() 加入协同
→ sizeCtl = -3(现在2个线程)
→ 线程B 领取 [0, 7]
时刻3:线程C 执行 get("key"),该 key 落在 bucket[14]
→ 读到 ForwardingNode
→ 自动转去 nextTable 查找,无感知!
时刻4:线程A 和线程B 都完成
→ 最后一个线程做收尾
→ table = nextTable,sizeCtl 恢复为 24(32 * 0.75)
→ 扩容完成
读操作会不会被阻塞?
不会,如果原来的桶没找到就去新的桶去找,有一个重定向
写操作会不会被阻塞?写线程会和迁移线程抢占同一个syncronized锁(如果写线程已经帮扩容完成了,再去写新表,未迁移的就会抢占锁)
扩容进行中(transferIndex 从16推进到0):
bucket索引: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
迁移状态: ✗ ✗ ✗ ✗ ✗ ✗ ✗ ✗ ✓ ✓ ✓ ✓ ✓ ✓ ✓ ✓
未迁移(可正常写) 已迁移(ForwardingNode)
写线程A put("apple") → 落在 bucket[3](未迁移)
→ 正常 synchronized 写入 ✓ 不阻塞扩容
写线程B put("bee") → 落在 bucket[5](迁移线程正在处理)
→ 等迁移线程释放 synchronized(bucket[5]) → 短暂等待(微秒)
写线程C put("cat") → 落在 bucket[10](已迁移完)
→ 发现 ForwardingNode → helpTransfer() → 协同扩容 → 再写新表
四十七、如果服务器中有大量的time_wait状态,是怎么引起的,要怎么解决
time_wait指的是TCP4次挥手最后客户端等待(会等待两个MSL周期)的状态。
可能引起的原因是:客户端使用大量的短连接,导致请求结束后后立即关闭连接。
导致:使得大量连接处于time_wait状态,对应临时端口也不可复用。
解决办法:使用长连接,提升允许空闲线程数。
四十八、如果线程池子线程执行任务抛异常了如何处理
方式 1:任务内部 try-catch(最基础)
✅ 适合:简单场景,但需要每个任务都写,容易遗漏。
方式 2:自定义 ThreadFactory 设置 UncaughtExceptionHandler
ThreadFactory factory = r -> {
Thread t = new Thread(r);
t.setUncaughtExceptionHandler((thread, e) -> {
log.error("[ThreadPool] 线程 {} 发生未捕获异常", thread.getName(), e);
// 告警、监控上报
AlertService.notify("线程异常: " + e.getMessage());
});
return t;
};
ExecutorService pool = new ThreadPoolExecutor(
coreSize, maxSize, keepAlive, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(),
factory // 关键!
);
✅ 适合: execute() 提交的任务,全局兜底。
⚠️ 注意:对 submit() 提交的任务无效,因为异常被 FutureTask 内部捕获了。
方式 3:重写 ThreadPoolExecutor.afterExecute() (推荐用于监控)
ExecutorService pool = new ThreadPoolExecutor(coreSize, maxSize, ...) {
@Override
protected void afterExecute(Runnable r, Throwable t) {
super.afterExecute(r, t);
// 处理 execute() 的异常
if (t != null) {
log.error("execute() 任务异常", t);
}
// 处理 submit() 的异常(从 Future 中取出)
if (t == null && r instanceof Future<?> future) {
try {
if (future.isDone()) {
future.get();
}
} catch (CancellationException e) {
t = e;
} catch (ExecutionException e) {
t = e.getCause(); // 真正的异常
log.error("submit() 任务异常", t);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
};
✅ 适合:统一监控 execute + submit 的异常,是最全面的方案。
方式 4: submit() 后主动处理 Future (最严谨)
Future<String> future = pool.submit(() -> {
return fetchData();
});
// 方式 4a:同步等待
try {
String result = future.get(5, TimeUnit.SECONDS);
} catch (ExecutionException e) {
log.error("任务执行异常", e.getCause());
} catch (TimeoutException e) {
future.cancel(true);
log.error("任务超时");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// 方式 4b:异步回调(Java 8+ CompletableFuture 更优雅)
CompletableFuture.supplyAsync(() -> fetchData(), pool)
.exceptionally(e -> {
log.error("异步任务异常", e);
return defaultValue; // 降级返回
})
.thenAccept(result -> process(result));
方式 5:使用 CompletableFuture
CompletableFuture<String> cf = CompletableFuture
.supplyAsync(() -> {
return riskyOperation();
}, pool)
.exceptionally(e -> {
log.error("业务异常,降级处理", e);
return "fallback_value";
})
.whenComplete((result, e) -> {
if (e != null) {
monitorService.reportError(e); // 告警上报
}
});
✅ 链式处理,异常处理和业务逻辑分离,可读性强。
几条核心原则:
- 永远不要静默吞掉异常 — 至少要打日志
- submit() 返回的 Future 不能丢弃不管 — 要么
get(),要么用whenComplete处理 - 配合监控告警 — 异常应触发监控系统(如 CAT、Raptor)
- 考虑线程上下文传递 — 如 MDC traceId,可用
TransmittableThreadLocal(TTL) - 区分可恢复/不可恢复异常 —
Error通常不应捕获并继续,应直接终止
四十九、Kafka消费如何保证顺序
Kafka每个Partition的消息是保证有序的,但前提是生产者得保证消息是有序生产的。实际情况是由于各种原因(网络,并发的生产消息)往往生产者无法保证消息的有序性,通过hash将消息投递到固定Partition的方案不太可行。所以需要在消费方保证消费顺序。
举一个具体的例子,比如消费订单消息,订单有创建,付款,核销,完成,退款,退款完成等状态,消费者如何在保证顺序的情况下去写入。
利用version或消息状态保证
在消费时查询DB判断前序消息是否已经处理,当前version是否大于已处理的version,只处理最新的version。若前序消息还未到来,放入延迟队列中等待,先等前序消息到来再处理该消息。
@Service
public class OrderConsumerService {
// 定义合法的状态流转
private static final Map<OrderStatus, Set<OrderStatus>> VALID_TRANSITIONS = Map.of(
OrderStatus.CREATED, Set.of(OrderStatus.PAID),
OrderStatus.PAID, Set.of(OrderStatus.WRITTEN_OFF),
OrderStatus.WRITTEN_OFF, Set.of(OrderStatus.COMPLETED)
);
@Transactional
public void processOrderMessage(OrderMessage msg) {
Order existingOrder = orderRepository.findByOrderId(msg.getOrderId());
if (existingOrder == null) {
// 首次创建
if (msg.getStatus() != OrderStatus.CREATED) {
// 乱序到了,可以选择:丢弃 or 放入延迟队列重试
delayRetryQueue.push(msg, 3000L);
return;
}
orderRepository.save(buildOrder(msg));
return;
}
// 校验状态流转合法性
Set<OrderStatus> validNext = VALID_TRANSITIONS.get(existingOrder.getStatus());
if (validNext == null || !validNext.contains(msg.getStatus())) {
log.warn("非法状态流转 orderId={} {} -> {}",
msg.getOrderId(), existingOrder.getStatus(), msg.getStatus());
// 乱序消息:延迟重试
delayRetryQueue.push(msg, 3000L);
return;
}
// 合法流转,更新状态
existingOrder.setStatus(msg.getStatus());
orderRepository.save(existingOrder);
}
}
这里使用version而不是orderStatus有几个好处:
- 时序语义与业务语义分开
- 避免orderStatus变更,发生意料不到的覆盖情况
如果批量消费如何保证顺序
在某些场景下,为了提高消费并发度,会批量消费。此时,需要在这一批消息中进行聚合处理。可根据orderId进行聚合或排序(可使用Map.merge或者PriorityQueue排序),保证每个订单的消息是有序处理的。
@Service
public class OrderConsumerService {
// 等待重试的消息暂存:orderId -> 待处理消息队列
private final Map<String, PriorityQueue<OrderMessage>> pendingMessages
= new ConcurrentHashMap<>();
public void processOrderMessage(OrderMessage msg) {
// 将消息放入该订单的优先队列(按 version 排序)
pendingMessages.computeIfAbsent(
msg.getOrderId(),
k -> new PriorityQueue<>(Comparator.comparingLong(OrderMessage::getVersion))
).offer(msg);
// 尝试消费队列中已经可以处理的消息
drainPendingQueue(msg.getOrderId());
}
private void drainPendingQueue(String orderId) {
PriorityQueue<OrderMessage> queue = pendingMessages.get(orderId);
if (queue == null) return;
while (!queue.isEmpty()) {
OrderMessage head = queue.peek();
ProcessResult result = tryProcess(head);
if (result == ProcessResult.SUCCESS) {
queue.poll(); // 消费成功,移除
} else if (result == ProcessResult.WAITING_PREV) {
break; // 前驱还没来,停止,等下次触发
} else if (result == ProcessResult.DUPLICATE) {
queue.poll(); // 重复消息,丢弃继续
}
}
}
private ProcessResult tryProcess(OrderMessage msg) {
Order current = orderRepository.findByOrderId(msg.getOrderId());
// 重复消息:版本号已处理过
if (current != null && msg.getVersion() <= current.getVersion()) {
return ProcessResult.DUPLICATE;
}
// 前驱未到:期望的前置状态不匹配
if (!isValidTransition(current, msg)) {
return ProcessResult.WAITING_PREV; // 放回队列等待
}
// 正常处理
applyAndSave(current, msg);
return ProcessResult.SUCCESS;
}
enum ProcessResult { SUCCESS, WAITING_PREV, DUPLICATE }
}
五十、Thrift
Thrift整体分层架构
┌──────────────────────────────────────────┐
│ Your Application Code │ ← 业务层(你写的代码)
├──────────────────────────────────────────┤
│ Generated Service Code │ ← IDL 生成的 Client/Server Stub
├──────────────────────────────────────────┤
│ Processor Layer │ ← 处理器:分发请求到具体实现
├──────────────────────────────────────────┤
│ Protocol Layer │ ← 协议层:序列化/反序列化
│ (Binary / Compact / JSON / TSimple) │
├──────────────────────────────────────────┤
│ Transport Layer │ ← 传输层:数据如何传输
│ (Socket / HTTP / Memory / Framed) │
├──────────────────────────────────────────┤
│ Server Layer │ ← 服务器模型
│ (Simple / ThreadPool / NonBlocking) │
└──────────────────────────────────────────┘
Thrift支持二进制,压缩二进制,JSON序列化,默认是二进制序列化。
1. IDL 层(Interface Definition Layer)
Thrift 使用 .thrift 文件定义接口
2. Protocol 层(序列化协议)
3. Transport 层(传输层)
TSocket → 基于 TCP 的 Socket 传输(最常用)
THttpClient → 基于 HTTP 传输
TMemoryBuffer → 内存传输(测试用)
TFramedTransport → 在数据前加 4 字节 frame size(配合非阻塞服务器)
TBufferedTransport → 带缓冲的传输,减少系统调用次数
TSSLSocket → SSL/TLS 加密传输
4. Server 层(服务器模型)
TSimpleServer → 单线程,一次处理一个连接(仅测试用)
TThreadPoolServer → 线程池模型,每个连接一个线程
TNonblockingServer → 基于 NIO 的非阻塞服务器,单线程处理 I/O
THsHaServer → Half-Sync/Half-Async,I/O 非阻塞 + 线程池处理
TThreadedSelectorServer → 多个 Selector 线程,推荐生产使用
调用链路图
Client App
│
▼
Generated Client Stub (e.g. BillingService.Client)
│ 封装参数,调用 send_getAccount()
▼
Protocol (TBinaryProtocol)
│ 序列化: 方法名 + seqId + 参数 → 二进制字节流
▼
Transport (TFramedTransport)
│ 加 4 字节 frame header,发送到网络
▼
═══ 网络传输 ═══
│
▼
Transport (服务端接收)
│ 读取 frame,解包字节流
▼
Protocol (TBinaryProtocol)
│ 反序列化 → 方法名 "getAccount" + accountId=12345
▼
Processor (BillingService.Processor)
│ 根据方法名分发到对应 Handler
▼
Your Handler Implementation
│ 执行业务逻辑,返回 BillingAccount 对象
▼
(反向序列化结果,返回给 Client)
与其他主流 RPC 框架对比
Thrift优缺点
✅ 优点
- 跨语言:25+ 语言代码自动生成,Java/Python/Go/C++/PHP 等无缝互调
- 高性能:Binary/Compact 协议序列化效率高,远超 HTTP + JSON
- 强类型:IDL 强制类型约束,接口契约清晰,减少运行时错误
- 灵活:Protocol/Transport/Server 三层均可替换,适应不同场景,协议也可选择TCP/HTTP
- 成熟稳定:Facebook 内部大规模使用 10+ 年,生产经过验证
❌ 缺点
- 不支持流式调用:gRPC 支持 ServerStream/ClientStream/BiStream,Thrift 不支持
- 服务治理需自建:无内置服务发现、负载均衡(需配合 ZooKeeper 等)
- 可读性差:Binary 协议调试困难,需要借助工具
- 向后兼容需谨慎:字段编号变更会破坏兼容性
- HTTP/2 支持弱:与云原生生态(Kubernetes、Istio)集成不如 gRPC 顺畅
gRPC
gRPC是在HTTP2基础上进行封装的,但封装很薄,K8s对HTTP2支持的很好,已深度理解和支持 HTTP/2,所以 gRPC 可以"免费"获得方法级路由、流量染色、自动追踪、标准健康检查等所有能力。
HTTP1.1 vs HTTP2
HTTP/2 的 Header 被 HPACK 算法压缩成二进制,Body 本身格式不变(你传什么它就搬运什么),但都封装在二进制的 Frame 结构里传输。相比 HTTP/1.1 的纯文本,HTTP/2 的二进制帧让解析效率更高,同时 HPACK 大幅降低了重复 Header 的带宽开销。
五十一、Dubbo
核心角色
┌─────────────────────────────────────────────────────────────┐
│ Dubbo 核心角色 │
│ │
│ ┌──────────┐ ①注册服务 ┌─────────────────┐ │
│ │ Provider │ ─────────────▶ │ Registry │ │
│ │ 服务提供者 │ │ (Nacos/ZK/Etcd) │ │
│ └──────────┘ └─────────────────┘ │
│ ▲ │ │
│ │ ②订阅/推送服务地址 │
│ │ ▼ │
│ │ ④远程调用 ┌──────────────┐ │
│ └─────────────────── │ Consumer │ │
│ │ 服务消费者 │ │
│ └──────────────┘ │
│ │ │
│ ③上报统计数据 │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Monitor │ │
│ │ (Dubbo Admin) │ │
│ └─────────────────┘ │
└─────────────────────────────────────────────────────────────┘
分层架构(10层模型)
Consumer 调用链(从上到下) Provider 处理链(从下到上)
┌────────────────────────┐
│ Business Layer │ ← 你写的业务代码
├────────────────────────┤
│ RPC Layer │
│ ┌──────────────────┐ │
│ │ Interface Proxy │ │ ← 动态代理层:让远程调用像本地调用
│ ├──────────────────┤ │
│ │ Registry │ │ ← 注册发现层:服务地址管理
│ ├──────────────────┤ │
│ │ Cluster │ │ ← 集群层:负载均衡 + 容错
│ ├──────────────────┤ │
│ │ Monitor │ │ ← 监控层:统计调用次数、耗时
│ ├──────────────────┤ │
│ │ Protocol │ │ ← 协议层:封装 Invoke,暴露/引用服务
│ └──────────────────┘ │
├────────────────────────┤
│ Remoting Layer │
│ ┌──────────────────┐ │
│ │ Exchange │ │ ← 信息交换层:Request/Response 模型
│ ├──────────────────┤ │
│ │ Transport │ │ ← 网络传输层:Netty/Mina 封装
│ ├──────────────────┤ │
│ │ Serialize │ │ ← 序列化层:Hessian2/Protobuf/Kryo
│ └──────────────────┘ │
└────────────────────────┘
1. 服务注册与发现
Provider
Provider 启动时:
1. Spring 容器初始化,扫描 @DubboService 注解
2. 根据配置生成 URL:
dubbo://192.168.1.100:20880/com.example.UserService
?version=1.0.0&timeout=3000&weight=100
3. 将 URL 写入注册中心(ZooKeeper/Nacos)
ZooKeeper 存储结构:
/dubbo
└── com.example.UserService
├── providers ← Provider 列表
│ ├── dubbo://192.168.1.100:20880/...
│ └── dubbo://192.168.1.101:20880/...
├── consumers ← Consumer 列表
└── configurators ← 动态配置
Consumer
Consumer 启动时:
1. 扫描 @DubboReference 注解
2. 订阅注册中心对应服务节点
3. 注册中心推送 Provider 地址列表
4. Consumer 在本地缓存地址列表(内存)
5. 注册中心挂了也能继续调用(本地缓存兜底)!
3. 集群容错策略
调用失败时,Dubbo 怎么处理?
Failover(默认): 失败自动切换,重试其他节点
├── 适合:读操作、幂等写操作
└── ⚠️ 注意:重试会增加响应时间,且对 Provider 有额外压力
Failfast: 快速失败,只调一次,失败立即报错
└── 适合:非幂等写操作(下单、支付)
Failsafe: 失败安全,出异常直接忽略
└── 适合:旁路操作(日志上报、审计)
Failback: 失败自动恢复,后台定时重试
└── 适合:消息通知等最终一致性场景
Forking: 并行调用多个 Provider,只要一个成功就返回
├── 适合:对响应时间要求极高
└── ⚠️ 浪费资源,慎用
Broadcast: 广播调用所有 Provider,任意一台报错则报错
└── 适合:刷新缓存等需要通知所有节点的场景
Cluster作用
Cluster是Consumer端本地的逻辑组件!
先理清"缓存地址"和"Cluster"的关系
本地缓存的是什么?
Registry 推送过来的 Provider 地址列表(原始数据):
[
dubbo://192.168.1.100:20880/UserService,
dubbo://192.168.1.101:20880/UserService,
dubbo://192.168.1.102:20880/UserService
]
这只是"原材料",就像你有一本通讯录。
但打电话时,你还需要决定:
→ 打给谁?(负载均衡)
→ 打不通怎么办?(容错)
→ 要同时打给所有人吗?(广播)
→ 某些号码不能打怎么判断?(路由过滤)
这些决策逻辑 = Cluster 的职责!
Cluster调用流程
Consumer 发起调用
│
▼
┌─────────────────────────────────────────────────────┐
│ Cluster 层 │
│ │
│ Step 1: Directory(目录服务) │
│ └── 从本地缓存取出地址列表 │
│ └── 通过 Router 过滤掉不符合条件的 Provider │
│ (标签路由/条件路由/灰度路由...) │
│ ↓ 过滤后:[100, 101](102 被路由规则排除) │
│ │
│ Step 2: LoadBalance(负载均衡) │
│ └── 从过滤后的列表中,按策略选择一个 │
│ ↓ 选中:192.168.1.100 │
│ │
│ Step 3: ClusterInvoker(容错执行) │
│ └── 真正发起调用 │
│ └── 失败了?根据容错策略决定下一步 │
│ ├── Failover → 重试其他节点 │
│ ├── Failfast → 直接报错 │
│ └── Failsafe → 忽略异常 │
└─────────────────────────────────────────────────────┘
│
▼
真正发 TCP 请求给 192.168.1.100:20880
Dubbo使用gRPC会比Hessian2+dubbo协议快吗
传输层都是TCP,应用层有区别,但两者都支持二进制,差别不大
Dubbo 协议栈对比:
dubbo:// 协议(默认) grpc:// 协议
─────────────────────────────────────────────
传输层: TCP (原始 Socket) 传输层: TCP
应用层: Dubbo 私有协议 应用层: HTTP/2
序列化: Hessian2 序列化: Protobuf
─────────────────────────────────────────────
Netty 直接操作 TCP 字节流 Netty + HTTP/2 编解码器
Triple:// 协议(Dubbo 3 推荐)
─────────────────────────────────────────────
传输层: TCP
应用层: HTTP/2(兼容 gRPC)
序列化: Protobuf 或 Hessian2(可选)
五十二、关注,粉丝系统设计
分库分表
实现类似微博的关注和粉丝功能,考虑到大量数据,需要两张表following(关注表)与follower(粉丝表)分别以follower_id与followee_id做分片hash
两张表双写一致性保障
方案一:先写following表,再写follower表,如果follower写失败,MQ异步重试,报警,实现最终一致性。
方案二:只写following表,通过Binlog同步至follower表,监听两者Binlog进行监控
缓存设计
关注列表和粉丝列表可用Redis ZSET进行缓存,value可使用时间便于排序
# 关注列表(我关注了谁)
Key: following:{userId}
Type: ZSet
Score: 关注时间戳
Member: followeeId
# 粉丝列表(谁关注了我)
Key: follower:{userId}
Type: ZSet
Score: 关注时间戳
Member: followerId
# 关注数 / 粉丝数
Key: follow_count:{userId}
Type: Hash
Field: following_count, follower_count
DB与Redis一致性保障
使用经典旁路缓存模式
写操作
┌─────────────────────────────────────────────────────┐
│ 关注操作:A 关注 B │
│ │
│ 1. 写 DB(following + follower 双表) │
│ 2. 删除 Redis 缓存(而非更新!) │
│ - DEL following:{A} │
│ - DEL follower:{B} │
│ - HINCRBY follow_count:{A} following_count 1 │
│ - HINCRBY follow_count:{B} follower_count 1 │
└─────────────────────────────────────────────────────┘
延迟双删策略(应对缓存与DB不一致)
写 DB
↓
第一次删除 Redis 缓存
↓
sleep 500ms(等待并发读请求完成旧缓存重建)
↓
第二次删除 Redis 缓存
读操作
查询用户 A 的关注列表
↓
读 Redis following:{A}
├── HIT → 直接返回
└── MISS → 查 DB following 表
↓
写入 Redis(设置过期时间,如 1 小时)
↓
返回结果
缓存击穿防护
没命中缓存,可以加分布式锁+singlFlight(查同一个key,可以将多个请求合并,只有一个请求去查DB其他请求等待Cache就绪)
public List<Long> getFollowing(long userId) {
String cacheKey = "following:" + userId;
String lockKey = "lock:following:" + userId;
String channel = "channel:following:" + userId;
// 1. 读缓存
List<Long> cached = redis.zrange(cacheKey, 0, -1);
if (cached != null && !cached.isEmpty()) return cached;
// 2. 尝试抢锁
if (redisLock.tryLock(lockKey, 5, TimeUnit.SECONDS)) {
try {
cached = redis.zrange(cacheKey, 0, -1);
if (cached != null && !cached.isEmpty()) return cached;
List<Long> dbResult = followingDao.query(userId);
redis.zadd(cacheKey, dbResult, 3600);
// 3. 通知所有等待者:缓存已就绪
redis.publish(channel, "ready");
return dbResult;
} finally {
redisLock.unlock(lockKey);
}
}
// 4. 没抢到锁:订阅通知,等缓存重建
return subscribeAndWait(cacheKey, channel);
}
private List<Long> subscribeAndWait(String cacheKey, String channel) {
CountDownLatch latch = new CountDownLatch(1);
redis.subscribe(channel, message -> latch.countDown());
try {
boolean signaled = latch.await(3, TimeUnit.SECONDS);
if (signaled) {
return redis.zrange(cacheKey, 0, -1); // 缓存已就绪
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// 超时降级
return followingDao.query(userId);
}
还可以使用ConcurrentHashMap+Future对象来实现SingleFlight(但仅限于单机)
// 核心数据结构:key → 正在进行的 Future
private final ConcurrentHashMap<String, CompletableFuture<List<Long>>> inflightMap
= new ConcurrentHashMap<>();
public List<Long> getFollowing(long userId) {
String cacheKey = "following:" + userId;
// 读缓存
List<Long> cached = getFromRedis(cacheKey);
if (cached != null) return cached;
// Singleflight:合并对同一 key 的并发请求
CompletableFuture<List<Long>> future = inflightMap.computeIfAbsent(
cacheKey,
key -> CompletableFuture
.supplyAsync(() -> {
// 只有第一个请求会执行这里
List<Long> result = followingDao.query(userId);
redis.zadd(cacheKey, result, 3600);
return result;
}, dbExecutor)
.whenComplete((result, ex) -> {
// 完成后从 map 中移除,下次重新触发
inflightMap.remove(cacheKey);
})
);
try {
// 所有并发请求共享同一个 Future,等待同一个结果
return future.get(3, TimeUnit.SECONDS);
} catch (TimeoutException e) {
log.error("Singleflight 超时, key={}", cacheKey);
return Collections.emptyList(); // 降级返回空
}
}
五十三、MySQL联合索引如何设计+索引覆盖
联合索引
首先,联合索引要满足最左前缀规则。其次,当涉及order by字段时需要注意有可能引起filesort(数据量较大时)
下面这句SQL查询,如何建立索引
SELECT user_id, status, created_at
FROM orders
WHERE user_id = ?
ORDER BY created_at;
- 如果建立 (user_id, created_at), 会打中索引,且因为在索引树中先按照user_id排序,再按照created_at排序,且此时ORDER BY是升序,可以直接按照索引顺序来排序,不需要额外内存排序。但是status不在索引中,需要回表。(一般同一个user_id下的记录是有序的)
- 如果建立 (user_id, status, created_at) 会打中索引,但由于会按status先排序,无法保证created_at是有序的,就可能有filesort(数据量大时),但不会有回表
- 如果建立 (user_id, created_at, status ) 会打中索引,且同一user_id下created_at是有序的,不会有filesort,也不会有回表
所以按照下面原则来设计联合索引,可以提高覆盖索引的概率
(WHERE 列)+(ORDER BY / GROUP BY 列)+(SELECT 列) 按这个顺序建联合索引,大概率能命中覆盖索引,但这个前提是where语句是等值查询(=)!!!, 范围查询大概率会失效!
order by
注意:order by方向与索引方向不同有可能导致filesort
-- 索引 (user_id, status, created_at) 全部升序存储
ORDER BY status ASC, created_at DESC; -- 一升一降
group by
group by的大概原理是先进行排序,按顺序遍历,相同的值会放进同一分组内,如果group by的字段无法保证有序,那么有可能会导致创建临时表,在临时表中排序聚合
会产生临时表的场景
场景1:GROUP BY 列没有索引
场景2:GROUP BY 列违反最左前缀
场景3:GROUP BY 列有函数运算
场景4:GROUP BY + ORDER BY 方向不一致
-- 索引 (user_id, status)
SELECT user_id, status, COUNT(*)
FROM orders
WHERE user_id = 100
GROUP BY status
ORDER BY status DESC; -- GROUP BY 用索引升序,ORDER BY 要降序
五十四、服务挂了如何排查
-
看报警
-
关键指标
-
进程存活(存活探针),或者上机器找java进程
# 查进程是否存在 ps aux | grep <appname> jps -l # 查端口是否在监听 netstat -tlnp | grep <port> ss -tlnp | grep <port> # 查服务健康检查 curl http://localhost:<port>/health -
响应时间TP99
-
错误率如5XX
-
内存使用率
-
线程数
-
-
-
查日志
- 查找ERROR/EXCEPTION日志
-
确认影响范围
-
先止血回滚
-
再根据日志分析根因
- OOM
- 死锁
- GC
- 下游影响
五十五、volatile 与内存可见性问题详解
volatile关键字做了什么?为什么可以保证内存可见性
一、先理解现代 CPU 的内存架构
CPU 核心1 CPU 核心2
┌─────────────────┐ ┌─────────────────┐
│ 寄存器 │ │ 寄存器 │
│ ↕ │ │ ↕ │
│ L1 Cache │ │ L1 Cache │ ← 每个核心私有
│ ↕ │ │ ↕ │
│ L2 Cache │ │ L2 Cache │ ← 每个核心私有
└────────┬────────┘ └────────┬────────┘
│ │
└──────────┬─────────────────┘
│
L3 Cache(共享)
│
主内存 Main Memory
每个CPU都有自己的缓存,先从主存读取到CPU缓存,再操作,再刷回主存,这期间是有GAP的。
二、Java 内存模型(JMM)的抽象
JMM 把上面的硬件结构抽象成:
线程1的工作内存 线程2的工作内存
┌──────────────┐ ┌──────────────┐
│ 变量副本 x=0 │ │ 变量副本 x=0 │ ← 各自持有一份副本
└──────┬───────┘ └──────┬───────┘
│ 不保证何时同步 │
└──────────┬────────────┘
│
主内存 x=0 ← 真正的共享内存
可以理解为每个线程在不同的CPU上,所以会有自己【线程的缓存】
三、具体例子:没有 volatile 的问题
public class VisibilityProblem {
private static boolean running = true; // 没有 volatile
public static void main(String[] args) throws InterruptedException {
// 线程1:一直循环,直到 running 变为 false
Thread t1 = new Thread(() -> {
System.out.println("线程1启动,开始循环");
while (running) {
// 空循环
}
System.out.println("线程1结束"); // 可能永远不会打印!
});
t1.start();
Thread.sleep(100);
// 主线程:修改 running = false
System.out.println("主线程设置 running = false");
running = false;
}
}
预期结果: 主线程设置 running=false 后,线程1停止循环。
实际结果: 线程1可能永远不停,一直循环下去。
四、volatile做了什么
1. 写操作:立即刷新到主内存
主线程修改 running = false
↓ volatile 写
立即强制刷新到主内存,不允许停留在缓存中
主内存: running = false ← 立即生效
2. 读操作:每次都从主内存读取
线程1 while(running)
↓ volatile 读
每次都绕过缓存,直接从主内存读取最新值
读到: running = false → 循环结束 ✅
3. 禁止指令重排序
对象发布问题
public class ObjectPublication {
private static MyObject obj = null; // 没有 volatile
// 线程A:初始化对象
public void init() {
obj = new MyObject(); // 这行代码看似原子,实际分3步:
// 1. 分配内存
// 2. 初始化对象
// 3. 将引用赋值给 obj
}
// 线程B:使用对象
public void use() {
if (obj != null) { // 看到了 obj 不为 null
obj.doSomething(); // 但 obj 内部可能还没初始化完!
}
}
}
五、使用对象包裹running字段是不是就能解决可见性了?
// 包装对象
public class Container {
boolean running = true; // 包在对象里,没有 volatile
}
public class VisibilityProblem {
private static Container container = new Container();
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
while (container.running) { // 读的是 container 对象里的 running
// 空循环
}
System.out.println("线程1结束"); // 仍然可能永远不打印!
});
t1.start();
Thread.sleep(100);
container.running = false; // 写的是 container 对象里的 running
}
}
先说结论:不行。CPU缓存的单位是 Cache Line(缓存行) ,大小通常是 64 字节,它直接缓存主内存某段地址的原始字节内容存到CPU缓存中(简单理解就是复制了部分对象),所以还是有可见性问题。
能解决可见性问题的方式
ReentrantLock 如何解决可见性问题
ReentrantLock是间接通过volatile解决可见性问题
可见性的传递过程
private int data = 0; // 普通变量,没有 volatile
ReentrantLock lock = new ReentrantLock();
// 线程A(写数据)
lock.lock();
data = 100; // step1:写普通变量
lock.unlock(); // step2:写 volatile state(0→1→0)
// 线程B(读数据)
lock.lock(); // step3:读 volatile state
int val = data; // step4:读普通变量
lock.unlock();
CPU 缓存层面发生了什么
线程A 执行 unlock():
底层调用 unsafe.compareAndSwapInt(this, stateOffset, ...)
修改 volatile state
↓
CPU 执行 StoreLoad 内存屏障(Memory Barrier)
↓
强制把当前 CPU 缓存中【所有待写回的数据】全部刷到主内存
┌──────────────────────────────────┐
│ 线程A的 CPU 缓存 │
│ data = 100 → 刷回主内存 ✅ │
│ state = 0 → 刷回主内存 ✅ │
│ 其他修改 → 全部刷回主内存 ✅ │
└──────────────────────────────────┘
线程B 执行 lock():
读取 volatile state
↓
CPU 执行 LoadLoad 内存屏障
↓
强制使当前 CPU 缓存中的数据全部失效,重新从主内存加载
┌──────────────────────────────────┐
│ 线程B的 CPU 缓存 │
│ 旧缓存全部失效 │
│ 重新从主内存读 data = 100 ✅ │
└──────────────────────────────────┘
所以内存屏障是关键,volatile读写都会触发内存屏障,读的时候强制从主存读,写的时候强制刷回主存,再通过happen-before原则,将整个过程涉及到的数据都同步到主存或从主存同步。
而syncronized是通过 monitorEnter/monitorExit 指令触发内存屏障
五十六、零拷贝mmap+write vs sendfile
mmap+write
mmap 本质上是让用户态虚拟地址直接映射到 PageCache 的物理页,省去了内核空间到用户空间的一次内存复制。应用程序读写那块地址时,实际上是直接操作 PageCache,因此
write() 调用时,内核可以直接从 PageCache 拷贝到 Socket 缓冲区,而不需要先经过独立的用户态缓冲区中转。
sendfile
五十七、@Tansactional代理 VS Mapper代理
【MyBatis Mapper 代理】 【@Transactional 代理】
① invokeBeanFactoryPostProcessors ① invokeBeanFactoryPostProcessors
MapperScannerRegistrar AutoProxyRegistrar
扫描 AdAccountMapper 接口 注册 InfrastructureAdvisorAutoProxyCreator
BeanDefinition.beanClass 的 BeanDefinition
= MapperFactoryBean.class
(接口根本没有实现类,必须在这里替换)
↓ ↓
② registerBeanPostProcessors ② registerBeanPostProcessors
(此阶段不涉及 Mapper) 实例化 InfrastructureAdvisorAutoProxyCreator
并注册为 BeanPostProcessor
↓ ↓
③ finishBeanFactoryInitialization ③ finishBeanFactoryInitialization
getBean("adAccountMapper") AdAccountService Bean 初始化完成
→ 实例化 MapperFactoryBean → postProcessAfterInitialization()
→ 调用 getObject() → 检测到 @Transactional
→ Proxy.newProxyInstance( → createProxy()
AdAccountMapper.class, → CGLIB 子类代理
MapperProxy)
→ 返回 JDK 代理对象
↓ ↓
④ 方法调用 ④ 方法调用
adAccountMapper.getByTuple(...) adAccountService.createAccount(...)
→ MapperProxy.invoke() → CGLIB 拦截
→ MapperMethod.execute() → TransactionInterceptor.invoke()
→ SqlSession.selectOne() → getTransaction() / BEGIN
→ JDBC PreparedStatement → adAccountMapper.insert() ← 此处才真正调用 Mapper
→ COMMIT / ROLLBACK
Spring的Context.refresh逻辑
ApplicationContext.refresh()
│
├─ ③ invokeBeanFactoryPostProcessors()
│ └─ ConfigurationClassPostProcessor(BeanDefinitionRegistryPostProcessor)
│ ├─ 解析 @EnableTransactionManagement
│ │ └─ @Import(TransactionManagementConfigurationSelector)
│ │ └─ selectImports(PROXY) 返回:
│ │ ├─ AutoProxyRegistrar
│ │ └─ ProxyTransactionManagementConfiguration
│ │
│ ├─ AutoProxyRegistrar.registerBeanDefinitions()
│ │ └─ AopConfigUtils.registerAutoProxyCreatorIfNecessary()
│ │ └─ 注册 BeanDefinition:
│ │ name="internalAutoProxyCreator"
│ │ class=InfrastructureAdvisorAutoProxyCreator ← 只是登记,未实例化
│ │
│ └─ ProxyTransactionManagementConfiguration(@Configuration)
│ ├─ 注册 BeanDefinition: AnnotationTransactionAttributeSource
│ ├─ 注册 BeanDefinition: TransactionInterceptor
│ └─ 注册 BeanDefinition: BeanFactoryTransactionAttributeSourceAdvisor
│
├─ ④ registerBeanPostProcessors()
│ └─ 找到实现 BeanPostProcessor 的 BeanDefinition
│ └─ 实例化 InfrastructureAdvisorAutoProxyCreator ★ 在此刻诞生
│ └─ addBeanPostProcessor() 注册到 BeanFactory
│
└─ ⑥ finishBeanFactoryInitialization()
└─ 实例化每个普通 Bean(如 OrderService)
└─ Bean 初始化完成 → postProcessAfterInitialization()
└─ InfrastructureAdvisorAutoProxyCreator.wrapIfNecessary()
├─ 拿到 BeanFactoryTransactionAttributeSourceAdvisor
├─ Pointcut 检查:OrderService 的方法上有 @Transactional?
├─ 有 → 创建 CGLIB 代理
└─ 返回代理对象,替换 IoC 容器中的原始 Bean
五十八、syncronized锁升级
无锁 -> 偏向锁
线程A发现锁对象头中ThreadId为null,通过cas设置自己的ThreadId
偏向锁 -> 轻量级锁
线程A持有偏向锁,线程B来竞争锁,流程如下:
- 线程B会先向VM线程申请撤销
- VM线程会在SafePoint暂停线程判断线程A是否还在同步块中
- 若不在,待线程恢复后,线程B可能重新偏向成功
- 若在,VM线程会帮助线程A升级为轻量级锁,在线程A栈帧LockRecord中记录MarkWord,对象记录LR的指针
- 线程恢复后若B没有获取到锁,则进行自旋获取轻量级锁
轻量级锁 -> 重量级锁
有几种场景会出现轻量级锁升级为重量级锁
- 一个线程持有锁,其他线程自旋到一定次数后锁升级
- 当调用了
wait()/notify()/notifyAll()三个方法,就会升级为重量级,这三个方法只属于重量级锁
五十九、Spring循环依赖与代理的处理
循环依赖
Spring是通过三级缓存来解决循环依赖的问题的
一级缓存 singletonObjects → 完全初始化好的 Bean
二级缓存 earlySingletonObjects → 提前暴露的 Bean(可能是代理)
三级缓存 singletonFactories → ObjectFactory,用于创建提前暴露对象
@Tansactional场景
@Service
public class OrderService {
@Autowired
private PayService payService;
@Transactional // 触发 CGLIB 代理
public void createOrder() {
payService.pay();
}
}
@Service
public class PayService {
@Autowired
private OrderService orderService; // 注入的是 OrderService 的代理
@Transactional
public void pay() {
System.out.println("paying");
}
}
关键流程
1. OrderService 实例化 → 三级缓存存入 ObjectFactory
2. 填充属性需要 PayService
3. PayService 实例化 → 三级缓存存入 ObjectFactory
4. PayService 填充属性需要 OrderService
5. 从三级缓存调用 ObjectFactory.getObject()
→ 生成 OrderService 的 CGLIB 代理
→ 存入二级缓存
6. PayService 完成 → 一级缓存
7. OrderService 完成 AOP → 发现二级缓存已有代理 → 直接使用
不会报错,这里存入三级缓存的是一个Lamda函数,具体是:
时间轴:
T1: A 实例化 → 三级缓存存入 ObjectFactory
(ObjectFactory 内部持有所有 BeanPostProcessor 引用)
T2: 填充 B 属性 → 触发 B 的创建
T3: B 实例化 → 三级缓存存入 ObjectFactory
T4: B 填充属性 → 需要 A
T5: 从三级缓存获取 A 的 ObjectFactory.getObject()
→ 调用 AbstractAutoProxyCreator.getEarlyBeanReference(A原始对象)
→ wrapIfNecessary → 生成 A 的 CGLIB 代理
→ 代理A 放入二级缓存 earlySingletonObjects
T6: B 注入的是"代理A" ✅
T7: B 走 postProcessAfterInitialization → 生成 B 的代理 → 一级缓存
T8: 回到 A 的初始化流程
T9: A 走 postProcessAfterInitialization
→ earlyProxyReferences.remove(A) == 原始A(已被记录)
→ 不重复创建代理,直接返回原始A
T10: AbstractBeanFactory 检测到二级缓存有代理A
→ 用代理A替换,放入一级缓存 ✅
最终:A 是代理,B 注入的也是同一个代理A → 一致 ✅
原因是AbstractAutoProxyCreator实现了SmartInstantiationAwareBeanPostProcessor接口
// AbstractAutoProxyCreator 同时实现了三个方法
public abstract class AbstractAutoProxyCreator
implements SmartInstantiationAwareBeanPostProcessor { // 注意这个接口!
// ✅ 关键:实现了 getEarlyBeanReference
// 这个方法在三级缓存的 ObjectFactory.getObject() 被调用时触发
@Override
public Object getEarlyBeanReference(Object bean, String beanName) {
Object cacheKey = getCacheKey(bean.getClass(), beanName);
this.earlyProxyReferences.put(cacheKey, bean);
// 提前创建代理!存入 earlyProxyReferences 标记
return wrapIfNecessary(bean, beanName, cacheKey);
}
// postProcessAfterInitialization 中会检查是否已经提前代理过
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) {
if (bean != null) {
Object cacheKey = getCacheKey(bean.getClass(), beanName);
// ✅ 如果已经在 getEarlyBeanReference 中处理过,直接返回原bean
if (this.earlyProxyReferences.remove(cacheKey) != bean) {
return wrapIfNecessary(bean, beanName, cacheKey);
}
}
return bean; // 已提前代理,不重复处理
}
}
而### @Async 的处理器没有实现SmartInstantiationAwareBeanPostProcessor接口,他只会在BeanPostProcessor的afterInitialization方法执行时创建代理对象
// AsyncAnnotationBeanPostProcessor 只继承了 AbstractAdvisingBeanPostProcessor
public class AsyncAnnotationBeanPostProcessor
extends AbstractAdvisingBeanPostProcessor { // 注意:不是 SmartInstantiationAware
// ❌ 没有实现 getEarlyBeanReference
// 只有这一个方法
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) {
// 在 Bean 完全初始化后才创建代理
return super.postProcessAfterInitialization(bean, beanName);
}
}
@Async场景
时间轴:
T1: A(AsyncService) 实例化 → 三级缓存存入 ObjectFactory
⚠️ ObjectFactory 中没有 getEarlyBeanReference 的 @Async 处理器
T2: 填充 B 属性 → 触发 B 的创建
T3: B(NotifyService) 实例化 → 三级缓存
T4: B 填充属性 → 需要 A
T5: 从三级缓存获取 A 的 ObjectFactory.getObject()
→ AbstractAutoProxyCreator.getEarlyBeanReference(A原始对象)
→ @Async 没参与!返回的是 A 的原始对象(或@Transactional代理但无异步增强)
→ 原始A 放入二级缓存
T6: B 注入的是"原始A" ⚠️
T7: B 完成初始化 → 一级缓存
T8: 回到 A 的初始化
T9: A 走 postProcessAfterInitialization
→ AsyncAnnotationBeanPostProcessor 创建 @Async 代理
→ 返回一个全新的 AsyncProxy-A ⚠️
T10: Spring 检查:
二级缓存中的 A = 原始A(或事务代理A)
postProcessAfterInitialization 返回 = 新的AsyncProxy-A
两者不一致!→
if (earlySingletonReference != null && exposedObject == bean) {
// ❌ exposedObject(AsyncProxy) != bean(原始A) 且
// earlySingletonReference(二级缓存的A) != exposedObject(AsyncProxy)
// 抛出异常!
}
BeanCurrentlyInCreationException ❌
三级缓存存的是一个 Lambda 对象(ObjectFactory 实现),Lambda 里写死了调 this.getEarlyBeanReference(beanName, mbd, bean)——而 this 就是 AbstractAutowireCapableBeanFactory 实例,getEarlyBeanReference 方法里拿着容器启动时就已收集好的 smartInstantiationAware 列表,挨个调一遍,InfrastructureAdvisorAutoProxyCreator 正好在这个列表里,所以它的 getEarlyBeanReference 就被调到了。
六十、AI CODING
流程
- 读需求PRD(让AI读PRD+历史PRD+历史设计文档 -> 匹配PRD与设计文档)
- 撰写详细设计文档(人工二次check)
- 读领域知识库,读代码仓库(匹配关键领域词到项目知识库,与关键领域模型做关联)
- 撰写单元测试(人工二次check)
- 撰写代码
- 执行单元测试,不通过修改代码再执行单测
- 人工check后,部署代码到线下泳道
遇到的问题
- 代码空实现
- 重复造领域模型
- 中间件调用没有按照开发习惯来或者规范来用
- 上下游交互,如接口如何定义
解决方案
- 增强编码规范约束,禁止空实现,增加代码注释帮助他理解
- 编码规范中强调使用已有模型,另外在知识库中细化对领域及其重要模型的解释
- skill匹配问题,如调用Mysql不使用开源的方法而是使用公司内部RDS规范
- 约定schema,不关心上下游具体实现,交代上下游概要的业务背景
六十一、线程交替打印1-10
ReentrantLock + Condition
private static volatile int count = 0;
public static void main(String[] args) {
ReentrantLock lock = new ReentrantLock();
Condition odd = lock.newCondition();
Condition even = lock.newCondition();
new Thread(() -> {
lock.lock();
try {
while (count < 10) {
// 只处理偶数
while (count % 2 != 0) {
odd.await();
}
if (count >= 10) {
break;
}
count++;
System.out.println("Thread-奇 -> " + count);
even.signal();
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
}
}).start();
new Thread(() -> {
lock.lock();
try {
while (count < 10) {
// 只处理奇数
while (count % 2 == 0) {
even.await();
}
if (count >= 10) {
break;
}
count++;
System.out.println("Thread-偶 -> " + count);
odd.signal();
}
} catch (InterruptedException e) {
throw new RuntimeException(e);
} finally {
lock.unlock();
}
}).start();
}
syncronized
private static volatile int count = 0;
public static void main(String[] args) {
Object lock = new Object();
new Thread(() -> {
synchronized (lock) {
try {
while (count < 10) {
// 只处理偶数
while (count % 2 != 0) {
lock.wait();
}
if (count >= 10) {
break;
}
count++;
System.out.println("Thread-奇 -> " + count);
lock.notify();
}
} catch (Exception e) {
logger.error("Thread-奇 异常", e);
}
}
}).start();
new Thread(() -> {
synchronized (lock) {
try {
while (count < 10) {
// 只处理奇数
while (count % 2 == 0) {
lock.wait();
}
if (count >= 10) {
break;
}
count++;
System.out.println("Thread-偶 -> " + count);
lock.notify();
}
} catch (Exception e) {
logger.error("Thread-偶 异常", e);
}
}
}).start();
}
六十二、JVM调优
一、核心调优维度
1.堆内存配置
# 生产环境建议:初始堆 = 最大堆,避免动态扩容带来的 STW
-Xms4g -Xmx4g
# 新生代大小(通常设为堆的 1/4 ~ 1/3)
-Xmn1g
# 或用比例控制
-XX:NewRatio=2 # 老年代:新生代 = 2:1
-XX:SurvivorRatio=8 # Eden:Survivor = 8:1:1
2. 垃圾收集器选择
3. G1 GC 调优(当前最主流)
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200 # 目标停顿时间(核心参数)
-XX:G1HeapRegionSize=16m # Region 大小(1m~32m,建议与大对象匹配)
-XX:G1NewSizePercent=20 # 新生代最小占比
-XX:G1MaxNewSizePercent=60 # 新生代最大占比
-XX:InitiatingHeapOccupancyPercent=45 # 触发并发标记的堆占用率
-XX:G1ReservePercent=10 # 预留空间防止 to-space 耗尽
-XX:ConcGCThreads=4 # 并发 GC 线程数
二、内存各区域调优
元空间(Metaspace,JDK 8+)
-XX:MetaspaceSize=256m # 初始大小(避免频繁 Full GC)
-XX:MaxMetaspaceSize=512m # 上限(防止 OOM)
直接内存(NIO/Netty 场景)
-XX:MaxDirectMemorySize=1g # 限制堆外内存
栈大小
-Xss512k # 每个线程栈大小,线程多时可适当降低(默认 512k~1m)
三、GC 日志配置(必须开启)
# JDK 8
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-XX:+PrintGCTimeStamps
-Xloggc:/opt/logs/gc.log
-XX:+UseGCLogFileRotation
-XX:NumberOfGCLogFiles=10
-XX:GCLogFileSize=100m
# JDK 11+(统一日志)
-Xlog:gc*:file=/opt/logs/gc.log:time,uptime,level,tags:filecount=10,filesize=100m
G1 的新生代大小是动态的,会在 [G1NewSizePercent, G1MaxNewSizePercent] 范围内自动调整,以满足 MaxGCPauseMillis 目标。
新生代越大 → 回收间隔越长 → 单次 STW 越久;新生代越小 → 回收越频繁 → 停顿越短。
场景 A:低延迟优先(API 服务、实时交互)
-XX:G1NewSizePercent=10 # 保证新生代不会太小导致对象过早晋升
-XX:G1MaxNewSizePercent=30 # 限制新生代上限,避免单次 Young GC 耗时过长
-XX:MaxGCPauseMillis=100
收窄范围 → G1 调整空间小 → 停顿时间更可预测
场景 B:吞吐量优先(批处理、数据计算)
-XX:G1NewSizePercent=20
-XX:G1MaxNewSizePercent=60 # 默认值,允许新生代扩大提升吞吐
-XX:MaxGCPauseMillis=500
场景 C:对象存活时间短(缓存、请求处理)
# 大多数对象在 Young GC 就被回收,可以适当放大新生代
-XX:G1NewSizePercent=30
-XX:G1MaxNewSizePercent=50
四、OOM 问题排查配置
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/opt/dumps/
-XX:+ExitOnOutOfMemoryError # OOM 时直接退出(配合容器健康检查)
-XX:OnOutOfMemoryError="kill -9 %p"
五、JIT 编译优化
-XX:+TieredCompilation # 分层编译(JDK 8+ 默认开启)
-XX:ReservedCodeCacheSize=512m # JIT 代码缓存,高并发应用建议加大
-XX:+UseStringDeduplication # G1 下开启字符串去重(内存敏感场景)
六、调优流程
1. 明确目标
├── 吞吐量优先?低延迟优先?内存敏感?
└── 建立基线指标(TP99、GC 频率、STW 时长)
2. 监控采集
├── GC 日志分析(推荐 GCViewer / GCEasy)
├── JVM 内存分析(jstat / jmap / MAT)
└── 火焰图定位热点(async-profiler)
3. 逐步调整
├── 优先排查代码问题(内存泄漏、大对象、频繁创建)
├── 再调整堆大小和 GC 参数
└── 每次只改一个变量
4. 验证回归
└── 压测环境验证,观察 TP99 和 GC 停顿变化
七、常见问题 & 解法
六十三、G1 GC调优
一、整体对比
二、新生代过小的具体影响
问题 1:Young GC 过于频繁
时间轴:
[0s]──YGC──[0.3s]──YGC──[0.6s]──YGC──[0.9s]──YGC──...
每次停顿短(50ms),但频率极高
CPU 大量时间消耗在 GC 上,吞吐量下降
问题 2:对象提前晋升老年代(过早晋升)
新生代空间小 → Survivor 区更小 → 对象没有足够机会在新生代"熬老" → 本来是"短命对象"却被晋升到老年代,占用宝贵老年代空间
正常情况:
新对象 → Eden → Survivor(age1) → Survivor(age2) → ... → 老年代(age=15)
新生代过小时:
新对象 → Eden → Survivor 不够放 → 直接晋升老年代(age 可能只有 1~2)
GC 日志中的告警信号:
# Survivor 放不下,直接溢出晋升
[GC pause (young)]
New threshold: 1 (max threshold: 15) ← 阈值降到 1,几乎所有对象都晋升
Desired survivor size: 50MB
Total survivor size: 120MB ← 实际 > 期望,溢出了
问题 3:老年代快速膨胀 → 频繁 Mixed GC / Full GC
新生代过小
→ 大量短命对象涌入老年代
→ 老年代使用率快速上升
→ 频繁触发并发标记(IHOP 阈值被穿越)
→ 频繁 Mixed GC 条件:堆的整体使用率 >= InitiatingHeapOccupancyPercent(IHOP,默认 45%)
→ 极端情况下触发 Full GC(STW 秒级)
问题 4:CPU 缓存效率下降
GC 频率太高 → 应用线程频繁被打断 → CPU 流水线和缓存命中率下降
三、新生代过大的具体影响
问题 1:单次 Young GC 停顿时间过长
G1 Young GC 是 STW 的,需要扫描并复制所有 Eden + Survivor Region。
新生代越大 → 需要回收的 Region 越多 → 停顿时间越长
新生代 = 4GB 时,一次 Young GC 可能需要停顿 500ms ~ 1s+
这会直接导致 API 超时、TP99 飙升
问题 2:停顿时间不可预测(G1 目标失效)
G1 最核心的能力是通过动态调整新生代大小来满足 MaxGCPauseMillis。
但如果你强制设置了很大的 G1NewSizePercent,G1 被绑住手脚,无法缩小新生代来控制停顿:
MaxGCPauseMillis=200ms ← 你的目标
G1NewSizePercent=50% ← 强制新生代最小 50%
实际停顿:600ms ← G1 无法缩小新生代,目标失效
问题 3:老年代空间变小,FULL GC的风险变大
六十四、消息中间件挂了如何处理
一、生产者侧:消息发不出去
最佳实践 1:本地消息表(事务发件箱模式)
存到Mysql中,或者落日志,日志转hive表
消息表+补偿任务
未发送成功的消息可通过后续补偿任务补发
最佳实践 2:熔断降级到同步调用
if (mqAvailable) {
sendAsync(message); // 正常:异步发送 MQ
} else {
callDownstreamSync(data); // 降级:直接 HTTP 调用下游
}
二、消费者侧:消息堆积怎么办
MQ 恢复后的消息积压处理
消费者消费时要保证幂等
策略 1:临时扩容消费者
平时 10 个消费者实例 → 临时扩到 50 个 → 5 倍速度消化积压
积压清完后再缩回来
策略 2:建临时 Topic 搬运
原 Topic(消费能力跟不上)
↓ 写一个搬运程序
临时 Topic(拆分为 N 个分区)
↓ 对应 N 倍消费者同时消费
消化完后再切回原 Topic
策略 3:丢弃过期消息(有损降级)
if (message.createTime < now - 1hour) {
log.warn("消息过期,直接丢弃: {}", message);
return; // 积压太严重时,直接跳过历史消息
}
适用场景:消息有时效性(如实时推送、库存扣减已有其他补偿)
三、MQ 高可用架构设计
最佳实践:多副本 + 主从同步
最佳实践:跨机房部署 + 多地部署容灾
四、常态化保证
监控核心指标
监控核心指标,在事前就能发现,提前处理
必须监控的指标:
生产者侧:
✅ 消息发送成功率(< 99.9% 告警)
✅ 发送延迟 P99(> 100ms 告警)
✅ outbox_message 表 PENDING 数量(> 1000 告警)
消费者侧:
✅ 消息积压量(堆积 > 10万 告警)
✅ 消费延迟(消息创建到消费的时间差)
✅ 消费失败率
Broker 侧:
✅ Broker 存活状态
✅ 磁盘使用率(> 80% 告警)
✅ ISR 副本数(< replication.factor 告警)
生产限流
对于生产者添加限流,避免流量洪峰导致MQ集群崩溃
业务队列解耦
不同业务如核心,非核心队列应解耦,避免互相影响
消费幂等
所有消费者保证消费幂等,避免消息重发影响业务
消费失败进死信队列
六十五、常见SQL优化手段
1、建立合理索引,开启慢SQL查询日志
2、避免索引失效
3、索引覆盖,避免回表
4、分页避免深分页,可带上上次最大id
5、尽量避免子查询,join
6、缩短大事务,使用小事务
6、EXPLAIN调优
进阶-架构优化
- 读写分离
- 分库分表
- 分表
- 分库分表
- 冷热分离
其他优化
- 连接池配置
- 传输包大小配置
- 服务器硬件配置