机会

每种境界, 都是我们了解自己的一次机会。 思考 我们经历的,不只是外部事件,而是在事件中看见自己 ...

三月 12, 2026

晋升与影响力

1. 学习主题 我要学习的内容: 各个职级的要求,如何晋升以及输出影响力。 ...

三月 12, 2026

ClaudeCode Skill 的实现

ClaudeCode Skill 的实现 1. 学习主题 我要学习的内容: ClaudeCode Skill 如何实现的 ...

三月 7, 2026

禅修

禅的本质, 是觉醒的心。 禅修, 就是帮助我们开发觉醒的心。 ...

三月 7, 2026

面试冲刺

算法 - 算法 - 通用操作技巧 - 数组常用操作 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/02/28/20260228105324869.png,414,460) - 列表常见操作 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/02/28/20260228111309641.png,508,732) - Set 常见操作 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/02/28/20260228111651357.png,295,241) - 字符串常见操作 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/02/28/20260228112522793.png,348,758) - 哈希 - 基本语法 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/02/28/20260228103647800.png,490,622) - LeetCode 1. 两数之和 - 将每个数存入 Map,统计 target - nums[i] 是否在 Map 中 - LeetCode 49. 字母异位词分组 - 统计每个字符数量,用 char[26] 存储 - 将 char[26] 转换成字符串或 hashCode 放入 map - map 的 value 是分组的列表 - LeetCode 128. 最长连续序列 - 以最小值为起点,当前值 - 1 不在 map 中 - 查看 当前值 + 1 是否在 map 中 - 双指针 - 常用模板 - 两边向中间移动 - 问题 - 两数之和等于第三数 - 每次排除掉 l/r 指针的一边 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/02/28/20260228120355590.png,339,335) - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/02/28/20260228120240384.png,469,400) - 两边向中间移动 - LeetCode 283 移动零 - slow 指针在左边,维护 0 的位置 - fast 指针在右边,找非零元素和 slow 交换 - LeetCode 11 盛最多水的容器 - l 指针和 r 指针每次向中间移动 - 比较 slow/fast 大小,将小的往中间移动 - LeetCode 15 三数之和 - 排序 - 固定左边界 left,剪枝 > 0 - l 和 r 在左边界两边往中间移动,找到目标值 - 滑动窗口 - 常用模板 - l,r 统一向一侧,每次增加 r - 增加一次 r,如果不满足条件,则不断移动 l - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/02/28/20260228135319330.png,418,277) - LeetCode 3 无重复字符的最长子串 - 每次向右移动一次 r - 如果增加的 r 导致出现了重复字符,不断移动 l 直到符合条件 - 将满足条件的长度和答案取最大值 - LeetCode 438 找到字符串中所有的字母异位词 - 维护 diff 差值 - 固定窗口向右移动 l 和 r - 每次移动更新 diff 值,diff 值根据 cnt 统计得出 - in 和 out 维护进入窗口和出窗口的值 - LeetCode 76 最小覆盖子串 - 同 LeetCode 438 滑动窗口维护 diff 值 - 不一样的是随着 l 的更新,需要不停更新最终答案 - 前缀和/单调队列 - 常用模板 - 队列 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/02/28/20260228143049720.png,328,242) - 单调队列模板 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/02/28/20260228142727925.png,500,395) - LeetCode 560 和为 K 的子数组 - 用 Map 维护前缀和 - 连续序列的和基本都是前缀和问题 - LeetCode 239 滑动窗口最大值 - 维护一个单调递减的单调队列 - 将左边过期的弹出队列 - 关键操作:过期、维护单调性、入队 - LeetCode 53 最大子数组和 - 维护前缀和即可 - 考虑前缀是否从当前元素开始 - 普通数组 - LeetCode 56 合并区间 - 将数组按第一个元素从小到大排序 - 每次更新右边界,同时左边界与之前的右边界比较 - LeetCode 189 轮转数组 - 轮转数取模并交换 - 轮转数取模并完成三次反转 - LeetCode 283 除了自身以外数组的乘积 - 维护前缀乘积和后缀乘积即可 - LeetCode 41 缺失的第一个正数 - 每次将元素交换到他原本的位置上 - 矩阵 - LeetCode 73 矩阵置0 - 复用第一行和第一列,将要置为 0 的行列记录在这里 - LeetCode 54 螺旋矩阵 - 按圈数模拟,维护 left、right、top、bottom - 原地修改已访问标签 - LeetCode 48 旋转图像 - 将旋转转换为两次折叠(纵轴 + 斜折叠) - LeetCode 240 搜索二维矩阵 - 从右上角搜索,比值大就往下,比值小就往左 - 链表 - 常用模板 - 伪头节点 dummy - 反转链表 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/02/28/20260228160308314.png,308,160) - LeetCode 160 相交链表 - 走到头时,回到另外一个链表的头结点 - LeetCode 206 反转链表 - 反转链表模板,核心是记录前一个链表节点 - LeetCode 234 回文链表 - 快慢指针中点断开 - 反转后面一段 - 进行比较 - LeetCode 141 环形链表 - 快慢指针 - 有交点说明有环 - LeetCode 142 环形链表 2 - 入环的这一段距离是 m - 走的步数是 k - 从环到交点的记录是 n - 环长是 c - k = m + n - 2 * k = m + n + t * c - 2m + 2n = m + n + t * c - n = t * c - m - c - n = m + (1 - t) c - 从相遇点,再走 m 步就好了,也就是从把一个指针放到起点,二者同时开始跑 - LeetCode 21 合并两个有序链表 - 给出新头节点,依次拼接即可 - LeetCode 2 两数相加 - 维护进位和当前位即可 - LeetCode 19 删除链表的倒数第 N 个节点 - 快慢指针,快指针先走 k 个 - LeetCode 24 两两交换链表中的节点 - 维护 pA pB 两个指针,分别指向第一个值和第二个值 - 注意使用伪头节点 dummy - LeetCode 25 K 个一组翻转链表 - 维护 pre、groupHead、tail - 将 groupHead 和 tail 中间的链表反转 - LeetCode 138 随机链表的复制 - 维护原链表节点到新链表节点的映射即可 - LeetCode 148 排序链表 - 归并排序,每次排序一段 - 先拆中点 - 然后 merge - LeetCode 23 合并 K 个升序链表 - 归并排序思路 - 将 K 个升序链表的排序转换为两个升序链表的排序 - LeetCode 146 LRU 缓存 - 维护一个链表,代表时间 - 维护一个 map,映射到链表的节点 - 二叉树 - 常用模板 - 层序遍历 - ![](https://an-img.oss-cn-hangzhou.aliyuncs.com/2026/02/28/20260228165114598.png,419,315) - LeetCode 94 二叉树的中序遍历 - 递归,迭代都要会写 - LeetCode 104 二叉树的最大深度 - dfs 一次,更新答案 - LeetCode 226 翻转二叉树 - 递归,交换左右子树 - LeetCode 101 对称二叉树 - 递归判断左子树与右子树对称 - LeetCode 543 二叉树的直径 - 求左右子树的最大深度相加即可 - LeetCode 102 二叉树的层序遍历 - 层序遍历模板题 - LeetCode 108 将有序数组转换为二叉搜索树 - 找中点 - 两边递归构建二叉搜索树 - LeetCode 98 验证二叉搜索树 - 中序遍历结果有序即可 - 确定左右子树上线界递归 - LeetCode 230 二叉搜索数中第 K 小的元素 - 中序遍历 - LeetCode 199 二叉树的右视图 - 层序遍历 - dfs 后续遍历,输出每层的第一个被遍历到的节点 - LeetCode 114 二叉树展开为链表(hard) - 不停将右子树接到左子树上 - LeetCode 105 从前序与中序遍历序列构造二叉树 - 由前序遍历找根节点 - 中序遍历找左右子树 - 可以通过 map 直接定位到中序遍历中根节点的位置 - LeetCode 437 路径总和 3 - 维护前缀和即可 - LeetCode 236 二叉树的最近公共祖先 - 递归左右子树,如果发现两课子树都包含 p/q 那么就是当前节点 - 如果不横跨,就是左右子树非空节点 - LeetCode 124 二叉树中的最大路径和 - dfs 左右两个子树,取最大值 八股 数据库 - 数据库 - 基础认识 - InnoDB 和 MyISAM 的区别是什么? - InnoDB 支持事务,MyISAM 不支持事务,所以像转账、订单这类需要回滚和一致性保证的业务,一般只能用 InnoDB - InnoDB 支持行级锁,MyISAM 主要是表级锁。因此在高并发写入场景下,InnoDB 的并发能力更强,而 MyISAM 更容易因为表锁导致阻塞 - InnoDB 支持崩溃恢复,MyISAM 恢复能力较弱。InnoDB 通过 redo log、undo log 等机制保证数据更安全,所以更适合线上核心业务 - 它们的索引组织方式也不同。InnoDB 是聚簇索引,主键索引叶子节点存整行数据;MyISAM 的索引和数据分开存储,索引叶子节点保存的是数据地址 - int(11) 中的 11 表示什么? - int(11) 中的 11 表示显示宽度,不表示存储长度 - INT 在 MySQL 中固定是 4 字节,取值范围由类型本身决定,和这个 11 没关系 - 现在已经不重要,而且相关能力已经被官方弃用 - MySQL 的逻辑架构是什么 - 连接层:负责连接管理、认证和权限校验 - SQL 层/Server 层:负责解析 SQL、优化执行计划并调用执行器 - 一条 SQL 进来之后,通常会先经过 Parser 做词法和语法解析,生成语法树;然后由 Optimizer 选择执行计划,比如决定走哪个索引、表连接顺序是什么;最后由 Executor 调用存储引擎接口去真正读写数据 - 存储引擎层:通过统一接口访问不同引擎,比如 InnoDB - 存储层:负责真正的数据文件、索引文件和日志落盘 - char 和 varchar 的区别是什么? - char 是定长字符串,varchar 是变长字符串 - char(n) 不管实际存多少字符,都会按固定长度 n 来存;而 varchar(n) 只会按实际内容长度来存,再额外加少量长度信息 - drop、delete、truncate 的区别是什么? - delete 是 DML 语句,用来删除表中的数据行,但不会删除表结构 - truncate 是 DDL 语句,用来快速清空整张表的数据,但保留表结构 - 不能带条件,执行速度通常比 delete 更快,并且一般会重置自增值,属于 DDL - drop 也是 DDL 语句,它删除的不只是数据,还包括表结构本身 - 索引 - MySQL 为什么不用红黑树 - 红黑树是二叉平衡树,节点分叉少,数据量大时树会很高 - 数据库索引主要瓶颈是磁盘 I/O,树越高,查一次要访问的磁盘页就越多 - B+Tree 是多叉树,一个节点能存很多 key,可以显著降低树高,从而减少磁盘 I/O - 并且 B+Tree 叶子节点有序且相连,更适合范围查询和排序 - 索引下推是什么? - 索引下推就是在使用索引查询时,把能够利用索引字段判断的条件,下推到存储引擎层先过滤,只有满足条件的记录才回表 - 核心价值就是减少回表次数,提升查询性能 - 什么是回表 - 先查二级索引,再根据主键去聚簇索引查整行 - 因为 InnoDB 的二级索引叶子节点不存整行数据,只存主键值,所以当查询字段不全在索引里时,就必须回表 - 什么是覆盖索引 - 如果一个查询走的是二级索引,并且查询条件里的字段、返回结果里的字段,都已经包含在这个索引里 - 那么 MySQL 只需要扫描这个索引就能拿到结果,不需要再根据主键去聚簇索引里查整行数据,这种情况就叫覆盖索引 - 避免回表,提高查询性能 - 什么是聚簇索引和二级索引 - InnoDB 的聚簇索引和二级索引,核心区别就在于叶子节点存的内容不同 - 聚簇索引 的叶子节点存的是整行数据 - 二级索引 的叶子节点存的不是整行数据,而是主键值 - 由于数据只能按一种方式组织,所以一张表只能有一个聚簇索引 - 什么情况下索引会失效? - 索引无法用于查找 - 没有遵守最左前缀原则 - 对索引列做函数、运算、表达式操作 - 发生隐式类型转换 - 使用前导模糊查询 - 使用 !=、<>、not in、not like - 使用 or,且 or 两边条件不是都能走索引 - 优化器主动放弃使用索引 - 索引列区分度太低 - 查询结果数据量过大 - 联合索引遇到范围查询,范围列后面的列索引利用会受影响 - 为什么不建议建太多索引? - 增加存储成本,每个索引都要额外占用磁盘空间 - 降低写性能,insert、update、delete 不仅要改数据,还要维护所有相关索引,索引越多,写入越慢 - 增加维护复杂度,包括页分裂、缓存占用、优化器选择成本,以及容易出现冗余索引和重复索引 - SQL 执行优化 - where、group by、having、order by、limit 的执行顺序是什么? - SQL 里这几个子句的逻辑执行顺序是:where、group by、having、order by、limit - where:先对原始数据做行过滤 - group by:对过滤后的结果进行分组 - having:对分组后的结果再做过滤 - order by:对最终结果排序 - limit:最后再截取指定条数的数据 - explain 的各字段是什么意思? - EXPLAIN 的核心作用是分析 SQL 执行计划 - 看执行层次和表结构 - id 表示查询中每个 SELECT 的标识,看执行顺序 - select_type 表示这条 SELECT 是什么类型 - table 表示当前这一行执行计划正在访问哪张表 - 看索引使用情况 - possible_keys 表示优化器认为“可能可以使用”的索引 - key 表示最终实际使用的索引 - key_len 表示 MySQL 决定使用的索引长度,单位是字节 - ref 表示当前表和索引做匹配时,拿什么值去和索引列比较 - 看扫描成本 - type 表示访问类型,也就是这张表是怎么被访问的。这个字段非常重要 - rows 表示优化器预估为了找到结果,需要扫描多少行 - filtered 表示经过当前表条件过滤后,大概还有多少百分比的行会继续参与后续处理 - 额外性能风险 - Extra 表示额外执行信息,这一列也非常重要 - type 各个值是什么意思 - 性能从好到差可以大致理解为:system > const > eq_ref > ref > range > index > ALL - system 是最优情况之一,表示表中只有一行数据 - const 表示通过主键或者唯一索引,并且是等值匹配,一次就能把记录确定下来 - eq_ref 一般出现在多表 join 中,表示对被驱动表使用主键或唯一索引进行等值匹配,并且每次匹配最多返回一条记录 - ref 表示使用普通索引做等值查询,返回的结果可能有多条 - range 表示索引范围扫描 - index 表示扫描了整个索引树 - ALL 表示全表扫描,是最差的一种常见情况 - 一条 SELECT 的执行过程是什么 - 客户端连接 MySQL - SQL 发送到 MySQL Server - 解析器进行词法分析和语法分析 - 词法分析:把 SQL 拆成一个个关键字和标记,比如 select、表名、字段名、where 条件等 - 语法分析:判断 SQL 是否符合 MySQL 语法规则,并生成语法树 - 预处理:检查表和字段是否存在,语义是否正确 - 优化器生成执行计划 - 优化器会根据 SQL 生成一个最优或较优的执行计划 - 执行器调用存储引擎读取数据 - join 的执行原理是什么? - 先由优化器确定表的连接顺序、访问方式和是否使用相关优化,再由执行器按这个计划去逐层匹配数据 - 最经典的 join 算法是 Nested-Loop Join,也就是先从驱动表取一行或一批数据,再拿这些数据的关联字段去被驱动表中查找匹配记录 - 如果是多表 join,就继续向下一层嵌套 - join 性能的关键在于外层循环次数和内层表的访问成本,所以通常会强调小表驱动大表,以及让被驱动表的 join 字段尽量命中索引 - 怎么定位慢 SQL? - 先发现慢 SQL - 慢查询日志,MySQL 可以开启 slow query log - APM / 监控系统,比如接口监控、数据库监控、链路追踪 - 再分析执行计划,有没有走索引,扫描的数据多不多,是否有额外排序或临时表 - 验证优化效果 - 深分页为什么慢? - 深分页慢,主要是因为 MySQL 不是“直接跳到第 100000 条”开始取数据,而是通常要先找到前面的记录,再把它们跳过 - 怎么优化深分页? - 使用基于上次游标的分页,也叫 keyset pagination - 先用覆盖索引查主键,再回表 - 限制最大分页深度 - 结合搜索条件缩小数据范围 - 大表加字段为什么有风险? - ALTER TABLE ADD COLUMN 这类操作在某些情况下并不是简单改一下表结构元数据,而可能触发表重建、数据拷贝、元数据锁竞争和大量日志写入 - 对于数据量很大的表,这些成本会被放大,容易导致业务抖动甚至阻塞 - 在线 DDL - 在执行 ALTER TABLE 这类表结构变更时,尽量不阻塞业务的读写,允许 DDL 和 DML 在较大程度上并发执行的一种机制 - MySQL 的 ALTER TABLE 可能有三种方式:ALGORITHM=INSTANT、ALGORITHM=INPLACE、ALGORITHM=COPY - INSTANT 最轻量,通常只改元数据,速度最快 - INPLACE 通常不需要整表拷贝,但可能仍会重建表或持有一定锁 - COPY 代价最大,需要复制整张表,风险也最高 - 如何给大表加索引? - 先评估这条索引是否真的必要,确认它能解决实际慢 SQL,而不是盲目加索引; - EXPLAIN 和业务 SQL 验证索引设计,尽量避免建错索引或冗余索引; - 确认 MySQL 版本、表引擎、索引类型和 DDL 算法,优先选择支持在线变更的方式,比如添加普通二级索引时尽量走 InnoDB Online DDL; - 在正式执行前评估主从延迟、磁盘空间、IO 峰值和 metadata lock 风险,避开业务高峰,并提前处理长事务和长查询; - 执行时尽量显式指定合适的算法和锁级别,例如 ALTER TABLE ... ADD INDEX ..., ALGORITHM=INPLACE, LOCK=NONE - 执行过程中持续监控 DDL 进度、主从延迟、CPU、IO 和锁等待 - 生产上更稳妥的方式通常是使用 gh-ost、pt-online-schema-change 这类在线变更工具,以影子表加索引、分批回放、最后切换的方式降低影响 - 事务 - 什么是事务 - 事务就是一组操作,这组操作要么全部成功,要么全部失败,不会只执行一部分 - 它的作用是保证数据从一个一致状态,变到另一个一致状态 - 事务的 ACID 是什么 - 原子性指的是事务中的操作要么全部成功,要么全部失败回滚,不能只执行一部分 - 一致性指的是事务执行前后,数据库都必须处于一致状态 - 隔离性指的是多个事务并发执行时,彼此之间应该尽量互不干扰 - 持久性指的是事务一旦提交,对数据的修改就应该永久保存下来,即使数据库宕机,也不能丢失 - 脏读、不可重复读、幻读分别是什么? - 脏读指的是:一个事务读到了另一个事务“还没有提交”的数据 - 不可重复读指的是:在同一个事务里,多次读取同一行数据,结果不一样 - 幻读指的是:在同一个事务里,按照相同条件多次查询,前后查询到的“记录条数”不一样,像是突然多出来或少了一些行 - MySQL 的事务隔离级别有哪些 - 读未提交(Read Uncommitted):一个事务可以读到另一个事务还没有提交的数据 - 读已提交(Read Committed):一个事务只能读到另一个事务已经提交的数据 - 可重复读(Repeatable Read):同一个事务中,多次读取同一条记录,结果会保持一致 - 串行化(Serializable):它会让事务串行执行,一个一个来,避免并发冲突 - 当前读和快照读有什么区别? - 快照读读取的是事务可见范围内的历史版本数据,本质上是基于 MVCC 的一致性读,通常不加锁,普通 select 一般就是快照读 - 当前读读取的是当前最新版本的数据,并且通常会对读到的记录加锁,以保证并发修改时的数据正确性,常见于 select ... for update、select ... lock in share mode,以及 update、delete 这类语句 - ReadView 是什么 - Read View 可以理解为 InnoDB 在做 MVCC 一致性读时生成的一张“可见性快照” - 因为一行记录在并发事务下可能存在多个版本,所以当前事务读取数据时,并不是直接读最新版本,而是要结合 Read View 来判断哪个版本对自己可见 - 如果当前版本不可见,就沿着 undo log 构成的版本链继续向前找,直到找到一个可见版本 - MVCC 是怎么实现的? - 核心思想是:一行数据在并发事务下,不是只保留一个版本,而是通过保留多个版本,让读操作尽量不加锁,也能读到对自己可见的数据,从而提升并发性能 - 隐藏字段:InnoDB 的聚簇索引记录里会带一些隐藏信息 - DB_TRX_ID:最后一次修改这行数据的事务 ID - DB_ROLL_PTR:指向这行记录旧版本的回滚指针 - undolog:当事务修改一行数据时,InnoDB 不会简单把旧值直接覆盖掉就算了,而是会把旧版本信息写到 undo log 里 - 版本链:一行数据可能被多次更新,所以每次更新都会生成一份旧版本。这些旧版本不会直接丢掉,而是通过 DB_ROLL_PTR 串起来,形成一条版本链 - Read View:当事务做快照读时,InnoDB 会生成一个 Read View。它本质上是一套可见性规则,用来判断版本链上的哪个版本对当前事务可见 - 查询看到的是某个时间点的数据库快照,只能看到在那个时间点之前已提交事务的修改,以及当前事务自己之前语句的修改 - 长事务有什么危害 - 容易长时间占用锁,影响并发 - 会导致 undo log 积压 - 会影响 MVCC 和 purge 清理 - 回滚成本高,出问题影响更大 - 更容易引发锁等待和死锁问题 - 会影响主从复制和系统稳定性 - 分布式事务 - 一个业务操作需要跨多个服务、多个数据库,甚至多个消息系统共同完成,但这些资源不在同一个本地事务里,所以不能只靠单机数据库事务来保证一致性 - 2PC / XA - 两阶段提交,属于强一致方案 - 协调者先问所有参与者能不能提交,大家都可以才真正提交 - TCC - Try、Confirm、Cancel - 优点是控制力强,缺点是业务侵入性很高,每个服务都要实现三套接口 - Saga - 把长事务拆成多个本地事务,每一步成功后继续下一步,如果失败就反向执行补偿操作 - 本地消息表 / 事务消息 - 先保证本地事务成功,再通过消息异步驱动其他系统完成后续操作 - 锁机制 - MySQL 有哪些锁 - 按粒度分,有表锁和行锁 - 表锁锁整张表,开销小但并发差;行锁锁单条记录,并发更好,InnoDB 主要依赖行锁 - 按性质分,有共享锁和排他锁 - 共享锁允许多个事务同时读;排他锁用于写操作,持有后别人不能再读写对应数据 - 意向锁、记录锁、间隙锁、临键锁 - 意向锁是表级锁,用来协调表锁和行锁;记录锁锁单条索引记录;间隙锁锁记录之间的区间,防止插入;临键锁是记录锁和间隙锁的组合,主要用于在可重复读下避免幻读 - 表锁和行锁的区别是什么? - 表锁是对整张表加锁,加锁开销小、实现简单,但锁冲突范围大,并发能力较差 - 行锁是对单条记录加锁,锁粒度更细,并发能力更强,但加锁和维护成本也更高 - 行锁是加在行上的吗,还是加在索引上? - InnoDB 的行锁不是直接加在物理数据行上的,而是加在索引记录上的 - InnoDB 是通过锁住索引项来间接锁住对应的数据行 - 没有走索引,数据库就可能扫描并锁住大量记录,效果上看起来就像锁了很多行 - 什么是共享锁和排他锁? - 共享锁也叫读锁,多个事务可以同时对同一份数据加共享锁,也就是说大家都可以读,但不能修改 - 排他锁也叫写锁,一旦某个事务对数据加了排他锁,其他事务就不能再对这份数据加共享锁或排他锁,也就是别人既不能读,也不能写 - 共享锁适合并发读场景,排他锁适合需要修改数据、保证独占访问的场景 - 意向锁的作用是什么? - 让 InnoDB 在表级锁和行级锁并存的情况下,能够快速判断一张表里是否已经有某些行被加锁,而不需要每次都去逐行检查 - 意向锁本质上是一种“加锁声明”,表示某个事务准备在这张表的某些行上加共享锁或排他锁 - 比如事务如果要对某些行加行级共享锁,就会先在表上加意向共享锁(IS);如果要对某些行加行级排他锁,就会先在表上加意向排他锁(IX) - 当另一个事务想对整张表加表锁时,只需要先判断这张表上是否存在冲突的意向锁,就能快速决定能不能加锁,而不需要扫描整张表里的每一行 - 事务加锁 - 事务里并不是一开始就把所有锁一次性加好,而是 SQL 在执行过程中,随着访问到具体记录再逐步加锁。不同语句、不同隔离级别、不同索引命中情况,加的锁也不一样 - 如果是普通 select,在 InnoDB 里通常属于快照读,一般不加行锁 - 如果是 select ... for update、select ... for share 这种锁定读,就会对扫描到的索引记录加锁,锁会一直持有到事务提交或回滚 - 如果是 update、delete 这类写操作,InnoDB 会在执行过程中对访问到的索引记录加排他锁 - 死锁如何排查 - 先通过 SHOW ENGINE INNODB STATUS 查看最近一次死锁的详细信息,因为这里能直接看到死锁涉及的两个事务、它们各自执行的 SQL、已经持有的锁、正在等待的锁,以及 InnoDB 最后回滚了哪个事务 - 根据死锁日志里的事务 ID、SQL 语句、锁类型和表索引信息,去定位到底是哪两条业务 SQL 在互相等待,重点看它们是不是以不同顺序访问了相同资源 - 再结合执行计划和索引设计分析根因,比如是否没命中索引导致锁范围扩大、是否范围条件触发了间隙锁或临键锁、是否事务过长导致持锁时间太久 - 统一事务中的访问顺序、补充合适索引、缩小事务范围、尽快提交事务,并在应用层做好 deadlock 重试机制 - update 一定会加行锁吗? - UPDATE 一定会加锁,但不一定只是精确的单行锁;如果命中了唯一索引等值条件,锁范围通常比较小,如果是范围更新或没有用到合适索引,锁范围就可能很大 - MVCC 与日志 - redo log 和 undo log 的区别是什么? - redo log 主要用于保证事务的持久性,记录的是“数据页做了什么修改”,核心是为了数据库宕机后能够把已提交事务的数据恢复出来 - undo log 主要用于保证事务的原子性和支持 MVCC,记录的是“怎么把这次修改撤销掉”,核心是为了事务回滚和一致性读 - redo log 是“向前恢复”的日志,undo log 是“向后回滚”的日志 - 事务提交后,如果数据库突然宕机,InnoDB 可以通过 redo log 把已经提交但还没来得及刷盘的数据重新恢复出来 - binlog 是什么?和 redo log 有什么区别? - binlog 是 MySQL Server 层的归档日志,记录的是数据库变更事件,主要用于主从复制和基于时间点恢复 - redo log 是 InnoDB 存储引擎层的重做日志,记录的是对数据页的物理修改意图,主要用于崩溃恢复,保证已提交事务在宕机后不丢失 - binlog 记录的是逻辑上的变更事件,可以是 statement、row、mixed 三种格式;redo log 记录的是更底层的数据页修改信息 - binlog 会持续追加写入二进制日志文件,redo log 则是固定容量、循环写的日志结构 - 两阶段提交是什么?为什么需要两阶段提交? - MySQL 为了保证 redo log 和 binlog 的一致性 而采用的一种提交机制 - 提交事务时,先写 redo log 并标记 prepare,再写 binlog,最后把 redo log 标记为 commit - 这样即使宕机,也能根据 redo log 状态和 binlog 是否完整,判断事务到底该提交还是回滚,避免数据恢复错误和主从不一致 - binlog 有哪几种格式?STATEMENT / ROW / MIXED 的区别是什么? - STATEMENT 模式记录的是 原始 SQL 语句,也就是把执行过的 SQL 直接写到 binlog 里 - ROW 模式记录的不是 SQL,而是 每一行数据被修改前后是什么样子 - MIX 能兼顾日志大小和复制安全性,本质上是 MySQL 自动帮你在 STATEMENT 和 ROW 之间选择 - 因为主从复制最重要的是一致性,虽然 ROW 日志更大,但更稳定、更安全,所以很多公司默认都会选 ROW - InnoDB 存储引擎 - 什么是页、区、段? - 在 InnoDB 里,数据不是一行一行零散存储的,而是按 页、区、段 这样的层次来管理的 - 页 是最小的数据存储和读写单位,InnoDB 默认页大小一般是 16KB - 数据库不可能为了查一行数据就只从磁盘读这一行,它通常是 把这一行所在的整个页一起读进内存 - 区 是一组连续页的集合,主要是为了提高磁盘分配效率 - 段 是更上层的逻辑概念,用来管理某一类数据对应的多个区 - 数据页内部结构了解吗? - 一个页里面既要存数据,也要存管理信息,还要支持页内快速查找和页之间的链式组织 - File Header 让很多数据页能够串起来,形成双向链表 - Page Header 负责描述“这个页现在装了多少数据、还剩多少空间、内部组织状态如何” - Infimum 可以理解为“页内最小记录”,Supremum 可以理解为“页内最大记录”,它们的作用是帮助 InnoDB 统一维护页内记录的链表结构 - InnoDB 的聚簇索引叶子节点,存放的其实就是完整的行记录,这些记录就是放在数据页里的 User Records 区域 - Free Space 表示当前页还能不能继续插入数据 - 页目录提高页内查找效率,避免单纯链表遍历太慢。先通过 页目录 做快速定位,再在局部范围内顺着记录链表查找 - File Trailer 主要用于页级别的校验 - 页分裂和页合并 - 页分裂:目标页空间不够了,但又必须保持记录按主键顺序存放 - 页合并:删除数据后,页利用率太低,空间浪费严重 - InnoDB Buffer Pool 是什么? - InnoDB Buffer Pool 是 InnoDB 在内存里开辟的一块缓存区,主要用来缓存磁盘中的数据页和索引页 - 把经常访问的数据先放到内存里,减少磁盘 IO,提高数据库的查询和更新性能 - 把“磁盘随机读”尽量变成“内存读” - 什么是脏页 - 内存中的页已经被修改了,但磁盘中的对应页还没来得及更新 - Change Buffer 是什么 - 当修改的是 普通二级索引,而且对应索引页 当前不在 Buffer Pool 中 - InnoDB 不会立刻把这个索引页从磁盘读到内存再修改,而是先把这次变更缓存到 Change Buffer 里 - 等以后这个页被读入内存时,再把这些变更合并进去 - 减少二级索引页的随机磁盘 IO,提升插入、删除、更新的性能 - 唯一索引需要立刻检查唯一性,所以通常 不能使用 Change Buffer - Double Write - Double Write 是 InnoDB 为了防止页刷盘过程中发生“部分写入”而设计的一种保护机制 - 如果数据库在把一个页写回磁盘时突然宕机,就可能出现 一个页只写了一半 的情况,这种问题叫 partial write,也就是页损坏 - InnoDB 在真正把脏页写回数据文件之前,会先把这些页顺序写到一块专门的区域,这块区域就叫 Double Write Buffer; - 只有这一步写成功后,才再把这些页写到真正的数据页位置 - 主从复制与高可用 - 主从复制的原理 - 主库执行事务提交后,会把这次数据变更记录到 binlog 里 - 从库会有一个 I/O 线程,去主库读取 binlog,并把读到的内容写入本地的 relay log(中继日志) - 从库再用 SQL 线程或者 worker 线程去执行 relay log 里的内容,把主库上的变更在从库再执行一遍,这样主从数据就一致了 - 因为 binlog 记录的是逻辑上的数据变更,适合跨机器同步;而 redo log 主要用于主库自身的崩溃恢复 - 主从延迟的原因有哪些? - 主库的变更已经提交了,但从库还没有来得及同步并执行完成 - 主库写入压力太大 - 如果主库短时间内产生了大量 binlog,比如高并发写入、批量 update、批量 delete,这些日志从库来不及消费,就会出现延迟 - 从库回放能力不足 - 大事务 - 如果主库上有大事务,比如一次改很多行、删很多数据,从库执行这类事务也会很慢,容易造成明显延迟 - 锁冲突 - 从库在回放 binlog 时,如果遇到锁等待,比如查询占用了资源,或者回放语句之间有冲突,也会拖慢复制速度 - 单线程复制的限制 - 网络问题 - 主从复制模式 - 异步复制 - 主库事务提交时,只要自己写成功就直接返回,不需要等待从库确认 - 如果主库刚提交就宕机,而 binlog 还没来得及传到从库,就可能发生 数据丢失 - 半同步复制 - 主库提交事务后,不是立刻返回,而是 至少要等一个从库收到 binlog 并返回确认,主库才认为提交完成 - 通常只是表示 从库收到了日志,不一定表示已经真正执行完成 - 组复制 - 多个节点组成一个复制组,事务提交时需要经过组内协调和一致性校验,然后再在多个节点之间复制 - 读写分离实现 - 把写请求走主库,读请求走从库,从而分摊数据库压力 - 最大的问题是主从延迟,容易导致读到旧数据,同时也会增加系统路由和运维复杂度 - 读写分离如何读到最新数据 - 让“有强一致性要求的读”不要去可能延迟的从库,而是优先走主库 - 用户刚完成写操作后,接下来一段时间内的查询直接走主库,这样就能保证一定能读到最新数据 - 如果一个业务逻辑本身就在同一个事务里,那么这个事务中的读和写通常都应该放在主库执行 - 系统可以监控主从延迟,如果发现从库延迟过大,就临时把请求切到主库 - 给关键业务做读主策略 - 主库宕机后如何切换? - 确认主库确实不可用 - 从多个从库里选择一个 数据最新、延迟最小、状态最健康 的从库作为新主库 - 把新主库提升上来 - 让其他从库重新挂到新主库 计算机网络 - 计算机网络 - TCP/UDP - TCP/UDP 区别 - TCP 是面向连接、可靠传输的协议;UDP 是无连接、尽最大努力交付的协议 - TCP 在通信前需要先建立连接,并且通过 ACK、序号、超时重传、滑动窗口、流量控制、拥塞控制 等机制保证数据可靠到达 - TCP 适合 文件传输、网页访问、数据库通信 这类对可靠性要求高的场景 - UDP 不需要建立连接,首部开销小,传输效率高,延迟低 - 不保证数据一定到达,也不保证顺序,所以更适合 音视频、直播、游戏、DNS 这类对实时性要求更高的场景 - TCP 为什么可靠 - TCP 给每个字节编号,接收方收到后会回复 ACK,发送方就知道哪些数据已经成功到达 - 如果发送后迟迟收不到 ACK,TCP 会认为数据可能丢了,然后重新发送 - 如果数据乱序到达,TCP 会先缓存,等前面的数据到了再按顺序交给应用层;如果重复到达,会直接丢弃 - TCP 不需要每发一个包就停下来等确认,可以连续发送多个包,提高传输效率 - TCP 会根据接收方的处理能力调整发送速度,避免接收方来不及处理 - TCP 会根据网络拥塞情况动态调整发送速率,避免把网络压垮 - TCP 三次握手 - 作用,是让通信双方都确认一件事:自己能发、自己能收、对方也能发、对方也能收 - 客户端向服务端发送 SYN,表示客户端想建立连接,同时告诉服务端自己的初始序列号,说明:客户端有发送能力 - 服务端收到后,返回 SYN + ACK - ACK 表示服务端收到了客户端的请求,SYN 表示服务端也想建立连接,并告诉客户端自己的初始序列号 - 说明:服务端有接收能力,也有发送能力 - 客户端收到服务端的 SYN + ACK 后,再发送一个 ACK 给服务端 - 客户端已经收到了服务端的响应,同时让服务端知道客户端也具备接收能力 - 为什么握手不是两次 - 两次握手只能保证客户端知道服务端能收、能发,但服务端并不能确认客户端是否收到了自己的响应 - 没有第三次握手,服务端就可能误以为连接已经建立,从而浪费资源 - TCP 四次挥手过程是什么 - 客户端发送 FIN,表示客户端这边已经没有数据要发了,想关闭连接 - 客户端进入半关闭状态,表示自己不能再发了,但还能接收 - 服务端收到 FIN 后,先回复一个 ACK,表示我知道你这边想关闭了 - 这时只是说明服务端知道客户端不发了,但服务端自己可能还有数据没发完,所以还不能立刻关闭 - 等服务端把剩余数据发送完后,再发送一个 FIN,表示服务端这边也没有数据要发了,也要关闭连接 - 客户端收到服务端的 FIN 后,回复一个 ACK,表示我知道你也发完了 - 为什么需要四次 - TCP 是全双工的,客户端关闭发送和服务端关闭发送是两件事 - 服务端收到客户端的 FIN 时,只能先回一个 ACK,表示“我知道你要关了” - 但这时服务端可能还有数据没发完,所以不能把自己的 FIN 和这个 ACK 合并发送 - TIME_WAIT 是什么?为什么需要 TIME_WAIT? - TCP 连接断开时,主动关闭连接的一方 在发送完最后一个 ACK 后进入的一个等待状态 - 保证最后一个 ACK 能被对方收到 - 如果主动关闭方发出的最后一个 ACK 丢失了,被动关闭方会重发 FIN - 主动关闭方还保留在 TIME_WAIT 状态,就还能再次发送 ACK,保证连接正常关闭 - 防止旧连接中的延迟报文影响新的连接 - 为什么是 2MSL? - MSL 是报文在网络中的最大生存时间 - 等待 2MSL,本质上是留出一个“报文来回”的最长时间: - 所以等待 2MSL 后,可以认为这次连接里的所有报文都已经从网络中消失了,连接才算真正安全结束 - CLOSE_WAIT 过多说明什么问题? - 对方已经把连接关闭了,但是本端应用程序没有及时调用 close 关闭连接 - 连接一直停留在 CLOSE_WAIT 状态 - 对方发送 FIN - 本端内核回复 ACK - 这时 TCP 连接就进入 CLOSE_WAIT - 所以如果 CLOSE_WAIT 特别多,一般不是网络问题,也不是内核参数问题,而是应用代码问题。常见原因有 - 没有正确调用 close - 异常分支没有释放连接 - 线程泄露 - 线程阻塞 - 业务处理太慢 - SYN Flood - 是一种针对 TCP 三次握手的 DoS / DDoS 攻击 - 利用 TCP 三次握手中服务端会先分配资源的特点,大量占满半连接队列,耗尽服务端资源 - 防御方式 - 在还没完成握手前,不急着分配连接资源,而是把状态编码到序列号里,等客户端真正回 ACK 后再分配资源 - 增大半连接队列 - 缩短半连接超时时间,减少重试次数 - 限流和防火墙过滤 - 源地址校验和流量清洗 - TCP 粘包/拆包 - TCP 是面向字节流的协议,没有消息边界 - 发送方连续发送的多个数据包,接收方一次可能一起读出来,这叫 粘包 - 发送方一个完整数据包,接收方可能分多次才读完整,这叫 拆包 - TCP 只保证字节流可靠、有序到达,但不保证应用层消息是一条一条交付的 - 滑动窗口 - 发送方不用每发一个数据包就停下来等 ACK,而是可以在窗口范围内连续发送多个数据包 - 随着 ACK 不断返回,已经确认的数据会从窗口左边移出去,新的可发送数据会从右边进入 - 窗口左边:已经发送并且已经确认的数据 - 窗口中间:已经发送但还没有确认的数据 - 窗口右边:暂时还不能发送的数据 - 当 ACK 到来后,窗口右移,发送方就能继续发送新的数据 - 接收方能处理多少,发送方就最多发多少,这就体现了流量控制 - TCP 拥塞控制有哪些机制? - 避免发送方一下子发太多数据,把网络压垮 - 慢启动 - 不会一上来就发很多数据,而是先从较小的拥塞窗口开始,收到 ACK 后再快速增大 - 拥塞避免 - 当拥塞窗口增长到一定阈值后,就不再指数增长,而改为线性增长 - 快重传 - 如果发送方连续收到三个重复 ACK,就认为某个报文很可能丢了,不必等超时,立刻重传对应报文 - 快恢复 - 先把窗口缩小一部分,再进入拥塞避免阶段 - 什么是零窗口?什么是窗口探测? - 接收方的接收缓冲区已经满了,于是告诉发送方自己的接收窗口大小为 0,让发送方先不要再发数据 - 体现的是 TCP 的流量控制,也就是接收方处理不过来了,先把发送方“刹住” - 窗口探测 - 当发送方发现接收方通告窗口为 0 后,会周期性发送很小的探测报文,询问接收方现在窗口是不是已经恢复了 - HTTP - 常用状态码 - 1xx 表示请求已收到,继续处理 - 2xx 表示请求成功 - 200 OK:请求成功,最常见 - 3xx 表示需要重定向 - 301 Moved Permanently:永久重定向 - 302 Found:临时重定向 - 304 Not Modified:资源没修改,走缓存 - 4xx 表示客户端请求有问题 - 400 Bad Request:请求参数有问题 - 401 Unauthorized:未认证,通常是没登录或 token 无效 - 403 Forbidden:已认证,但没有权限 - 404 Not Found:资源不存在 - 405 Method Not Allowed:请求方法不允许 - 408 Request Timeout:请求超时 - 5xx 表示服务器处理有问题 - 500 Internal Server Error:服务器内部错误 - 502 Bad Gateway:网关收到上游错误响应 - 503 Service Unavailable:服务暂时不可用 - 504 Gateway Timeout:网关等待上游响应超时 - HTTP 长连接 - 一次 TCP 连接建立后,不是只收发一个请求就立刻关闭,而是可以在这个连接上继续发送多个 HTTP 请求和响应 - 复用同一条 TCP 连接,减少频繁建立和关闭连接的开销 - 在 HTTP/1.1 中,长连接默认就是开启的,通常通过 Connection: keep-alive 来体现 - HTTP 队头阻塞 - 前面的请求或数据没有处理完,后面的就只能等 - 虽然可以在一个 TCP 连接上发送多个请求,但响应必须按顺序返回 - 前一个请求如果处理很慢,后面的请求即使已经到了,也不能先返回,只能排队等待,这就是 HTTP 层的队头阻塞 - 如果前面的某个 TCP 报文丢了,即使后面的报文已经到了,应用层也不能先拿到,必须等前面的报文重传成功后,后面的数据才能一起交付 - HTTP/2 引入了 多路复用,把多个请求拆成不同的流并发传输,解决了 HTTP/1.1 中“请求和响应必须严格排队”的问题 - HTTP 1/1.1/2/3 - 不断解决 性能低、连接利用率低、队头阻塞严重 - HTTP/1.0 的特点是:默认短连接 - HTTP/1.1 的改进是:默认长连接,支持连接复用,支持 Host 字段、缓存控制等能力 - HTTP/2 的核心改进是:二进制分帧、多路复用、头部压缩 - 把原来文本格式改成二进制帧,解析效率更高; - 多个请求可以在同一个连接里并发传输,不再像 HTTP/1.1 那样严格排队 - 通过头部压缩减少了重复头信息带来的开销 - HTTP/3 的核心改进是:基于 QUIC,也就是基于 UDP 来实现可靠传输 - 不再直接使用 TCP,而是把连接管理、重传、流控这些能力放到 QUIC 里做 - Cookie 属性 - Name 和 Value:就是 Cookie 的名字和值,用来保存会话信息或业务数据 - Expires 和 Max-Age:用来控制 Cookie 的过期时间 - Domain:指定 Cookie 对哪个域名生效 - Path:请求路径匹配时,浏览器才会带上这个 Cookie - Secure:这个 Cookie 只能在 HTTPS 连接中传输,不能通过 HTTP 明文传输 - HttpOnly:这个 Cookie 不能被 JavaScript 读取,主要是为了降低 XSS 窃取 Cookie 的风险 - SameSite:用来限制跨站请求时是否携带 Cookie,主要是为了防 CSRF - HTTP 缓存 - 主要分为两类:强缓存 和 协商缓存 - 强缓存 - 浏览器先看本地缓存是否还有效,如果没过期,就直接使用本地缓存,不会向服务器发请求 - Expires:绝对过期时间 - Cache-Control:相对过期时间,优先级更高,实际开发更常用 - 协商缓存 - 本地虽然有缓存,但浏览器还是会先向服务器确认一下这个资源有没有变化 - 如果没变,服务器返回 304 - Last-Modified 和 If-Modified-Since:基于资源最后修改时间判断 - ETag 和 If-None-Match:基于资源唯一标识判断,通常更准确 - HTTPS - TLS 握手过程是什么 - 客户端发起请求 - 客户端先发送 ClientHello,里面会带上自己支持的 TLS 版本、加密套件、随机数 等信息,告诉服务端:我支持哪些加密方式 - 服务端返回协商结果 - 服务端收到后,返回 ServerHello,告诉客户端:我最终选用哪个 TLS 版本、加密套件,同时也会返回服务端自己的随机数 - 服务端发送证书 - 服务端会把自己的 数字证书 发给客户端,客户端会校验证书是否合法,比如是不是受信任的 CA 签发、证书是否过期、域名是否匹配 - 双方协商出会话密钥 - 客户端和服务端会通过密钥交换算法,结合前面的随机数等信息,生成本次通信使用的 会话密钥 - 握手完成,开始加密通信 - CA 和证书链 - CA 就是 证书颁发机构,作用是给网站签发数字证书 - 证书链 - 一个网站的证书,往往不是直接由浏览器内置信任的根 CA 签发的,而是由 中间 CA 来签发 - 中间 CA 又是被更上层的 CA 签发,最后一直追溯到 根 CA - 根 CA 数量有限,而且非常重要,不会直接大量给网站签证书 - IP/路由/ARP/ICMP - ARP - ARP 全称是 地址解析协议,作用是:根据 IP 地址找到对应的 MAC 地址 - 先查 ARP 缓存 - 如果没有,就发送 ARP 请求,在局域网内发一个 广播包 - 目标主机回复 ARP 响应,返回一个 单播响应,把自己的 MAC 地址告诉发送方 - 发送方拿到目标 MAC 后,会把这条 IP 和 MAC 的映射 存到本地 ARP 缓存里 - 为什么有了 IP 还需要 MAC - IP 负责跨网络定位目标主机 - MAC 负责在局域网内把数据交给具体网卡 - IP 是端到端的逻辑地址,MAC 是每一跳局域网内的物理地址 - IP 管“到哪里”,MAC 管“怎么到下一跳” - ICMP - ICMP 全称是 互联网控制报文协议,它是 网络层 的辅助协议,主要用来做 差错报告、网络诊断和连通性检测 - ping 的原理,本质上就是利用 ICMP 的 Echo Request 和 Echo Reply 报文来测试两台主机之间是否连通,以及大概的往返时延 - 发送方发送 ICMP Echo Request - 目标主机收到后返回 ICMP Echo Reply - 发送方收到回复后,就知道网络是通的 - traceroute 原理 - 查看数据包从源主机到目标主机,中间经过了哪些路由器 - TTL 表示数据包还能经过多少跳,减到0,路由器就会把这个包丢弃并返回一个 ICMP Time Exceeded - 利用 IP 头里的 TTL 字段 和路由器返回的 ICMP 超时报文 来实现逐跳探测 - 先发一个 TTL=1 的包 - 再发一个 TTL=2 的包 - 不断把 TTL 加 1,直到到达目标主机 - Socket/ 服务端编程 - Socket 是什么 - 本质上就是:操作系统提供给应用程序的网络通信接口 - 应用程序并不是直接去操作 TCP 或 UDP,而是通过 Socket 来完成网络数据的收发 - Socket 是应用层和传输层之间的一层编程抽象 - listen/accept/connect - listen:把一个 socket 变成 监听 socket,表示服务端开始监听某个端口,准备接收客户端连接 - accept:用于从监听队列里取出一个已经建立好的连接,返回一个新的 已连接 socket - connect:用于客户端主动向服务端发起连接请求 - 流程 - 服务端先 socket,再 bind - 然后 listen,表示开始监听端口 - 服务端调用 accept 取出这个连接 - 阻塞 I/O、非阻塞 I/O、I/O 多路复用 - 阻塞 I/O:当程序调用 read 或 recv 时,如果数据还没准备好,线程就会一直阻塞住,什么都干不了,只能等数据到了再继续执行 - 非阻塞 I/O:当程序调用 read 或 recv 时,如果数据还没准备好,不会阻塞线程,而是立刻返回,告诉你现在还不能读 - I/O 多路复用:通过 select、poll、epoll 这类机制,一次监听多个 socket,先等内核告诉我“哪些连接已经就绪了”,然后再去读写这些就绪的连接 - select/poll/epoll - select、poll、epoll 本质上都是 I/O 多路复用机制,目的都是让一个线程同时监听多个 socket,谁就绪就处理谁 - select - 用 位图 保存要监听的 fd,每次调用都要把 fd 集合从用户态拷贝到内核态 - 内核返回后,应用程序还要 遍历整个 fd 集合,找出哪些 fd 就绪了 - select 一般还有 最大 fd 数量限制,常见是 1024 - poll - 把位图改成了 链表或数组结构 来保存 fd - 没有 select 那种固定 1024 的数量限制 - 本质上还是要把 fd 集合拷贝到内核,也还是要 线性遍历所有 fd 找就绪事件,所以性能问题本质没有解决 - epoll - 是 Linux 下更高效的实现 - 它会在内核里维护监听的 fd,不需要每次都把整个 fd 集合重新拷贝进去 - 不是每次遍历所有 fd,而是通过 就绪链表 直接返回已经就绪的 fd - 连接数很大、但真正活跃的连接不多时,epoll 效率会高很多 - LT 和 ET 模式 - LT 是 水平触发 - 只要一个 fd 里还有数据没读完,或者还有空间可以写,epoll 每次都会继续通知你 - 条件只要一直满足,就会反复触发 - ET 是 边缘触发 - 当 fd 的状态发生变化的那一刻,epoll 才会通知一次 - 这次没有把数据一次性读完,只要状态没再变化,epoll 不一定再次提醒你 - Reactor 模型 - 事件驱动的网络编程模型 - 不是一个连接一个线程去阻塞处理,而是由一个或多个线程统一监听 I/O 事件,谁就绪就分发给对应的处理器 - 监听事件:通过 select、poll、epoll 这类 I/O 多路复用机制,统一监听很多连接上的事件,比如连接到来、可读、可写 - 分发事件:一旦某个连接就绪了,Reactor 就把这个事件分发给对应的 Handler 去处理,比如读取数据、业务处理、发送响应 - I/O 事件来了,先由 Reactor 感知,再分发给处理器执行 - 零拷贝 - 在数据传输过程中,尽量减少用户态和内核态之间的数据拷贝,以及减少 CPU 参与搬运数据的次数 - 传统方式 - 磁盘把数据读到内核缓冲区 - 再从内核拷贝到用户缓冲区 - 用户缓冲区再拷贝回内核的 socket 缓冲区 - 最后再发给网卡 - 发生多次数据拷贝和多次用户态、内核态切换,开销比较大 - 核心思想:数据尽量留在内核里直接转发,避免来回拷贝到用户态 - sendfile - 应用程序不再把文件先读到用户态,再写回 socket - 让内核直接把文件数据从磁盘缓冲区传到 socket,提高传输效率 - 最典型的零拷贝方式 - mmap - 把文件映射到用户进程的地址空间,减少一次数据拷贝 - 应用层访问起来像读内存一样,但底层仍然是内核在管理 - 拥塞、性能、优化 - 带宽和吞吐量有什么区别? - 带宽 指的是:链路理论上的最大传输能力 - 吞吐量 指的是:单位时间内实际传输了多少数据 - 带宽是理论值、上限值 - 吞吐量是实际值、结果值 - 什么是心跳机制?为什么需要心跳? - 通信双方按照固定时间间隔,周期性发送一个很小的探测消息,用来确认对方是否还活着、连接是否还正常 - 作用:检测连接是否存活;及时发现异常;维持长连接 - 网络安全 - XSS - XSS 是 跨站脚本攻击,本质上是:攻击者把恶意脚本注入到网页里,让受害者浏览器去执行 - 脚本执行成功,攻击者就可能窃取 Cookie、伪造页面、冒充用户操作,甚至获取用户信息 - 网站把用户输入的内容当成代码执行了,而不是当普通数据处理 - 防御方式 - 对用户输入做转义和过滤 - 输出到页面时做 HTML 编码 - 使用 CSP - 给 Cookie 加 HttpOnly - CSRF - 跨站请求伪造,本质上是:攻击者诱导用户在已登录状态下访问恶意页面,借助浏览器自动携带 Cookie 的特性,冒充用户向目标网站发请求 - 防御方式 - 加 CSRF Token - 校验 Origin / Referer - 使用 SameSite Cookie - 对关键操作做二次确认 - 场景题 - WebSocket 是什么? - WebSocket 是一种 全双工、长连接的网络通信协议, - 不只是客户端发请求、服务端被动响应,服务端也可以主动向客户端推送消息 - 先通过 HTTP 发起一次升级请求 - 服务端同意,就返回 101 Switching Protocols,表示协议切换成功 Java - Java - Java 集合 - HashMap 为什么线程不安全? - put、resize、get 等操作都没有加锁,多线程并发读写时,多个线程会同时修改同一份底层数据结构,导致数据错乱 - 多个线程同时往同一个桶位置插入数据,都会先判断当前位置为空,或都基于旧值进行修改,其中一个线程的结果会覆盖另一个线程的结果,数据丢失 - 如果多个线程同时扩容,或者一个线程在扩容、另一个线程在插入,会同时修改桶数组和节点指针 - ConcurrentHashMap 为什么线程安全? - 在并发读写时,对共享数据的访问做了专门的同步控制 - 插入时,如果桶位置为空,会先通过 CAS 尝试把新节点放进去,CAS 成功就直接插入,不需要加锁 - 如果对应桶已经有元素,说明发生哈希冲突,这时会对桶头节点加锁 - 在这个桶内部进行链表或红黑树的插入操作,也就是说它锁的是 单个桶,不是整张表 - 扩容时,也支持多线程协助扩容,降低单线程扩容带来的性能问题 - 为什么负载因子默认是 0.75? - 负载因子表示:哈希表中元素个数达到数组长度的多少时开始扩容 - 如果负载因子太小,会浪费空间 - 如果负载因子太大,会增加哈希冲突 - 0.75 是时间和空间的经验平衡点 - 为什么数组长度总是 2 的幂? - (n - 1) & hash 的效果就等价于 对 n 取模,也就是等价于 hash % n,但是 位运算比取模运算更高效 - 数组长度是 2 的幂时,n - 1 的低位全是 1,做 & 运算时,可以利用 hash 的低位信息,使元素更均匀地分布到各个桶中 - Java 并发 - 线程状态 - NEW:新建状态,对象已经被创建出来了,但是还没有调用 start() 方法 - RUNNABLE:可运行状态,调用了 start() 之后,就进入 RUNNABLE 状态 - BLOCKED:阻塞状态,在等待获取 synchronized 锁 时,如果锁被别的线程占用,就会进入 BLOCKED 状态 - WAITING:无限期等待状态,等待其他线程显式唤醒 的状态,没有超时时间 - TIMED_WAITING:限时等待状态,带超时时间,时间到了会自动恢复 - TERMINATED:终止状态,线程执行完毕,或者因为异常退出,生命周期结束 - synchronized 原理 - 依赖的是 对象头里的监视器锁,也就是 Monitor - Java 中每个对象都可以关联一个 Monitor,线程进入 synchronized 修饰的代码时,本质上就是在竞争这个对象的 Monitor 锁 - synchronized 修饰的是 代码块 - 编译后会生成两个字节码指令:monitorenter/monitorexit - 同步代码块的底层就是通过 字节码指令 + Monitor 机制 来实现加锁和解锁的 - synchronized 修饰同步方法时 - 在方法的访问标志上加一个 ACC_SYNCHRONIZED 标记 - 调用这个方法时,看到有这个标记,就会自动先去获取对应对象的 Monitor 锁,执行完方法后再释放锁 - synchronized 锁的是谁? - 基于 对象监视器 Monitor 实现的,所以它锁住的一定是某个对象 - AQS 是什么 - AQS 的全称是 AbstractQueuedSynchronizer,抽象队列同步器 - 用来构建锁和同步器的底层框架 - 如果线程竞争资源失败,就把线程封装成节点,加入一个双向队列中排队 - 当前面的线程释放资源后,再唤醒队列中的后继线程继续竞争 - 核心构成 - 用一个 state 变量表示同步状态 - AQS 内部有一个 volatile int state 变量,用来表示当前同步状态 - AQS 只提供这个状态字段和管理机制,具体这个状态代表什么,由子类自己定义 - 用一个 FIFO 双向队列管理等待线程 - 线程获取同步状态失败时,AQS 不会让所有线程一直自旋,而是会把获取失败的线程包装成一个 Node 节点,加入到一个 FIFO 双向链表队列里等待 - 工作流程 - 尝试获取资源 - 入队等待 - 阻塞线程:入队之后,线程会被挂起,避免一直空转浪费 CPU - 释放资源并唤醒后继节点 - ThreadLocal 是什么 - 给每个线程都提供一份独立的变量副本,从而做到线程之间互不干扰 - 同一个 ThreadLocal 对象,在不同线程中读取到的值,实际上是各自线程单独保存的,不是共享的 - 每个线程 Thread 对象内部,都有一个 ThreadLocalMap,真正的数据是存放在线程自己的 ThreadLocalMap - JMM - 是一套关于多线程环境下共享变量如何读写、以及线程之间如何保证可见性、有序性和原子性的规范 - 为了规范这些问题,JMM 定义了一系列规则,并通过 volatile、synchronized、Lock、happens-before 等机制来实现 - happens-before - 一个操作的结果,对另一个操作是否可见,以及这两个操作之间是否存在有序关系 - happens-before 是 JMM 中定义的一种 内存可见性和有序性规则 - 前一个操作对共享变量的修改,后一个操作一定能够看到 - 前一个操作的执行顺序,排在后一个操作之前 - 常见规则 - 程序次序规则:在同一个线程内,按照代码顺序,前面的操作 happens-before 后面的操作 - 监视器锁规则:对同一把锁,unlock 操作 happens-before 后续对这把锁的 lock 操作 - volatile 变量规则:对一个 volatile 变量的写操作 happens-before 后续对这个 volatile 变量的读操作 - 线程启动规则:线程对象的 start() 方法 happens-before 此线程中的每一个操作 - 线程终止规则:线程中的所有操作 happens-before 其他线程检测到该线程已经终止 - 传递性 - 线程池核心参数 - corePoolSize:核心线程数 - maximumPoolSize:最大线程数 - keepAliveTime:空闲线程存活时间 - unit:时间单位 - workQueue:任务阻塞队列,用来缓存等待执行任务的阻塞队列 - threadFactory:线程工厂 - handler:拒绝策略 - 拒绝策略 - AbortPolicy:直接抛异常 - CallerRunsPolicy:由提交任务的线程自己执行 - DiscardPolicy:直接丢弃任务 - DiscardOldestPolicy:丢弃队列中最老的任务,再尝试提交当前任务 - JVM - JVM 内存区域 - 程序计数器 - Java 虚拟机栈 - 本地方法栈 - 堆 - 方法区 - 方法区存放什么 - 方法区主要存放的是 类的元信息,以及一些和类加载后相关的公共数据 - 类的元信息 - 运行时常量池 - 静态变量 - 即时编译后的代码缓存相关信息 - 类加载过程 - 加载 - 通过类的全限定名获取定义这个类的二进制字节流 - 将这个字节流所代表的静态存储结构,转换成方法区中的运行时数据结构 - 在内存中生成一个对应的 Class 对象,作为这个类的访问入口 - 验证 - 确保加载进来的类文件是合法的,不会危害 JVM 安全 - 文件格式是否正确 - 元数据是否合理 - 准备 - 为类变量,也就是 static 变量分配内存,并设置默认初始值 - 赋的是默认值,不是代码里写的真实值 - 解析 - 把常量池中的符号引用,替换成直接引用 - 符号引用:用字符串、类名、方法名这种“符号形式”来描述目标 - 直接引用:真正能定位到内存地址、偏移量或者句柄的引用 - 初始化 - 初始化阶段是真正开始执行类中 Java 代码的阶段 - 这一步主要就是执行类构造器 <clinit>() 方法 - 对象可回收判断 - 可达性分析 - 从一组 GC Roots 作为根节点出发,沿着对象之间的引用关系向下搜索 - 一个对象能够被 GC Roots 直接或间接访问到,说明这个对象仍然存活 - 如果一个对象从 GC Roots 出发无法到达,也就是不可达,那么这个对象就会被判定为可回收对象 - GC Roots - 虚拟机栈中引用的对象,也就是方法中的局部变量引用的对象 - 方法区中类静态属性引用的对象 - 方法区中常量引用的对象 - 被同步锁持有的对象等 - 一组一定处于活跃状态、能够作为起点向下搜索对象引用链的根节点 - Minor GC/Major GC/Full GC - Minor GC 指的是 发生在新生代的垃圾回收 - Major GC 一般指的是 针对老年代的垃圾回收 - Full GC 指的是 对整个 Java 堆,通常还包括方法区在内的全面垃圾回收 - STW - 在 JVM 执行某些操作时,会让所有用户线程暂时停下来,只让 JVM 自己的后台线程工作 - 为了保证在垃圾回收或其他关键操作过程中,内存中的对象引用关系是相对稳定的 - G1 的回收原理是什么? - 是一种面向大堆、可预测停顿时间的垃圾回收器 - 不再把整个堆简单划分为固定的新生代和老年代连续空间 - 把整个 Java 堆切分成很多大小相等的 Region - 每个 Region 在某个时刻可以扮演 Eden、Survivor、Old 甚至 Humongous 的角色 - 不必每次都处理整片内存,而是可以优先回收那些“垃圾最多、收益最高”的 Region - G1 的回收过程 - Young GC,也就是只回收年轻代相关的 Region - 这个过程会暂停用户线程,把 Eden 和部分 Survivor 中还活着的对象复制到新的 Survivor 或 Old Region - 并发标记周期 - 当老年代占用达到一定阈值时,G1 会启动一次全堆并发标记 - 先会有一个初始标记,这个阶段需要短暂停顿,用来标记 GC Roots 直接可达的对象 - 然后进入并发标记阶段,和用户线程一起执行,去找出整个堆里哪些对象仍然存活 - 之后还有重新标记阶段,这一步也需要 Stop-The-World - 最后会进入清理和筛选阶段,统计出哪些 Region 存活对象少、回收价值高,形成后续的回收候选集 - 采用“标记整理 + 局部复制”的思路 - 会把选中的 Region 中存活对象复制到别的空 Region,再把原来的 Region 整体回收掉 - G1/CMS - CMS 和 G1 都是 面向低停顿 的垃圾回收器 - CMS 的核心思路是:针对老年代,尽量和用户线程并发执行,减少停顿时间 - G1 的核心思路是:把整个堆划分成很多 Region,按收益优先回收,尽量在可控停顿时间内完成垃圾回收 - G1 把整个堆划分成很多大小相等的 Region - CMS 在老年代主要使用的是 标记-清除算法,回收后会产生 内存碎片 - G1 从整体上看采用的是 标记-整理 思想,在局部 Region 回收时,也会涉及 复制算法 - G1 会把存活对象整理到新的 Region 中,所以能够有效减少甚至避免碎片问题 - 反射/动态代理/注解 - 注解的本质是什么? - 写在代码里的元数据 - 不会直接改变程序的执行逻辑,而是给编译器、JVM 或框架提供一些额外信息,让它们在编译期、类加载期或运行期决定要做什么处理 Spring - Spring - Spring 核心基础 - 什么是 Spring?它解决了什么问题? - 解决的是企业级应用开发里对象管理复杂、模块耦合高,以及很多通用能力需要重复造轮子的问题 - Spring 通过 IOC,也就是控制反转,把对象的创建、依赖装配和生命周期管理交给容器 - 开发者不需要再手动 new 各种对象,这样能显著降低模块之间的耦合,提高可维护性和可测试性 - Spring 通过 AOP,也就是面向切面编程,把事务、日志、权限、监控这些横切逻辑从业务代码里抽离出来 - Spring 提供了统一的生态整合能力,比如整合数据库访问、Web 开发、缓存、消息队列、安全框架等 - Spring 中循环依赖是怎么解决的? - 把 Bean 的创建过程拆成了“实例化、属性填充、初始化”几个阶段 - 在 Bean 还没完全初始化完成时,就提前把一个“早期引用”暴露出去 - 另一个 Bean 在依赖它的时候,就能先拿到这个早期对象,从而打破相互等待 - 第一级缓存放的是已经完全初始化好的单例 Bean - 第二级缓存放的是提前暴露出来的早期 Bean 对象 - 第三级缓存放的是一个对象工厂,用来在必要的时候生成早期引用 - 创建 A 的时候,发现它依赖 B,于是先实例化 A,但还没填充属性,就把 A 的 ObjectFactory 放进三级缓存 - 接着去创建 B,B 又依赖 A,这时 Spring 就会从三级缓存中拿到 A 的早期引用注入给 B - B 创建完成后,再回过头完成 A 的属性注入和初始化 - Spring 不仅要解决“拿到一个半成品对象”的问题,还要解决“这个半成品对象将来可能需要被代理”的问题 - 所以要多一层工厂来延迟决定最终暴露的是原始对象还是代理对象 - `@ComponentScan` 的原理是什么? - 告诉 Spring 去哪些包路径下扫描组件类,然后把符合条件的类注册成 Bean,交给 IOC 容器统一管理 - 确定扫描路径 - 默认情况下如果我们在启动类或者配置类上加了 @ComponentScan,Spring 会以这个类所在的包作为根路径向下扫描 - 扫描 class 文件并解析元数据 - Spring 不会一上来就把所有类都加载进 JVM,而是先基于资源路径去查找包下面的 .class 文件 - 然后通过 ASM 这类字节码读取方式解析类的注解信息 - 是否标了 @Component、@Service、@Controller、@Repository - 把符合条件的类封装成 BeanDefinition,注册到 BeanDefinitionRegistry,也就是容器的 Bean 定义注册表里 - 后续在容器 refresh 过程中再完成实例化、依赖注入和初始化 - 负责的并不是直接创建对象,而是先把“哪些类应该交给 Spring 管理”这件事识别出来并注册进去 - Spring Bean 的生命周期是什么? - Spring 容器启动时会先根据 BeanDefinition 去创建 Bean 实例,也就是实例化对象 - 这一步本质上就是通过构造方法或者工厂方法把对象创建出来 - Spring 会把这个 Bean 依赖的其他 Bean 注入进来,比如通过 @Autowired、@Resource 或者配置文件完成依赖注入 - 如果这个 Bean 实现了某些 Aware 接口,比如 BeanNameAware、BeanFactoryAware、ApplicationContextAware,Spring 会在这一阶段把对应的容器能力注入给它 - 往后会进入初始化前置处理阶段,这时会执行所有 BeanPostProcessor 的 postProcessBeforeInitialization 方法 - 如果 Bean 实现了 InitializingBean 接口,就会调用它的 afterPropertiesSet 方法 - 如果在配置里指定了 init-method,也会执行自定义初始化方法 - 初始化完成后,还会再执行一次 BeanPostProcessor 的 postProcessAfterInitialization 方法 - 很多 Spring AOP 的代理对象就是在这里生成的 - 到这里,一个 Bean 才算真正完成初始化,可以被业务代码正常使用 - 等到容器关闭时,如果 Bean 实现了 DisposableBean,Spring 会调用它的 destroy 方法 - 如果配置了 destroy-method,也会执行自定义销毁方法,完成资源释放,比如关闭连接、释放线程池之类的 - Spring 是如何管理 Bean 的? - Spring 会先读取配置来源,把类的信息解析成 BeanDefinition,也就是 Bean 的定义元数据 - 里面会描述这个 Bean 的类型、作用域、依赖关系、初始化方法等 - Spring 容器在启动过程中会把这些 BeanDefinition 注册到容器里,之后根据作用域和依赖关系来创建 Bean - Spring 会把创建好的 Bean 放到容器中统一维护,比如单例 Bean 会缓存到单例池里,后续再获取时直接返回 - ApplicationContext 刷新的过程是什 - 首先,Spring 会做刷新前的准备工作,也就是 prepareRefresh() - 设置容器的启动状态,记录启动时间,初始化一些上下文环境属性,并校验必要的配置 - 接着会进入 obtainFreshBeanFactory(),这一步的核心是拿到一个全新的 BeanFactory,并把配置源里的 BeanDefinition 加载进去 - 然后是 prepareBeanFactory(beanFactory),这一步是在对 BeanFactory 做一些标准初始化,比如设置类加载器、表达式解析器、属性编辑器 - 如果当前容器是子类实现,比如 WebApplicationContext,还会执行 postProcessBeanFactory(beanFactory) - 留给具体子类去扩展自己的 BeanFactory 处理逻辑 - invokeBeanFactoryPostProcessors(beanFactory)。这里会执行所有 BeanFactoryPostProcessor 和 BeanDefinitionRegistryPostProcessor - 在 Bean 真正实例化之前,Spring 允许开发者对 Bean 定义做进一步修改和增强 - 比如常见的 ConfigurationClassPostProcessor,就是在这里解析 @Configuration、@ComponentScan、@Import、@Bean 等注解配置 - 很多注解驱动能力本质上都是在这里完成的 - registerBeanPostProcessors(beanFactory),也就是注册所有 BeanPostProcessor - 像 AutowiredAnnotationBeanPostProcessor 负责处理依赖注入 - CommonAnnotationBeanPostProcessor 负责处理 @PostConstruct - 之后 Spring 会执行 initMessageSource(),初始化国际化消息源 - 执行 initApplicationEventMulticaster(),初始化事件广播器 - 执行 onRefresh(),给具体子类一个扩展点,比如 Web 容器可以在这里初始化特殊的 Web 基础设施 - 接着执行 registerListeners(),把事件监听器注册到容器中 - finishBeanFactoryInitialization(beanFactory),这一步会完成剩余 BeanFactory 的初始化,并且实例化所有非懒加载的单例 Bean - 包括实例化、属性填充、执行 Aware 接口回调、执行 BeanPostProcessor、调用初始化方法、必要时创建 AOP 代理对象等 - finishRefresh(),这一步表示整个容器刷新完成 - Spring 会初始化生命周期处理器,回调实现了 Lifecycle 接口的组件 - AOP - 什么是切面、连接点、切点、通知、织入? - AOP 的目标是把日志、事务、权限校验这类和主业务无关但很多地方都会重复出现的横切逻辑,从业务代码里抽离出来统一管理 - 连接点,连接点指的是程序执行过程中那些可以被增强的点,在 Spring AOP 里通常就是方法执行这个层面 - 切点可以理解为对连接点的筛选规则,也就是到底拦截哪些方法,不是所有连接点都会被增强,只有匹配切点表达式的那些连接点才会生效 - 通知是指在切点匹配到的方法前后要执行的增强逻辑,比如方法执行前记录日志、执行后统计耗时、抛异常时做告警 - 切面则是切点和通知的组合体,你可以把它理解成一个完整的增强模块,它定义了“对哪些方法”在“什么时机”做“什么增强“ - 织入,织入就是把切面应用到目标对象并生成代理对象的过程,也就是把原本独立的横切逻辑真正嵌入到业务执行链路里的过程 - AOP 实现原理是什么 - Spring 在容器启动、Bean 创建完成之后,会判断这个 Bean 是否需要被增强 - 某个 Bean 匹配了切面定义的切点表达式,Spring 就不会直接把原始 Bean 放进容器,而是为它创建一个代理对象 - 业务代码从容器中拿到的其实是这个代理对象,而不是目标对象本身 - AOP 代理对象通常是在 Bean 初始化后创建的,核心扩展点是 BeanPostProcessor - AOP 这样的自动代理能力通常由 AbstractAutoProxyCreator 这类后置处理器参与完成 - Spring 在 Bean 创建完成后,会检查这个 Bean 是否匹配 Advisor,也就是通知加切点的组合,如果匹配,就用代理对象替换原始 Bean 放进容器 - 拦截 Bean 的创建过程 - 判断当前 Bean 是否匹配某些 Advisor,也就是切点加通知的组合 - 如果匹配,就为这个 Bean 创建代理对象并返回,用代理对象替换原始 Bean - 后续从容器里拿到的就不是目标对象本身,而是增强后的代理对象 - 为什么类内部自调用会导致 AOP 失效? - Spring AOP 基于代理实现,而自调用绕过了代理对象,直接调用了目标对象本身,导致增强逻辑无法织入 - 事务 - Spring 事务是如何实现的? - Spring 事务的本质是基于 AOP 和数据库事务机制做的一层统一封装 - Spring 在容器启动过程中会解析 @Transactional,为目标 Bean 创建代理对象 - 当外部调用这个代理对象的方法时,请求不会直接进入目标方法,而是先进入事务拦截器,比如 TransactionInterceptor - 这个拦截器会先根据事务注解解析出事务属性,比如传播行为、隔离级别、超时时间、是否只读、遇到什么异常回滚等 - 事务管理器底层会把数据库连接和当前线程绑定起来,通常是通过 ThreadLocal 实现的,这样同一个线程里的多个数据库操作就可以共享同一个事务上下文 - MVC - Spring MVC 的核心组件有哪些? - Spring MVC 本质上是一个基于前端控制器模式实现的 Web 框架 - 它把一次 HTTP 请求的处理流程拆分成多个职责清晰的组件协同完成 - 最核心的组件首先是 DispatcherServlet,它可以理解为 Spring MVC 的总入口,也就是前端控制器,所有请求都会先到它这里,再由它统一分发给后续组件处理 - HandlerMapping,它的作用是根据请求的 URL、请求方法这些信息,找到当前请求应该由哪个处理器,也就是哪个 Controller 方法来处理 - HandlerAdapter,因为不同类型的处理器调用方式可能不一样,所以 Spring MVC 不会直接由 DispatcherServlet 去调用 Controller,而是通过 HandlerAdapter 做一层适配 - 处理器本身,也就是我们常说的 Controller,它负责接收请求参数、执行业务逻辑,并返回 ModelAndView 或者直接返回 JSON 数据 - ViewResolver,它主要用于传统 MVC 场景,把 Controller 返回的逻辑视图名解析成真正的视图对象 - View,它负责最终把模型数据渲染成页面返回给客户端 - HandlerExceptionResolver,它负责统一处理请求过程中的异常; - SpringBoot - Spring Boot 自动配置是如何生效的? - Spring Boot 在启动时批量导入一组自动配置类,这些配置类再基于条件注解按需生效 - 启动类上一般会加 @SpringBootApplication - @EnableAutoConfiguration - @EnableAutoConfiguration 再通过 @Import 机制,导入一个非常核心的组件,也就是 AutoConfigurationImportSelector - 这个类的职责就是在容器启动阶段,把所有候选的自动配置类找出来并导入到 Spring 容器中 - AutoConfigurationImportSelector 会去读取 Spring Boot 约定好的自动配置清单 - 这个文件里列出了一批自动配置类,比如数据源自动配置、MVC 自动配置、Redis 自动配置、事务自动配置等 - Spring Boot 启动时会把这些类批量加载进来,作为候选配置类参与后续容器处理 - 每个自动配置类上通常都会带很多条件注解,比如 @ConditionalOnClass,表示类路径下存在某个类时才生效 系统设计 - 系统设计 - RPC 框架 - 第一层是服务定义与代理层 - 服务提供方暴露接口,启动时把接口名、方法签名、版本号、分组等元数据注册到注册中心 - 服务消费方本地通过动态代理,比如 JDK 动态代理或者字节码增强,拦截接口方法调用,把本地调用转换成一个 RPC Request - 第二层是注册中心和服务发现层 - 引入注册中心,比如基于 ZooKeeper、Nacos 或者 Etcd - 服务提供者启动时注册自己的地址,并定期续约或者通过心跳保持存活 - 消费者会订阅服务列表变更,本地维护一个服务节点缓存 - 第三层是网络通信层 - 我一般会基于 Netty 来做,因为它性能高、事件驱动模型成熟,适合高并发场景 - 底层通信可以基于 TCP 长连接,这样可以减少频繁建连的成本 - 为了避免粘包拆包问题,协议上我会设计固定的消息头,比如魔数、协议版本、消息类型、序列化方式、压缩方式、请求长度,再加上消息体 - 消息头解决协议识别和扩展性,消息体承载真正的请求参数 - 第四层是编解码与序列化层 - 会给框架留一个 SPI 扩展点,支持 JSON、Hessian、Kryo、Protobuf 这类序列化方式 - 第五层是调用容错与治理层 - 首先要支持超时控制,因为网络调用最怕无限等待 - 其次要支持重试机制,但我会强调重试不是默认无脑开启,因为非幂等接口可能导致重复写,所以一般只对读操作或者明确幂等的接口开启 - 再往上是负载均衡,常见策略有随机、轮询、一致性哈希、加权随机等; - 如果是有状态服务或者缓存类场景,一致性哈希会比较合适 - 再就是熔断、限流、降级,比如某个下游实例失败率持续升高,就暂时摘除,避免雪崩扩散 - Redis - Redis - 基础认知 - Redis 为什么快 - Redis 是基于内存的 - Redis 的数据结构设计非常高效 - Redis 采用单线程模型处理命令,避免了线程切换和锁竞争 - Redis 使用 IO 多路复用,提高了网络处理效率 - Redis 6.0 之后引入了多线程优化网络读写 - Redis 为什么常被当作缓存,而不是数据库主存储 - Redis 的核心优势是高性能访问,更适合做缓存 - Redis 的数据可靠性通常不如传统主数据库 - Redis 在复杂查询和事务能力上不如数据库 - Redis 基于内存,存储成本更高 - Redis 线程模型是什么样的 - Redis 早期采用的是单线程事件循环模型,也就是由一个主线程结合 I/O 多路复用来完成连接处理、请求读取、命令解析、命令执行和结果返回 - 单线程主要是指命令处理主路径单线程,而不是整个 Redis 进程只有一个线程,因为它内部还会有一些后台线程处理持久化、异步任务等工作 - Redis 6.0 之后,又引入了 I/O 多线程,可以把 socket 读写等网络 I/O 工作交给多个线程处理 - 主线程负责命令执行,配合 I/O 多路复用;在新版本中,再用多个 I/O 线程来分摊网络处理开销 - Redis 的事件模型 / IO 多路复用是什么? - IO 多路复用,就是通过 select、poll、epoll 这类机制,让一个线程能够同时监听多个 socket 连接 - Redis 的事件主要分成两类,一类是文件事件,也就是 socket 的读写事件,负责客户端请求和响应 - 另一类是时间事件,也就是定时任务,比如过期键检查等 - Redis 主线程会不断在事件循环中监听事件、分发事件、处理事件,然后再进入下一轮循环 - 数据类型类 - Redis 的五种基础数据类型是什么? - String 是最基础的数据类型,适合存普通字符串、数字、JSON 以及做缓存和计数器 - List 是有序可重复的列表,适合做消息队列和动态列表 - Hash 是 field-value 结构,适合存对象的多个属性,比如用户信息、商品信息 - Set 是无序且元素唯一的集合,适合做去重、标签、共同好友这类场景 - ZSet 是带分数的有序集合,元素唯一并且可以按 score 排序,所以特别适合做排行榜、热度榜这类业务 - String 的底层实现是什么? - Redis 的 String 底层本质上是 SDS,也就是简单动态字符串 - Redis 没有直接使用 C 原生字符串,因为 SDS 可以 O(1) 获取长度、支持二进制安全、减少字符串修改时的扩容开销,并且更安全 - 如果值能表示成 64 位整数,就会用 int 编码 - 如果是较短的字符串,通常用 embstr 编码,也就是把对象头和 SDS 分配在同一块内存中 - 如果是较长的字符串,则使用 raw 编码 - List 的底层实现是什么? - 底层实现核心是 quicklist - quicklist 可以理解为一个双向链表,但它的每个链表节点里并不是只放一个元素,而是放一个紧凑存储结构 listpack - 如果直接用普通双向链表,每个元素都要额外付出很多指针开销,内存利用率比较低 - Hash 的底层实现是什么? - 小 Hash 使用 listpack,大一点的 Hash 使用 hashtable - listpack 是一种紧凑型连续内存结构,适合字段数量不多、field 和 value 都比较短的小对象,这样可以明显节省内存 - 当 Hash 的元素数量变多,或者字段和值超过阈值时,Redis 会自动把它转换成 hashtable 编码 - Set 的底层实现是什么 - 如果一个 Set 里的元素全都是整数,而且数量比较少,Redis 会优先使用 intset,因为这种结构更紧凑、更省内存 - 如果是小型集合,在 Redis 7.2 及以上也可能使用 listpack 编码,本质上也是为了节省空间 - 当集合规模变大,或者元素类型不再适合紧凑编码时,Redis 就会自动转换成 hashtable,这样查找、插入和删除性能会更稳定 - ZSet 的底层实现是什么? - 小型 ZSet 使用 listpack,普通 ZSet 使用 skiplist - 普通 ZSet 虽然对象编码上叫 skiplist,但实际实现并不是只有一个跳表,而是“跳表 + 哈希表”的组合 - 是因为跳表适合维护按 score 排序、做排名和范围查询,而哈希表适合按 member 快速定位元素 - List 和 Stream 的区别是什么? - Redis 的 List 和 Stream 都可以用来做消息队列 - List 本质上是一个有序列表,更适合做简单队列,通常就是一端写入、一端弹出,消息被消费后就从队列里移除了 - Stream 本质上是一个追加式日志,每条消息都有唯一 ID,消费者是按 ID 往后读取,而不是简单 pop 元素 - 支持消息回放、断点续读和多消费者读取 - Stream 原生支持消费组、待确认消息和 ack 机制,适合需要失败重试、消息追踪和多消费者协作的场景 - 底层数据结构 - 什么是渐进式 rehash?为什么要渐进式? - 当哈希表需要扩容或缩容时,Redis 不会一次性把旧表里的所有数据全部搬到新表里,而是先申请一个新哈希表,然后把数据分多次、分批次慢慢迁移过去 - 如果一次性 rehash,在数据量很大时会导致主线程长时间阻塞,造成请求延迟升高 - 过程 - 保留旧哈希表 - 新建一个更大或更小的新哈希表 - 把 rehashidx 设为起始位置 - 之后每次在处理正常请求时,顺便搬迁一部分桶中的数据 - 等所有桶都迁移完后,再释放旧表 - 什么是跳表?为什么 Redis 的 ZSet 选择跳表而不是红黑树? - 跳表本质上是一种带多层索引的有序链表 - 它在最底层维护一个有序链表,在上层再增加多级索引,这样查找、插入、删除的平均复杂度都可以做到 O(logN) - 跳表实现更简单,不需要像红黑树那样处理复杂的旋转和染色逻辑 - ZSet 很强调按 score 做范围查询、顺序遍历和排名查询,而跳表在这类操作上非常自然,找到起点后顺着链表往后扫就行 - 跳表的平均性能已经足够好,插入、删除、查找都是 O(logN) - ziplist / listpack 是什么?为什么后来更多使用 listpack? - 在 ziplist 中间插入或删除元素时,可能出现 cascading effect,也就是连锁更新问题 - 持久化 - Redis 有哪几种持久化方式? - RDB 是快照持久化,也就是在指定时间点把内存中的数据做成快照写到磁盘 - 优点是文件紧凑、恢复快、适合做备份 - 缺点是两次快照之间宕机可能丢数据 - AOF 是追加日志持久化,会把每一次写命令追加到文件里,Redis 重启时通过重放命令恢复数据 - 优点是数据更完整、持久化粒度更细 - 缺点是文件通常更大、恢复可能更慢 - AOF 重写机制是什么? - Redis 根据当前内存中的最新数据状态,重新生成一份更精简的 AOF 文件,而不是去直接修改或压缩旧的 AOF 日志 - AOF 会记录每一次写操作,文件会越来越大,包含很多对恢复当前状态已经没有意义的历史命令,需要通过重写来把这些冗余命令收敛掉 - Redis 会 fork 一个子进程,由子进程在后台生成新的 AOF 文件,主进程则继续处理客户端请求,不会中断服务 - 重写期间产生的新写命令,Redis 也不会丢失 - 父进程会单独写增量 AOF 文件,最后通过 manifest 做原子切换 - RDB 快照是怎么生成的? - 它可以通过 save 配置自动触发,也可以通过 SAVE 或 BGSAVE 手动触发 - Redis 主进程先 fork 一个子进程,子进程基于 fork 那一刻看到的内存数据,把整个数据集写入一个临时 RDB 文件 - 主进程则继续处理客户端请求,不会中断服务 - 等子进程写完后,再用新的 RDB 文件替换旧文件 - fork 之后还能继续处理请求,是因为 Redis 利用了操作系统的写时复制机制,也就是 Copy-On-Write - 什么是写时复制(Copy-On-Write)? - 父进程 fork 子进程后,操作系统不会立刻把整份内存复制一遍,而是先让父子进程共享同一批物理内存页,并把这些页标记成只读 - 到某个进程真的要修改某个页时,操作系统才会把这个页复制一份,让修改发生在新副本上 - Redis 特别依赖这个机制,因为它在做 RDB 快照和 AOF 重写时,需要 fork 子进程在后台写磁盘,而主进程还要继续处理客户端请求 - 子进程可以看到 fork 那一刻的数据视图,主进程也能继续工作,只是后续被修改到的页才会额外复制 - 过期键与淘汰策略类 - Redis 过期键是立即删除吗? - Redis 的过期键通常不是立即删除的 - 通过惰性删除和定期删除两种机制配合处理 - 惰性删除是指客户端访问某个 key 时,Redis 发现它已经过期了,才会删除并返回不存在 - 定期删除是指 Redis 会周期性随机抽取一部分带过期时间的 key 进行检查,把已经过期的 key 清理掉 - Redis 内存淘汰策略有哪些? - allkeys-* 表示所有 key 都可以参与淘汰 - volatile-* 表示只有设置了过期时间的 key 才参与淘汰 - 按最近最少使用淘汰的 LRU,有按访问频率淘汰的 LFU,有按最近最少修改淘汰的 LRM,有随机淘汰的 random,还有按剩余 TTL 最短淘汰 - allkeys-lru 在很多符合二八分布的缓存场景下是个不错的默认选项 - 缓存问题类 - 缓存穿透 - 缓存穿透,就是查询一个根本不存在的数据。因为这个数据既不会命中缓存,数据库里也查不到,所以每次请求都会穿过缓存,直接打到数据库。 - 缓存空值,也就是当数据库查不到数据时,也把这个空结果写入缓存,这样下次再查同一个 key 时,就可以直接从缓存返回,避免再次访问数据库 - 布隆过滤器,在请求进入缓存和数据库之前,先判断这个 key 是否可能存在,如果判断一定不存在,就直接拦截 - 缓存击穿 - 某一个热点 key 在某个时刻失效了,而这时候又正好有大量并发请求同时访问这个 key - 给热点 key 加互斥锁或者分布式锁,在缓存失效时只让一个线程去查数据库并重建缓存,其他线程等待或快速失败 - 可以对热点数据做逻辑过期,也就是缓存物理上不立刻删除,而是由后台异步刷新,这样前台请求始终能拿到旧值 - 对特别核心的热点数据做预热和永不过期 - 缓存雪崩 - 在某一个时间段内,大量缓存 key 同时失效,或者 Redis 整体不可用 - 原本应该由缓存承接的请求一下子全部打到数据库,从而造成数据库压力骤增 - 给不同 key 的过期时间加随机值,避免大量 key 在同一时刻同时失效 - 对热点数据提前预热,或者对特别核心的数据设置永不过期 - 做好 Redis 的高可用,比如主从、哨兵或集群,避免因为单点故障导致整片缓存不可用 - 缓存失效时配合限流、降级、熔断等保护手段 - 对回源数据库的请求做互斥控制,比如只让少量线程去重建缓存 - 分布式锁类 - 如何用 Redis 实现分布式锁 - 加锁时使用 SET lock_key unique_value NX PX 过期时间,其中 NX 表示只有这个锁不存在时才能加锁成功,保证互斥 - PX 或 EX 用来给锁设置过期时间,避免持锁线程宕机后锁永远不释放 - 释放锁不能直接 DEL,而要用 Lua 脚本先比较 value 是否还是自己,再删除 - 避免自己的锁过期后被别人拿到,而自己误删了别人的锁 - 什么是锁续期(看门狗)? - 一个线程拿到 Redis 分布式锁之后,如果业务执行时间可能超过锁的过期时间 - 系统会在锁快过期之前,自动把锁的 TTL 延长,避免业务还没执行完锁就提前失效 - 事务与原子性类 - MULTI、EXEC、WATCH 的作用是什么 - MULTI 的作用是开启一个事务块,执行它之后,后面的命令不会立刻执行,而是先进入队列 - EXEC 的作用是一次性按顺序执行前面在事务里排队的所有命令,并把连接状态恢复成普通模式 - WATCH 的作用是给一个或多个 key 加“乐观锁”,也就是在执行事务前先监视这些 key,只要它们在 EXEC 之前被其他客户端修改过,本次 EXEC 就不会真正执行事务 - 通常一起用来实现“先检查、再批量提交”的原子操作 - Lua 为什么能保证原子性 - Redis 执行 Lua 脚本时,会把整段脚本当成一个“单条命令”来执行 - Redis 的命令执行主路径以单线程事件循环为核心,所以当 EVAL/EVALSHA 开始执行后,Redis 会在同一个线程里把脚本里的所有 Redis 命令按顺序跑完 - 在这段时间里不会切换去处理其他客户端请求,也不会出现其他命令插进来“穿插执行”的情况 - Lua 的原子性保证的是“脚本执行期间不被其他命令打断” - 高可用类 - 主从复制到原理 - 主从复制本质上是 leader-follower 模型,也就是主节点负责处理写请求,然后把数据变更以命令流的形式异步发送给从节点,让从节点尽量保持和主节点一致 - 主从连接稳定时,主节点会持续把客户端写入、过期删除、淘汰等导致数据集变化的操作同步给从节点 - 网络短暂中断,从节点重连后会优先尝试部分重同步,也就是把断线期间漏掉的那段命令流补回来,这个过程依赖 replication id + offset 来判断从节点缺了哪一段数据 - 如果主节点的复制积压缓冲区已经没有这段历史了,或者历史对不上,就会退化成 全量同步 - 全量同步时,主节点会后台生成一份 RDB 快照,同时把这期间新的写命令先缓存在内存里,等 RDB 发送给从节点并加载完成后,再把这段缓冲的增量命令继续发给从节点 - Redis 默认采用的是 异步复制,也就是说主节点写成功并不会等待从节点确认,所以性能高、延迟低 - 全量复制和增量复制的区别是什么? - 全量复制一般发生在从节点第一次连接主节点,或者断线后发现没法继续部分同步的时候 - 主节点先后台生成一份 RDB 快照,把这份全量数据发给从节点 - 从节点加载完成后,主节点再把生成快照期间以及之后积累的写命令继续发给从节点,让从节点追上最新状态 - 增量复制更准确地说是部分重同步,一般发生在主从短暂断线后重新连上时 - 如果从节点保存的 replication id 和 offset 还能和主节点的复制积压缓冲区对上,那么主节点只需要把从节点断线期间漏掉的那一小段命令流补发给它 - Sentinel 是如何判断主节点下线的? - 先判断主观下线,再判断客观下线。 - 每个 Sentinel 都会持续给主节点发 PING,如果在配置的时间内一直拿不到有效回复,它就会先认为这个主节点是 主观下线,也就是 SDOWN - 只有当足够多的 Sentinel 也都认为这个主节点处于 SDOWN,并且相互确认,达到里配置的 quorum 数量后,这个主节点才会被认定为 客观下线 - SDOWN 是单个 Sentinel 的本地判断,ODOWN 是多个 Sentinel 达成法定票数后的集体判断 - 脑裂问题是什么?怎么解决? - 网络分区,旧主节点并没有真的宕机,只是和大多数 Sentinel、从节点失联了 - Sentinel 在另一侧把某个从节点提升成了新主节点 - 和旧主节点同一侧的客户端还在继续向旧主节点写数据,于是系统短时间内同时出现两个都在接受写入的主 - 部署上要有足够的 Sentinel 和副本,形成法定人数,避免少数派分区误判 - 在主库上配置,这样旧主一旦发现自己已经无法把写同步给足够数量的副本,就会在限定时间后停止接受写入 - 客户端必须通过 Sentinel 做主节点发现和切换,不要把旧主地址写死 - Sentinel 的故障转移流程是什么? - Sentinel 故障转移的本质,就是通过多数派判断主库真的挂了,再由一个领导 Sentinel 协调整个“选新主、切复制关系、通知客户端 - 复制积压缓冲区 - replication backlog,你可以把它理解成 主节点在内存里维护的一块环形缓冲区,用来暂存最近一段时间已经发送过的复制命令流 - 是支持主从断线后的部分重同步:当从节点短暂掉线后再重连,不需要立刻重新做一次全量复制,而是先带着自己上次复制到的 offset 回来请求补数据 - 主节点的复制积压缓冲区里还保留着这段从节点缺失的命令流,主节点就只把缺的这一段补发给它,这样就能快速恢复同步 - 断线重连后从节点会优先尝试 partial resynchronization,而能不能成功,关键就取决于主节点 backlog 里是否还保留着那段历史命令 - 制积压缓冲区本质上就是主节点为复制链路准备的“最近增量命令缓存”,它不是给业务读写直接用的,而是专门用来提高主从断线重连后增量续传成功率、减少全量复制发生概率的 - 集群类 - Redis Cluster 是什么? - 是 Redis 官方提供的分布式集群方案,它的核心目标是同时解决 Redis 单机存在的容量瓶颈、并发瓶颈和单点故障问题 - 把整个 key 空间划分成 16384 个 hash slot,每个主节点负责其中一部分槽位 - 数据根据 key 计算后分布到不同节点上,这样就实现了数据分片 - 每个主节点通常还会挂从节点,用来做主从复制和故障转移 - Redis Cluster 为什么是 16384 个槽? - 在分片粒度、元数据开销和实现复杂度之间做一个平衡 - Redis Cluster 不是直接把 key 映射到节点,而是先把 key 映射到固定数量的 hash slot,公式就是 CRC16(key) mod 16384 - 取的是 CRC16 的低 14 bit,计算简单、定位快 - 它本质上就是用一个 2 的幂次槽位数,在“槽位足够细、计算足够简单、集群元数据又不会太重”之间取得的工程折中 - 集群扩容和缩容是怎么做的? - 先把新的 Redis 节点加入集群 - 如果要加主节点,就先让它以空节点身份 add-node 进来,然后把一部分槽位从现有主节点迁移到这个新主节点上,也就是做 reshard/rebalance - 如果加的是从节点,则一般是把它加入后挂到某个主节点下面做副本 - 缩容时,流程正好相反:先把准备下线的主节点上的槽位全部迁移到其他主节点,等它不再持有任何槽位和数据后,再把这个节点从集群里删除 - 集群在线扩缩容的核心机制就是 moving a hash slot from one node to another,实际迁移时会把目标节点的槽位标记成 IMPORTING、源节点标记成 MIGRATING - Redis Cluster 如何做数据迁移? - 迁的是 hash slot,不是直接“整库搬家” - Cluster 里每个 key 先通过 CRC16(key) mod 16384 映射到某个槽位,再由负责这个槽位的主节点存储 - 扩容、缩容或者做负载均衡时,核心动作就是把一部分槽位从源节点迁到目标节点 - 先把目标节点对应的槽标记成 IMPORTING,把源节点标记成 MIGRATING,然后把这个槽里的 key 通过 MIGRATE 命令逐个搬到目标节点 - 该槽的数据迁完后,再更新集群里这个槽的归属关系 - 在线进行、不需要停机,客户端在迁移过程中如果访问到正在搬迁的槽,集群会通过重定向机制把请求引导到正确节点上 - 本质就是在线槽位迁移:先迁 slot,再随 slot 迁 key,最后更新槽位归属 RocketMQ - RocketMQ - 基础认知 - RocketMQ 解决了哪些问题? - 主要解决的是系统之间 异步通信、应用解耦、流量削峰、最终一致性 这几类问题 - Producer、Consumer、Broker、NameServer、Topic、MessageQueue 分别是什么? - Producer 就是消息生产者,负责创建并发送消息 - 本身不直接把消息随便发到某台 Broker - 而是先从 NameServer 获取 Topic 的路由信息 - 再选择一个合适的 MessageQueue - 把消息发到这个 Queue 所在的 Broker 上 - Consumer 就是消息消费者,负责订阅 Topic 并处理消息 - 通过 NameServer 感知 Topic 路由,然后从对应 Broker 拉取消息进行消费 - 消费通常还会配合 Consumer Group 一起使用,这样同一组消费者可以共同分担一个 Topic 下多个 Queue 的消息 - Broker 是 RocketMQ 的核心服务节点,真正负责 存储消息、接收生产者发送的消息、向消费者提供消息读取能力 - Broker 启动后会把自己的信息以及它上面的 Topic 路由注册到 NameServer - Producer 和 Consumer 最终都是通过 NameServer 找到 Broker,再和 Broker 交互 - NameServer 本质上是 RocketMQ 的路由注册与发现中心 - 它不负责存储业务消息,主要负责保存 Topic 和 Broker 的映射关系 - Producer 和 Consumer 启动后都会连接 NameServer,去查询某个 Topic 在哪些 Broker 上、有哪些 Queue - Topic 可以理解为消息的逻辑分类,类似一个业务主题 - 生产者是把消息发到某个 Topic,消费者也是按 Topic 订阅消息 - MessageQueue 则是 Topic 在物理实现上的更细粒度拆分单元 - 一个 Topic 往往不会只对应一个队列,而是会拆成多个 Queue,分布在不同 Broker 上 - 提高并发能力、支撑负载均衡,以及在顺序消息场景下把同一类业务消息路由到同一个 Queue - 架构原理 - NameServer 为什么可以无状态设计? - 只负责路由发现,不负责业务消息存储,也不承担复杂的一致性协调 - 每个 Broker 会把自己的路由信息注册到每一个 NameServer - NameServer 自己不产生核心状态,只是被动接收 Broker 上报的路由信息并提供查询服务 - NameServer 里的路由信息在极短时间内可能不是绝对实时一致的 - RocketMQ 的路由信息是如何注册和发现的? - Broker 启动之后,会把自己的地址信息、自己承载了哪些 Topic、每个 Topic 下有多少个 MessageQueue、读写权限等路由元数据注册到 NameServer - 注册到配置的所有 NameServer - Broker 还会持续发送心跳,NameServer 通过心跳来判断这个 Broker 是否还存活 - 长时间收不到心跳,就会把它对应的路由信息剔除掉 - Producer 和 Consumer 启动时会先拿到 NameServer 地址,然后去 NameServer 查询某个 Topic 的路由信息 - 客户端并不是直接把 Topic 硬编码到某台 Broker,而是先查路由,再直连 Broker 通信 - 每个 NameServer 基本都会保存一份相对完整的路由视图,而且多个 NameServer 之间通常不互相通信 - 如果某个 NameServer 挂了,会有什么影响? - 客户端本身已经拿到过路由信息 - 只要 Broker 没问题、客户端手里还有可用路由,业务通常还能继续跑 - 客户端正好需要刷新路由,或者刚启动时去查路由 - 只要客户端配置了多个 NameServer 地址,通常只是短暂影响 - Broker 为什么要做主从架构? - 消息中间件天然要求高可用和高可靠,主从可以通过副本复制避免单点故障 - 在 Master 宕机时尽量保证消息不丢、服务不停,并通过同步或异步复制在性能和可靠性之间做权衡 - 消息发送机制 - RocketMQ 有哪几种消息发送方式? - 同步发送:Producer 把消息发出去之后,会阻塞等待 Broker 返回发送结果,拿到成功或者失败的响应之后,才继续往下执行 - 异步发送:Producer 把消息发出去之后不会一直阻塞等待,而是注册一个回调函数,等 Broker 返回结果后,再通过回调通知发送成功还是失败 - 单向发送:只负责把请求发出去,不等待 Broker 返回结果,也没有回调确认 - 消费机制 - 同一个 Consumer Group 下多个消费者如何分摊 Queue? - 集群消费:队列级负载均衡 - 一个 Topic 下的多个 MessageQueue 会分配给同一个 Consumer Group 里的不同消费者,每个 Queue 在同一时刻只会分配给组内一个消费者处理 - 默认策略一般是平均分配 - 如果 Queue 数量少于消费者数量,那多出来的消费者可能分不到 Queue,就会处于空闲状态 - 消费位点(offset)是什么?存在哪里? - 消费位点,也就是 offset,本质上就是消费者的消费进度 - 对于某个 Consumer Group 来说,它已经消费到某个 Topic 的某个 Queue 的哪个位置了 - 消费位点默认保存在服务端,也就是 Broker 端 - 广播消费因为每个消费者都要各自消费全量消息,所以它的 offset 往往保存在客户端本地 - 消息可靠性 - RocketMQ 如何保证消息不丢失? - 不能绝对保证 100% 不丢消息,但它是通过生产端、Broker 端、消费端三层机制,把消息丢失风险尽量降到很低 - 核心业务一般要用同步发送,拿到 Broker 的成功响应再认为发送成功 - 消息到了 Broker 以后,真正决定可靠性的关键是刷盘和副本复制,高可靠场景通常会配 同步刷盘 - 消费端,RocketMQ 的语义更接近 at least once,也就是说它更倾向于宁可重复,也尽量不要丢 - 延时/定时消息 - RocketMQ 如何实现延时消息? - 消息发送到服务端后,不会立刻投递给消费者,而是要等到指定时刻才变成可消费状态 - Producer 先发送一条带延时属性的消息,Broker 收到后不会马上把它交给 Consumer,而是先根据这个消息的投递时间做暂存和时间管理 - 等到到期之后,Broker 再把这条消息重新放到正常投递链路里,消费者这时候才会像消费普通消息一样把它拉走 - 为什么延时消息不一定能做到绝对精确时间触发? - RocketMQ 追求的是‘接近指定时间投递’,而不是操作系统级实时调度 - 消息发送时间是生产者本地时间,消息生效/存储时间是 Broker 本地时间 - 时间粒度限制、Broker 侧的调度与二次写入链路、存储异常或重启带来的延迟,以及客户端和 Broker 时钟不一致 - 存储与刷盘 - RocketMQ 的消息是如何存储到磁盘的? - 消息体顺序追加写入 CommitLog,再为消费和查询额外构建索引 - Producer 把消息发到 Broker 后,Broker 不会按 Topic 各写各的物理文件,而是把所有 Topic 的消息统一顺序追加到 CommitLog 里 - CommitLog 是消息真正的物理存储文件,这种顺序写磁盘的方式吞吐高、磁盘 I/O 更友好 - 两类索引 - ConsumeQueue,它可以理解成每个 Topic 的每个 Queue 对应的一份逻辑消费索引 - 不存完整消息体,只记录这条消息在 CommitLog 里的物理偏移量、消息大小以及 Tag 相关信息 - 消费者拉消息时通常先查 ConsumeQueue,再根据里面的 offset 去 CommitLog 定位并读取真实消息 - IndexFile,主要用于按消息 Key 或时间范围做查询 - CommitLog 负责真正存消息,ConsumeQueue 负责消费定位,IndexFile 负责条件检索 - 零拷贝在 RocketMQ 里有没有用到?你怎么理解? - RocketMQ 更核心的是利用 mmap 把文件映射到用户进程地址空间,让消息写入 CommitLog、读取消息时尽量直接走内存映射和 PageCache - 减少传统 read/write 模式下用户态和内核态之间的多次数据拷贝 Agent 开发 - Agent 开发 - 基础概念 - 什么是 Agent?它和普通 Chatbot 的本质区别是什么? - 以大模型为决策核心、能够感知状态、调用工具、分解任务并执行动作,最终完成目标闭环的智能体系统。 - 什么是 tool calling / function calling?为什么 Agent 离不开它? - 让大模型不只是输出自然语言,而是能够按照约定好的结构,去选择并调用外部工具完成任务 - tool calling 做的事情,就是给模型一组可用工具,并告诉它每个工具是做什么的、参数长什么样 - 这个任务是否需要借助外部能力,如果需要,应该调用哪个工具,以及传什么参数。 - 没有 tool calling,Agent 就没有真正的行动能力 - 没有 tool calling,Agent 很难获得真实、最新、可验证的信息 - 没有 tool calling,复杂任务无法形成闭环 - Agent、Workflow、Chain 三者有什么区别? - 流程是不是固定的、模型有没有决策权、系统的可控性和灵活性分别有多强 - Chain 是最简单的一种组织形式,本质上就是把多个步骤线性串起来 - 第一步提取关键词,第二步根据关键词检索,第三步让模型总结答案 - Workflow 可以理解为比 Chain 更复杂一层的编排方式 - 那 Workflow 更像一个流程图或者状态机 - 流程仍然是预先设计的,但路径可以更丰富,控制逻辑更强,工程上也更稳定 - 不是人把流程完全写死,而是把“下一步该做什么”的部分决策权交给模型 - 面对任务时,Agent 不一定按照固定路径执行,而是会根据当前目标、上下文、工具返回结果,动态决定下一步动作 - ReAct、Plan-and-Execute、Router-based workflow 各适合什么场景? - ReAct 的特点是 Reason + Act - 模型先基于当前上下文做一步推理,决定下一步行动,拿到工具返回结果之后,再继续思考下一步 - 本质上是一种 逐步决策、逐步执行 的方式,而不是一开始就把整条路径规划好 - 适合那种 信息不完整、过程不确定、需要根据中间结果动态调整方向 - 探索型、开放型、动态性强 - Plan-and-Execute 先规划,再执行 - 是模型先根据目标把任务拆成几个较大的步骤,形成一个 plan。 - 然后再按 plan 逐步执行,每一步需要时再调用工具。 - 任务目标明确、步骤较多、链路较长 - 复杂任务、长链路任务、需要任务拆解的场景 - 前面的 plan 可能并不完全正确 - 成熟的 Plan-and-Execute 系统,通常还要允许 plan revise - 是执行过程中根据新信息修正原计划,而不是机械地照着旧计划跑 - Router-based Workflow - 先做一个 路由决策:判断当前请求属于哪一类,然后把它送进对应的固定流程 - 适合的是 任务类型相对清晰、业务边界明确、流程可预定义 的场景 - 判断它是知识问答、数据查询、代码生成、工单处理,还是闲聊; - 不同类型走不同分支,每个分支里的步骤通常是预先设计好的 - 单 Agent 和多 Agent 的优缺点分别是什么? - 让一个智能体统一负责理解、规划和执行,还是把不同职责拆给多个智能体协作完成 - 单 Agent 的核心特点是:一个 Agent 持有主要上下文,围绕用户目标统一做决策,并根据需要调用工具完成任务 - 职责容易过载。 - 当任务越来越复杂时,一个 Agent 既要理解意图,又要规划,又要调用工具,还要整合结果,这会导致 prompt 非常臃肿,上下文也会越来越重 - 多 Agent 把复杂任务拆成多个相对独立的角色或能力单元,让它们分工协作 - 更适合复杂任务分治 - 任务天然包含多个不同领域、不同能力要求的子问题时,让一个 Agent 全包并不总是最佳选择 - 后续业务增加新能力,比如新增一个“数据分析 Agent”或者“审批 Agent”,多 Agent 架构通常更容易横向扩展 - 系统复杂、协作成本高、上下文传递有损耗、时延和成本更高 - 什么叫“agent loop”?如何判断循环该停止? - agent loop,本质上就是 Agent 完成任务时的一个 闭环执行过程 - 不断重复下面这几个步骤:理解当前状态、决定下一步动作、执行动作、观察结果、再基于新结果继续决策 - 业务层面的完成条件:任务目标已经满足了,就应该停止 - 模型主动判断无需继续 - 停止条件 - 是否触发了工程兜底条件,比如最大步数、重复调用、无新信息、连续失败或成本超限 - handoff/delegation - delegation 更强调‘把子任务分派给更合适的执行者’ - 这个问题里有一部分任务,并不是自己最擅长处理的,或者交给专门模块处理会更稳,那它就把这部分任务委派出去 - 把某个子任务交给更专业的执行单元处理,再把结果拿回来继续整合 - handoff 更强调‘把当前会话或任务控制权切换给另一个角色或模块’ - 责任和上下文的转交,后续由新的主体接手推进任务 - vibe coding - 不要让 Claude 直接写代码,而是先让它“深入研究 → 写计划 → 你批注修正计划 → 再执行实现” - Research(研究) - 先让 Claude 深入阅读代码库某一部分,搞清楚系统怎么工作、有哪些边界条件、潜在 bug 在哪 - 然后把理解结果写成一个持久化的 research.md 文件 - 如果 Claude 对现有系统理解错了,后面的计划和实现都会跟着错,最后就会出现“局部看似可用,但破坏了整体系统”的情况 - Planning(规划) - 在研究结果确认后,再让 Claude 基于真实代码库写一份详细的 plan.md。 - 这份计划通常会包含:实现思路、要改哪些文件、示例代码、权衡点等 - 自己维护 markdown 计划文件,因为这样可编辑、可持久保存、也更适合审阅 - 如果某个功能在开源项目里已经有很好的参考实现,他会把那段参考代码直接贴给 Claude - Annotation Cycle(批注循环) - 纠正假设、删除不需要的方案、补充业务约束、指定技术选择,然后再让 Claude 回来“根据这些批注更新计划,但先不要实现 - 这个循环通常会重复 1 到 6 次,直到计划真正贴合项目需求 - Claude 很擅长理解代码和产出方案,但它不知道你的产品优先级、历史包袱、团队习惯和你愿意接受的工程折中 - 这些必须由人通过批注注入进去 - Claude 把计划拆成详细 todo list - Implementation(实现) - 当计划完全定稿后,作者才会下达统一的实现指令 - 全部实现、完成一个任务就更新 plan、不要半路停下来、不要写无意义注释、不要用 any/unknown、持续跑 typecheck - 到这一步时,创造性的设计决策其实已经完成了,剩下的工作应该尽量变成“机械执行” - “你没实现某个函数”“你把页面放错应用里了”“再宽一点”“还有 2px 缝隙”。因为这时 Claude 已经拥有完整上下文,简短修正就足够 - 若某次实现方向彻底错了,他甚至会直接 git 回滚,然后重新缩小需求范围 - 因为作者认为“推倒重来并重设范围”通常比在错误方案上不断打补丁效果更好 - Claude 做的是“机械劳动和候选方案生成”,而真正的架构判断、范围裁剪、接口保护和技术取舍,仍由人负责 - 研究、规划、批注、实现都放在一个长会话里连续完成,而不是拆成多个短会话 - multi-agent research - 概述 - Research 功能不是单个 Claude 一路搜到底,而是一个**lead agent(主代理)+ subagents(子代理)**的多智能体系统 - 主代理先理解用户问题、制定研究策略 - 再并行创建多个子代理,让它们分别去不同方向搜索信息 - 最后由主代理汇总,再交给一个专门的 CitationAgent 做引用定位和归因 - 研究任务特别适合多 Agent - 研究任务不是固定流程,而是开放式探索 - 研究过程天然是“边查边改路线”的 - 单轮、线性、一次性生成的 pipeline 很难做好这种任务 - 多 Agent 的核心价值 - 并行探索多个方向 - 多 Agent 可以把问题拆成多个独立方向并行搜索 - 相当于扩展了“有效上下文容量” - 每个子代理都有自己的上下文窗口。它们各自探索、压缩,再把关键结果交回主代理 - 降低路径依赖 - 单个 Agent 一旦前几步搜偏了,后面可能一路都偏 - 多个子代理独立探索,可以减少“因为早期路线选错而导致全局失败”的问题。 - 本质上是在“花更多 token 解决更难问题” - 系统架构 - orchestrator-worker 架构,也就是“编排者—执行者”模式 - 用户提交问题 - LeadResearcher 先做计划 - 主代理会把研究计划保存到 Memory 里 - 创建多个 Subagent 并行工作 - 它们会独立进行多轮搜索、评估结果、修正查询,再把结果返回给主代理 - 主代理综合结果,决定是否继续 - CitationAgent 补齐引用 - 它和传统 RAG 的本质区别是什么 - RAG:先捞资料,再回答 - Research Agent:边想、边查、边改策略、边分工 - 带工具与任务编排能力的主动式研究系统 - 总结的 8 条 Prompt / Agent 设计原则 - 先建立对 Agent 行为的直觉 - 教会 orchestrator 怎么“分工” - 努力程度要跟问题复杂度匹配 - 工具设计和工具说明极其关键 - 让 Agent 帮你改 Agent - 搜索策略要“先宽后窄” - 显式引导 thinking 过程 - 并行工具调用极大提升速度 - 怎么评估这种多 Agent 系统 - 一开始就做小规模 eval - 用 LLM-as-judge 做大规模评分 - 人工评测不可替代 - 生产问题,比算法问题还难 - Agent 是有状态的,错误会累积 - 要结合“模型适应性 + 工程保护” - 调试非常难,因为 agent 非确定性强 - 部署不能粗暴替换版本 - 当前还是同步执行,未来可能异步 - 附录 - 如何评估“会改动状态”的 Agent:看终态,不要死盯过程 - 如何管理超长对话:压缩上下文 + 外部记忆 + 新上下文接力 - 当某个阶段完成后,让 agent 先把这一阶段总结出来,再把关键内容存到外部 memory - 当上下文快满时,可以创建一个新的 subagent,给它一个干净上下文,同时通过 handoff 把必要信息传过去 - agent 还可以从 memory 里重新取回之前存下来的研究计划 - 而不是因为上下文截断把前面的工作丢掉 - 让子 Agent 直接把结果写到文件系统,减少“传话失真” - 不要让子代理把所有内容都先口头汇报给主代理,再由主代理转述。 - 更好的方式是让子代理直接把产物写到某个外部 artifact / filesystem 里,只把轻量引用返回给主代理 - “传话游戏效应”——信息每转述一层,就更容易被压缩、改写、漏掉 - - 评估上看终态,运行上靠记忆与上下文接力,产物上让子代理直接落盘而不是层层转述 - Prompt 与工具调用设计 - 你会怎么给 Agent 设计 system prompt? - 定义角色和目标 - 明确告诉 Agent:你是谁,你的职责是什么,你服务的对象是谁,你最终要优化的目标是什么 - 定义决策原则 - Agent 和普通问答模型最大的区别在于,它需要不断判断:现在该直接回答,还是该调用工具,还是该进一步检索,还是应该拒绝 - 信息不足时,不要猜测,要优先检索或调用工具 - 涉及外部事实、实时信息、业务数据时,不要只靠参数知识回答 - 如果工具可以更可靠地完成任务,优先用工具 - 如果当前证据已经足够,就不要过度调用工具 - 定义工具使用规范 - 什么时候该用工具、什么时候不该用、如何处理工具返回结果 - 定义输出格式和交互风格 - Agent 的结果往往不是单纯给用户看,有时还要给下游系统消费,或者作为下一步流程输入 - 是定义边界、风险和兜底策略 - 权限边界、敏感操作、数据泄露风险、人工接管条件,这些都应该提前定义 - 模块 - Role:你是谁,服务谁,目标是什么 - Capabilities:你有哪些工具和能力 - Decision Policy:什么时候检索,什么时候调用工具,什么时候停止 - Tool Rules:工具选择、参数使用、失败处理 - Output Rules:回答格式、结构化输出、引用方式 - Safety / Boundaries:权限、安全、拒答、人工兜底 - 工具返回结果过长时,你怎么做压缩和摘要? - 关键信息被淹没、模型注意力分散、后续决策质量下降 - 先做结构化裁剪,再做分层摘要,最后按任务目标回注上下文,而不是一次性全量喂给模型 - 当前这段长结果里,哪些内容对当前目标是高价值的,哪些只是噪声 - 优先做结构化提取,而不是直接写自然语言摘要 - 先把长结果压成一个 结构化中间表示,比如 key-value、JSON、表格化摘要或者 bullet points - 分层压缩,而不是只做一层摘要 - 粗裁剪,也就是先把明显无关、重复、噪声大的内容去掉 - 关键信息摘要,也就是保留当前任务真正需要的核心事实 - 可追溯引用层,也就是虽然正文里不全放原文,但要保留原始片段索引、来源 ID、文档位置、URL 或 chunk 标识 - 区分“给模型看的摘要”和“给用户看的摘要” - Agent 架构与编排 - LangGraph 这类图式编排相比纯 prompt agent 的优势是什么? - 纯 prompt agent 更像是把任务尽量交给模型在一个循环里自由决策 - LangGraph 这类图式编排,是把系统拆成显式的状态、节点和边,让‘哪些地方交给模型决策,哪些地方由程序控制’变得更清楚 - 纯 prompt agent 很多时候本质上是“消息历史 + system prompt + tool loop” - LangGraph 的核心建模方式是 State + Nodes + Edges - 用共享 state 表示应用当前状态,用 nodes 执行业务逻辑,用 edges 控制固定或条件跳转 - workflow 和 agent:workflow 是预定义路径,agent 是动态决定流程和工具使用 - durable execution:也就是长时间运行、失败后可从中断点恢复。 - human-in-the-loop 直接作为核心能力之一,支持在执行过程中检查和修改 agent state - 图式编排最大的工程收益之一,就是把“Agent 的执行过程”从黑盒,变成半透明甚至可观测的系统。 - 官方把短期记忆直接纳入 agent state,并通过 checkpointer 持久化,使线程可以恢复;长期记忆则通过 store 跨会话保存 - 如何设计 agent state? - state 不是把所有信息都塞进去,而是只保留那些需要跨步骤持续存在、会影响后续决策、或者需要被恢复与追踪的信息 - 如果一条信息只在当前节点临时计算用一次,那它不应该进 state - 任务目标与任务上下文 - 执行过程状态 - 工作记忆,也就是对后续决策有价值的中间产物 - 控制信息 - 可观测与恢复相关信息 - 多 Agent 之间如何共享上下文 - 不是让所有 Agent 都看到全部信息,而是让每个 Agent 在合适的时机拿到完成自己任务所需的最小必要上下文 - 是全局任务上下文 - 所有 Agent 都可能需要知道的基础信息,比如用户目标是什么、当前任务 ID、任务的主约束是什么、当前系统处在哪个大阶段 - 局部执行上下文 - 某个 Agent 为了完成自己职责所必需的信息 - 共享上下文不是共享全部历史,而是共享经过裁剪和重组后的任务视图。 - 是中间产物上下文 - Agent 之间尽量传“结构化中间结果”,而不是传“大段原始对话和原始输出“。 - 方式 - 是共享状态仓,也就是 shared state / blackboard。 - 共享的是状态,不是无限扩展的聊天记录 - handoff 时显式传递上下文包 - 当前要解决的子问题是什么 - 为什么要转给这个 Agent - 已经完成了哪些步骤 - 当前有哪些关键事实或证据 - 还有哪些不确定点 - 通过外部记忆或工件存储共享 - RAG 和 Agent 结合 - RAG 和 Agent 的关系是什么?什么时候只用 RAG 就够了? - RAG 主要解决的是‘让模型拿到更相关、更真实的外部知识再回答’ - Agent 主要解决的是‘让模型围绕目标动态决策、调用工具并完成任务’ - RAG,也就是 Retrieval-Augmented Generation,本质上是“先检索,再生成”。 - 检索前置(retrieve-then-read)和动态检索(decide-whether-to-retrieve)怎么选? - 检索前置,也就是 retrieve-then-read:不管用户问什么,先检索,再让模型基于检索结果阅读和回答 - 检索是默认前置步骤,系统不需要先判断“要不要检索”,而是统一走“先找资料、再生成答案”的固定流程 - 动态检索,也就是 decide-whether-to-retrieve - 先让系统判断当前问题到底要不要检索,如果需要,再决定检索什么、检索几轮;如果不需要,就直接基于已有上下文回答 - 更灵活、更节省资源 - 召回、重排、生成分别可能出什么问题? - RAG 的问题要分层看,召回解决‘找不找得到’,重排解决‘排得准不准’ - 召回阶段 - 从大量候选知识里尽可能不要漏掉真正有用的内容 - 问题 - 召回不足,也就是该找的没找出来 - 召回噪声太多 - query 与知识表达不匹配 - 切块和索引策略问题 - 重排阶段 - 在召回回来的候选集里,把真正最值得给模型看的内容排到前面 - 问题 - 高相关内容没排到前面 - 表面相关高,但任务相关低 - 多片段信息没有被整体考虑 - 排序结果缺乏多样性 - 生成阶段 - 没有忠实使用检索证据 - 幻觉和过度补全 - 证据整合能力不足 - 如何让 Agent 基于检索证据回答,而不是凭空补全? - 先保证 Agent 真正拿到了足够好的证据 - 要把“证据”和“任务”在上下文里组织清楚,而不是一股脑把文本塞进去 - 在 prompt 和输出协议里明确要求“答案必须可追溯到证据” - 要让 Agent 在证据不足时优先补检索,而不是硬答 - 要通过评测专门检查它是不是在‘基于证据回答’,而不是只看答案像不像对 - 如何评估 RAG Agent:看 answer correctness 还是看 trajectory? - answer correctness - 你最后给的答案是不是对的,是不是完整,是不是有用 - answer correctness 决定系统有没有业务价值下限 - trajectory - 在 Agent 场景里,证据不足时有没有继续检索而不是直接生成 - trajectory 评估的是过程层 - Memory 记忆 - 短期记忆(short-term memory)和长期记忆(long-term memory)分别是什么? - 在 Agent 系统里,记忆的本质不是“把信息存起来”这么简单 - 而是让系统在合适的时间保留合适的信息,从而支持多轮交互、任务连续性和个性化决策。 - 短期记忆 - Agent 在当前会话或当前任务执行过程中使用的工作记忆 - 它是任务相关的 - 它是动态变化的 - 它通常生命周期较短 - 它更偏执行态 - 长期记忆 - 那些跨会话、跨任务仍然有价值的信息 - 下次再见到这个用户或类似场景时,系统能不能利用过去沉淀的信息做得更好 - Agent 的 长期知识沉淀 或 跨任务可复用经验层 - 跨会话复用的 - 更偏稳定信息 - 需要检索或选择性注入 - 更偏个性化和沉淀化 - 会话历史太长导致上下文爆炸时怎么处理? - 先区分“哪些历史真的有必要继续带着”,而不是把整个聊天记录原样续上 - 当前任务强相关信息 - 阶段性中间过程信息 - 低价值历史噪声 - 做分层压缩,而不是简单截断 - 原始最近几轮对话:保留高保真上下文,因为最近几轮通常最直接影响当前回答 - 中期历史摘要:把较早但仍有价值的内容压成结构化摘要 - 长期稳定事实:把跨轮仍然有效的用户偏好、任务背景、关键约束单独沉淀出来 - 把“消息历史”和“任务状态”分开管理 - 很多真正重要的东西不应该只存在于对话文本里,而应该被提炼成显式 state - 聊天记录负责保留交互表面,state 负责保留任务骨架 - 根据任务阶段动态构造上下文,而不是固定拼接 - 评测 - 你如何评估一个 Agent 是否“好用”? - 先看结果层:它到底有没有把事做成 - task success rate,也就是任务成功率 - groundedness 或可验证性。 - 结果正确性和完整性,factual correctness - 再看过程层:它是不是稳定、合理地完成任务 - 决策路径是否合理 - 有没有重复劳动和无效动作 - 在失败时怎么表现 - 再看工程层:成本、延迟、稳定性是否可接受 - 在线评测和离线评测有什么区别? - 离线评测:在一个预先准备好的测试集、基准集或者人工构造的 case 集上,对系统进行批量评估 - 在线评测:在真实流量、真实用户、真实业务环境下,对系统效果进行观测和比较 - 什么是 task success rate? - 用户交给 Agent 的任务,最后到底有没有被成功完成 - task success rate 就是 任务成功率 - 如何构造 eval dataset - eval dataset 要把真实业务目标、典型任务分布、关键风险场景和失败案例系统化地沉淀成一个可持续迭代的数据集 - 先定义评什么,而不是先收样本 - 用“任务分布”来采样,而不是只收容易做的题 - 人工精选样本 - 真实历史样本 - 定向构造样本 - 做分层分桶,而不是把所有样本混成一锅 - 每条样本不只要有输入,还要有“判分依据” - Nacos 配置中心 - Nacos 配置中心 - Nacos 是什么?解决了什么问题? - 服务实例很多,IP 和端口是动态变化的,如果没有注册中心,服务之间就很难稳定地互相发现和调用 - 配置分散在各个服务本地,不方便统一管理,一旦数据库地址、限流阈值、开关参数变更,往往需要改配置、重启甚至重新发布 - Nacos 通过服务注册与发现能力解决了服务寻址问题,通过配置中心能力解决了配置集中管理、动态推送和环境隔离问题 - Nacos 为什么既能做注册中心,又能做配置中心? - 底层都是在做一件事:集中管理分布式系统中的动态元数据 - 注册中心管理的是服务实例信息 - 配置中心管理的是应用配置数据 - 服务注册与发现流程是怎样的? - 服务提供者启动时,会把自己的服务名、IP、端口、实例信息、权重、元数据等注册到 Nacos Server - Nacos 会把这个服务对应的实例列表维护起来,并根据健康检查结果更新可用状态 - 服务消费者启动时,会向 Nacos 订阅自己关心的服务,比如某个订单服务或者用户服务,Nacos 会把当前可用的实例列表返回给消费者 - 后续如果这个服务有新增实例、实例下线或者健康状态变化,Nacos 还会把变更后的服务列表推送给消费者 - 本地一般会缓存一份服务列表,真正发起 RPC 或 HTTP 调用时,再基于本地拿到的实例列表做负载均衡,选出一个目标实例去调用 - 服务提供者注册、Nacos 维护实例健康状态、服务消费者订阅服务、消费者基于本地缓存做调用 - 服务实例注册到 Nacos 后,消费者是怎么发现它的? - 同时后续如果有实例上线、下线或者状态变化,Nacos 还会把最新列表推送给消费者,所以消费者本地一般会维护一份服务列表缓存 - 真正发起调用时,消费者并不是每次都实时去查 Nacos,而是优先从本地缓存里拿可用实例,再结合负载均衡策略 - 客户端是如何监听配置变化的? - 注册监听器 + 长轮询 + 配置变更后回调刷新 - 应用启动时,客户端会针对某个 DataId 和 Group 向 Nacos Server 注册监听器,并把自己本地缓存的配置内容摘要,比如 MD5,一起带过去 - 服务端收到后不会立刻断开,而是采用 长轮询 的方式挂住这个请求:如果在这段时间内发现服务端配置的 MD5 和客户端传来的 MD5 不一致,就说明配置发生了变化,这时会立即返回变更结果 - 如果一直没有变化,请求大概在 30 秒左右超时返回空结果 - 客户端拿到响应后,如果发现配置变了,就会再去服务端拉取最新配置内容,更新本地缓存,并触发对应的回调逻辑 WebSocket - WebSocket - 解决了什么问题 - 一种基于 TCP 的应用层全双工通信协议 - 核心价值是让客户端和服务端在建立一次连接之后,可以进行双向、实时、持续的数据传 - HTTP 在实时通信场景下效率不高的问题 - HTTP 本质上是半双工、请求驱动的,服务端不能主动推送消息给客户端 - WebSocket 就是为了解决 HTTP 在实时双向通信上的天然不足而设计的,它把频繁的请求响应,变成了一次建连后的持续双向通信 - WebSocket 和 SSE - WebSocket 适合双向实时通信,SSE 更适合服务端单向推送 - 是一个独立的全双工协议,基于 TCP,建立连接之后,客户端和服务端都可以主动发消息,所以它特别适合那种双向交互频繁的场景 - 只能由服务端主动推送给客户端,客户端不能通过同一条连接反向发消息 - 服务端持续推送、但客户端不需要频繁反向交互 - WebSocket 可以发文本也可以发二进制,SSE 主要是文本流 - WebSocket 握手成功后脱离传统 HTTP 请求响应模式,而 SSE 本质上还是一个持续不断开的 HTTP 响应流 - WebSocket 为什么需要先走一次 HTTP 握手? - WebSocket 之所以先走一次 HTTP 握手,本质上是为了完成协议升级和连接协商 - 复用现有的 HTTP 基础设施 - 在握手阶段完成能力协商 - 可以顺带复用 HTTP 阶段已有的认证和上下文 - WebSocket 建立连接的握手过程是怎样的 - 客户端先发起一个 HTTP/1.1 请求,请求行通常是 GET - 在请求头里带上 Upgrade: websocket 和 Connection: Upgrade,表示希望把当前这条 TCP 连接从 HTTP 升级成 WebSocket - 同时还会带上 Sec-WebSocket-Key,这是客户端生成的一个随机值 - Sec-WebSocket-Version: 13,表示自己使用的 WebSocket 协议版本 - 服务端收到请求之后,会先校验这是不是一个合法的 WebSocket 升级请求 - 响应头里同样会带 Upgrade: websocket 和 Connection: Upgrade,最关键的是会返回 Sec-WebSocket-Accept - 服务端拿客户端发来的 Sec-WebSocket-Key,拼接一个固定 GUID,再做 SHA-1 和 Base64 得到的 - 服务端确实理解 WebSocket 协议,也愿意建立这条连接 - 从这一刻开始,这条连接就不再按传统 HTTP 的请求-响应模式通信了,而是进入 WebSocket 的数据帧通信阶段 - 客户端和服务端都可以在这条长连接上主动发送消息,实现全双工通信 - WebSocket 传输的数据格式是什么?文本和二进制怎么区分? - WebSocket 在传输层面不是直接发一整块裸数据,而是按“数据帧”来传输的 - 应用层消息会被封装成一个个 WebSocket Frame,再在 TCP 连接上发送 - 文本和二进制的区分,核心看 opcode - 文本帧的 payload 按协议要求应该是 UTF-8 编码文本,二进制帧的 payload 则是原始二进制数据,具体怎么解释交给应用层 - 接收文本消息时通常直接拿到字符串;接收二进制消息时,浏览器会根据 binaryType 把它交给你,常见是 ArrayBuffer 或 Blob - WebSocket 帧结构了解吗? - WebSocket 在建立连接之后,双方传输的数据不是直接裸发业务内容,而是要封装成一个个 Frame,也就是数据帧 - 前面是帧头,后面是 Payload Data。帧头里最核心的字段有 FIN、RSV1/RSV2/RSV3、opcode、MASK、Payload Length - FIN,它表示这是不是当前消息的最后一个分片 - opcode,这是帧结构里最关键的字段之一,它决定这个帧是什么类型 - MASK 和 Masking Key 主要是给客户端到服务端方向用的 - 如果 MASK=1,说明 payload 被掩码处理了,这时帧里会多一个 4 字节的 masking key - 协议要求客户端发给服务端的帧都必须带 mask,所以浏览器发出去的消息一般都是 masked;服务端回给客户端通常不加 mask - WebSocket 的帧结构本质上就是“帧头 + 负载”,帧头负责描述消息边界、消息类型、是否掩码、负载长度等信息,负载里才是真正的业务数据 - WebSocket 为什么要有 masking,为什么客户端要 mask、服务端通常不 mask - WebSocket 的 masking,本质上是一种对帧 payload 做按位异或处理的机制 - 而是防止中间网络设备被“误导”或者被攻击 - 为什么服务端通常不 mask,是因为这个防护模型主要针对的是“浏览器里的不可信客户端代码” - 断线重连怎么设计才合理? - 什么时候该重连 - 一类是收到了 close 事件; - 一类是底层连接异常,比如 error 或 TCP 被动断开; - 还有一类是“假在线”,也就是连接表面还在,但心跳超时、长时间收不到服务端响应,这种情况也应该主动判定为失效并触发重连 - 重连一定要做退避,而不是立即无限重试 - 区分“可重连”和“没必要重连”的关闭场景 - 重连成功后不能只恢复连接,还要恢复业务状态 - 要做好上限控制和降级处理 - 消息推送为什么常和 Redis、MQ 一起使用? - WebSocket 只解决了“连接通道”的问题,但没有解决分布式场景下的消息路由、跨节点转发、削峰解耦和可靠投递问题 - 实际做消息推送时,通常会把 WebSocket 和 Redis、MQ 组合起来使用 - Redis 常见的作用有两个 - 连接路由信息存储 - 节点间的轻量级消息分发 - 没有 WebSocket,就很难高效实时推到前端 - 没有 Redis,就很难在集群里快速找到用户连在哪台机器 - 没有 MQ,就很难优雅处理高并发、异步化和消息可靠性 - 为什么有些场景需要 sticky session? - Sticky session,也叫会话粘滞,本质上是让同一个用户的后续请求,尽量持续落到同一台服务器上 - 这些场景里的状态是保存在单机内存里的,或者说连接和上下文是和某台具体机器强绑定的 - sticky session 本质上是一种工程上的折中方案,不是最理想的终态 - 能无状态就尽量无状态,把 Session 放到 Redis,把用户路由放到集中式存储,把消息转发交给 MQ 或 Redis Pub/Sub - WSS 和 WS 的区别是什么? - ws:// 类似于 HTTP,数据在传输过程中默认不经过 TLS 加密 - wss:// 类似于 HTTPS,是在 WebSocket 外面再套一层 TLS,所以它具备加密传输、身份校验和更好的抗窃听、抗篡改能力 - Token 放在什么位置比较常见? - 如果是浏览器场景,最常见的几种位置是 Cookie、URL 查询参数,或者在连接建立后作为第一条业务消息发送 - 心跳是前端做还是后端做?一般怎么设计? - WebSocket 心跳不是单纯前端做,也不是单纯后端做,而是要前后端配合做 - 心跳更偏向后端主导;如果从浏览器场景落地看,通常又要补一层前端应用级心跳 - 生产里比较常见的设计是“双层心跳” - 服务端协议级心跳 - 服务端定时给客户端发 Ping,客户端按协议自动回 Pong,这一层主要解决连接保活和基础探活问题 - 浏览器应用级心跳 - 前端定时发一条轻量业务消息,比如 "ping" 或带时间戳的 heartbeat - 服务端回 "pong" 或 ack - 这一层主要解决浏览器侧无法直接操作 Ping/Pong、以及业务层需要感知延迟、假在线、会话状态的问题 - 服务端负责统一治理连接,前端负责尽早感知用户侧链路异常 - 心跳间隔一般不会设计得特别短 - 如何判断 WebSocket 断开了? - 判断 WebSocket 断开,不能只靠一个信号,通常要分成“显式断开”和“隐式断开”两类来看。 - 连接已经被浏览器或服务端明确关闭了,这时候最直接的判断方式就是监听 close 事件 - 很多时候 WebSocket 不是“明确断了”,而是“假在线 - 如何避免网络抖动导致频繁重连? - 不能一断就立刻无限重连,核心做法是“指数退避 + 随机抖动” - 不要只靠 close 事件判断,要把“真断开”和“假在线”区分开 - 要给重连加“稳定窗口”和失败阈值,而不是一次超时就判死 - 要按关闭原因决定是否立刻重连 - 服务端也要配合,不然客户端再聪明也容易抖 - WebSocket 为什么比长轮询更适合实时场景? - 长轮询本质上还是客户端驱动的:客户端发一个 HTTP 请求,服务端如果暂时没数据就挂住,等有数据再返回 - 客户端收到后还要再发下一次请求 - 它始终存在“本次响应结束到下次请求发起之间”的衔接成本 - 长轮询每来一条消息,本质上都伴随着一次 HTTP 请求和一次 HTTP 响应,里面有请求行、响应行、Header 等一整套开销 - 长轮询在这种场景下会不断地“请求—响应—再请求”,服务端需要频繁创建和回收 HTTP 请求上下文 Linux - Linux - 基础认知 - Linux 的内核态和用户态有什么区别? - 用户态是应用程序运行的受限环境,内核态是操作系统内核运行的特权环境 - 用户态下,程序不能直接访问硬件,也不能随意执行像内存管理、进程调度、文件系统操作这类敏感指令; - 而内核态拥有最高权限,可以直接操作 CPU、内存、磁盘、网卡等系统资源 - Linux 中一切皆文件是什么意思? - Linux 会把大多数资源都抽象成统一的文件接口来管理和访问 - 对上层程序来说,很多资源都可以用类似的方式操作 - 比如打开、读取、写入、关闭,也就是常说的 open、read、write、close 这一套 - Linux 在设计上把很多资源统一抽象成了“文件”这种访问模型 - Linux 系统的目录结构有哪些?各自是做什么的? - Linux 是一个统一的目录树,不管是本地磁盘、移动硬盘,还是网络存储,最终都会挂载到这棵树的某个目录下面 - /bin 存放普通用户也能使用的基础命令,比如 ls、cp、cat - /sbin 存放更多偏系统管理的命令,比如网络配置、磁盘管理这类,通常 root 用得更多 - /usr 可以理解为系统级应用和共享资源目录,里面也有 bin、sbin、lib 这些子目录 - /etc 非常重要,它主要放系统和应用的配置文件,比如用户配置、网络配置、服务启动配置等 - /home 是普通用户的家目录,每个用户一般在这里有自己的工作空间 - /root 是 root 用户自己的家目录 - /tmp 用来放临时文件,系统重启后很多内容可能会被清理掉 - /var 存放经常变化的数据,比如日志、缓存、队列、邮件这些,所以像排查问题时经常会去看 /var/log - /dev 存放设备文件,因为 Linux 里很多硬件设备也被抽象成文件 - /proc 和 /sys 则更偏内核和系统信息,/proc 里能看到进程和内核运行时状态,/sys 更多是设备和内核参数的接口 - 配置看 /etc,用户数据看 /home,日志看 /var/log,设备看 /dev,进程和系统状态看 /proc,临时文件看 /tmp - `/bin`、`/sbin`、`/usr/bin`、`/usr/sbin` 有什么区别? - /bin 主要放最基础、最常用的用户命令,比如 ls、cp、mv、cat 这类,即使系统进入单用户模式,通常也要保证这些命令可用 - /sbin 放的是更偏系统管理和维护的命令,比如 fdisk、iptables、mount 这类,一般是管理员或者 root 更常使用 - /usr/bin 和 /usr/sbin 可以理解为 /bin 和 /sbin 的扩展 - /usr/bin 里放的是大量普通用户程序和应用命令 - /usr/sbin 里放的是非核心但同样偏管理类的系统命令 - 在现代 Linux 发行版里,这几个目录很多时候已经做了合并或者软链接处理,比如 /bin 可能直接链接到 /usr/bin - Linux 的启动流程大致是什么? - BIOS 或 UEFI 加电自检 - 加载 BootLoader,最典型的就是 GRUB - BootLoader 的作用是把操作系统内核从磁盘加载到内存里,同时把启动参数传给内核 - Linux 内核启动 - 内核被加载到内存之后,会先初始化最核心的系统能力,比如内存管理、进程调度、中断处理、设备驱动这些 - 然后内核会挂载根文件系统,并启动第一个用户态进程 - 启动 init 进程 - 早期这个进程通常是 init,进程号是 PID 1 - 现在大多数发行版用的是 systemd,本质上它也是用户空间的第一个核心进程 - 它负责继续拉起后续所有用户空间服务,并负责系统初始化工作 - 启动各类系统服务和登录环境 - Linux 的环境变量有哪些?`PATH` 是什么? - Linux 的环境变量,本质上就是 操作系统传递给进程的一组键值对配置 - 告诉程序当前运行环境是什么 - 通过这些环境变量知道自己的用户是谁、当前家目录在哪、命令去哪里找、默认语言是什么等 - PATH 是最重要的一个,它表示命令搜索路径 - 告诉 Shell,输入一个命令名时,应该去哪些目录下查找可执行程序 - 常用环境变量 - 比如 HOME 表示当前用户的家目录 - USER 或 LOGNAME 表示当前用户名 - PWD 表示当前工作目录 - SHELL 表示当前使用的 Shell - HOSTNAME 表示主机名 - LANG 表示语言和地区设置 -JAVA_HOME 这种则是应用程序自己约定的环境变量,用来指定 JDK 安装目录 - 环境变量分成临时生效和永久生效两种 - 在当前终端里执行 export PATH=/opt/bin:$PATH,这是临时修改 - 只对当前 Shell 和它启动的子进程有效 - 想永久生效,通常会写到 ~/.bashrc、~/.bash_profile 或 /etc/profile 这类配置文件里 - 文件与目录操作 - 常见文件操作命令有哪些? - 查看、创建、复制、移动、删除 这几类 - ls,它是用来查看目录内容的 - touch,是一个比较轻量的文件创建和时间戳更新命令 - cp 是复制命令,用来把文件或者目录复制到另一个位置 - mv 是移动命令,也常拿来做重命名 - rm 是删除命令,用来删除文件或目录 - ls -l 输出的各列分别表示什么? - 第一列是 文件类型和权限信息 - 第二列是 硬链接数 - 第三列是 属主,也就是这个文件归哪个用户所有 - 第四列是 属组,表示这个文件属于哪个用户组 - 第五列是 文件大小,单位默认是字节 - 目录,这个大小并不等于目录下所有文件大小之和,而只是目录项本身占用的空间 - 第六到第八列通常是 最后修改时间 - 最后一列是 文件名 - 如果是软链接,通常还会看到类似 link -> target 这样的形式 - 如何查找某个文件? - 最常用的命令是 find - 按文件名、类型、大小、时间、权限等条件去搜索指定目录 - 在当前目录及其子目录里查找一个叫 app.log 的文件,会用 find . -name "app.log" - 查找目录可以用 -type d,查找普通文件可以用 -type f - 按大小查可以用 -size,按修改时间查可以用 -mtime - 不仅能回答“这个文件在哪”,还可以回答“满足某些条件的文件有哪些” - 如果只是想更快地按文件名查,而且系统已经建立过文件索引,也可以用 locate - 查的是数据库,不是实时遍历磁盘 - 什么是 inode?文件名和 inode 的关系是什么? - inode 是 Linux 文件系统里用来描述文件元数据的数据结构 - 是“文件的身份证”,里面记录的是这个文件的关键信息,比如文件大小、权限、属主属组、时间戳、数据块位置 - inode 里通常不直接存文件名,文件名实际上是保存在目录这个特殊文件里的 - 目录里保存的是“文件名 -> inode 编号”的映射关系 - 系统拿到文件名后,先到对应目录里找到 inode 编号,再根据 inode 里的信息去定位真实的数据块 - 文件名更像是给人看的入口,inode 才是操作系统真正识别文件的依据 - 硬链接 本质上就是多个文件名指向同一个 inode,所以它们其实是同一个文件,只是有多个名字 - 软链接 则不一样,软链接有自己独立的 inode,它保存的是目标文件的路径 - 进程与线程 - `top` 命令你怎么用?重点看哪些指标? - 第一块是 load average,也就是 1 分钟、5 分钟、15 分钟的平均负载 - 如果 load 很高,而 CPU 不高,那我会怀疑是不是有大量进程阻塞在 IO 上 - 第二块我会看 CPU 使用情况,重点关注 us、sy、id、wa 这几个值 - us 表示用户态 CPU 占用 - sy 表示内核态占用 - id 是空闲比例 - wa 是等待 IO 的时间 - 内存区域 - 重点看总内存、已用内存、空闲内存以及 buff/cache - 进程列表 - 先按 P 看 CPU 排序 - 按 M 看内存排序,快速找到最消耗资源的进程 - PID、USER、%CPU、%MEM、VIRT、RES、SHR、S - 其中 %CPU 看谁最耗 CPU - %MEM 看谁最占内存 - VIRT 是虚拟内存 - RES 是实际驻留物理内存 - S 是进程状态,比如运行中、睡眠中、僵尸状态等 - 如果我发现某个 Java 进程 CPU 很高,我下一步通常会结合 ps、jstack 去看具体是哪个线程在忙 - 按 P 按 CPU 排序,按 M 按内存排序,按 H 可以看线程级别,排查 Java 高 CPU 时很常用 - `kill`、`kill -9`、`pkill`、`killall` 有什么区别? - 本质上都是给进程发送信号,让进程结束或者执行某种动作 - kill,它是最基础的命令,按 PID 给进程发信号 - 这个信号比较“温和”,意思是通知进程你该退出了,进程可以先做一些善后操作 - kill -9 本质上还是 kill,只是它发的是 SIGKILL,也就是 9 号信号 - 这个信号是强制杀死,进程不能捕获、不能忽略、不能自行处理,内核会直接把它干掉 - pkill,它和 kill 的主要区别是:kill 按 PID,pkill 按进程名或条件匹配 - killall 也是按进程名杀进程,它会把同名的进程都处理掉 - 什么是前台进程和后台进程? - 前台进程会占用当前终端,和用户直接交互 - 后台进程不占用当前终端,启动后可以在后台继续运行 - 父进程和子进程的关系是什么? - 子进程是由父进程创建出来的一个新进程,它们是两个独立的进程 - 但子进程在创建初期会继承父进程的一部分运行环境 - 通常父进程通过 fork 来创建子进程 - 从操作系统角度看,它们是两个平级的进程,不是说子进程只是父进程里的一个线程,这一点要区分清楚 - 会继承父进程的一些上下文信息,比如环境变量、当前工作目录、打开的文件描述符、用户身份、信号处理方式等 - 如果子进程结束了,但父进程一直不回收它的状态信息,就会形成 僵尸进程 - 如果父进程先退出了,而子进程还在运行,那这个子进程就会变成 孤儿进程 - 一个进程占用 CPU 很高,你怎么排查? - 我会先用 top 或者 ps -ef、ps aux 确认到底是哪个进程占用 CPU 高 - 是 Java 进程,我通常会先记下它的 PID - 会进一步用 top -Hp <pid> 看这个进程里到底是哪个线程占用 CPU 高 - 拿到高 CPU 线程之后,我会把线程 ID 转成十六进制 - 像 Java 的线程栈信息里,线程 ID 一般是十六进制展示的 - 如果是 Java 应用,我会用 jstack <pid> 导出线程栈,然后搜索对应线程 ID - 找到这个高 CPU 线程当前到底在执行什么代码 - 结合业务场景判断原因 - 死循环或空转、自旋锁竞争、频繁 GC、算法复杂度过高、某些异常逻辑导致无限重试,或者流量突然增大导致计算压力上升 - 系统态 CPU 也高,我还会怀疑是不是系统调用、网络中断、上下文切换过多这类内核层问题 - 看是不是偶发还是持续问题 - 一个进程占用内存很高,你怎么排查? - 先用系统命令确认到底是谁占用了内存 - 用 top、ps aux --sort=-%mem、free -h 先看整体机器内存是否真的紧张 - 排查内存问题时更关注 RES,也就是实际驻留物理内存,而不是只看 VIRT - 判断这是正常业务占用,还是异常持续增长 - 一种是机器已经开始频繁使用 swap,性能明显下降 - 另一种是进程内存持续上涨,而且不回落 - 如果是 Java 进程,我通常会重点看 JVM 堆的使用情况 - 先用 jstat -gc <pid> 看 GC 和各代内存变化,判断是年轻代、老年代还是元空间压力大 - 再用 jmap -histo <pid> 看当前对象直方图 - 怀疑有内存泄漏,我会进一步导出 heap dump - 如果不是 Java 进程,我会看是不是有资源泄漏 - 内存管理 - Linux 内存分为哪些区域? - 代码段、数据段、BSS 段、堆、栈,以及内存映射区 - 代码段,也叫 text 段,主要存放程序的机器指令,也就是可执行代码 - 数据段,主要存放已经初始化的全局变量和静态变量 - 堆。堆是程序运行时动态申请内存的区域 - 栈。栈主要用于保存函数调用过程中的局部变量、参数、返回地址、寄存器现场等 - 内存映射区,也就是 mmap 区。它主要用于共享库加载、文件映射、匿名映射等 - `free -h` 的输出怎么看? - free -h 是 Linux 里查看内存使用情况最常用的命令之一 - -h 表示 human-readable,也就是把结果按 KB、MB、GB 这种更容易读的单位展示出来 - 第一列 total 表示总内存,也就是这台机器物理内存总共有多少 - 第二列 used 表示已经被使用的内存,但这里要注意,它不是单纯表示“真正被业务进程占满”的内存 - Linux 会把一部分空闲内存拿去做缓存,所以这个值看起来通常会比较大 - 第三列 free 表示当前完全空闲、还没有被使用的内存 - available。判断系统内存是否真的紧张,要重点看 available,而不是只看 free - available 表示在不明显影响系统运行的前提下,当前大概还能提供给新进程使用的内存 - 磁盘与文件系统 - `df -h` 和 `du -sh` 的区别是什么? - df 看的是文件系统层面的磁盘使用情况 - df -h 更适合回答“这块盘整体还有没有空间”这个问题 - 线上如果有人说磁盘快满了,我通常先看 df -h,因为我要先知道到底是哪个挂载点满了 - du 看的是目录或文件本身占用了多少空间 - 磁盘满了怎么排查? - 先用 df -h 看整体磁盘使用情况,先确认到底是哪个挂载点满了 - 因为只有先定位到具体文件系统,后面的排查才有方向 - 确认了具体盘之后,我会进入对应目录 - 用 du -sh * 或者 du -h --max-depth=1 一层层往下找,定位到底是哪个目录占用最大 - 找到大目录以后,我会继续往下定位到具体大文件 - find /path -type f -size +500M 这种方式 - 网络基础 - `ifconfig` 和 `ip addr` 有什么区别? - ifconfig 和 ip addr 都可以用来查看和配置网卡信息 - ifconfig 是传统工具 - ip addr 是现在更推荐的现代工具 - ifconfig 看网卡信息,ifconfig eth0 up 启动网卡 - ip addr 主要用来查看和管理 IP 地址 - 日常查看 IP 地址我更倾向用 ip addr,因为这是现代 Linux 的推荐方式 - `netstat` 和 `ss` 的区别是什么? - netstat 是传统工具,ss 是现在更推荐的工具 - 常用 netstat -tunlp 看监听端口,或者看 TCP 连接状态 - ss 本质上也是看 socket 信息,但它获取数据的方式更高效,尤其是在连接数很大的机器上,查询速度通常比 netstat 更快 - 如何抓包分析网络问题? - 先明确问题现象。比如到底是连不上、连接很慢、请求发不出去、响应回不来、还是频繁重传和超时 - 会先做一些基础确认,而不是立刻抓包。 - 先用 ping 看网络是否可达 - 用 telnet、nc 或 curl 看目标端口和接口能不能通 - 用 ss 或 netstat 看本地连接状态和监听情况 - 如果基础命令还不能定位,我才会开始抓包 - Linux 下我最常用的是 tcpdump - 尽量缩小范围,比如指定网卡、主机、端口、协议,减少无关数据 - 抓和某个服务端口相关的包,我会按 目标 IP 加端口 去抓 - 看几个关键点。 - 三次握手有没有成功 - 连 SYN 都发不出去,可能是本机路由、防火墙或者网卡问题 - 发了 SYN 但收不到 SYN-ACK,可能是中间网络丢包、目标机器不可达,或者目标端口没开 - 有没有重传、乱序、窗口过小、RST、FIN 这些异常标志 - 频繁重传,通常说明链路质量差、丢包严重,或者对端压力太大来不及处理 - 收到 RST,说明连接被一方强制重置 - 请求和响应的时间关系 - 请求很快发出去了,但响应很久才回来,那要区分是网络传输慢,还是服务端处理慢 - TIME_WAIT 是什么?为什么会出现大量 TIME_WAIT? - TIME_WAIT 是 TCP 连接关闭过程中的一个状态,通常出现在主动关闭连接的一方 - 确保最后的 ACK 能让对方收到 - 防止旧连接里延迟到达的报文影响后续新连接 - 在发出最后这个 ACK 之后,主动关闭方不会立刻把连接彻底释放 - 而是会进入 TIME_WAIT 状态,并等待一段时间,通常是 2MSL - 大量 TIME_WAIT 通常说明系统存在高并发短连接或者频繁主动关闭连接的情况,它不一定是错误,但可能带来端口和资源压力 - CLOSE_WAIT 是什么?大量 CLOSE_WAIT 通常说明什么问题? - 它表示本机已经收到了对方发来的 FIN,也就是对方已经准备关闭连接了 - 但本机这边还没有真正执行 close,所以连接还没有完全关闭 - 大量 CLOSE_WAIT 通常说明服务端或者本机应用存在资源回收不及时的情况 - 先用 ss -antp 或 netstat -antp 看哪些连接处于 CLOSE_WAIT - `tcpdump` 你用过吗?常见用法是什么? - 是 Linux 下非常常用的命令行抓包工具,主要用于网络问题排查和协议分析 - tcpdump 更适合在线上或服务器环境里做快速抓包 - 按网卡抓包,比如先用 tcpdump -i eth0 指定网卡;如果不确定网卡名,可以先看机器网卡列表 - 按主机过滤,比如只抓和某个 IP 之间的流量,可以用 host 条件 - 按端口过滤,比如只抓 80、443、8080 这种业务端口 - 按协议过滤,像 tcp、udp、icmp 分开抓,这样可以大幅减少无关数据 - 把抓包结果保存到文件,比如写到 .pcap 文件里,后续再用 Wireshark 分析 - Linux 性能监控 - 什么是负载 load?和 CPU 使用率有什么区别? - 它表示在一段时间内,系统中处于可运行状态和不可中断睡眠状态的平均进程数量 - 而 load 看的是系统里有多少任务在等待 CPU 或者等待某些关键资源 - 服务管理与部署 - Linux 中如何启动、停止、重启服务? - 现在最常见的方式是用 systemctl,因为大多数现代发行版都使用 systemd 来管理系统服务 - 启动服务用 systemctl start 服务名 - 停止服务用 systemctl stop 服务名 - 重启服务用 systemctl restart 服务名 - 查看服务状态用 systemctl status 服务名 - reload 表示重新加载配置,通常不会中断服务进程 - enable 是设置开机自启,disable 是取消开机自启 - Linux 服务是什么 - 在系统中长期运行、为系统或其他程序提供某种功能的后台进程 - 往往会随着系统启动自动拉起 - 它通常会监听端口、处理请求,或者周期性执行任务 - 服务本质上还是进程,只不过它是被系统长期托管和管理的进程 - Linux 服务通常由专门的管理器来维护。早期常见的是 init 或 SysV,现在大多数发行版主要用 systemd - 常用文本处理命令 - `awk` 是干什么的? - awk 会一行一行地读取输入内容,然后按照分隔符把每一行拆成多个字段 - 默认分隔符是空格或制表符,我们可以直接用 $1、$2、$3 这样的方式取第 1 列、第 2 列、第 3 列数据 - 最常见的能力有四类 - 第一类是取列,比如提取某一列内容 - 第二类是条件过滤,比如只保留某一列满足条件的行 - 第三类是统计计算,比如计数、求和、求平均值 - 第四类是格式化输出,把原始文本重新整理成自己想要的结构 - sed 是干什么的? - 它会按行读取输入内容,然后按照我们写的规则对文本做处理,处理完直接输出结果 - 它通常不是把整个文件一次性读到内存里再编辑,而是一行一行地处理输入流 - 替换,这也是最常见的,比如把某个字符串批量替换成另一个 - 删除,比如删掉某些行、空行、注释行 - 截取,比如只打印某几行内容 - 插入和追加,比如在某一行前后加内容 计算机组成原理 - 计算机组成原理 - 存储系统 - 虚拟内存与地址转换 - 什么是虚拟地址?什么是物理地址? - 虚拟地址是程序运行时看到的地址,也叫逻辑地址 - 物理地址是内存条上真实存在的地址,是硬件真正访问的地址 - 程序里用的通常不是直接的物理地址,而是虚拟地址 - 再通过 MMU,也就是内存管理单元,结合页表把虚拟地址翻译成物理地址,最后再去访问真正的内存 - 地址转换是怎么做的? - 转换过程第一步通常会先查 TLB,也就是快表 - TLB 本质上是页表项的高速缓存,里面存的是“虚拟页号到物理页框号”的映射 - 如果 TLB 没命中,就要进一步去查页表 - 页表一般放在内存里,操作系统会维护它,记录每个虚拟页映射到哪个物理页框 - 如果查页表时发现这个虚拟页当前不在物理内存中 - 由操作系统介入,把对应页面从磁盘调入内存,必要时还会淘汰一个旧页 - 多级页表是为了解决什么问题? - 多级页表本质上是为了解决单级页表过大、内存开销太高的问题 - 多级页表的核心思想就是把原来一张连续的大页表拆成分层结构,按需创建 - 页表不再一次性全部分配,而是先有一个上层页表,只有当某一段虚拟地址空间真的被用到了,才去创建对应的下一级页表 ElasticSerach - ElasticSerach - 基础概念 - ElasticSearch 的核心应用场景有哪些? - 全文检索与相关性排序 - 日志与可观测性场景 - 聚合分析与报表统计 - 什么是倒排索引?它为什么适合全文检索? - 倒排索引本质上是一种“从词到文档”的索引结构 - 传统数据库更常见的是正排,也就是根据文档 ID 去找到文档内容 - 而倒排索引是先把文档内容经过分词,拆成一个个 term - 为每个 term 维护一个倒排列表,记录这个词出现在哪些文档中 - 它适合全文检索 - 核心原因是查询路径特别短 - 用户一搜某个关键词,系统可以直接定位到这个词对应的文档集合 - 它天然支持分词后的检索 - 不仅能查到,还能支持相关性排序 - 倒排索引里通常不只是保存“出现过”,还会保存词频、位置等信息 - 搜索引擎就可以结合 TF、IDF 或 BM25 等算法判断某篇文档和查询词的相关程度 - ElasticSearch 为什么检索快? - 最根本的原因是倒排索引 - ES 会先分词,再基于 term 检索 - ES 是分布式架构,可以并行查询 - Lucene 底层做了很多针对检索的优化 - 什么是 Lucene?ElasticSearch 和 Lucene 是什么关系? - Lucene 是一个高性能的全文检索库,本质上是“搜索引擎的核心算法与索引实现” - ElasticSearch 和 Lucene 的关系可以理解为:ES = Lucene + 分布式 + 工程化能力 - ElasticSearch 中的 segment 是什么? - segment 可以理解为 Lucene 里的一个最小索引单元,或者说是一个不可变的倒排索引文件集合 - Lucene 不会把一个分片的数据维护成一个单独的大索引文件,而是会拆成多个 segment - 每个 segment 内部都包含这个批次文档对应的倒排索引、词典、posting list、存储字段等数据结构 - 查询时其实不是只查一个整体索引,而是同时在多个 segment 上查,再把结果合并 - 索引与数据结构类 - ElasticSearch 的文档写入后,底层经历了哪些过程? - 请求路由到主分片 - 主分片执行写入:先写 translog,再写内存缓冲 - ES 会先把这次操作以追加的方式写到 translog - 把文档写入到 in-memory buffer(indexing buffer) - refresh 让数据“可被搜索” - 当触发 refresh(默认大约 1 秒一次,或手动 refresh) - buffer 里的数据会被写成一个新的 Lucene segment 并打开一个新的 searcher - 使这批文档变成 near real-time 可检索 - flush 触发真正的持久化与 translog 截断 - 随着 translog 增长或达到条件,会触发 flush - 把当前 segment 的提交点(commit point)持久化,并生成新的 translog - 后台 merge 合并 segment 优化查询 - ES/Lucene 会在后台做 segment merge,把多个小段合并成更大的段,减少段数量、提升查询效率 - 同步到 replica 分片并返回结果 - doc_values 是什么?有什么作用? - ES 为“按文档读取字段值”专门构建的一种列式存储结构 - 有一类场景不是“找哪些文档包含某个词”,而是“我已经拿到这些文档了,还要基于某个字段做排序、聚合、脚本计算” - 最核心的作用,是支持排序、聚合和脚本访问字段值 - doc_values 的设计目标,是用磁盘空间换内存压力 - 查询原理类 - ES 一次查询的流程是什么 - 请求进入协调节点(coordinating node) - 客户端把搜索请求发到集群里任意一个节点,这个节点就会扮演协调节点 - 负责解析 DSL、校验索引与参数、并根据索引的分片分布决定这次查询要打到哪些 shard - 分片路由与并行分发 - 协调节点会根据目标索引的 shard 列表,把查询请求并行发给每个相关分片的一个副本 - Query Phase:各分片本地执行查询并返回 TopN - 如果是全文检索,会做分词、走倒排索引,计算相关性 _score - 如果有 filter,会做结构化过滤 - 如果有排序/聚合,也会在分片侧先做一部分计算 - 然后每个分片会返回本分片的 TopK 文档 ID + score/排序字段 - 协调节点做全局归并与排序 - Fetch Phase:回表取文档内容 - 全文检索时,分词、倒排索引、BM25 打分这个过程具体是怎么跑的 - 建索引阶段 - 有个 title 字段内容是“Java 分布式系统设计”,如果这个字段类型是 text - ES 会先用 analyzer 对它做分析 - 先做字符过滤,然后由 tokenizer 分词,最后再经过 token filter 做小写化、停用词处理、同义词扩展等 - 原始文本不会直接拿去全文匹配,而是会变成一个个 term - ES 会基于这些 term 建立倒排索引 - 会存成“某个词出现在哪些文档里” - 系统 -> doc1, doc2, doc5, doc9 - 倒排列表里通常不只是 docID,还会记录词频、位置等信息 - 建索引阶段的核心,就是把文本拆成 term,然后维护 term 到文档集合的映射关系 - 查询阶段,用户输入的关键词也会走一遍分析流程 - 也会先分词,得到类似“java”和“系统”这两个查询 term - 然后 ES 就会去倒排索引里找这两个 term 对应的 posting list - 也就是分别找到包含“java”的文档集合和包含“系统”的文档集合 - 找到 posting list 之后,会做候选文档召回 - 接下来才是相关性打分,也就是 BM25 发挥作用的地方 - 查询词在当前文档里出现了多少次,也就是词频 - 这个词在整个索引里稀不稀有,越稀有权重越高 - 文档本身有多长,太长的文档会被做一定归一化,避免天然占优 - 各个分片会先在本地完成这个召回和打分过程 - 如果进入 fetch phase,才会去拿完整文档内容 - 全文检索和精确查询有什么区别? - 全文检索强调的是“分词后匹配”,适合搜文本内容 - 精确查询强调的是“值是否完全一致”,适合结构化字段 - 两者在底层使用场景上也不一样 - 全文检索更关注“召回”和“相关性排序” - 而精确查询更关注“过滤” - 它们对应的典型查询方式也不同 - BM25 是什么? - BM25 是 ElasticSearch 默认使用的一种相关性评分算法,用来衡量“某篇文档和用户查询到底有多相关” - 词频、逆文档频率、文档长度归一化 - 词频,也就是某个词在当前文档里出现得越多,通常说明这篇文档和查询更相关 - 逆文档频率,也就是越稀有的词,区分度越强,权重越高 - 文档长度归一化 - BM25 比较适合搜索场景 - BM25 可以理解为在 TF-IDF 基础上的改进版。 - 保留了“词频 + 逆文档频率”的核心思想,但对词频增长做了饱和控制 - 同时对文档长度处理得更合理,所以通常比传统 TF-IDF 更适合现代搜索引擎 - 写入性能与查询优化 - 热点分片是什么?怎么解决? - 热点分片,本质上是指某一个或少数几个 shard 的压力明显高于其他 shard,导致集群负载不均衡 - 它的本质问题不是“集群性能不够”,而是“流量分布不均” - 热点分片通常有两大类原因 - 写热点 - 读热点 - 一般会从“数据分布、请求分布、资源分布”三层来处理 - 优先从数据分布上解决,也就是让数据尽量均匀落到各个 shard - 从请求分布上缓解热点,如果是读热点,可以增加 replica shard - 从资源和架构上做隔离 - 必要时通过重建索引来调整分片策略 - 系统设计 - 系统设计 - 设计一个短链接系统(类似 TinyURL) - 客户端把长链接发到服务端,服务端先做参数校验,比如 URL 格式是否合法,是否命中黑名单 - 系统生成一个全局唯一的短码,再把短码和原始长链接的映射关系存到数据库里,最后返回类似 https://xx.com/abc123 这样的短链接 - 用户访问这个短链接时,请求会先到接入层,再到短链接解析服务,系统根据短码查到原始 URL,然后返回 302 或 301 重定向 - 如何把一条长链接映射成一个短且唯一的标识 - 自增 ID 加 Base62 编码,也就是数据库先生成一个唯一 ID,比如 1000001,再把它编码成短字符串 - 对长链接做哈希,比如 MD5,再截取一部分字符作为短码,但这种方式会有哈希冲突问题,需要额外处理 - 发号器或分布式 ID,比如 Snowflake,再做 Base62 编码 - 存储设计上,我会准备一张核心映射表,比如字段包括:短码、原始长链接、创建时间、过期时间、创建人、状态、访问次数等 - 短码字段要建立唯一索引,因为跳转查询本质上就是根据短码查原始 URL,这是系统最核心的查询路径 - 跳转成功后异步发送一条埋点消息到 MQ,由后面的日志分析或实时计算系统去消费,写入 ClickHouse、ES 或数仓中做分析报表 - 设计一个聊天系统(类似微信 / WhatsApp) - 接入层,也就是长连接网关层,客户端通过 WebSocket 或者移动端长连接协议和接入层建立持续连接 - 这一层主要负责连接管理、心跳保活、路由转发和用户在线状态维护 - 后面是业务层,包括消息服务、会话服务、群组服务、离线消息服务、历史消息服务、推送服务 - 存储层,通常会结合 Redis、MySQL、消息队列以及可能的对象存储来实现 - 客户端登录成功后,会和某个接入节点建立长连接,这时候系统需要维护一份‘用户 ID 到连接 ID,再到机器节点’的映射关系 - 有人给这个用户发消息时,消息服务可以先查路由,找到他当前连接在哪台机器上,再把消息投递过去 - 消息发送流程上 - 发送方把消息发给接入层,接入层先做鉴权和基础校验,然后把请求转发到消息服务 - 消息服务会先生成全局唯一的 messageId,并做消息落库或者写入消息日志 - 根据接收方是单聊还是群聊走不同流程 - 对于单聊,系统查接收方在线路由,如果在线,就把消息投递到对应接入节点,再由接入节点推送给接收方客户端 - 如果不在线,就把消息写入离线消息存储,并通过厂商推送做通知 - 用户上线后,再拉取离线消息并同步到本地 - 存储设计 - 第一类是会话数据,比如用户最近联系人列表、每个会话的最后一条消息、未读数,这类数据读写都很频繁,适合放 Redis 以及 MySQL 持久化 - 消息数据,也就是聊天记录,这类数据量最大,通常会做分片存储,可以按会话 ID 或者用户 ID 哈希分库分表 - 关系数据,比如群成员、好友关系,这类更适合放 MySQL - 消息表,字段一般包括消息 ID、会话 ID、发送方、接收方、消息类型、内容、发送时间、状态等 - 严格全局有序在分布式系统里代价很高,聊天系统通常只保证‘单会话内尽量有序’ - 单聊里,可以以会话维度串行化处理,或者让同一个会话的消息固定路由到同一个队列分区 - 群聊里也可以按群 ID 做分区,这样同一个群内的消息进入同一个顺序队列处理 - 离线消息和多端同步 - 不能只依赖实时推送,因为用户可能离线、弱网或者切后台,所以服务端一定要保存消息投递记录和消息内容 - 用户重新上线后,可以基于上次同步到的游标或者 seq 去增量拉取消息,这样就能保证消息补偿 - 群聊是另一个重点 - 群聊发送时最直观的方式是服务端拿到一条群消息后展开成多份,分别投递给每个成员 - 但如果群特别大,这种在线展开代价就会非常高 - 群聊一般要区分大小群。小群可以实时扇出,也就是服务端直接展开分发 - 大群可以采用写扩散和读扩散结合的方式,或者通过消息流加拉模型来降低服务端写放大 - 设计一个微博 / Twitter 时间线系统 - 用户可以发微博,也就是发布内容 - 用户可以关注别人,建立关注关系 - 用户可以看到自己的时间线,也就是关注的人最近发了什么 - 支持点赞、评论、转发这些互动能力 - 发布微博 - 发布一条微博后,请求先到内容服务,内容服务先做参数校验、敏感词过滤和内容存储 - 正文、作者 ID、发布时间这些元数据可以先写入 MySQL 或者分布式 KV 存储 - 图片和视频这种大对象一般放对象存储 - 写成功后,系统会生成一个全局唯一的 postId,然后把这条发布事件发送到消息队列,由后续的时间线分发服务异步处理 - Push 模式和 Pull 模式 - Push 模式,也叫写扩散,就是一个用户发微博后,系统把这条微博直接推送到所有粉丝的时间线缓存里。这样用户读时间线时非常快 - 如果一个大 V 有上千万粉丝,一次发帖就会造成巨大写放大,系统压力很大 - Pull 模式,也叫读扩散,就是用户读时间线时,系统实时去拉取他关注的人最近发的内容,再做聚合排序 - 写操作很轻,但读操作会比较重,特别是一个用户关注了很多人时,聚合成本会很高 - 混合模式 - 普通用户走 Push,大 V 用户走 Pull - 普通用户粉丝少,发帖时推送给粉丝成本可控,换来读性能很好 - 而大 V 粉丝太多,如果还坚持 Push,单次发帖就会带来海量写入,不划算 - 可以给用户分层,比如粉丝数低于某个阈值的按写扩散处理,超过阈值的大 V 按读扩散处理 - 最终一个用户读取时间线时,先读取自己 inbox 里已经推送好的内容 - 再补充拉取他关注的大 V 最近的内容,最后做合并排序 - 设计一个朋友圈 / Feed 流系统 - 内容写成功后,系统生成一个全局唯一的 feedId,然后发送一条发布事件到 MQ,由后续的 Feed 分发服务异步处理 - 设计一个关注 / 粉丝系统 - 关注关系本质上是一张有向边,也就是 userA 关注 userB,可以表示成一条 (follower_id, followee_id) 的记录 - 一般会设计一张关系表,核心字段包括:id、follower_id、followee_id、status、create_time、update_time - 这里 status 一般不一定直接物理删除,可以做成逻辑状态,比如 1 表示已关注,0 表示已取消 - 设计一个搜索系统 - 搜索系统的核心目标,是让用户输入一个查询词之后,系统能够在海量数据中快速召回相关内容,并按照相关性排序 - 业务系统里通常有商品表、文章表或者内容表,这些数据的新增、修改、删除不能直接靠搜索服务主动查库 - 业务数据变更后通过 Binlog、CDC 或 MQ 把变更事件发送到索引构建服务 - 索引构建服务接收到变更后,做字段清洗、分词、结构化处理,然后写入搜索引擎更新索引 项目 - 项目 - 异步收银台 - 问题 - WebSocket 与长连接网关 - 差量协议 - 收益 - BPMN L4 - 事件网关 - 异步回调 - 问题挑战 - 视图构建并发修改 panic - 机器资源 - 上屏率 - 未来要做的 - 上屏率提升 - 权益中心 - 痛点 - 接入成本高 - 权益存储分散,不可追溯 - 规则复杂 - 解决方案 - 数据模型 - 权益元数据-权益是什么/业务含义 - 权益来源/策略-权益从哪里来(免费试用/商业订单/审批补发) - 权益度量/实例:度量-订单 1vs1 实例:度量的组合 - 流水:审计、对账 - 库存:消耗记录,度量、实例 - 权益属性:配置信息 - 规则表 - SPI 机制(平台与业务分离) - 参数填充 - 发放 - 到期 - 回收 - 不同权益规则很大,平台放无法全包 - 历史业务前移 - 模型映射转换-SPI 实现 - 权益中心较晚出现,历史业务切流 - 消耗数据,同步流水,异步聚合(流水对账) - 历史权益发放数据:订单级别重放 + 对账 - 新流入数据:权益 + 业务方双写 - 切流灰度 - 热点问题 - 发放没有热点问题,查询/消耗有热点问题 - 大组织热点/mysql 无法承载,转换成 Redis - Redis 将库存记录转换为 list 存储起来 - 每次路由到桶,写记录 - 跨桶扣减会加锁(桶碎片问题) - 只会少买,不会超卖(扣库存成功,写流水失败,后续补偿) - 富客户端,请求不直接打进来 - 异步写流水 - 规则引擎 - 发放规则复杂(商品、购买周期、企业行业、时间) - 不用硬编码,直接 JEXL 规则引擎配置 - 其他,版本号 + 阶段控制 vibe coding 了后台 - 报价管理 - 商机客保规则限制,跨商品线下下单困难 - 订单价格不统一,无法统一改价 - 流程繁多、改价审批、商机转交审批、时间长 - 新人培训成本高,企业信息了解少 - 报价的审批会改变订单的状态,报价审批拒绝了,订单关单 - 合同分散、集中合同,报价统一出 - AI 做什么 - 热点词捕捉 - 什么时候微调/什么时候 RAG - Planning - ReAct,还没有做 Planning - 历史服务数据承担 SFT 微调 - MCP 服务开放,HSF 转 MCP,订单/小记 - 客户情报说明/雷达图/商品打分/服务话术推荐 - 合单分期 - 痛点:支付方式不灵活 - 不同店铺单支付方式冲突 - 搭配购、顺手买 - 可扩展数据结构 mixPayOption { payOption<string> } - 店铺单分期方式扩展 - 渲染订单号 - 实际订单号转换的过程 - 扩展监控面板 - 店铺单/拍下单支付方式路由(一个资产、多个资产) - 新面板,提单信息 - 商品信息 - 店铺信息 - 价格信息 - 运费信息 - 冲突校验 - 懒加载 - 面板不请求(前端聚合计算) - 未来方向 - 组合支付 - 自选快递 - 提单 - 识别场景能力 - 从时效获取支持快递 + 运费模板 - 从运费模板 + 获取加价运费 - 楼层渲染 - 冲突规则 - 失效可能会变/运费可能会变 -> 冲突文案 - 下单 - 选中打标

二月 28, 2026

leetcode-155-最小栈

实验元数据 (Meta Data) 用于日后检索和归档,建立知识索引。 实验编号/标题:例如:LeetCode-155-最小栈 ...

二月 27, 2026

实验-leetcode-153-寻找旋转排序数组中的最小元素

实验元数据 (Meta Data) 用于日后检索和归档,建立知识索引。 实验编号/标题:实验-leetcode-153-寻找旋转排序数组中的最小元素 ...

二月 27, 2026

实验-leetcode-198-打家劫舍

实验元数据 (Meta Data) 用于日后检索和归档,建立知识索引。 实验编号/标题:实验-leetcode-198-打家劫舍 ...

二月 27, 2026

实验-leetcode-20-有效的括号

实验元数据 (Meta Data) 用于日后检索和归档,建立知识索引。 实验编号/标题:实验-leetcode-20-有效的括号 ...

二月 27, 2026

实验-leetcode-213-打家劫舍 2

实验元数据 (Meta Data) 用于日后检索和归档,建立知识索引。 实验编号/标题:实验-leetcode-213-打家劫舍 2 ...

二月 27, 2026