笔记

47 阅读1小时+

一、mybatis动态SQL解析流程

image.png转存失败,建议直接上传图片文件

编译期解析为 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

image.png转存失败,建议直接上传图片文件

阶段二 动态代理

image.png转存失败,建议直接上传图片文件

阶段三 动态SQL拼接

image.png转存失败,建议直接上传图片文件

阶段四 Executor — 缓存检查与执行分发

阶段五 StatementHandler — 准备 Statement

image.png转存失败,建议直接上传图片文件

阶段六: ParameterHandler — 设置 ? 参数

image.png转存失败,建议直接上传图片文件

阶段七:JDBC 执行 → ResultSetHandler 映射结果

image.png转存失败,建议直接上传图片文件

完整执行时序(以本 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

image.png转存失败,建议直接上传图片文件

二、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实现最终一致性
    image.png转存失败,建议直接上传图片文件
  • 数据迁移也是一个问题,需要做增量数据与存量数据迁移,还需做迁移前后数据diff

这里需要注意下Query Cache 和 Buffer Pool 是完全独立的两个缓存!

Query Cache是缓存的查询结果,Buffer Pool缓存的是磁盘数据页(16KB原始数据块)

所以关闭query_cache_type=OFF缓存开关后,Buffer Pool也不会受影响

三、分布式ID

实际应用举例

image.png

对于百度 UidGenerator实现

image.png

四、HBase与Doris对比

image.png

HBase与mysql对比,mysql的优点是

  • 支持复杂查询
  • 支持事务
  • 运维成本低
  • 数据量较低时mysql更快

image.png

五、MySQL中B+树叶子节点是双向链表的原因

B+树在插入节点时通过分裂来保证树的平衡,保证所有叶子结点的深度相同。双向链表主要是方便范围查询,节点内部是单向链表,节点之间是双向链表

image.png

六、Redis中的跳表

本质是在链表的基础上,增加了索引节点,通过二分法提升定位速度

image.png转存失败,建议直接上传图片文件

七、Tomcat与Netty架构对比

image.png

两者是如何实现连接数卡控的

image.png

  • 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:637910.0.0.1:52341)
  fd 6  → client_fd  (192.168.1.1:637910.0.0.2:48921)
  fd 7  → client_fd  (192.168.1.1:637910.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在集群某个节点上短时间内被高频访问,导致节点负载过高

image.png 解决方案

  • 允许从节点可读
  • 使用本地缓存
  • Key分片(一个Key分成多个Key存在不同节点,写的时候需要都写一遍,且可能会导致不一致问题)
  • 热点Key自动探测
    当探测到时热点key时,主动上报给服务端,服务端会开启推送,将最新内容推送给客户端,客户端会更新本地缓存,达到解除热点key的作用\

image.png

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

十三、限流,降级,熔断的区别

image.png

十四、Redis Cluster

Redis集群可以分为代理模式与去中心化模式

  • 代理模式常见有TwemProxy与Codis,依赖代理层管理连接与节点路由
  • 去中心化模式则没有代理层,在Client缓存Slot信息进行路由,node节点本身也是哨兵节点,每个节点中都存储了所有Slot对应节点信息

image.png

Redis 主从节点同步方式

触发时机同步方式流程备注
从节点第一次连接主节点/断开重连全量同步从节点 --psync-->主节点,进行全量同步image.png转存失败,建议直接上传图片文件主节点接受到psync后会fork子进程来bgsave RDB文件,同时将增量请求存储在Replication Buffer中。RDB发送完成后,将Replication Buffer中的命令发送给子节点
正常流程增量同步主节点写入后主动推送给从节点image.png转存失败,建议直接上传图片文件主节点将增量数据同步至从节点用的是主线程,但是却是【异步】的,当前时间循环主线程只负责将数据写入backlog,写入output_buffer,后续下次事件循环时主线程读取output_buffer中数据发送给从节点。

image.png

主从数据同步使用的是后台线程吗

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 IDPID):
  每个 Producer 实例启动时,向 Broker 申请一个唯一 PID
  PIDProducer 重启后会变化(重启会申请新 PIDSequence 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(重平衡)是消费者发生变化时(新增,减少或分区数量变化)分区进行重新分配,导致整个消费组停止消费。

解决方案:

  1. 参数调优(如降低每次拉取数量,防止超时),防止误触
  2. 使用CooperativeStickyAssignor或StickyAssignor重平衡,只停止涉及变动的Partition就行

StickyAssignor是什么?

是Kafka分区分配策略,有四种策略

  • RangeAssignor(范围分配,旧默认)

image.png

  • RoundRobinAssignor(轮询分配)

image.png

  • StickyAssignor(粘性分配)

image.png

*   优点:停顿后分区迁移量小了
*   缺点:但停顿本身没有消除STW
  • CooperativeStickyAssignor(增量协作粘性分配)

image.png

*   优点:只有少数分区短暂停止,大部分分区正常消费

如果新增订阅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分区与新增消费的区别

image.png

十七、什么是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的其他用途

  1. 大数据实时处理:由于其高吞吐量和低延迟的特性,Kafka常被用于实时数据流处理场景,作为数据源接入层,对接各种数据处理框架如Apache Storm、Spark Streaming或Flink,用于实时分析和处理海量数据流。
  2. 日志聚合与传输: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:

  1. 并发标记跟不上分配速度(最常见) Concurrent Mode Failure: 并发标记还没完成,老年代已经满了

  2. Mixed GC 时 Evacuation 失败 复制存活对象时,找不到足够的空闲 Region (Evacuation Failure / To-Space Exhausted)

  3. 显式调用 System.gc()(除非 -XX:+ExplicitGCInvokesConcurrent)

  4. Humongous 对象(大对象)分配失败 大于 Region 大小 50% 的对象分配到连续 Old Region 分配不下时触发

  5. 元空间(Metaspace)不足

执行过程

YoungGC:并发清理,会STW

Mixed GC: 老年代是并发标记,清理和新生代一起

FULL GC:单线程(JDK8)STW清理

G1 什么时候进入老年代

  1. 年龄到达一定阈值
  2. Young GC后S Region没有足够空间,会直接进入老年代
  3. 年轻代中同意年龄的占有内存大于总内存的50%
  4. 大对象直接分配到老年代(G1 特有机制:当对象大小 超过 Region 大小的 50%  时,会被识别为 Humongous 对象,直接分配到连续的 Humongous Region(逻辑上属于老年代管理范畴))

G1是如何控制 STW 时间的

  1. 设置停顿时间目标
-XX:MaxGCPauseMillis=200   # 默认 200ms,G1 会据此动态调整 CSet 大小

2. 动态调整Eden大小 G1 会自动增减 Eden Region 的数量来控制 GC 频率和单次停顿时间之间的平衡。

ZGC vs G1 vs CMS 优缺点详析

ZGC 优点
  1. 极低延迟:停顿时间与堆大小无关,始终在 ms 级以下
  2. 支持超大堆:可轻松管理 TB 级内存
  3. 无内存碎片:并发整理
  4. 无需 Remember Set:染色指针替代,减少内存开销
ZGC 缺点
  1. 不支持压缩指针:每个对象指针多占 8 字节,内存利用率低
  2. 吞吐量略低:读屏障有额外开销
  3. JDK 版本要求高:JDK 15+ 才 GA,JDK 21 才引入分代 ZGC
  4. 调优参数少:目前可调项不如 G1 丰富
  5. 不支持 32 位:仅限 64 位系统

十九、Kafka中Zookeeper与KRaft区别

ZookeeperKRaft
架构,存储元数据存在 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队列为什么使用双向队列

  1. 节点取消时方便找到前驱结点

  2. 当节点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好,工程使用更多

image.png转存失败,建议直接上传图片文件

二十五、Redis中Hash扩容机制

Redis中HashTable的扩容机制是【渐进式扩容】,主要是为了避免ReHash阻塞主线程。在每次执行命令的时候就捎带进行扩容,将旧HashTable内容同步到新的HashTable当中去。此外,在读写时的策略如下

image.png

二十六、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,用于主从同步定位数据已同步的位置

主从如何选举

image.png转存失败,建议直接上传图片文件

主从同步延迟升高,如何排查

  1. 查看是否有大事务,导致同步时间较长

    -- 查找主库上的大事务(执行时间超过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万行的回放
    
  2. 是否主库并发写入量变大,可能是从库性能问题,看下CPU与磁盘写入情况可以考虑开启并发复制(Mysql5.7+)

二十七、高可用网关系统设计

  1. Nginx接入(多机房部署,崩溃后可切换)

  2. API网关

    1. 支持限流(令牌桶),熔断(监听下游接口失败率),降级(熔断后兜底方案,如兜底配置)
    2. 支持多支付渠道
    3. 身份校验
    4. 参数加密
    5. 支付模块路由

二十八、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;

image.png

  1. 最左前缀:必须从索引第一列开始使用,中间不能跳列
  2. 范围查询截断: > < BETWEEN LIKE "xx%" 之后的列索引失效
  3. 排序方向一致性:ORDER BY 中所有列方向必须与索引方向一致(MySQL 8.0 支持降序索引可解)
  4. 覆盖索引优化:SELECT 的列都在索引中时,无需回表,性能最优
  5. 优化器会自动调整条件顺序: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  ← 数值提升为 long3L == 3L
System.out.println(g.equals(a + b)); // falseequals 不做类型转换,Long != Integer
System.out.println(g.equals(a + g)); // false  ← a+g 提升为 long,装箱为 Long(3L),但 3!=4

自动类型提升

image.png转存失败,建议直接上传图片文件

坑点

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在执行写入,导致数据不一致。

如何避免脑裂

  1. 奇数节点部署(3、5、7)

  2. Quorum机制:多数派才能写入(如果主节点发现连接的从节点少于多数,就停止写入)

  3. Fencing机制:主动隔离旧Master(关闭主节点)

三十四、MySQL死锁

MySQL死锁出现的case

场景流程原因加锁类型
两个事务更新顺序相反image.png行锁加锁顺序有交叉导致只会加行锁
并发批量写入image.png行锁加锁顺序有交叉导致只会加行锁
间隙锁与插入意向锁冲突image.png间隙锁阻塞插入,但间隙锁可以有多个事务拥有,形成互相等待间隙锁插入意向锁
并发INSERT ON DUPLICATE KEY UPDATE 死锁image.pngINSERT冲突加S锁,再尝试升级X锁,并发导致互相等待释放S锁S锁升级X锁

MySQL间隙锁触发条件

触发条件:范围查询 / 非唯一索引查询 + 加锁操作

即使用select ... for update

image.png

使用update,delete操作会加Next-Key Lock吗?

可能会,UPDATE / DELETE 的加锁逻辑 = 先按 SELECT ... FOR UPDATE 的规则加锁,再执行修改。所以加锁的场景和上面一样。

所以建议尽量用主键/唯一索引等值查询,可以退化为纯行锁,并发度最高

三十五、热点账户如何应对

热点账户指同时间并发进行入账或出账,业内常见方案有:

  • 顺序记账(台账方式),只记账不扣减余额(适用于B端商家,可按结算周期进行结算)
  • 账户分桶,将热点账户分为多个子账户,写入时做Hash分片,避免同时写入
    image.png转存失败,建议直接上传图片文件
  • 通过负载均衡将同一账户的写入请求路由至同一个服务节点,存储在内存队列中,聚合后再进行处理
    image.png转存失败,建议直接上传图片文件
  • C端用户一般没有热点问题,可以使用乐观锁

三十六、Kafka与主流MQ对比

image.png

为什么Kafka高吞吐,可回放

image.png

三者都是顺序写,但是

  • RabbitMQ在处理每条ACK时会找到消息(产生随机IO)更新消息状态,在高并发下会有大量随机IO,此外RabbitMQ是推送模式的,有专门的进程负责消息的push,在量大的时候内存会有瓶颈
  • RocketMQ也是顺序写CommitLog,但因为所有topic的消息写在一起,所以需要借助ConsumeQueue来定位topic位置,相比Kafka多了一次IO(消费时会使用mmap提高随机读取速度,但RocketMQ有TAG过滤逻辑,因此不会使用sendfile),但写入时比Kafka快
  • Kafka是批量提交,在消费时批量消费后只提交一个offset并不会产生额外IO(读取index时会使用mmap)所以消费快,另外使用sendfile零拷贝,减少拷贝时间,另外批量拉取也会提高吞吐

image.png转存失败,建议直接上传图片文件

Kafka吞吐量大的核心原因

  1. 存储:顺序写log文件(虽然会写.index文件但并不是每次写log都会写.index文件,而是积累到一定数量后写),相比之下RocketMQ每次都会写CommitLog与ConsumeQueue(异步),存在写放大问题。
  2. 批处理:Kafka在生产或消费时都会进行批量处理,并进行压缩,提高批处理大小,RocketMQ也支持批量,但批量大小相对较小。
  3. 异步刷盘:Kafka消息持久化是异步刷盘,消息会先落到PageCache中,后续再刷盘,大量消费可能会直接命中缓存不会产生磁盘IO。相比之下,RocketMQ刷盘频率更高。
  4. 内存占用少:Kafka只负责对消息存储和消费并无其他业务加工逻辑,内存占用较少。RocketMQ还支持tag过滤,延迟队列,重试队列等。

为什么RabbitMQ低延迟

能低延迟是因为:

  1. 每个 Queue 是一个 Erlang 轻量进程 Erlang 调度器:抢占式,微秒级进程切换 消息 Queue 在 Erlang 进程间传递,几乎是内存拷贝速度

  2. PUSH模式,消息到了就推给消费者

但存在以下问题:

  1. 内存优先 → 内存耗尽时性能断崖式下跌(flow control 触发) Producer → Broker 内存使用超过 40% → 触发流控 → Producer 被阻塞 延迟从 μs 级突变为秒级,非常不稳定

  2. Push 模型 → Consumer 处理不过来时,消息堆积在Broker → Broker 内存爆了→ 开始 Page Out 到磁盘 → 性能急剧下降

  3. 不适合大消息/高吞吐持续场景 → 吞吐一旦上去,内存/GC压力让延迟变得不可预测

三者缺点对比

image.png转存失败,建议直接上传图片文件

为什么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)。

核心概念

  1. 地址空间抽象
  • 每个进程有独立的虚拟地址空间(如64位系统上理论可达 128TB)
  • 进程看到的地址是"虚拟地址",CPU 通过 MMU(内存管理单元) 将其翻译为物理地址
  1. 分页(Paging)
  • 虚拟内存和物理内存都被切分成固定大小的块,称为页(Page),通常4KB
  • 操作系统维护页表(Page Table),记录虚拟页→ 物理页的映射关系
  1. 缺页中断(Page Fault)
  • 进程访问一个虚拟地址,对应的物理页不在内存中时触发
  • OS 从磁盘 Swap 区加载该页到内存,然后继续执行

虚拟内存的作用

image.png转存失败,建议直接上传图片文件

共享内存与零拷贝中内存映射的关系

image.png转存失败,建议直接上传图片文件

image.png转存失败,建议直接上传图片文件

传统拷贝与零拷贝对比

传统方式(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),依赖一个前提:高层的节点数约是低层的一半,形成类似二分的索引结构。但这个"每层节点数减半"的理想结构,是靠随机数来维持的,不是强制保证的。

四十一、MySQL中COUNT(*)执行过程

如果没有where条件

SELECT COUNT(*) FROM orders;

优化器会:

  1. 找出表上所有索引

  2. 选择叶子节点数据量最小的那棵索引树

    1. 这里说的最小指的是叶子节点(Page)数量最少,行数是固定的,但每个字段大小不一样,不同索引占用的Page数量不同,选取Page数量最少的索引树来遍历时最快的
  3. 从头到尾扫描该索引的所有叶子节点

  4. 累加可见行数

不同 COUNT写法的差异

image.png

加了 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条收件箱,但更新收件箱消息状态时每次只用更新一条。

image.png转存失败,建议直接上传图片文件

方案二、读扩散

消息只存一份,新增游标表来记录用户在某个会话中已读取的最大seq

image.png转存失败,建议直接上传图片文件

会话序列号seq是单调递增的

在同一会话中,seq代表消息的顺序,简单实现可用Redis的INCR来实现,保证不重复,递增。

但Redis节点挂了,需要考虑重新恢复seq:

  • 可考虑读取DB中max_seq + 1作为Redis初始值

四十四、Spring中AOP的实现

以@Async简单举例来看,主要是依赖于BeanPostProcessor(BPP)接口,Spring会先注册BPP,然后在创建业务Bean的时候会挨个走BPP。而代理的逻辑就是在BeanPostProcessor.postProcessAfterInitialization方法内实现的,返回的Bean就是代理对象会替代掉原有Bean。

image.png 1. Spring 启动 refresh() 2. registerBeanPostProcessors: - AsyncAnnotationBeanPostProcessor 被实例化并注册 - 此时业务 Bean 还没创建 3. finishBeanFactoryInitialization: - 开始创建业务 Bean(如 MyService) - initializeBean 时,遍历所有已注册的 BPP - 调用 AsyncAnnotationBeanPostProcessor.postProcessAfterInitialization - 判断 isEligible → 发现有 @Async 方法 → 创建代理 - 返回代理对象,替换原始 MyService- 代理对象被存入 singletonObjects

可能存在的问题\

image.png

四十五、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 恢复为 2432 * 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); // 告警上报
        }
    });

✅ 链式处理,异常处理和业务逻辑分离,可读性强。

几条核心原则:

  1. 永远不要静默吞掉异常 — 至少要打日志
  2. submit() 返回的 Future 不能丢弃不管 — 要么 get() ,要么用 whenComplete 处理
  3. 配合监控告警 — 异常应触发监控系统(如 CAT、Raptor)
  4. 考虑线程上下文传递 — 如 MDC traceId,可用 TransmittableThreadLocal (TTL)
  5. 区分可恢复/不可恢复异常 — 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 层(序列化协议)

image.png转存失败,建议直接上传图片文件

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=12345Processor (BillingService.Processor)
    │  根据方法名分发到对应 Handler
    ▼
Your Handler Implementation
    │  执行业务逻辑,返回 BillingAccount 对象
    ▼
(反向序列化结果,返回给 Client

与其他主流 RPC 框架对比

image.png转存失败,建议直接上传图片文件

Thrift优缺点

✅ 优点
  1. 跨语言:25+ 语言代码自动生成,Java/Python/Go/C++/PHP 等无缝互调
  2. 高性能:Binary/Compact 协议序列化效率高,远超 HTTP + JSON
  3. 强类型:IDL 强制类型约束,接口契约清晰,减少运行时错误
  4. 灵活:Protocol/Transport/Server 三层均可替换,适应不同场景,协议也可选择TCP/HTTP
  5. 成熟稳定:Facebook 内部大规模使用 10+ 年,生产经过验证
❌ 缺点
  1. 不支持流式调用:gRPC 支持 ServerStream/ClientStream/BiStream,Thrift 不支持
  2. 服务治理需自建:无内置服务发现、负载均衡(需配合 ZooKeeper 等)
  3. 可读性差:Binary 协议调试困难,需要借助工具
  4. 向后兼容需谨慎:字段编号变更会破坏兼容性
  5. HTTP/2 支持弱:与云原生生态(Kubernetes、Istio)集成不如 gRPC 顺畅

gRPC

gRPC是在HTTP2基础上进行封装的,但封装很薄,K8s对HTTP2支持的很好,已深度理解和支持 HTTP/2,所以 gRPC 可以"免费"获得方法级路由、流量染色、自动追踪、标准健康检查等所有能力。

HTTP1.1 vs HTTP2

image.png转存失败,建议直接上传图片文件

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 要降序

五十四、服务挂了如何排查

  1. 看报警

    1. 关键指标

      1. 进程存活(存活探针),或者上机器找java进程

        # 查进程是否存在
        ps aux | grep <appname>
        jps -l
        
        # 查端口是否在监听
        netstat -tlnp | grep <port>
        ss -tlnp | grep <port>
        
        # 查服务健康检查
        curl http://localhost:<port>/health
        
      2. 响应时间TP99

      3. 错误率如5XX

      4. 内存使用率

      5. 线程数

  2. 查日志

    1. 查找ERROR/EXCEPTION日志
  3. 确认影响范围

  4. 先止血回滚

  5. 再根据日志分析根因

    1. OOM
    2. 死锁
    3. GC
    4. 下游影响

五十五、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缓存中(简单理解就是复制了部分对象),所以还是有可见性问题。

能解决可见性问题的方式

image.png

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

image.png mmap 本质上是让用户态虚拟地址直接映射到 PageCache 的物理页,省去了内核空间到用户空间的一次内存复制。应用程序读写那块地址时,实际上是直接操作 PageCache,因此 write() 调用时,内核可以直接从 PageCache 拷贝到 Socket 缓冲区,而不需要先经过独立的用户态缓冲区中转。

sendfile

image.png

五十七、@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

image.png

五十八、syncronized锁升级

无锁 -> 偏向锁

线程A发现锁对象头中ThreadId为null,通过cas设置自己的ThreadId

偏向锁 -> 轻量级锁

线程A持有偏向锁,线程B来竞争锁,流程如下:

  • 线程B会先向VM线程申请撤销
  • VM线程会在SafePoint暂停线程判断线程A是否还在同步块中
    • 若不在,待线程恢复后,线程B可能重新偏向成功
    • 若在,VM线程会帮助线程A升级为轻量级锁,在线程A栈帧LockRecord中记录MarkWord,对象记录LR的指针
  • 线程恢复后若B没有获取到锁,则进行自旋获取轻量级锁

轻量级锁 -> 重量级锁

有几种场景会出现轻量级锁升级为重量级锁

  1. 一个线程持有锁,其他线程自旋到一定次数后锁升级
  2. 当调用了 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

流程

  1. 读需求PRD(让AI读PRD+历史PRD+历史设计文档 -> 匹配PRD与设计文档)
  2. 撰写详细设计文档(人工二次check)
  3. 读领域知识库,读代码仓库(匹配关键领域词到项目知识库,与关键领域模型做关联)
  4. 撰写单元测试(人工二次check)
  5. 撰写代码
  6. 执行单元测试,不通过修改代码再执行单测
  7. 人工check后,部署代码到线下泳道

遇到的问题

  1. 代码空实现
  2. 重复造领域模型
  3. 中间件调用没有按照开发习惯来或者规范来用
  4. 上下游交互,如接口如何定义

解决方案

  1. 增强编码规范约束,禁止空实现,增加代码注释帮助他理解
  2. 编码规范中强调使用已有模型,另外在知识库中细化对领域及其重要模型的解释
  3. skill匹配问题,如调用Mysql不使用开源的方法而是使用公司内部RDS规范
  4. 约定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. 垃圾收集器选择

image.png

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 停顿变化

七、常见问题 & 解法

image.png

六十三、G1 GC调优

一、整体对比

image.png

二、新生代过小的具体影响

问题 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
但如果你强制设置了很大的 G1NewSizePercentG1 被绑住手脚,无法缩小新生代来控制停顿:

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调优

进阶-架构优化

  • 读写分离
  • 分库分表
    • 分表
    • 分库分表
  • 冷热分离

其他优化

  • 连接池配置
  • 传输包大小配置
  • 服务器硬件配置