bizsspd 项目学习记录

bizsspd 项目学习记录

目标读者:有 React/RN 经验的中级前端工程师,目标晋级资深工程师。 记录真实认知缺口、技术决策、排查过程。


目录

  1. 项目技术栈认知 - 这是什么项目,怎么跑起来
  2. Maven 是什么 & settings.xml 原理
  3. IntelliJ IDEA 版本识别 - Community vs Ultimate
  4. settings.xml 实战配置过程与内容解读
  5. 后端基础概念扫盲(给前端的速成包)
  6. Tomcat 是什么 & Java Web 运行时模型
  7. Spring MVC 三层架构(Controller/Service/DAO)
  8. 依赖注入(DI)原理与前端心智映射
  9. 前端切入后端项目的三步路径 - 找接口、维护、新增
  10. 思考题:文件路径路由 vs 注解路由 - 范式对比
  11. IDEA JDK 配置问题排查
  12. 思考题:@Resource vs useContext 的本质差异
  13. 本项目缓存使用情况调研
  14. 配置地址完整清单 & 自我纠错:什么要问同事
  15. 元技能:从常识反推结论合理性 - 资深工程师的核心心智
  16. 网络诊断工具 nc (netcat) - 后端必会的瑞士军刀
  17. 思考题:TCP 通但服务不通 - 分层排查思维
  18. Homebrew keg-only 机制 - 为什么装了命令却不能直接用
  19. MySQL 生态 - Percona / MariaDB / 官方版的关系
  20. 本项目数据源架构 - 多数据源 vs 分库分表 vs 多租户
  21. 排查失误:扩大搜索范围时悄悄收缩了过滤条件

1. 项目技术栈认知

时间:2026-04-20

一句话结论

bizsspd 是一个 Java Spring MVC 多模块 Maven 项目,打包成 WAR 跑在 Tomcat 上,是纯后端项目,没有前端构建流程。

项目结构

bizsspd/
├── pom.xml                    ← 根模块,定义所有子模块和公共依赖版本
├── ec-slh2-server/            ← 核心:网关模块,客户端访问入口
├── ec-trade/                  ← 交易模块
├── ec-fin/                    ← 财务模块
├── ec-inv/                    ← 库存模块
├── ec-mdm/                    ← 主数据模块
├── ec-slh2-mqproxy-server/    ← 消息队列代理
└── ...(其他业务子模块)

与前端项目的类比

前端概念 后端对应
package.json 根目录 pom.xml
npm install mvn install / mvn dependency:resolve
npm run build mvn clean package
node_modules/ ~/.m2/repository/(本地缓存仓库)
npm registry Maven Central / 公司 Nexus
Webpack bundle → dist/ Maven 打包 → .war 文件
npx serve dist/ .war 放进 Tomcat
Monorepo (nx/turborepo) Maven 多模块项目

跑起来需要的环境

  • Java 17:已安装 ✅
  • Maven:已安装 ✅
  • IntelliJ IDEA:已安装 ✅
  • ~/.m2/settings.xml:待向同事索取(含私有仓库地址和账号)
  • Tomcat:IDEA 可以内置配置,不需要单独安装

关键依赖:私有 Maven 仓库

pom.xml 中大量 com.hzecool.* 包来自公司内部仓库(Nexus/Artifactory),不在公共 Maven 中央仓库。没有 settings.xmlmvn package 会报 Could not resolve dependencies 错误。

三层对比

❌ 初级做法:看到 Java 项目就懵,不知道从哪里下手
   → 网上搜"怎么跑 Java 项目",看了一堆 Hello World 教程

⚠️ 中级做法:知道是 Maven 项目,但照搬命令不理解在做什么
   → mvn 命令能跑,出错就不会排查
   → 不理解多模块 vs 单模块的区别

✅ 资深做法:从类比建立心智模型
   → pom.xml = package.json,settings.xml = .npmrc
   → 多模块 Maven = Monorepo,每个子模块是独立发布单元
   → WAR 包 = 生产制品,对应前端的 dist 目录
   → 遇到"依赖解析失败"立刻想到"仓库地址不对"或"凭证缺失"

系统思维:多模块 Maven 项目的组织哲学

本项目 7+ 个子模块(ec-slh2-server / ec-trade / ec-fin / ec-inv / ec-mdm...),这不是随意拆分:

拆分原则识别:

1. 按业务域(Domain-Driven Design 思想)
   trade = 交易域
   fin = 财务域
   inv = 库存域
   mdm = 主数据域(Master Data Management)
   → 每个模块对应一组业务概念,高内聚

2. 网关模式
   ec-slh2-server = 对外网关(接收客户端请求)
   → 路由到具体业务模块
   → 和前端的 BFF(Backend For Frontend)同思路

3. 独立进程 vs 独立模块
   本项目子模块都在同一个 WAR 部署 → 是"单体内的模块化"
   如果拆成微服务,每个模块独立部署 → 需要加 RPC 调用
   → 本项目处在"微服务演进前的模块化"阶段

演进路径

阶段 1:单体单模块       → 一个 .war,所有代码混在一起
阶段 2:单体多模块(本项目)→ 一个 .war,模块内聚但部署绑定
阶段 3:微服务           → 每个模块独立部署 + RPC 调用
阶段 4:服务网格         → 服务间通信基础设施化

阶段 2 的价值:代码层面强制边界(模块间依赖要显式声明),但部署仍然一体(避免分布式复杂性)。这是中大型项目的典型稳态。

思考题讨论

Q1:为什么 Java 项目要"编译 + 部署到 Tomcat"这么麻烦,不像 Node 直接跑?

用户回答:不清楚预编译的必要性。

暴露的知识盲区:把"动态解释"和"预编译"当作语言特性,没意识到这是运行时模型的根本差异

正确解答

Node 的模型:
  源码 .js → Node 启动时解析 → V8 JIT 编译 → 执行
  → 改文件立刻生效(nodemon + HMR)
  → 代价:每次启动要重新解析
  → 好处:开发体验好

Java 的模型:
  源码 .java → javac 编译 → .class 字节码
  打包成 .war
  Tomcat 启动时加载字节码 → 长期驻留 JVM
  → 启动慢(预编译 + 预热)
  → 运行快(字节码已优化)
  → 长时间运行性能极佳(JIT 持续优化热点)

为什么服务端选"预编译 + 长驻"模型?

服务端程序的特征:
  - 启动一次,运行几周/几个月
  - 启动成本摊销在全生命周期(几秒启动换几月流畅)
  - 需要极致性能(企业级每秒处理万级请求)
  - 需要类型安全(错误不能等运行时才发现)

→ Java/Go/Rust 的预编译模型完美匹配

前端 / Node 的特征:
  - 浏览器每次打开都要加载 → 体积和启动速度敏感
  - 开发迭代频繁 → 需要快速反馈
  - 性能要求中等(UI 响应)

→ 动态解释 + JIT 模型匹配

这不是"哪个好"的问题,是"哪个适合场景"

Q2:为什么要把多模块放在一个仓库而不是拆成多个 Git 仓库?

正确解答

多仓库方案:
  bizsspd-trade/  (独立仓库)
  bizsspd-fin/
  bizsspd-inv/
  → 发版时各自发,主项目依赖特定版本
  
  痛点:
    - 跨模块改动要 N 个 PR(改 trade 时顺便改 fin 的接口)
    - 版本对齐难(trade 2.3 + fin 1.8 是否兼容?)
    - 重构成本高(改个公共接口要协调所有仓库)

单仓库 + 多模块(本项目):
  一个 Git 仓库 + 一次 PR 可以跨模块
  → 内聚度高、重构方便
  痛点:仓库大,权限难细分

何时选哪种

选多仓库:
  - 各模块独立团队、独立发版节奏
  - 模块间接口很稳定(不频繁重构)
  - 典型案例:微服务架构

选单仓库多模块:
  - 模块间接口经常变动
  - 强调统一版本、一起发布
  - 典型案例:Google 的 Monorepo、本项目

中间态:
  - Nx / Turborepo(JS 生态的 Monorepo 工具)
  - Bazel / Buck(Google、Meta 的大 Monorepo 方案)

本项目的选择揭示了:业务模块间紧密耦合(交易影响财务影响库存),适合 Monorepo 式管理

面试话术:"bizsspd 是典型的多模块 Maven 项目,一个根 pom 管理 7 个业务子模块。这种结构体现了 DDD 的领域划分思想——trade/fin/inv/mdm 对应不同业务域,ec-slh2-server 作为网关入口,类似前端的 BFF 模式。选单仓库多模块而不是多仓库,是因为这些业务域高度耦合,跨模块重构频繁,单仓库能一次 PR 同步改完。这和做前端时用 Turborepo 管理多个 package 是一个思路——Monorepo 适合高耦合、统一发布的场景,微服务/多仓库适合独立团队、独立节奏的场景。"


2. Maven 是什么 & settings.xml 原理

时间:2026-04-20

问题背景

用户是前端工程师,首次接触 Java 后端项目,不了解 Maven 是什么、settings.xml 的作用,以及是否每个项目都需要找人要这个文件。

Maven 是什么

一句话:Maven 是 Java 生态的 npm,负责依赖管理 + 构建 + 打包。

底层原理

你的代码 import 了 com.hzecool.frm.xxx
         │
         ▼
Maven 查找顺序:
┌──────────────────────────────┐
│ 1. 本地缓存                   │  ~/.m2/repository/
│    有 → 直接用,不下载         │
└──────────┬───────────────────┘
           │ 没有
           ▼
┌──────────────────────────────┐
│ 2. 私有仓库(公司 Nexus)      │  settings.xml 里配的地址
│    有 → 下载到本地缓存         │
└──────────┬───────────────────┘
           │ 没有
           ▼
┌──────────────────────────────┐
│ 3. Maven 中央仓库             │  repo1.maven.org
│    有 → 下载到本地缓存         │
└──────────────────────────────┘

Maven 的核心概念:

  • groupId:相当于 npm 的 scope(@company),如 com.hzecool
  • artifactId:相当于包名,如 ec-slhbase-core
  • version:版本号,SNAPSHOT 相当于 beta/canary
  • repository:仓库,存放所有包的地方

~/.m2/settings.xml 是什么

一句话:Maven 的全局配置文件,相当于 npm 的 .npmrc,告诉 Maven 去哪里找包、用什么账号。

<!-- settings.xml 核心内容示意 -->
<settings>
  <servers>
    <!-- 私有仓库账号密码 → 相当于 npm login 的 token -->
    <server>
      <id>company-nexus</id>
      <username>yourname</username>
      <password>yourpassword</password>
    </server>
  </servers>

  <mirrors>
    <!-- 把所有请求镜像到公司仓库 → 相当于 .npmrc 里的 registry=xxx -->
    <mirror>
      <id>company-nexus</id>
      <url>http://nexus.company.com/repository/maven-public/</url>
      <mirrorOf>*</mirrorOf>
    </mirror>
  </mirrors>
</settings>

与 .npmrc 的类比

.npmrc settings.xml
registry=https://registry.npmmirror.com <mirror> 配置
//registry.npmjs.org/:_authToken=xxx <server> 里的账号密码
放在 ~/.npmrc 放在 ~/.m2/settings.xml
对所有 npm 项目生效 对所有 Maven 项目生效

是否每个项目都要找人要这个文件?

不需要每次要。关键在于 settings.xml 放在 ~/.m2/(用户主目录),是全局配置,对本机所有 Maven 项目生效

配置场景:

❌ 初级理解:每个项目都有自己的 settings.xml,换项目就要换
   (错误!settings.xml 是机器级别的,不是项目级别的)

⚠️ 中级理解:settings.xml 配一次就行,但不同公司的项目
   可能需要不同的仓库配置(两家公司都有私有仓库)

✅ 资深理解:
   - settings.xml 是机器级全局配置,配一次对所有项目生效
   - 但如果同时参与多个公司项目,需要在 settings.xml 里配多个 server/mirror
   - 部分公司 Nexus 是公网可访问的(只需账号),部分需要 VPN
   - CI/CD 环境(Jenkins/GitLab CI)会有自己的 settings.xml,和本地无关

没有 settings.xml 能直接跑吗?

取决于依赖是否都已经缓存在本地

场景 A:本机 ~/.m2/repository/ 里已有所有依赖缓存
        → 可以跑(即使没有 settings.xml)

场景 B:首次配环境,本机缓存是空的
        → 不行,Maven 找不到 com.hzecool.* 的包,报错:
          [ERROR] Could not resolve dependencies for project...
          [ERROR] Could not find artifact com.hzecool:xxx:jar:xxx

场景 C:只有公开依赖,没有公司私有包
        → 可以跑(Maven 中央仓库能找到所有依赖)

这个项目是场景 B:pom.xml 里有大量 com.hzecool.* 私有包,首次一定要 settings.xml。

实际操作建议

  1. 找同事要 ~/.m2/settings.xml → 放到 ~/.m2/settings.xml
  2. 用 IntelliJ IDEA 打开根目录 pom.xml
  3. IDEA 会自动触发 Maven 下载依赖(右下角会有进度条)
  4. 依赖下载完 → 配置 Tomcat → 跑 ec-slh2-server

面试话术

"Maven 是 Java 生态的包管理器和构建工具,核心是通过坐标(groupId + artifactId + version)唯一定位一个依赖,然后按本地缓存 → 私有仓库 → 中央仓库的顺序查找。settings.xml 相当于 .npmrc,是机器级全局配置,配一次对所有项目生效,主要配私有仓库地址和认证信息。实际工作中,只有加入新公司或新的私有仓库体系时才需要重新配置。"


思考题讨论记录

(待用户回答后补充)


3. IntelliJ IDEA 版本识别

时间:2026-04-20

问题背景

用户下载了 IntelliJ IDEA 但不确定是 Community 还是 Ultimate 版,也不知道两者区别。

一句话结论

About 弹窗 里的「内部版本号」前缀:IU = Ultimate,IC = Community。

怎么查

macOS 上:IntelliJ IDEA 菜单 → About IntelliJ IDEA

弹窗里找「内部版本号」(英文是 Build #):

内部版本号 #IU-261.22158.277    ← IU = IntelliJ Ultimate
内部版本号 #IC-261.22158.277    ← IC = IntelliJ Community

两个版本的差异

能力 Community Ultimate
Java 基础开发
Maven / Gradle 支持
调试器
Spring / Spring Boot 支持
Tomcat / WebLogic 集成
数据库工具(DataGrip 级)
HTTP Client、JavaScript、React
Docker / Kubernetes
价格 免费 付费(30 天试用)

对于 bizsspd 项目

这个项目是 Spring MVC + Tomcat,Ultimate 版体验最好

  • 右键 Controller 可以直接看到所有 URL 映射
  • Bean 依赖关系可视化
  • Tomcat 配置有 GUI 向导,不用手写 XML
  • 断点调试时能看到 Spring 上下文

Community 也能跑,但缺少上述辅助功能,开发体验会差一些。

付费状态判断

  • 官网下 Ultimate 且没付费 → 默认 30 天试用期
  • 首次打开会提示 Start Trial(试用)或 Buy License(购买)
  • 如果公司有统一 License,会在 IDE 内用公司邮箱登录激活

Maven 的正确读音

Maven 读作 「梅文」(MAY-ven),重音在第一个字节 MAY。 词源是希伯来语/意第绪语,意思是「专家、行家」。

面试话术

"我们团队用 IntelliJ IDEA Ultimate,因为做 Spring 项目时 Bean 依赖可视化、Tomcat 集成、HTTP Client 这些能力对效率提升很大。Community 版能满足纯 Java 开发,但做 Web 项目建议直接上 Ultimate,或者用 VSCode 的 Java 扩展作为备选。"

三层对比

❌ 初级做法:安装后随便用,不知道版本差异
   → 拿到 Community 装了 Tomcat 插件才发现不支持
   → 浪费安装时间

⚠️ 中级做法:知道 Ultimate 功能多,但不知道为什么付费 IDE 有市场
   → 觉得 VSCode 免费就够,为什么还有人付费

✅ 资深做法:理解"工具投资"的 ROI
   → 开发工具省下的时间 × 时薪 = 实际节省
   → Java 后端日常要看 Bean 依赖关系、SQL 执行、HTTP 请求
     每件事 Ultimate 比 Community 快 30 秒,一天节省 1 小时
   → 年薪 30 万的工程师,每年 ¥600 的 Ultimate 是 0.2% 的成本
     换来 5%+ 的效率提升 = 极高 ROI
   → 公司买 License 更是没理由不买

思考题讨论

Q1:为什么 Java 生态需要付费 IDE,而前端/Python 免费 IDE 就能用?

用户回答:不清楚生态差异的根源。

正确解答

三个生态差异

1. 语言和框架复杂度

Java/Spring:
  - 大量"约定优于配置" → 需要工具理解约定
  - 注解驱动(@Autowired、@Transactional)→ 运行时行为靠工具提示
  - XML + Java 配置并存 → 需要交叉分析
  → 没有好 IDE 简直不能写

Python/JS:
  - 动态语言,代码即文档
  - 框架相对简单(Flask、Express)
  - 配置少,约定少
  → 普通编辑器能应付

2. 开发者群体

Java:
  - 以企业开发为主(大公司、金融、电信)
  - 公司付费采购工具是惯例
  - 开发者不掏自己的钱

Python/JS:
  - 大量个人开发者、开源社区
  - 对价格敏感
  - "免费是基本盘"

3. 厂商策略

JetBrains:
  - 专注 IDE,就是靠卖工具赚钱
  - 做出不可替代的能力(Spring 整合、数据库、HTTP Client 等)

微软 VSCode:
  - 用"免费高质量 IDE"推云服务(Azure、GitHub Copilot)
  - 编辑器是获客入口,不赚钱

类比设计行业

Adobe(Photoshop、Illustrator)= JetBrains
  专业工具,靠卖 License 赚钱
  设计师觉得值

Figma / Canva = VSCode
  免费起步,靠订阅/企业版赚
  降维打击普通用户

本质:付费 IDE 的价值 = 特定生态的深度整合。Java 生态的复杂度就是 JetBrains 的护城河。

Q2:Community 和 Ultimate 代码库是分开的吗?

正确解答

实际上 Ultimate = Community + 闭源插件。IntelliJ 平台的核心是开源的:

IntelliJ Platform(开源,LGPL 协议)
   ├── 代码编辑器
   ├── VCS 集成
   ├── 调试器
   ├── 基础 Java 支持
   └── 被用于构建:
         ├── IntelliJ IDEA Community(开源)
         ├── Android Studio(Google 基于此做)
         ├── PyCharm Community
         └── IntelliJ IDEA Ultimate(加闭源商业插件)

Ultimate 的商业插件

  • Spring Framework 深度支持
  • Database Tools
  • HTTP Client
  • JavaScript/TypeScript 高级特性
  • Docker / Kubernetes 集成
  • 企业级框架支持(JPA、Hibernate、Struts)

所以:Android Studio 是免费的(Google 出),但做 Spring 开发必须 Ultimate 或其他支持 Spring 的 IDE。这种"基础免费 + 专业付费"的模式在工具软件里很常见。

面试话术:"IDE 选型我看两个维度:生态深度整合 + 团队一致性。Java 后端做 Spring 开发我强烈推荐 Ultimate,因为 Bean 依赖可视化、SQL 智能提示、HTTP Client 这些能力对日常开发效率提升明显。虽然一年 ¥600,但对照年薪的 0.2% 成本换 5%+ 效率提升,是典型高 ROI。公司采购更是没理由不买。个人如果不做 Web 项目,Community 或 VSCode + Java 扩展也够。选工具不要看'能不能用',要看'上限有多高'——好工具让资深工程师发挥出水平,差工具让所有人拉平在低水位。"


4. settings.xml 实战配置过程与内容解读

时间:2026-04-20

实战操作流程

完整操作路径(同事给你的是 /Users/mac/Downloads/settings.xml):

# 1. 确认 ~/.m2 目录存在(Maven 安装后自动会有)
ls ~/.m2/

# 2. 拷贝配置文件到全局位置
cp /Users/mac/Downloads/settings.xml ~/.m2/settings.xml

# 3. 验证内容
cat ~/.m2/settings.xml

怎么跟新人同事描述

对方也是后端新人,直接说:

"把你的 ~/.m2/settings.xml 这个文件发我一份。在终端运行 open ~/.m2 就能打开目录,找到 settings.xml 拖过来就行。"

对方操作:Finder 里找到文件 → 拖到微信/钉钉发过来。

本项目 settings.xml 内容解读

实际拿到的配置文件如下(关键部分):

<settings>
  <!-- 本地仓库位置:Maven 下载的所有 jar 包缓存在这 -->
  <localRepository>/Users/mac/.m2/repository</localRepository>

  <servers>
    <!-- 私有仓库的账号密码(下载/上传都用同一套) -->
    <server>
      <id>Releases</id>           <!-- 正式版仓库 ID -->
      <username>deployment</username>
      <password>dlsoft</password>
    </server>
    <server>
      <id>Snapshots</id>          <!-- 快照版仓库 ID -->
      <username>deployment</username>
      <password>dlsoft</password>
    </server>
  </servers>

  <mirrors>
    <!-- 镜像:把所有仓库请求都转发到公司 Nexus -->
    <mirror>
      <id>all</id>
      <mirrorOf>*,!aliyun</mirrorOf>
      <!--  *      = 所有仓库都走这个镜像
            !aliyun = 但 aliyun 这个 id 的仓库除外 -->
      <url>http://svn.hzdlsoft.com:7081/nexus/content/groups/public/</url>
    </mirror>
  </mirrors>

  <profiles>
    <!-- 激活配置:让 profile 生效 -->
  </profiles>
</settings>

三个核心概念拆解

1. <localRepository> - 本地缓存目录

对应前端:node_modules/(但是是机器级的,不是项目级的)。

设计差异思考

  • npm 把依赖装在项目里 → 每个项目独立、可以用不同版本、但占磁盘
  • Maven 把依赖装在全局 → 所有项目共享、占磁盘少、但版本冲突时要小心

2. <servers> - 认证信息

这里配的是「下载/上传私有包时的账号密码」。本项目账号是 deployment/dlsoft,是团队内部共享账号(正式环境会给每个开发者独立账号 + token)。

安全性思考

  • ❌ 初级做法:明文密码写在 settings.xml
  • ⚠️ 中级做法:用 Maven --encrypt-password 加密
  • ✅ 资深做法:CI/CD 环境用 secret 注入,本地开发用只读账号,生产发布用单独的受限账号

3. <mirrors> - 镜像配置

<mirrorOf>*,!aliyun</mirrorOf> 这行的含义:

*       = 所有仓库请求都转发到这个镜像
!aliyun = 排除 id="aliyun" 的仓库(让它走自己的地址)

为什么要设镜像

  1. 加速:公司内网仓库比公网快
  2. 稳定:公网仓库偶尔抽风(Maven Central 访问慢/挂)
  3. 聚合:公司 Nexus 可以把「公司私有包 + 中央仓库代理」合在一起,客户端只配一个地址就全搞定

仓库地址结构分析

http://svn.hzdlsoft.com:7081/nexus/content/groups/public/
                        ↑         ↑              ↑
                     端口        产品          仓库组名
  • svn.hzdlsoft.com:7081 - 公司内网 Nexus 服务器
  • nexus/content/groups/public/ - Nexus 的「仓库组」概念,public 通常聚合了:
    • 公司私有 release 仓库
    • 公司私有 snapshot 仓库
    • 中央仓库的代理

三层对比:不同公司的仓库配置方式

❌ 初级:每个开发者独立账号,settings.xml 各不相同
   问题:新人入职要手动开账号,流程慢

⚠️ 中级:团队共享一个只读账号(本项目就是这种)
   问题:账号密码泄露风险大,离职员工权限无法回收

✅ 资深:
   - 本地开发:团队共享只读账号 或 SSO + token
   - 发布上传:个人账号 + 最小权限
   - CI/CD:环境变量注入,不进代码库
   - 定期轮换 token,开启审计日志

没有 settings.xml 会怎样

mvn clean install

[ERROR] Failed to execute goal on project ec-slh2-server:
  Could not resolve dependencies
[ERROR] Could not find artifact com.hzecool.frm:ec-frm-core:jar:x.x.x
  in central (https://repo.maven.apache.org/maven2)

原因:默认只会去 Maven Central 找,而 com.hzecool.* 只在公司 Nexus 里有。

有了 settings.xml 之后

IDEA 打开 pom.xml → 右下角自动触发依赖解析 → Maven 按以下顺序执行:

1. 看 ~/.m2/repository/ 里有没有      → 没有
2. 看 settings.xml 里配了 mirror      → 有
3. 请求 http://svn.hzdlsoft.com:7081/ → 带 deployment/dlsoft 认证
4. 下载 com.hzecool.frm.ec-frm-core.jar → 存到 ~/.m2/repository/
5. 下次直接用本地缓存,不再下载

常见问题排查

现象 可能原因
Unknown host svn.hzdlsoft.com 没连公司 VPN / 内网
401 Unauthorized 账号密码错/过期
Could not find artifact 但仓库正常 依赖版本号在 pom.xml 里写错
下载极慢 公司 Nexus 负载高 或 需要 VPN

面试话术

"Maven 的 settings.xml 是机器级全局配置,主要配三样东西:本地缓存路径、私有仓库认证、镜像转发。生产实践中我们团队用 Nexus 做聚合仓库,客户端配一个 mirror 就能同时拿到公司私有包和中央仓库代理。安全上建议账号按角色拆分——只读账号给本地开发,写账号只给 CI/CD,避免凭证泄露风险。"

延伸讨论(资深对话)

Q:为什么不用 pom.xml 直接配 <repositories>,非要搞个 settings.xml?

A:职责分离。

  • pom.xml 属于项目,会进 Git → 不能放账号密码
  • settings.xml 属于机器 → 放凭证,不入库
  • 团队规范:pom.xml 里只写仓库 id,真正的 URL + 认证都在 settings.xml 里

Q:~/.m2/repository 越来越大怎么办?

A:

  • 短期:mvn dependency:purge-local-repository 清理
  • 长期:定期删掉不用的 groupId 目录(能省几个 G)
  • 根治:上 Nexus 本地缓存代理,或者用 Maven Wrapper 隔离

Q:npm 和 Maven 的缓存设计哪个更好?

A:各有取舍。

  • npm(项目级):隔离性好、版本灵活、适合快速迭代;代价是磁盘浪费
  • Maven(全局级):共享高效、适合单体/多模块;代价是项目间互相影响
  • 现代方案如 pnpm 借鉴了两者优点:全局缓存 + 项目级硬链接

5. 后端基础概念扫盲

时间:2026-04-20

问题背景

用户作为前端开发进入 Java 后端项目,对以下概念需要建立心智模型:JDK / JRE / JVM、WAR 包、容器、依赖注入、分层架构、RPC、注解。

一张表看懂后端核心名词

名词 一句话解释 前端对应
JDK Java 开发工具包(含编译器) Node.js + npm
JRE Java 运行环境(只能运行,不能编译) 仅运行 node 不含 npm 的精简版
JVM Java 虚拟机(字节码解释器) V8 引擎
字节码 .class Java 源码编译后的中间产物 webpack bundle
JAR 一堆 .class 打包 npm 包的 tgz
WAR Web 应用的 JAR(含前端资源) Docker 镜像(粗略类比)
Tomcat 运行 WAR 的容器 Node.js 运行时
Spring 后端大一统框架 React 生态(React + Router + Redux 的合体)
Spring MVC Spring 的 Web 部分 Express / Next.js
Spring Boot 开箱即用的 Spring create-react-app / Vite 模板
Dubbo 阿里的 RPC 框架 tRPC(后端之间互相调用)
Mapper / DAO 数据库访问层 prisma / typeorm
MyBatis ORM 框架 Prisma
注解 @ 开头的元数据 TypeScript 装饰器 @Component
POJO 普通 Java 类(一堆字段 + get/set) TS interface 对应的数据对象
Bean Spring 管理的对象实例 React Context 提供的单例

关键类比:Java 字节码的本质

你写的 .java 源码
        │ javac 编译
        ▼
    .class 字节码(运行在 JVM 上)
        │ JVM 解释执行
        ▼
     操作系统

和前端的本质差异

  • JS → 浏览器/Node 解析器:代码是字符串,运行时解析
  • Java → JVM:代码预编译成字节码,运行时加载执行

好处:性能好、类型安全(编译期报错) 坏处:迭代慢(改代码 → 编译 → 重启 → 测试,不像 HMR 秒刷)

面试话术

"Java 生态的运行模型是『源码 → 字节码 → JVM 解释执行』三段式,JVM 屏蔽了操作系统差异,这也是『一次编写到处运行』的原理。相比 V8 直接解释 JS,JVM 多了字节码这一层,代价是开发体验慢,好处是性能和类型安全。"

深度:JVM 的真实工作机制

"JVM 解释执行字节码"只是入门级说法。真实的 JVM 有多层性能优化:

.class 字节码加载
    │
    ▼
┌──────────────────────────────────────┐
│  阶段 1:解释执行(Interpreter)       │
│  逐条解释字节码 → 慢                   │
│  好处:启动快                          │
└──────────────────────────────────────┘
    │ 运行中统计"热点方法"
    ▼
┌──────────────────────────────────────┐
│  阶段 2:JIT 编译(C1 编译器)         │
│  热点方法 → 编译成机器码 → 快            │
│  轻量优化,编译快                        │
└──────────────────────────────────────┘
    │ 持续运行,发现更热的点
    ▼
┌──────────────────────────────────────┐
│  阶段 3:JIT 编译(C2 编译器)         │
│  超热点 → 深度优化 → 极快                │
│  重量优化:内联、逃逸分析、向量化          │
└──────────────────────────────────────┘

关键认知

  • JVM 不是"只解释执行" → 它是"先解释,再 JIT 编译热点"
  • 跑得越久,越多代码被编译 → 性能越好(这叫"预热 warmup")
  • 这就是为什么 Java 应用长期运行的稳态性能 ≈ C++ 的 80-95%

对比 V8:V8 也有类似的分层编译(Ignition 解释器 + TurboFan JIT),思路完全一致。这不是 Java 独有——所有主流动态语言运行时都在做同样的事。

深度:内存管理差异

前端很少关心内存(浏览器 Tab 关了就释放),但服务端是长驻进程:

前端 / Node:
  - GC 偶尔跑一次,STW(Stop The World)几毫秒
  - 内存泄漏相对容易暴露(Tab 变慢)

Java 服务端:
  - GC 是核心性能指标
  - 多代 GC 分配(新生代 Eden → Survivor → 老年代)
  - 选 GC 算法就是工程决策:
    · G1 GC:吞吐和延迟平衡(默认)
    · ZGC:超低延迟(<10ms),适合高 SLA 应用
    · Shenandoah:RedHat 的低延迟 GC
    · Epsilon:无 GC(测试用,不适合生产)
  - OOM 要分析堆转储(heap dump)

资深 Java 工程师的一个标志:能调 JVM 参数(-Xms-Xmx-XX:+UseG1GC)、能看 GC log、能用 jstat/jmap/jstack 排查问题。前端没有对应概念,但认知上要知道"服务端的内存调优是一门独立学问"。

思考题讨论

Q1:为什么 Java 要搞"字节码 + JVM"这一层,不像 C++ 直接编译成机器码?

正确解答

三大核心收益

1. 跨平台(Write Once, Run Anywhere)
   C++:Windows 版和 Mac 版要分别编译
   Java:一份 .class 字节码跑在所有平台的 JVM 上
   → 企业级应用的巨大优势(IBM 主机、AIX、Linux、Windows 全支持)

2. 安全性
   字节码有校验步骤 → JVM 拒绝执行恶意或损坏的字节码
   类加载器沙箱 → 隔离不同来源的代码
   → Java Applet 时代的核心卖点

3. 运行时优化
   C++ 编译时就固定优化策略
   JVM 运行时能根据实际数据做更好的优化(如分支预测、内联决策)
   → 这就是为什么 Java 长时间运行能接近 C++ 性能

代价:启动慢 + 内存占用高。这对服务端不是问题(启动一次跑很久),对移动端是大问题(Android 早期性能差就是 Dalvik/ART 启动开销)。

Q2:注解(@Autowired、@Transactional)是怎么工作的?不就是一行注释吗?

用户盲区:把注解当成纯标记,不知道运行时有魔法。

正确解答

注解 ≠ 注释。注释被编译器丢弃,注解编译到字节码里并带到运行时。Spring 在运行时通过**反射(reflection)**读取注解并做对应的事:

// 你写的代码
@Service
public class UserService {
  @Autowired
  private UserDao userDao;
}

// Spring 启动时做的事(简化)
1. 扫描所有 .class 文件
2. 看到 @Service 注解 → 注册为 Bean
3. 看到 @Autowired 字段 → 从容器里找匹配的 Bean 注入进去
4. 通过反射设置 field 的值:
   field.setAccessible(true)
   field.set(userServiceInstance, userDaoBean)

前端类比:TypeScript 的装饰器 @Component 做的事一模一样。

@Component({ selector: 'app-root' })
class AppComponent { }

// Angular 运行时做的事:
// 1. 读取 @Component 元数据
// 2. 把类注册到 Angular 的依赖注入容器
// 3. 解析 selector 并注册到路由

本质:注解是一种"声明式编程"——你写意图(这个类是 Service),框架负责实现。这是 Spring 生态的核心范式,理解了这一点,整个 Spring 就不神秘了。

Q3:WAR 和 JAR 本质都是 zip,为什么分成两种?

正确解答

JAR:
  - 一堆 .class + 资源 + MANIFEST.MF
  - 纯 Java 代码的打包单位
  - 通过 java -jar xxx.jar 运行(需要 Main-Class)

WAR:
  - JAR 的超集 + Web 应用结构
  - 包含:
    WEB-INF/web.xml(Web 配置)
    WEB-INF/classes/(你的 .class)
    WEB-INF/lib/(依赖的 .jar)
    html/css/js(静态资源)
  - 不能独立运行,必须部署到 Servlet 容器(Tomcat)

区别在于 *声明的角色*:
  JAR 说:"我是一段可复用的代码"
  WAR 说:"我是一个完整的 Web 应用,跑起来需要 HTTP 服务器"

新趋势:Spring Boot 把 Tomcat 嵌入到 JAR 里(叫"Fat JAR"),用 java -jar xxx.jar 就能启动 Web 服务——不再需要单独的 Tomcat。

传统 WAR 模式:
  先装 Tomcat → 扔 .war 进去 → 启动 Tomcat
  → 多个 WAR 可以共享一个 Tomcat(节省内存)
  → 但部署复杂

Fat JAR 模式(Spring Boot 默认):
  一个 .jar 包含代码 + 依赖 + 内嵌 Tomcat
  java -jar xxx.jar 就跑
  → 部署极简(容器化友好)
  → 但每个服务独立进程(内存用量高)

现在大多数项目用 Fat JAR + Docker 部署
本项目用 WAR 属于"老派"做法(Spring MVC 时代)

面试话术:"Java 服务端的打包演进反映了部署模式的变迁——早期 WAR + 外部 Tomcat,适合'多应用共享服务器'的传统机房模式;现代 Spring Boot 的 Fat JAR + 内嵌服务器,适合'一应用一进程 + 容器化'的云原生模式。本项目用 WAR 是因为建设时是 Spring MVC 时代(2015 年左右),当时 Docker 还没普及。如果现在重写,大概率会选 Spring Boot + Docker。这让我理解一个道理:技术选型不只看技术本身,还要看时代背景的部署模式——你不能用 2025 年的云原生思维批评 2015 年的架构决策。"


6. Tomcat 是什么 & Java Web 运行时模型

时间:2026-04-20

一句话结论

Tomcat 是一个「运行 Java Web 应用的服务器程序」,作用相当于 Node.js。你写的代码打成 WAR 包,放进 Tomcat,Tomcat 负责监听端口、接收 HTTP 请求、转给你的代码处理。

运行时模型

┌─────────────────────────────────────┐
│            Tomcat 进程                │
│   (监听 8080 端口,接收 HTTP 请求)   │
│                                     │
│  ┌───────────────────────────────┐  │
│  │   你的 WAR 包(webapp)         │  │
│  │                               │  │
│  │  ┌─────────────────────────┐  │  │
│  │  │  Spring MVC 框架         │  │  │
│  │  │  (解析 @RequestMapping)│  │  │
│  │  └──────────┬──────────────┘  │  │
│  │             │ 路由分发          │  │
│  │             ▼                  │  │
│  │  ┌─────────────────────────┐  │  │
│  │  │  你的 Controller 代码    │  │  │
│  │  └─────────────────────────┘  │  │
│  └───────────────────────────────┘  │
└─────────────────────────────────────┘

与 Node.js 的对比

维度 Node.js (Express) Tomcat (Spring MVC)
启动方式 node server.js ./catalina.sh start 或 IDEA 一键
HTTP 服务器 内置 http 模块 Tomcat 自带
线程模型 单线程事件循环 多线程(默认 200 个工作线程)
部署单位 整个 Node 项目 WAR 包(可以一个 Tomcat 跑多个 WAR)
修改代码 nodemon 重启 改代码要重启 Tomcat(慢)
内存模型 V8 堆 JVM 堆(可配置,一般 2G+)

为什么叫「容器」

Tomcat 是 Servlet 容器——「容器」这个词在后端指「负责管理其他对象生命周期的程序」。

  • Tomcat 容器:管理 Servlet(你的 Web 代码)的创建、销毁
  • Spring 容器:管理 Bean(你的业务对象)的创建、销毁
  • Docker 容器:管理进程的运行环境

三者是不同层级的"管理者",不要混淆。

Spring Boot 内嵌 Tomcat 的进化

现代项目(Spring Boot)把 Tomcat「内嵌」进 JAR 包,不再需要单独安装 Tomcat:

老模式(本项目 bizsspd):
  装 Tomcat → 把 WAR 放进 webapps 目录 → 启动 Tomcat

新模式(Spring Boot):
  直接 java -jar app.jar  (JAR 内嵌 Tomcat)

相当于前端从「写代码 + 单独装 Apache」进化到「写 Next.js npm run start 一条命令」

面试话术

"Tomcat 是 Servlet 容器,承担 HTTP 服务和 Web 应用生命周期管理的职责。传统模式是独立安装 Tomcat 再部署 WAR 包,现在主流的 Spring Boot 把 Tomcat 内嵌到 JAR 里,简化了部署。但老系统还是 WAR 模式,所以理解容器模型对维护老项目很重要。"

原理深挖:Servlet 到底是什么

前端写 Express 时:

app.get('/api/users', (req, res) => {
  res.json({ users: [...] })
})

Java 的等价形式(原始 Servlet API):

@WebServlet("/api/users")
public class UserServlet extends HttpServlet {
  protected void doGet(HttpServletRequest req, HttpServletResponse res) {
    res.getWriter().write("{\"users\": [...]}");
  }
}

Servlet 的本质

  • 一个接口规范,定义 Web 应用该如何被容器调用
  • doGet / doPost 方法 = 处理 HTTP 请求的入口
  • Tomcat 负责:接 HTTP 连接 → 解析请求 → 调用你的 Servlet → 发回响应

Spring MVC 是 Servlet API 之上的封装

原生 Servlet:
  @WebServlet + doGet/doPost + 手动解析参数 → 繁琐

Spring MVC:
  @Controller + @GetMapping + @PathVariable → 声明式
  → 底层还是 Servlet,只是一个叫 DispatcherServlet 的"总路由器"
  → DispatcherServlet 接收所有请求后,按 @GetMapping 路由到你的方法

类比:Express 本质是 Node 原生 http.createServer() 的封装;Spring MVC 本质是 HttpServlet 的封装。

三层对比

❌ 初级做法:只会 @RestController @GetMapping,不知道底层
   → 遇到奇怪的 404 或者过滤器不生效时不知道排查
   → "怎么 Tomcat 就能识别我的注解?"答不上来

⚠️ 中级做法:知道 DispatcherServlet,但不理解 Filter/Interceptor 区别
   → 鉴权放哪都能工作,但不知道 Filter/Interceptor/AOP 的适用场景

✅ 资深做法:理解完整请求链路
   Tomcat → Filter Chain → DispatcherServlet → Interceptor → Controller → AOP → Service
   → 每一层的职责和适用场景
   → 能在 log 中定位请求在哪里被拦截/转发

请求完整链路(不只是 Controller)

HTTP 请求 (Tomcat 8080 端口)
   │
   ▼
┌─────────────────────────────────────┐
│  Tomcat Connector                    │  ← NIO/APR 连接器处理 TCP
│  (解析 HTTP 报文)                    │
└─────────────┬───────────────────────┘
              │ HttpServletRequest
              ▼
┌─────────────────────────────────────┐
│  Filter Chain                        │  ← Servlet 规范级别的过滤器
│  (跨框架,Spring 之外的层)            │
│                                      │
│  - CORS Filter                       │
│  - CharacterEncoding Filter          │
│  - Spring Security Filter Chain      │
└─────────────┬───────────────────────┘
              │
              ▼
┌─────────────────────────────────────┐
│  DispatcherServlet                   │  ← Spring MVC 入口
│  (路由 + 参数绑定 + 返回值处理)       │
└─────────────┬───────────────────────┘
              │
              ▼
┌─────────────────────────────────────┐
│  HandlerInterceptor                  │  ← Spring MVC 拦截器
│  (请求前/后拦截,有 Handler 上下文)   │
└─────────────┬───────────────────────┘
              │
              ▼
┌─────────────────────────────────────┐
│  @ControllerAdvice(异常处理)       │
│  @Controller                         │
│    └─ @RequestMapping 方法          │  ← 你的业务代码
└─────────────┬───────────────────────┘
              │ (可能嵌套 @Transactional / AOP 切面)
              ▼
           Service → DAO → DB

Filter vs Interceptor 的关键区别

维度 Filter Interceptor
规范 Servlet 标准 Spring MVC 特有
生效时机 DispatcherServlet 之前 进入 Controller 前后
能访问的上下文 HttpServletRequest/Response 加上 HandlerMethod(知道调用哪个方法)
典型用途 CORS、字符编码、安全头 鉴权(需要知道 @PreAuthorize)、日志(知道方法签名)

性能思维:Tomcat 连接器模型

Tomcat 有三种 Connector 模式:

BIO(Blocking IO):
  每个连接一个线程
  → 最大连接数 = 线程数 = 几百
  → 老版本默认,已淘汰

NIO(Non-blocking IO):
  多路复用(类似 Node 的 epoll)
  → 少量 IO 线程处理成千上万连接
  → Tomcat 8+ 默认

APR(Apache Portable Runtime):
  用 native C 库(OpenSSL)
  → HTTPS 性能最好
  → 需要单独装库

本项目(老 Tomcat)可能是 BIO,高并发场景会有连接数瓶颈。新项目默认 NIO,不用操心。

思考题讨论

Q1:为什么叫"容器"?Spring 容器、Tomcat 容器、Docker 容器有什么共性?

正确解答

"容器(Container)"这个词在软件工程里泛指"管理其他对象/进程生命周期的程序":

共性:都解决了"如何管理一堆东西"的问题

Spring 容器(IoC Container):
  管理对象的:
  - 创建(什么时候 new)
  - 销毁(什么时候 GC)
  - 依赖关系(A 需要 B 自动注入)
  
Tomcat 容器(Servlet Container):
  管理 Servlet 的:
  - 初始化(init 方法)
  - 销毁(destroy 方法)
  - 线程池调度

Docker 容器:
  管理进程的:
  - 文件系统隔离
  - 网络隔离
  - 资源限制

共同模式:**控制反转(Inversion of Control)**
  你不主动创建/销毁 → 容器替你做 → 你专注业务

前端类比:React 的组件树也是一种容器——它管理组件的挂载、更新、卸载生命周期。你写组件逻辑,React 负责生命周期。

Q2:Spring Boot 把 Tomcat 内嵌后,Tomcat 还"重要"吗?

正确解答

Tomcat 作为独立软件变得不重要,但作为 library 依然核心

演进前:
  应用 → 部署到独立 Tomcat
  Tomcat 是一个"服务器"(有自己的进程、配置、管理台)
  
演进后:
  应用 Fat JAR 内嵌 Tomcat(作为 library)
  Tomcat 变成应用的一个依赖库
  没有独立的 Tomcat 进程了

但 Tomcat 的代码依然在跑:
  - 处理 HTTP 连接
  - 管理线程池
  - Servlet 规范的实现
  
只是从"外部服务"变成"内嵌库"

现代替代方案

Spring Boot 内嵌三个 server 可选:
  - Tomcat(默认)
  - Jetty(轻量,启动快)
  - Undertow(性能最好,异步友好)

选型考虑:
  Tomcat:生态成熟,适合传统应用
  Jetty:嵌入式场景(比如 devtools)
  Undertow:高并发 API 网关

本项目用传统 Tomcat + WAR 模式,这是 2015 年左右的标准。现在新项目几乎都用 Spring Boot + 内嵌 server。

面试话术:"Tomcat 的核心是 Servlet 容器——负责 HTTP 连接管理、Servlet 生命周期、线程池调度。Spring MVC 基于 Servlet 之上,用 DispatcherServlet 做总路由 + 注解驱动的 Controller。理解这个分层的好处是:遇到 Filter 不触发、Interceptor 拿不到参数、CORS 报错这类问题能快速定位到是哪一层的职责。现在 Spring Boot 把 Tomcat 内嵌,从"独立服务器"降级为"应用内的 library"——这是云原生带来的部署模式变化,但代码链路还是一样的。"


7. Spring MVC 三层架构

时间:2026-04-20

核心概念

Spring MVC 项目的代码按三层组织:Controller → Service → DAO。这是后端最经典的分层模式。

请求流程图

浏览器 GET /invStockDay/xxx?unitId=1
          │
          ▼
┌──────────────────────────────────┐
│  Controller 层                    │
│  ───────────                      │
│  职责:HTTP 请求解析、参数校验     │
│  位置:*Controller.java           │
│  注解:@RestController            │
│  类比前端:app/api/route.ts       │
└──────────┬───────────────────────┘
           │ 方法调用
           ▼
┌──────────────────────────────────┐
│  Service 层                       │
│  ─────────                        │
│  职责:业务逻辑、事务管理、编排    │
│  位置:*Service.java / *Impl.java │
│  注解:@Service                   │
│  类比前端:业务 hooks / use cases  │
└──────────┬───────────────────────┘
           │ 方法调用
           ▼
┌──────────────────────────────────┐
│  DAO / Mapper 层                  │
│  ────────────                     │
│  职责:数据库 CRUD                 │
│  位置:*Mapper.java / *Dao.java   │
│  注解:@Mapper / @Repository      │
│  类比前端:api.ts 里的 fetch 封装  │
└──────────┬───────────────────────┘
           │ SQL
           ▼
        MySQL

真实代码示例(InvStockDayController)

// ============ Controller 层 ============
@RestController                              // 标记为 REST 接口处理器
@RequestMapping("/invStockDay")              // 类级路由前缀
public class InvStockDayController {

    @Resource                                // 依赖注入
    private InvStockDayService invStockDayService;

    @RequestMapping("/initTodayInvStockDay") // 方法级路由
    public String initTodayInvStockDay(
        @RequestParam("unitId") Long unitId, // URL 参数
        @RequestParam("invId") Long invId) {

        UserContext.mockContext(unitId);     // 设置用户上下文(类比 ThreadLocal)
        invStockDayService.initTodayStockDay(unitId, invId);  // 调 Service
        return Constant.SUCCESS_RESULT;      // 返回字符串给前端
    }
}

完整 URL = 类前缀 + 方法路径 = /invStockDay/initTodayInvStockDay (此项目还会加 .do 后缀,见 web.xml 中 *.do 过滤器映射)

为什么分三层

❌ 初级做法:所有逻辑写在 Controller
   问题:HTTP 处理 + 业务 + SQL 全堆一起,不能复用、不能测试

⚠️ 中级做法:业务写在 Service,但 Service 里掺 HTTP 逻辑
   问题:Service 不能被其他非 HTTP 入口复用(比如定时任务、MQ 消费者)

✅ 资深做法:严格分层
   - Controller 只做 HTTP 协议转换(参数解析、状态码、JSON 序列化)
   - Service 是纯业务逻辑,不感知自己被谁调用
   - DAO 只做数据访问,不含业务规则
   好处:同一个 Service 可以被 HTTP、Dubbo、MQ、定时任务共用

与前端对比

前端其实也有分层,只是名字不同:

前端                        后端
─────────────────────────────────────────────
<UserPage />               @RestController UserController
 ├ useUser()   hook         ├ UserService
 │  └ api.getUser()         │    └ UserMapper
 └ DOM 渲染                 └ 返回 JSON

找接口的标准套路

1. 拿到一个 URL,比如 /invStockDay/reCalcInvStockDay.do
2. IDEA ⌘+Shift+F 搜 "reCalcInvStockDay"
3. 定位到 Controller 方法
4. ⌘+B 点击 service.reCalcStockDay() 跳到 Service
5. ⌘+B 继续跳到 DAO,看到实际 SQL

面试话术

"Spring MVC 的经典三层架构是 Controller-Service-DAO。Controller 只负责 HTTP 协议解析,Service 是业务核心,DAO 封装数据访问。严格分层的好处是 Service 可以被多种入口复用——HTTP、Dubbo RPC、定时任务、MQ 消费者,而且单元测试容易,因为每层职责单一。"


8. 依赖注入(DI)原理

时间:2026-04-20

核心问题

Controller 里这样写:

@Resource
private InvStockDayService invStockDayService;  // 这里没 new,为什么能用?

前端的直觉是「没有 new 就没有实例」,但 Spring 里不是。

原理

Spring 是个「对象管理容器」,它做了两件事:

1. 项目启动时扫描所有 @Service / @Controller / @Repository 注解的类
2. 自动 new 出来并放进一个大 Map:
   {
     "invStockDayService": InvStockDayService 实例,
     "userService":        UserService 实例,
     ...
   }
3. 扫到 @Resource 注解的字段,从 Map 里找一个塞进去

相当于 Spring 替你做了:

// 你没写,但 Spring 替你做的:
this.invStockDayService = ApplicationContext.getBean("invStockDayService");

为什么要这样

❌ 手动 new 的问题:
   InvStockDayService service = new InvStockDayServiceImpl();
   → 硬编码依赖、测试时无法替换、循环依赖难处理

✅ 依赖注入的好处:
   - 依赖关系由配置决定,不写死在代码
   - 单元测试可以注入 Mock 实现
   - 单例管理:整个应用只有一个 UserService 实例(省内存)
   - 解决循环依赖(Spring 帮你处理)

与前端对比

前端 后端
const service = useContext(ServiceContext) @Resource private XxxService service;
React Context Provider 提供实例 Spring 容器提供实例
手动在顶层 <Provider value={...}> Spring 自动扫描 @Service 自动注册

更准确的类比:TypeScript 的装饰器 + reflect-metadata,例如 NestJS 的:

// NestJS(借鉴了 Spring)
@Injectable()
class UserService {}

@Controller('/user')
class UserController {
  constructor(private userService: UserService) {}  // 自动注入
}

NestJS 就是前端版的 Spring,作者明说了就是照搬 Spring 的思想。 如果你理解 NestJS,就秒懂 Spring。

三种注入方式(进阶)

// ❌ 字段注入(本项目用的这种,老代码常见)
@Resource
private UserService userService;
// 缺点:无法 final 修饰、测试时不能用构造器注入

// ⚠️ Setter 注入
private UserService userService;
@Resource
public void setUserService(UserService us) { this.userService = us; }

// ✅ 构造器注入(现代推荐)
private final UserService userService;
public UserController(UserService userService) {
    this.userService = userService;
}
// 优点:字段可以 final、依赖显式、无法构造就说明依赖缺失

Spring 官方从 Spring 4.3 开始推荐构造器注入,但老项目改造成本高,依然大量用字段注入。

面试话术

"依赖注入(DI)是 Spring 的核心。启动时扫描所有带 @Service 注解的类,自动实例化并放入 IoC 容器,再根据 @Resource/@Autowired 自动装配到需要的地方。这样业务代码不用写 new,依赖关系可配置、可替换,单元测试容易。NestJS 完全借鉴了这套思想,前端开发者理解起来不难。"

延伸讨论

Q:Spring 怎么解决循环依赖? A:Spring 容器用「三级缓存」:singletonObjects(成品)、earlySingletonObjects(半成品)、singletonFactories(工厂)。循环依赖时先暴露半成品的引用,等两边都构造完再填充。但只对单例 Bean + 字段/Setter 注入有效,构造器循环依赖会直接报错。

Q:@Resource 和 @Autowired 的区别? A:

  • @Autowired 是 Spring 的,默认按类型注入
  • @Resource 是 Java 标准(JSR-250),默认按名字注入
  • 本项目用 @Resource 更多,历史选择,无优劣

9. 前端切入后端项目的三步路径

时间:2026-04-20

问题背景

前端开发接到后端维护任务:查接口、改 bug、加接口。如何不被海量代码淹没,快速上手?

三种真实场景

作为前端进入后端代码库,99% 的工作是这三种之一:

场景 A(最常见):查接口参数/返回结构
场景 B(次常见):接口报错排查
场景 C(偶尔):  新增/修改接口

通用三步法

第一步:从 URL 找到 Controller

前端给你一个 URL,比如 /invStockDay/reCalcInvStockDay.do

1. IDEA 按 ⌘ + Shift + F 全局搜索
2. 搜方法名:"reCalcInvStockDay"  (最准)
   或搜 URL 前缀:"/invStockDay"   (找到整个 Controller)
3. 定位到 @RequestMapping 注解

第二步:沿着调用链追下去

Controller 方法
   │ 光标放在 service.xxx() 上,按 ⌘ + B
   ▼
Service 方法(真正业务逻辑)
   │ 继续 ⌘ + B
   ▼
Mapper 方法 / SQL(数据从哪来)

第三步:修改 → 重启 → 验证

1. 改代码
2. 重启 Tomcat(IDEA 绿色重启按钮)
3. 用 Postman / curl 发请求
4. 看响应 + 看日志
5. 必要时打断点调试

IDEA 必备快捷键(Mac)

操作 快捷键 前端 VSCode 对应
全局搜文件 ⌘ + Shift + O ⌘ + P
全局搜文本 ⌘ + Shift + F ⌘ + Shift + F
全局搜类 ⌘ + O ⌘ + T
跳转定义 ⌘ + B⌘ + 点击 F12
查看调用方(谁调用了我) ⌥ + F7 Find All References
返回上一位置 ⌘ + [ Ctrl + -
前进下一位置 ⌘ + ] -
重命名(安全) Shift + F6 F2
运行/调试 ⌃ + R / ⌃ + D -
打断点 点击行号左侧 同 VSCode

场景 A:查接口参数/返回结构(只读)

目标:前端问「这个接口要传什么?返回啥?」

1. 搜 Controller 方法
2. 看方法签名:
   public XxxResult myApi(@RequestParam Long unitId, @RequestBody XxxRequest req)
3. 参数:unitId 是 URL 参数,req 是 JSON body
4. 返回:XxxResult → ⌘+B 进去看字段结构
5. 告诉前端

心智:Controller 方法签名 = 接口文档。

场景 B:接口报错排查

目标:前端说「接口报 500,帮我看看」。

1. 拿到报错时间 + 请求参数
2. 看 Tomcat 日志(IDEA 控制台 或 logs/catalina.out)
3. 搜异常堆栈,定位到抛错的那一行
4. 或在 Controller 入口打断点,让前端再请求一次,一步步 F8 跟
5. 变量窗口里看每一步的值,找到异常原因

高频错误类型

异常 意思 排查方向
NullPointerException 空指针 哪个对象是 null
SQLException SQL 错误 看具体报错 SQL
ClassCastException 类型转换失败 实际类型和预期不符
ConstraintViolationException 参数校验不过 @Valid 注解对应字段

场景 C:新增接口

目标:给现有 Controller 加一个新方法。

// 1. 打开已有 Controller(照葫芦画瓢)
@RestController
@RequestMapping("/invStockDay")
public class InvStockDayController {

    @Resource
    private InvStockDayService invStockDayService;

    // 2. 加一个新方法
    @RequestMapping("/myNewApi")
    public String myNewApi(@RequestParam("unitId") Long unitId) {
        return invStockDayService.myNewMethod(unitId);
    }
}

// 3. 在 Service 里加对应方法(如果不存在)
//    ⌘+B 点击方法名 → 会提示 "Create method" → 自动生成

// 4. 重启 Tomcat

// 5. 用 curl 或 Postman 测试:
//    curl http://localhost:8080/invStockDay/myNewApi.do?unitId=1

给前端的上手时间表

第 1 周:只看不写
  - 随便点 3-5 个 Controller,沿调用链走一遍
  - 熟悉项目命名规范(模块前缀、包结构)
  - 打开 3-5 个接口打断点,亲眼看请求怎么流转

第 2 周:小修小补
  - 改现有接口(加日志、调参数校验)
  - 写单元测试(Junit)

第 3-4 周:能独立新增
  - 新增简单接口(增删改查类)
  - 配合前端联调

第 2 个月+:能做架构微调
  - 抽公共方法、优化 SQL、处理并发场景

心态建议

不要试图读懂所有代码。大型后端项目几十万行代码,没人全懂,大家都是"用到哪看到哪"。你的目标是建立「从 URL 快速定位到代码」的能力,而不是"理解整个项目"。

面试话术

"我进这个项目的第一件事是建立 URL 到代码的映射感知。用全局搜索从 Controller 切入,沿着 ⌘+B 的调用链追到 Service 和 DAO。前两周不写代码只读,第三周开始改简单 bug,第二个月能独立加接口。核心心态是『用到哪看到哪』,不追求全量理解。"


10. 文件路径路由 vs 注解路由

时间:2026-04-20

思考题原文

前端的 fetch('/api/user') 和后端的 @RequestMapping("/user.do"),从网络协议看两者完全一样(都是 HTTP),但代码路由机制不同——前端用文件路径或 <Route>,后端用注解。为什么后端选注解?如果用文件路径(像 Next.js app router)有什么好处和坏处?

用户的回答

用户表示暂时没概念,需要详细解答。这暴露的知识盲区:对"路由约定"的设计范式缺乏理性认识,只是感性地用。

详细解答

两种范式的本质

文件路径路由(约定优于配置)

pages/
├── user/
│   ├── [id].tsx    → 自动生成 URL /user/:id
│   └── list.tsx    → 自动生成 URL /user/list

注解路由(配置优于约定)

@RequestMapping("/user/{id}")    // 显式配置
public User getUser(@PathVariable Long id) {}

@RequestMapping("/user/list")
public List<User> list() {}

文件路径路由的优势

优势 说明
可视化结构 看文件树就知道有哪些路由,不用翻代码
零心智负担 新建文件即新增路由,无需再写配置
天然代码分割 每个路由文件独立 chunk,按需加载
类型安全 Next.js Link 可以做 URL 类型推导

文件路径路由的劣势

劣势 说明
URL 和文件强绑定 改 URL 就要改文件路径(或目录)
动态路由表达力弱 复杂规则(嵌套、通配、权限)要借助 middleware
文件级粒度太粗 相关逻辑被拆散到多个文件,难共享状态

注解路由的优势

优势 说明
聚合相关逻辑 一个 Controller 类放 20 个接口,共享依赖注入
URL 与文件解耦 改 URL 不用改文件位置
方法级元数据丰富 @PreAuthorize@Cacheable@Transactional 链式组合
动态路由容易 /{id}/orders/{orderId} 直接在注解里写

注解路由的劣势

劣势 说明
全局路由表不透明 想知道所有 URL 要靠工具扫描(IDEA 有 URL Mappings 面板)
容易写重复 每个方法都要写一遍 @RequestMapping
注解爆炸 一个方法上可能叠 5-6 个注解

为什么后端选注解——本质原因

后端类的「重量」远大于前端组件。

一个 Controller 需要:

  • 依赖注入(Service、配置、Dao)
  • 事务管理(@Transactional
  • 权限控制(@PreAuthorize
  • 缓存(@Cacheable
  • 监控埋点

如果每个 URL 独立成一个文件,每个文件都要重新声明这堆依赖,代码冗余爆炸。

前端组件就没这问题,因为 React hooks 极轻量,useUser() 在任何文件里都能用。

三层对比

❌ 初级理解:
   「注解是 Spring 自己搞的语法糖,没啥特别」

⚠️ 中级理解:
   「注解和文件路由都是路由方式,各有利弊,按喜好选」

✅ 资深理解:
   「路由范式的选择,本质是代码组织粒度的选择。前端组件
    轻量,适合文件级粒度;后端类重量,适合类内聚合粒度。
    两种范式正在融合:Next.js Route Handlers 支持类聚合,
    Spring Boot 的 RouterFunction 支持配置化路由。语言和
    运行时特性决定了最佳范式,而非审美偏好。」

与 Next.js App Router 的类比(更好理解)

Next.js 14 的 App Router 其实部分借鉴了后端:

app/
├── user/
│   └── route.ts
│       └── export GET()       ← 类似 @GetMapping
│       └── export POST()      ← 类似 @PostMapping
│       └── export PUT()

一个 route.ts 文件导出 GET/POST/PUT 方法 = 一个 Controller 类里有多个方法。文件路径决定 URL 前缀,文件内导出决定 HTTP 方法,这就是混合范式

行业趋势

  • 后端向前端学:Spring Boot RouterFunction、Micronaut 都在尝试配置式路由
  • 前端向后端学:tRPC 用装饰器风格、Next.js Route Handlers 融合类聚合思想
  • 两种范式殊途同归,现代框架在找最佳平衡点

面试话术

"路由范式的选择本质是代码组织粒度的选择。文件路径路由适合前端组件这种轻量单元,所见即所得、支持代码分割。注解路由适合后端类这种重量级单元,可以在一个类里聚合相关业务、共享依赖注入、叠加横切关注点(事务/权限/缓存)。现代框架如 Next.js App Router 和 Spring RouterFunction 都在做范式融合,因为两者在大规模项目中都有各自的痛点。"

暴露的知识盲区小结

缺口 补齐方向
没意识到路由是一种「设计范式」 下次看任何框架,先问"这是约定式还是配置式"
不知道「代码组织粒度」是架构决策 后续学习设计模式时重点看这个
对装饰器/注解的理解停留在语法层面 学 TypeScript 装饰器 + NestJS,迁移到 Spring

11. IDEA JDK 配置问题排查

时间:2026-04-20

问题现象

IDEA 打开 Controller 文件时顶部出现黄色提示:「未定义项目 JDK」,右边有「安装 SDK」按钮。

根因

IDEA 首次打开项目,没自动识别到本机已安装的 JDK。不是缺 JDK,是配置没连上。

解决办法(从简到繁)

方法 1:直接点「安装 SDK」

弹出菜单 → 选 「Add JDK from disk...」 → 选路径:

/opt/homebrew/Cellar/openjdk@17/17.0.16/libexec/openjdk.jdk/Contents/Home

方法 2:Project Structure 手动配置

File → Project Structure (⌘+;)
  ├─ Project
  │    └─ SDK: 选 17
  │    └─ Language level: 选 17
  └─ Modules
       └─ 每个模块的 Language level: 选 17

常见坑:版本不匹配

本项目声明用 Spring 5.2.6,理论上 Java 8/11/17 都支持。但如果遇到:

[ERROR] error: Source option 7 is no longer supported. Use 8 or later.
[ERROR] error: package javax.annotation does not exist

说明有版本兼容问题:

❌ Java 17 的问题:
   - 移除了 javax.annotation(变成 jakarta.annotation)
   - 部分反射 API 默认禁用
   - 老的 Lombok/Mapstruct 可能不兼容

✅ 解决方案:
   方案 A:降级到 Java 8(本项目最稳)
           brew install openjdk@8
   方案 B:给 JVM 加启动参数开启反射
           --add-opens java.base/java.lang=ALL-UNNAMED
   方案 C:升级依赖到支持 Java 17 的版本(成本大)

怎么判断项目要求的 JDK

1. 看 pom.xml 中 <maven.compiler.source> 配置
2. 看依赖的 Spring 版本:
   - Spring 5.x → Java 8/11(17 勉强)
   - Spring 6.x → Java 17+
3. 看项目的 .java-version / .sdkmanrc 文件
4. 问同事「你们本地用的什么 JDK」

本项目判断

根据 pom.xml

  • parent 是 ec-parent 3.0.6-SNAPSHOT(公司父 POM,没显式 source/target)
  • Spring 5.2.6 兼容 Java 8-17
  • 建议先用 Java 17 试,失败再装 Java 8

安装额外 JDK(备用)

# 装 Java 8(如果 17 不行)
brew install openjdk@8

# 列出所有已装 JDK
/usr/libexec/java_home -V

# 切换使用不同版本(临时)
export JAVA_HOME=$(/usr/libexec/java_home -v 1.8)

面试话术

"Java 版本兼容是老项目常见坑。JDK 17 移除了 javax.annotation、加强了模块封装,老代码很容易踩雷。生产实践中:新项目直接 Java 17+(Spring Boot 3),老项目维护用 Java 8 或 11 最稳妥。本地用 jEnv 或 SDKMAN 管理多版本,切换成本低。"

三层对比:多 JDK 管理

❌ 初级做法:装一个 JDK,项目跑不起来就换
   → 今天跑项目 A 装 Java 8,明天跑项目 B 重装 Java 17
   → 来回卸装浪费时间,还可能搞坏环境变量

⚠️ 中级做法:装多个 JDK + 手动改 JAVA_HOME
   → 能工作,但每次切换要敲命令
   → 忘记切换 → IDE 用错版本编译失败

✅ 资深做法:版本管理工具 + 项目级配置
   工具:jEnv(类 nvm)/ SDKMAN(功能更全)
   项目级:.java-version 文件(自动切换)
   IDE:每个项目独立 SDK 配置
   → 切项目自动切 JDK,零心智负担

系统思维:JDK 供应商选择

一个常被忽略的问题:JDK 不只 Oracle 一家,选供应商本身是工程决策:

主流 JDK 发行版:

1. Oracle JDK(商业版)
   - 经典,官方出品
   - Java 17 开始生产环境需要商业授权 ⚠️
   - 中小公司避开

2. OpenJDK(开源参考实现)
   - 免费,Oracle 主导开发
   - 需要自己编译或找发行版

3. Eclipse Temurin(前 AdoptOpenJDK)
   - 社区免费 OpenJDK 构建
   - 企业无顾虑使用
   - 推荐默认选择

4. Amazon Corretto
   - AWS 提供,免费
   - AWS 生态优化
   - 长期支持承诺

5. Azul Zulu
   - 高性能 JVM 可选
   - 有免费版和商业版

6. GraalVM
   - 支持原生镜像编译(AOT)
   - 启动极快(毫秒级)
   - 适合 Serverless

选择框架

中小团队 / 无特殊需求 → Temurin(免费 + 社区)
AWS 深度用户 → Corretto
需要极致性能 / 低延迟 → Azul Zulu Prime(商业)
Serverless / 冷启动敏感 → GraalVM 原生镜像

本项目:根据 java -version 查供应商。Homebrew 默认装的是 openjdk(OpenJDK 社区版),生产环境最好明确指定 Temurin 之类的企业级发行版。

思考题讨论

Q1:为什么 Java 版本兼容性这么脆弱?Node/Python 没这么多坑?

用户回答:不清楚 Java 版本差异的本质原因。

正确解答

Java 的兼容性问题来自三个层面的变化:

1. 语法层(相对稳定)
   Java 8 的 lambda 代码 → 在 Java 17 上照跑
   这个维度一般不出问题

2. JDK API 层(不稳定的地方)
   Java 17 移除了:
   - javax.annotation 系列(给 Jakarta 了)
   - sun.misc.* 内部 API(禁止访问)
   - Nashorn JavaScript 引擎
   - CORBA、JAXB(企业级老功能)
   → 依赖这些 API 的老代码直接编译不过

3. 字节码 / JVM 层
   新字节码版本老 JVM 认不出
   Java 17 编译出来的 .class 不能在 Java 8 JVM 上跑
   反之可以:Java 8 的 .class 在 Java 17 JVM 上能跑

Node/Python 为什么问题少?

Node:
  - JS 语言自身向后兼容极好
  - Node API 几乎不删(只加)
  - ESM 切换有些痛苦但可控

Python:
  - Python 2→3 是历史性灾难(已过去)
  - 3.x 之内各版本相对稳定
  - 但 C 扩展(numpy 等)需要对应版本重新编译

Java:
  - JDK 每 6 个月发一个版本
  - 模块化(Java 9)+ API 清理激进
  - 企业级框架(Spring)依赖深,受影响大

本质Java 是"强约定"的企业语言,迭代时会做激进的清理。这对长期维护好(代码更干净),对迁移痛苦(要改的地方多)。

Q2:Spring 5 和 Java 17 有冲突吗?为什么?

正确解答

理论上兼容,实际上有坑

Spring 5.x 声明兼容 Java 8-17
但实际会遇到:

1. javax → jakarta 命名空间变化
   Jakarta EE 9 后 javax.* 全部改成 jakarta.*
   Spring 5 用 javax.* API
   Java 17 默认没有 javax.annotation-api → 要显式引入

2. Lombok 兼容
   老 Lombok(<1.18.24)不支持 Java 17
   很多老项目的 Lombok 版本过旧

3. 反射警告
   Java 17 强制模块化,反射访问内部 API 会 warning 或 error
   Spring 大量用反射,可能触发
   → 需要 --add-opens 参数放行
   
4. 依赖库的兼容
   老版本的 MyBatis、PageHelper 等可能用了被禁的 API

实际解决

启动参数添加(开放模块访问):
  --add-opens java.base/java.lang=ALL-UNNAMED
  --add-opens java.base/java.util=ALL-UNNAMED
  --add-opens java.base/java.nio=ALL-UNNAMED

pom.xml 显式引入:
  <dependency>
    <groupId>jakarta.annotation</groupId>
    <artifactId>jakarta.annotation-api</artifactId>
  </dependency>

或者退回 Java 11 —— 实际项目最稳的选择

本项目建议:先试 Java 17,失败就退 Java 11(LTS 版本 + Spring 5 完美兼容)。不要在老项目上追新 Java 版本。

Q3:LTS 版本是什么?为什么 Java 8/11/17/21 特殊?

正确解答

LTS = Long Term Support(长期支持),Oracle 承诺提供长期安全更新的版本:

Java 版本发布节奏(2017 年改革后):
  每 6 个月发一个"特性版本"
  每 2-3 年一个 LTS(长期支持 5-8 年)

LTS 版本时间线:
  Java 8    (2014) ← 企业主力,2030 年才 EOL
  Java 11   (2018) ← 现代化改造第一站
  Java 17   (2021) ← 当前新项目首选
  Java 21   (2023) ← 虚拟线程(Virtual Threads)革命性特性
  Java 25   (2025) ← 预计

非 LTS(Java 9/10/12-16/18-20):
  - 试验场,社区测试新特性
  - 发布半年后就停止支持
  - 企业几乎不用

企业选 LTS 的原因

1. 稳定性
   LTS 会有 patch 修 bug 和 CVE
   非 LTS 发布后就不再修

2. 工具链支持
   Maven/Gradle/IDE/Spring 都优先支持 LTS
   非 LTS 上可能插件不兼容

3. 招聘成本
   市场上大多数 Java 工程师熟悉 LTS 版本
   招用 Java 18 的人难找

本项目的 Java 8(如果最终用 8 的话)是 LTS,所以还在活跃维护。但 Java 8 距今 10 年,工具链越来越不友好——新趋势都是 Java 17 / 21。

面试话术:"Java 版本管理的核心是 LTS 概念。Java 8/11/17/21 是 LTS,企业生产几乎只用这四个版本。我本地用 jEnv 管理多个 JDK,每个项目放 .java-version 文件自动切换——类似 nvm 管理 Node。遇到老项目和新 Java 17 不兼容的情况,一般是 javax → jakarta 包名变化、反射警告、Lombok 老版本这三类问题。实际工程中,新项目直接上 Java 17/21,老项目维护到 EOL 前就保持 Java 8/11,不要激进升级 JDK 版本去换 Spring 5 老代码的坑,ROI 太低。"


12. @Resource vs useContext 的本质差异

时间:2026-04-20

思考题原文

Spring 的 @Resource 和 React 的 useContext 都是"不 new 就能拿到实例"。但两者本质有一个关键差异——React 的 Context 必须在 <Provider> 包裹的组件树内才能用,而 Spring 的注入是全局可用的。这个差异背后的原因是什么?

用户的回答

用户表示不懂,需要详细解释。这暴露的知识盲区:对"生命周期"和"作用域"这两个底层概念缺乏跨技术栈的统一认识

核心答案一句话

前端对象是"随渲染而生、随卸载而亡"的短命鬼;后端对象是"应用启动就存在、关服务才销毁"的常驻灵魂。 生命周期差异决定了注入范围差异。

深度解答

1. 两者的表面相似

// React
const ThemeContext = createContext(null);

function App() {
  return (
    <ThemeContext.Provider value={{ color: 'red' }}>
      <Child />  {/* Child 内任何地方都可以 useContext(ThemeContext) */}
    </ThemeContext.Provider>
  );
}

function Child() {
  const theme = useContext(ThemeContext);  // 不需要 prop drilling
  return <div style={{ color: theme.color }} />;
}
// Spring
@Service
class ThemeService {
    String getColor() { return "red"; }
}

@RestController
class MyController {
    @Resource
    private ThemeService themeService;  // 不需要 new
}

两者看起来都是「不用手动传递,就能在需要的地方拿到实例」。但底层机制天差地别。

2. 本质差异:生命周期和作用域

React 组件的生命周期

用户打开页面
    │
    ▼
┌────────────────────┐
│   mount 挂载        │ ← 组件诞生,Context 才开始工作
│   render 渲染       │
│   update 更新       │
│   unmount 卸载      │ ← 组件死亡,Context 也消失
└────────────────────┘
    │
    ▼
用户关闭页面(所有组件和 Context 灰飞烟灭)

特点

  • 生命周期以「秒」为单位
  • 每个用户访问都是独立的生命周期实例
  • 组件可能随时创建、销毁、重新挂载
  • Context 是渲染树上的数据通道,必须沿着树走

Spring Bean 的生命周期

Tomcat 启动
    │
    ▼
┌────────────────────────────┐
│  Spring 扫描所有 @Service   │
│  逐个 new 出来放进 IoC 容器  │
└────────────────────────────┘
    │
    ▼
┌────────────────────────────┐
│   应用运行期(可能数月)      │
│   Bean 一直存在于内存        │
│   所有请求共享同一个实例      │
└────────────────────────────┘
    │
    ▼
Tomcat 关闭(Bean 才销毁)

特点

  • 生命周期以「天/月」为单位
  • 整个应用只有一份 Bean(默认单例)
  • 启动时创建,关机才销毁
  • 容器是全局对象池,没有树状结构

3. 为什么差异如此巨大

根源:运行模型不同。

前端运行模型:
  用户交互驱动 → 组件树动态变化 → 数据必须"随树漂移"
  → 所以 Context 必须附着在组件树上

后端运行模型:
  请求到达 → 无状态处理 → 立即响应
  → 对象是"被共享的工具",不绑定到任何请求
  → 所以 Bean 可以全局放置,任何地方都能拿到

4. 具象类比:图书馆 vs 背包

React Context = 背包

  • 你(组件)出门时带着一个背包(Context)
  • 背包里的东西只有你和你携带的人能用
  • 你不在了,背包就散了

Spring Bean = 公共图书馆

  • 图书馆(IoC 容器)常驻城市
  • 任何人(Controller/Service/Task)都能进去借书
  • 不跟着任何人走,永远在那里

5. 为什么前端不能学后端的"全局模式"

你可能会问:前端为什么不能搞个全局对象池?用 Redux/Zustand 不就行了?

答案:可以,但有问题:

方式 问题
全局 JS 单例(export const service = new Service() SSR 时多个用户共享同一个实例 → 数据串号
Redux 全局 store 每次路由切换整个应用状态不重置,内存泄露
React Context 范围明确,跟随组件树,用户之间天然隔离

SSR(服务端渲染)是个关键线索:前端代码在服务器运行时,每个请求都需要独立的 Context 实例,不能共享。所以 React 设计了 Context 这种"随树走"的机制。

而 Spring 天生是服务端,每个 Bean 就是要被所有请求共享(否则每个请求都 new 一遍 UserService,性能爆炸)。

6. Spring 也有"作用域"

Spring Bean 默认是单例,但也可以配成其他作用域,越往下越像前端 Context:

@Scope("singleton")  // 默认,全局一个(类比全局 store)
@Scope("prototype")  // 每次注入新 new 一个(类比 new Service())
@Scope("request")    // 每个 HTTP 请求一个(类比请求级 Context)
@Scope("session")    // 每个用户会话一个(类比用户级 Context)

@Scope("request") 的 Bean 生命周期就和 React Context 很像:请求到达创建、响应结束销毁。

7. 三层对比

❌ 初级理解:
   "@Resource 和 useContext 都是依赖注入嘛"
   (混淆了表象,没看到生命周期差异)

⚠️ 中级理解:
   "一个是后端注解,一个是前端 hook,用法不同但思想相通"
   (知其然不知其所以然)

✅ 资深理解:
   "依赖注入的作用域设计必须匹配运行时的对象生命周期。
    前端组件生命周期短、树状、用户隔离 → Context 随树走;
    后端 Bean 生命周期长、无状态、多请求共享 → 全局单例。
    @Scope('request') 这种中间形态就是在两种模型间的过渡。"

延伸讨论

Q:Node.js 后端(Express)怎么做依赖注入?

A:Node.js 长期没有主流 DI 框架,常见做法:

// 方式 1:模块级单例(简单但有 SSR 问题)
export const userService = new UserService();

// 方式 2:工厂函数每次 new(类似 Spring prototype)
export const createUserService = () => new UserService();

// 方式 3:NestJS(完全照搬 Spring)
@Injectable() class UserService {}
@Controller() class UserController {
  constructor(private userService: UserService) {}
}

NestJS 的成功证明了前端工程师也需要全局单例模式,只是 React 组件的生命周期特性让它在 UI 层不适用。

Q:如果在 React 组件里用了全局 import { api } from './api',和 useContext 有什么区别?

A:

  • 全局 import:所有组件共享同一个 api 实例(类似 Spring 单例)
  • useContext:每个 Provider 子树可以有不同的实例(可注入 mock 用于测试)

生产代码里混用很常见:纯工具类用全局 import,有状态或需要测试替换的用 Context。

Q:后端有没有类似"组件树"的概念?

A:事务传播ThreadLocal 勉强类似。

  • 一个请求进来,经过 Controller → Service A → Service B,如果 A 开启了事务,B 默认加入这个事务(沿"调用树"传播)
  • UserContext.mockContext(unitId) 这种 ThreadLocal 存储的数据,跟随调用链传播(类似 Context 随组件树)

但后端这种"树"是调用栈的瞬时结构,不像 React 组件树是显式可见的结构。

在本项目中的体现

InvStockDayController.java

@RestController
public class InvStockDayController {
    @Resource
    private InvStockDayService invStockDayService;     // ← Spring 容器注入,单例
    @Resource
    private InvDailyRecalcTask invDailyRecalcTask;     // ← 单例
    @Resource
    private InvDailyAutoFixTask invDailyAutoFixTask;   // ← 单例

    public String initTodayInvStockDay(...) {
        UserContext.mockContext(unitId);  // ← ThreadLocal,类似 Context
        // 随调用链传递的用户上下文
    }
}
  • 三个 @Resource 字段是全局单例,Tomcat 启动时一次性注入
  • UserContext.mockContext()请求级上下文,存在 ThreadLocal 里,和当前线程绑定,调用链结束就清理

面试话术

"依赖注入的作用域设计必须匹配运行时对象的生命周期。React Context 跟随组件树是因为前端组件短命、树状、用户隔离;Spring Bean 全局注入是因为后端 Bean 长寿、无状态、多请求共享。两者本质是同一个问题(怎么传递依赖)的不同答案,答案取决于运行环境的约束。NestJS 把 Spring 思想搬到 Node.js 能成功,就是因为 Node.js 后端的运行模型更接近 Spring 而不是 React。"

暴露的知识盲区小结

缺口 补齐方向
"生命周期"概念只停留在 React 的 useEffect 理解"所有对象都有生命周期,长短决定了架构选择"
没意识到"作用域 scope"是普适概念 函数作用域、请求作用域、会话作用域都是同一个概念家族
依赖注入 = Spring 专属 依赖注入是通用模式,每种语言/框架都有自己的实现

13. 本项目缓存使用情况调研

时间:2026-04-20

调研结论

本项目重度使用缓存,且是分布式多层缓存架构。想跑起来 = 需要 Redis + Memcached。

发现的缓存类型

1. Redis(分布式缓存,主力)

// 大量代码引用
import com.hzecool.core.cache.mc.redis.RedisMap;
import com.hzecool.core.cache.mc.redis.RedisCounterUtils;

封装位置:com.hzecool.core.cache.mc.redis.*(在私有 Maven 包 ec-core 里)

用途

  • RedisMap - 分布式 Map(多服务共享的 KV 数据)
  • RedisCounterUtils - 计数器(如订单号生成、限流计数)

2. Memcached(老牌缓存)

import com.hzecool.core.cache.mc.McFactory;
import com.hzecool.core.cache.mc.McMapUtil;

注意:这里的 mc 前缀本意是 Memcached,但项目把 Redis 也封装成了类似 Memcached 的 API(所以 mc.redis 这种命名空间看起来奇怪,其实是迁移兼容层)。

这是典型的 适配器模式:旧代码用 Memcached API 写的,后来迁到 Redis,用适配器包一层保持 API 不变,老代码不用改。

3. 本地内存缓存(ConcurrentHashMap)

ec-mdm-api/src/main/java/com/hzecool/mdm/chain/utils/JoinedChainCacheUtils.java
ec-fin-plugin/src/main/java/com/hzecool/acct/fin/service/FinAcctService.java
ec-inv-plugin/src/main/java/com/hzecool/inv/newinv/service/SwitchInvNewService.java
...

用途:单机进程内的热数据缓存,不需要跨服务共享。例如:

  • 配置表(读多写少)
  • 字典数据(国家、币种、类目)

三层缓存架构

本项目是经典的三级缓存模型

请求到达
   │
   ▼
┌─────────────────────────────┐
│  L1: 本地内存 (ConcurrentMap) │  纳秒级,每个 JVM 独立
│  容量小,命中率不保证          │
└────────┬────────────────────┘
         │ miss
         ▼
┌─────────────────────────────┐
│  L2: Redis                   │  毫秒级,所有服务共享
│  容量大,支持复杂结构          │
└────────┬────────────────────┘
         │ miss
         ▼
┌─────────────────────────────┐
│  L3: MySQL                   │  几十毫秒,最终数据源
└─────────────────────────────┘

为什么要用三级

❌ 只用数据库:
   每次查询都走 SQL,QPS 高了数据库先崩

⚠️ 只用 Redis:
   网络往返成本(即使 1ms,高 QPS 场景也是瓶颈)
   Redis 挂了整个服务雪崩

✅ 三级架构:
   大部分请求命中 L1(零网络成本)
   L1 miss 查 L2(共享命中率高)
   L2 miss 才走 DB(保护数据库)

前端类比

层级 后端 前端对应
L1 本地 ConcurrentMap React useMemo / useRef / 模块级 Map
L2 Redis Service Worker Cache / IndexedDB
L3 MySQL 真实 API 请求

前端的 React Query / SWR 就是个分层缓存:先看内存 → 看 sessionStorage → 最后发请求。思想完全一致。

本地跑这个项目需要什么

结合 Dubbo 和缓存的需求,本地启动至少需要:

组件 地址来源 状态
MySQL db.properties(要向同事要) ❌ 缺
Zookeeper dubbo.properties(192.168.0.37:2288) ✅ 有
Redis ec-core 包的私有配置里 ❌ 需要问
Memcached 同上,可能和 Redis 共享配置 ❌ 需要问

向同事索要的完整话术

本地跑 bizsspd 还需要几个地址,帮我发一下:

  1. db.properties(数据库配置)
  2. Redis 地址(内网 IP + 端口 + 密码)
  3. Memcached 地址(如果和 Redis 分开部署)
  4. 有没有测试环境可以直接连,不用本地起 Redis

最省力的方案:直连测试环境

不要本地起 Redis/Memcached,直接连公司测试环境的实例:

  • 省去本地安装的麻烦
  • 和同事用同一份数据,方便联调
  • 出问题直接看现有监控

前提:测试环境允许开发机直连(公司 VPN + IP 白名单)。

Dubbo 的 registry.file 坑

发现 dubbo.properties 里有 Windows 路径:

dubbo.registry.file=c:/work/temp/dubbocache/slh2

Mac 上需要改成 /tmp/dubbocache/slh2

不要直接改提交到 Git(会污染团队配置)。正确做法:

  • 在 IDEA 的 Tomcat Run Configuration 里通过 VM Options 覆盖:
    -Ddubbo.registry.file=/tmp/dubbocache/slh2
    
  • 或在本地 ~/ 下建个 profile 文件,启动时指定

三层对比:不同时代的缓存方案

❌ 初级做法:
   直接在代码里拼字符串 key 调 Redis
   jedis.set("user:" + userId, json);
   问题:key 规范不统一、序列化混乱、没有过期管理

⚠️ 中级做法(本项目):
   封装工具类 RedisMap / RedisCounterUtils
   好处:统一 API、隐藏序列化细节
   不足:还是要手动管理 key、expire、空值缓存

✅ 资深做法(现代):
   Spring Cache 抽象 + @Cacheable 注解
   @Cacheable(value = "user", key = "#userId", unless = "#result == null")
   public User getUser(Long userId) { ... }
   好处:声明式、AOP 切面、一行注解顶十行代码、易替换底层

面试话术

"本项目用了三级缓存架构:本地 ConcurrentMap + Redis + MySQL,典型的高并发场景优化。Redis 做分布式共享缓存,本地做热点数据二级缓存,避免每次都走网络。代码层面用了适配器模式,把 Redis 封装成老 Memcached 的 API,这是典型的老项目迁移兼容手法。现代项目会用 Spring Cache 抽象 + 注解式缓存,声明式代码更清晰。"

延伸讨论

Q:三级缓存怎么保证一致性?

A:常见策略:

  • 更新时:先删缓存 → 更新 DB → 再删缓存(防止并发读脏)
  • 延时双删:更新后 sleep 500ms 再删一次(等并发读完成)
  • 消息队列:DB 更新后发 MQ 通知所有节点删本地缓存
  • 终极方案:接受短时不一致(最终一致性),业务能容忍就用 TTL

Q:Redis 和 Memcached 怎么选?

A:

维度 Redis Memcached
数据结构 丰富(string/hash/list/zset) 只有 string
持久化 支持(RDB/AOF) 不支持
集群 Cluster 原生支持 客户端分片
性能 10w QPS 级 稍高于 Redis
现代选择 ✅ 主流 ❌ 逐渐被淘汰

新项目基本都选 Redis,本项目 mc 命名只是历史遗留。

Q:本地缓存用 HashMap 会有什么问题?

A:线程不安全。多线程同时 put 可能触发死循环(JDK 7 的经典坑)或丢数据。必须用:

  • ConcurrentHashMap(推荐,本项目用的)
  • Collections.synchronizedMap()(性能差)
  • Caffeine / Guava Cache(带过期、淘汰策略)

14. 配置地址完整清单 & 自我纠错:什么要问同事

时间:2026-04-20

重要纠错

我之前建议用户"向同事索要 db.properties / Redis 地址 / MQ 地址"是错误判断。实际上这些配置全部都在项目的代码库里

这次纠错暴露了一个资深工程师常见的陷阱:没有先穷尽项目本身,就急着建议外部求助

教训提炼

❌ 初级做法:
   看到需要配置 → 直接问同事要 → 得到答案

⚠️ 中级做法:
   先看项目 README → 没有 → 问同事

✅ 资深做法:
   - 先全局搜 `jdbc:mysql` / `redis` / `mq` 这些关键字
   - 看 resources 目录下所有 .properties / .xml 配置
   - 看是否有 `*_local` / `*_dev` 文件
   - 看是否有 uconfig.xml 这种配置覆盖链
   - 全部穷尽后还缺 → 才问同事
   - 即使问同事,也带着问题问:"我已经看到 X,是不是缺 Y?"

本项目完整配置清单(一次查清)

🗄️ MySQL 数据库

来源
URL jdbc:mysql://192.168.0.100:3366/slh2 ec-slh2-server/src/main/resources/db.properties
用户 myslh 同上
密码 CottonXu 同上
数据库 slh2 同上

同模块其他库

  • slh2-mqproxyslh2 库(共享)
  • slh2-trans-serversts
  • slh2-rfid-chkrfid_chk

🔴 Redis 缓存

来源
模式 Sentinel(哨兵模式,自动故障切换) myenv.xml
地址 192.168.0.84:26633 同上
密码 CottonXu 同上
DB 索引 11(业务)/ 0(session) 同上

🧩 Dubbo / Zookeeper(服务注册中心)

来源
Zookeeper 192.168.0.37:2288 dubbo.properties
服务端口 20889 同上
QoS 端口 30889 同上
Provider 超时 5000ms 同上

📬 RocketMQ 消息队列

来源
NameServer 192.168.0.37:9876 myenv.xml
Producer Group slh2Producer 同上
Consumer Group slh2Consumer 同上

🔧 应用基础配置

集群标识 slh2_local
机房 devRoom(开发机房)
应用名 slh
版本 7.64.4

真正需要问同事的:只有一个

公司内网/VPN 接入

所有上述地址都是 192.168.x.x 内网 IP,你本机外网访问不通。

问法

新人上手请教一下:

  1. 怎么接入公司内网?有 VPN 账号我要申请吗?
  2. 上面那些 192.168 的地址本地开发能直连吗?还是要走跳板机?
  3. (可选)本地跑的话有没有踩过什么坑要注意的

配置覆盖机制(资深必懂)

项目有套优雅的多级配置覆盖链(uconfig.xml):

优先级:低 → 高
┌──────────────────────────────────────────┐
│ 1. classpath*:myenv_base.xml             │ 公共基础
│ 2. classpath:myenv.xml                   │ 项目默认(代码库里)
│ 3. classpath:myenv/myenv_local.xml       │ 本地开发配置
│ 4. file:///${user.home}/slh2/myenv.xml   │ ★ 个人用户配置
│ 5. file:///mysh/etc/web/myenv.xml        │ 部署机器级
│ 6. nacos://myenv/${app_name}             │ Nacos 动态配置
└──────────────────────────────────────────┘
                高优先级覆盖低优先级

本地开发最佳实践

所有个性化配置放在 ~/slh2/ 下,绝不改 Git 里的文件

mkdir -p ~/slh2

# 个人 Redis / MQ / DB 地址(覆盖代码库里的)
vim ~/slh2/myenv.xml

# 个人数据库配置(比如连自己的测试库)
vim ~/slh2/db.properties

# 个人 Dubbo 配置(解决 Windows 路径的坑)
vim ~/slh2/dubbo.properties

解决 Dubbo registry.file 的 Mac 坑

之前发现 dubbo.properties 里是 Windows 路径:

dubbo.registry.file=c:/work/temp/dubbocache/slh2

Mac 下正确做法

创建 ~/slh2/dubbo.properties,写入:

dubbo.registry.local.address=192.168.0.37:2288
dubbo.registry.file=/tmp/dubbocache/slh2
dubbo.protocol.port=20889
dubbo.application.qos.port=30889

这个文件会被 uconfig 加载并覆盖代码库里的 Windows 路径,不影响 Git,不污染其他同事。

三层对比:工程师对"配置管理"的认知

❌ 初级理解:
   改哪个配置就改哪个文件,管它 Git 里有没有冲突
   → 经常 commit 本地配置,被同事骂

⚠️ 中级理解:
   知道本地配置不能提交,但不知道怎么隔离
   → 要么 .gitignore,要么改完记得 revert(容易忘)

✅ 资深理解:
   配置要分层:代码库放团队公共配置,个人配置放用户目录,
   环境专属配置用环境变量/配置中心。Git 里永远放最小可运行版本,
   个人和环境变化用覆盖链机制。

业界对比:主流配置管理方案

方案 代表 特点
文件多级覆盖 本项目 uconfig / Spring profiles 简单、直接、不依赖外部服务
环境变量 12-factor app 容器时代主流、K8s 友好
配置中心 Nacos / Apollo / Consul 动态热更新、多环境、灰度
Secret 管理 Vault / AWS Secrets Manager 专门管敏感信息,有审计

本项目混合使用:文件覆盖 + Nacos(看 uconfig.xml 最底有 nacos:// 引用)。

面试话术

"成熟项目的配置管理一般是分层覆盖机制:代码库里放公共最小配置,个人用户目录放开发者定制,部署机器目录放环境专属,最高优先级是配置中心做动态配置。这样既保证了 Git 干净,又支持了多环境部署和动态调整。本项目用了 uconfig 自研方案,支持 classpath/文件/Nacos 多级合并,相比单纯 Spring profiles 更灵活。"

延伸讨论

Q:为什么测试环境的密码敢写在 Git 里?

A:三个前提:

  1. 这是测试环境(192.168 内网,外网无法访问)
  2. 数据是可重置的测试数据,泄漏无实际损失
  3. 内网防护假设"内网可信"(零信任架构下这是不合格的)

生产环境密码绝对不能入库,应该用 Vault / KMS / 配置中心加密。

Q:Nacos 和文件配置怎么选?

A:

  • 启动时配置(数据库连接、端口等)→ 文件(启动就要有)
  • 运行时可变配置(开关、阈值、限流规则)→ Nacos(热更新)
  • 敏感信息(密码、token)→ Vault(加密 + 审计)

本项目的做法是合理的:基础设施地址用文件,业务开关用 Nacos。

Q:我在家办公能跑这个项目吗?

A:需要:

  1. 公司 VPN(打通 192.168 内网)
  2. 或者跳板机 + SSH 隧道转发
  3. 或者公司把测试环境搬到公网可访问的云(多数公司不会这么干)

没 VPN 就别想跑,代码再熟悉也没用。

纠错反思(元认知)

这次我两次建议用户"向同事要 db.properties",都是没先充分搜索项目就下结论。 对前端工程师来说,听到"向同事要"可能会产生社交成本负担(怕打扰、怕显得菜)。

资深工程师的行为模式应该是

  1. 先自己穷尽所有可能的信息源(代码 + Git 历史 + 文档 + 搜索引擎)
  2. 再问同事时带着具体上下文:"我看到 X 和 Y,但不理解 Z,能否解释?"
  3. 永远不要让同事做"我自己 5 分钟就能查到的事"

这是技术之外的工作习惯,和代码能力同等重要。


15. 元技能:从常识反推结论合理性

时间:2026-04-20

问题背景

在第 14 章的纠错讨论后,AI 评价用户说:

"这种从常识反推 AI 结论是否可能有漏洞的能力,就是你和资深工程师之间的核心差距之一。"

用户追问:这句话是什么意思?

这个追问本身就是这种能力的体现——不把 AI 的评价当成既定事实,而是要求解释清楚。所以这条是元级别的元认知知识点,价值极高。

核心答案(一句话)

资深工程师不把任何外部结论当"标准答案"直接用,而是把它当作"假说",用自己的常识、经验、一致性检查去验证它。

中级 vs 资深的行为差异

情境 中级反应 资深反应
AI 说"需要问同事要配置" 好的,去问 同事之前怎么跑起来的?不合理,再查
Stack Overflow 高赞说"加这参数" 复制粘贴 答案是 2017 年的,框架大版本升级过,还适用吗?
编译器说"类型错在第 45 行" 改第 45 行 我推理没错,真实问题可能在第 40 行
同事说"这个库有 bug,别用" 换一个 他的 bug 场景是什么?我的场景一致吗?
文档说"API 线程安全" 直接用 "线程安全"的具体保证是什么?高并发也成立吗?
业务方说"这是紧急需求" 立刻开工 紧急的根因是什么?如果不做/延后会怎样?
性能测试说"QPS 1000" 满意交付 测试条件是什么?单机还是集群?缓存命中吗?

本次案例的思维链还原

用户的原话:「再检查一下 问同事所要的东西是否确定是无法在项目中获取的」

这句话背后的思维过程:

Step 1: AI 给结论 → "需要问同事要 db/redis/mq 配置"
         │
         ▼
Step 2: 常识警报触发 →
         │
         ├─ 项目已存在多年,同事能跑起来
         ├─ 这些基础配置不可能完全缺失
         └─ AI 可能没充分调查
         │
         ▼
Step 3: 用反问验证 → "再检查一下"
         │
         ▼
Step 4: AI 重新搜索 → 发现所有配置都在

关键在 Step 2——中级工程师在这一步通常直接跳到"去做",不会触发警报。

为什么这是资深的"核心差距"

本质原因:信息不可信任

软件工程的残酷真相——你接触的所有外部信息都可能有问题

信息源 可能的问题
AI 幻觉、知识过时、没读过你的具体代码
同事 基于他的上下文给建议,未必适配你的场景
官方文档 滞后于代码、描述理想场景、漏掉边界条件
编译器/linter 报错位置未必是根因位置
性能测试报告 测试条件可能不代表真实负载
Stack Overflow 时效性差、场景适配性差
Git 提交信息 可能骗人("fix bug"但其实改了别的)

如果无条件相信:你的上限就是信息源的平均质量

❌ 复读机模式:
   外部结论 → 直接使用 → 产出质量 = 外部结论质量的平均值
   这种工程师本质上是"人肉搜索引擎"

✅ 验证者模式:
   外部结论 → 假说验证 → 产出质量 = 所有信息源的最优子集 + 自己的修正
   这种工程师产出质量始终高于单一信息源

这个能力的三个层次

❌ 初级:全盘接受
   "AI 说的/文档写的/同事说的 → 那就是对的"
   产出风险:下游 bug、方向性错误

⚠️ 中级:选择性怀疑
   熟悉领域的结论会验证,不熟悉的照单全收
   产出风险:盲区大,新领域决策质量差

✅ 资深:系统性验证习惯
   所有外部结论都先假设为"假说",再用常识/经验验证
   不熟悉的领域更警觉,而不是更迷信

刻意训练方法

方法 1:三问框架

每次接受外部结论前,强制问三个问题:

① 这个结论的前提假设是什么?在我场景下成立吗?
② 如果这个结论是错的,哪里会先露出破绽?
③ 有没有别的解释能同时解释观察到的现象?

方法 2:逆向验证

听到结论 A,主动找一个反例试试:

AI 说:"这个方法是 O(1) 复杂度"
你做:传一个超大输入,真的常数时间吗?
结果:发现实际有线性扫描 → AI 错了

方法 3:记录警报时刻

每次感到"这个结论哪里怪怪的"但不敢深究时,强迫自己细想 30 秒

不需要每次都对,错了也没关系,关键是训练**"警报响起 → 深入验证"** 的肌肉记忆。

方法 4:事后复盘

像这次一样。抓到 AI / 文档 / 同事的错误后,回头想:

  • 我为什么会警觉?警觉信号来自哪里?
  • 如果当时没警觉会发生什么?
  • 下次类似情境,哪些信号能更快识别?

前端场景的实战应用

你做 React 项目时,这种思维可以这样用:

场景 1:包的 README 说"零配置开箱即用"

中级:直接 npm install
资深:先想——真的零配置?那 Vite 和 Webpack 的底层差异怎么抹平的?
     我的打包工具链可能有坑 → 先去 Issues 看看有没有人报过集成问题

场景 2:setState 看起来没生效

中级:console.log 不对,可能是 React 的 bug?
资深:不会的,React 这层很稳定。先怀疑自己——
     是不是闭包陷阱?是不是 setState 异步还没 flush?是不是 key 有问题?

场景 3:性能测试报告说"FCP 1.2s"

中级:达标了,上线
资深:什么设备?什么网络?是首次访问还是缓存命中?
     真实用户 75 分位是多少?Lighthouse 实验室数据和真实用户数据差多远?

为什么 AI 时代这个能力更重要

AI 会给你大量看似正确的结论,其中有 5-20% 是错的(幻觉/过时/不适配)。

中级工程师 × AI = 更快地产出可能错误的代码 资深工程师 × AI = 更快地产出经过验证的高质量代码

AI 不会让你自动变强,它会放大你原有的能力层次。缺乏验证习惯的人用 AI 反而更危险,因为产出速度加快,但错误也加快传播。

行业视角

Google 的工程师培训里有个概念叫 "Calibrated Trust"(校准的信任):

  • 对同一个信息源,不同类型的问题给不同的信任度
  • AI 回答 "Python 语法问题" → 高信任
  • AI 回答 "我们公司内部系统的 bug" → 低信任
  • 同事回答 "他负责的模块" → 高信任
  • 同事回答 "他听别人说的架构决策" → 中等信任

这不是"不信任",是按信息源的能力边界给出差异化信任

在本次对话中的二阶应用

你问"这句话什么意思"——这是对"夸奖"本身做反推验证

AI 夸我:你有资深思维
       │
       ▼
常识警报:AI 可能是在套话/鼓励/无脑夸
       │
       ▼
验证动作:让它解释清楚
       │
       ▼
如果解释不清 → 夸奖可能不成立
如果解释得清 → 夸奖有实质内容,可以接受

不被表扬冲昏头脑,追问评价本身是否有依据——这也是资深心智的体现。很多人被 AI 一夸就飘,你选择追问,就是在做第二层的"从常识反推"。

面试话术

"我在实际工作中养成了一个习惯:对所有外部结论——AI 答复、文档、同事建议、Stack Overflow 答案——都先当作假说处理。我会问三个问题:这个结论的前提假设在我场景下成立吗?如果错了哪里会先露破绽?有没有别的解释?这种『校准信任』的习惯能让我在 AI 时代避免被错误答案带偏,产出质量稳定高于信息源的平均水平。"

暴露的认知升级

之前的认知 现在的认知
AI 很强,它说什么基本是对的 AI 是强大的信息源,但必须验证
资深 = 知道更多 资深 = 过滤和验证外部信息的能力更强
不确定时应该问专家 先穷尽自己能做的,再问专家
被夸奖时应该接受 被夸奖时应该追问依据

延伸讨论

Q:如果事事都验证,不会效率低下吗?

A:会,所以资深不是"每件事都深度验证",而是按风险等级分配验证成本

  • 低风险(改个样式)→ 直接信 AI
  • 中风险(加一个接口)→ 看几眼代码
  • 高风险(改架构/上线生产)→ 深度验证 + 多源交叉核对

这就是 "Calibrated Trust" 的精髓。

Q:怎么判断自己的"常识警报"准不准?

A:靠复盘积累。每次警报触发后,记录:

  • 警报来源(什么线索让我警觉)
  • 验证结果(真的有问题 or 是我多疑)
  • 次日再看还觉得当时反应对吗

长期下来你会发现自己的警报越来越准。不怕误报,就怕漏报。

Q:怎么训练团队里的中级工程师获得这种能力?

A:

  • Code Review 时问"为什么"而不是"是什么":强迫他说出选择依据
  • 让他们写技术决策文档:写的过程中会暴露"这个假设我没验证过"
  • 事故复盘不追责,追"你那一刻的推理链":让思维过程可见
  • 给他们"挑战 AI"的任务:"这段代码是 AI 生成的,找出三个问题"

16. 网络诊断工具 nc (netcat)

时间:2026-04-20

问题背景

用户验证内网连通性时,我让他跑 nc -zv 192.168.0.100 3366 这类命令。用户没用过 nc,提问"这是什么"。前端开发日常遇不到底层 TCP 诊断,但后端开发必会。

核心答案

nc(读作 "netcat")是 Unix 系统自带的网络诊断工具,用来测试 TCP/UDP 端口连通性、收发原始数据。在后端工程师工具箱里相当于"瑞士军刀"级别的存在。

一句话定义

nc 能在两个网络端点之间建立任意 TCP/UDP 连接,做以下事情:

  • 测端口通不通(最常用)
  • 监听端口接收数据
  • 传文件
  • 模拟简单服务器/客户端

参数速查

nc -zv  192.168.0.100  3366
│  │ │       │            │
│  │ │    目标 IP       端口
│  │ │
│  │ └─ verbose:显示连接详情(不加就静默)
│  │
│  └─ zero I/O mode:只扫描不发数据,测完就退出
│
└─ netcat 主命令

其他常用参数:

-l      监听模式(当服务端用)
-u      用 UDP 而非 TCP
-w N    N 秒超时
-p PORT 指定本地端口

工作层级(OSI 模型定位)

应用层     HTTP/MySQL/Redis 协议    ← curl 测这层
  │
传输层     TCP / UDP                 ← nc 测这层
  │
网络层     IP / ICMP                 ← ping 测这层
  │
链路层     以太网 / WiFi

关键:nc 工作在 TCP 层,只关心"能不能建立 TCP 连接",不关心上层是什么协议。所以它能测任何 TCP 服务(MySQL、Redis、Zookeeper、RocketMQ、自定义协议),通用性最强。

与同类工具对比

工具 层级 用途 局限
ping 网络层(ICMP) 测主机是否存活 不能测具体端口;很多防火墙会 block ICMP
nc -z 传输层(TCP) 测端口是否开放 不能测应用层行为
telnet 传输层(TCP) 同 nc,但交互式 老派,部分新系统已不预装
curl 应用层(HTTP) 发 HTTP 请求看响应 只懂 HTTP 协议
nmap 传输/应用层 端口扫描、指纹识别 需要单独安装;能力过强可能被误报为攻击

前端已知工具的类比

场景 前端对应 用 nc 做
看域名是否解析 DNS 查询 / nslookup nc 不做 DNS,它只吃 IP
看网站能不能打开 浏览器地址栏 nc -zv host 80
看 API 响应 Postman / curl curl(nc 太底层)
看某个服务开没开 没有对应工具 nc -zv host port

前端基本不需要 nc 是因为:前端遇到的服务 99% 是 HTTP,用 curl/浏览器就够。后端要连 MySQL/Redis/MQ 这些非 HTTP 服务,才必须有 nc 这种协议无关的工具。

输出解读

# ✅ 成功
$ nc -zv 192.168.0.100 3366
Connection to 192.168.0.100 port 3366 [tcp/*] succeeded!

# ❌ 端口没程序监听(服务挂了/端口号错了)
$ nc -zv 192.168.0.100 3366
nc: connect to 192.168.0.100 port 3366 (tcp) failed: Connection refused

# ❌ 网络不通(防火墙/路由/IP 不存在/VPN 没连)
$ nc -zv 192.168.0.100 3366
nc: connect to 192.168.0.100 port 3366 (tcp) failed: Operation timed out

三种状态的诊断意义

输出 含义 下一步排查
succeeded 端口开着,网络通 进入应用层测(mysql 连接、redis-cli 等)
Connection refused 主机可达但端口无服务 确认服务是否启动、端口号是否正确
Operation timed out 网络层不通 确认 IP 正确、VPN 已连、防火墙规则
No route to host 路由不可达 网络配置问题、不同网段

实战:并行扫描多个端口

用 bash 循环并发测试本项目所有依赖:

for addr in 192.168.0.100:3366 192.168.0.84:26633 192.168.0.37:2288 192.168.0.37:9876; do
  host=${addr%:*}    # 取 : 前
  port=${addr#*:}    # 取 : 后
  nc -zv -w 2 $host $port 2>&1 | head -1
done

-w 2 是 2 秒超时,避免不通时卡死。

进阶用法(后端老手必会)

1. 当临时服务器接收数据

# 终端 A(服务端)
nc -l 8080

# 终端 B(客户端)
echo "hello" | nc localhost 8080
# 终端 A 会显示 "hello"

用途:调试网络应用时,先用 nc 做 mock 服务端,看客户端发的是什么。

2. 传文件(Linux/内网应急)

# 接收方(先启动)
nc -l 8080 > received.txt

# 发送方
nc receiver-ip 8080 < file.txt

用途:没装 scp/rsync 的内网机器间传文件。

3. 反向 shell(安全测试 / 运维应急)

# 运维机监听
nc -l 4444

# 被控机反向连接(场景:被控机在内网,运维机在外网)
bash -i >& /dev/tcp/ops-ip/4444 0>&1

这是安全测试和运维应急的经典手法。了解原理有助于理解攻击面(比如为什么公司要限制出站流量)。

4. 模拟 HTTP 客户端(学习 HTTP 原理必备)

# 手动敲 HTTP 请求
nc example.com 80
GET / HTTP/1.1
Host: example.com

# (空行回车)

学习价值:让你直观感受 HTTP 就是文本协议,浏览器只是在发你敲的这些字。面试考过 HTTP 报文结构时,你可以说"我用 nc 手动构造过 HTTP 请求"。

三层对比:查端口连通性的心智

❌ 初级做法:
   "连不上就重启试试" / "不知道怎么查,问运维"
   结果:根因不清,问题反复

⚠️ 中级做法:
   会用 ping 测主机、curl 测 HTTP 接口
   缺陷:不知道 ping 通但端口不通的情况如何诊断

✅ 资深做法:
   按 OSI 层级逐层排查:
   1. ping → 主机是否活
   2. nc -zv → 端口是否开
   3. 应用层客户端(mysql/redis-cli)→ 服务是否健康
   4. 抓包(tcpdump/Wireshark)→ 协议层是否异常
   通过分层,永远知道"问题出在哪一层"

在本项目中的体现

本项目需要连四个内网服务:

服务 地址 nc 命令
MySQL 192.168.0.100:3366 nc -zv 192.168.0.100 3366
Redis (哨兵) 192.168.0.84:26633 nc -zv 192.168.0.84 26633
Zookeeper 192.168.0.37:2288 nc -zv 192.168.0.37 2288
RocketMQ 192.168.0.37:9876 nc -zv 192.168.0.37 9876

启动 Tomcat 前先跑 nc 扫一遍,能提前发现网络问题,不用等 Spring 启动失败再看一堆堆栈。

为什么前端没机会学这个

前端开发的"服务依赖"基本都是 HTTP API + 浏览器,诊断栈是:

  • 浏览器 DevTools Network 面板
  • Postman/curl
  • 最多看看 DNS

HTTP 层之下的问题,前端通常扔给运维/后端。但一旦切入全栈或后端维护,nc 是诊断底层网络问题的第一道防线

面试话术

"排查网络连通性我习惯按 OSI 分层法。先 ping 看主机活不活,再用 nc -zv 看目标端口是否开放,最后用应用层客户端(mysql/redis-cli/curl)测服务是否健康。这种分层排查能让我精确定位问题出在哪一层,避免盲目重启或猜测。nc 是其中最关键的工具,因为它工作在 TCP 层,协议无关,能测任何 TCP 服务。"

延伸讨论

Q:nc 和 telnet 哪个好?

A:现代系统都选 nc。

  • telnet 设计给"远程登录"用,测端口是副业
  • nc 设计给"网络工具箱"用,功能更全(监听、传文件、UDP 都能)
  • 很多新系统(RHEL 8+、macOS Catalina+)默认不再装 telnet

Q:为什么有时候 ping 通但 nc 不通?

A:几种常见原因:

  1. 服务没启动(主机活但端口没服务)→ Connection refused
  2. 防火墙只放 ICMP 不放 TCP → nc timeout
  3. 服务绑定在 127.0.0.1 而非 0.0.0.0 → 外部连不上

反过来 ping 不通但 nc 通也可能(ICMP 被 block 但 TCP 放行),比如云服务商默认关 ICMP。

Q:nc 有安全风险吗?

A:nc 本身只是工具,但它是"红队"(攻击方)常用工具:

  • 反向 shell 的经典实现就是 nc
  • 端口扫描可以用 nc 做基础版
  • 很多企业的 EDR(终端检测)会告警 nc 的使用

生产服务器不建议保留 nc,开发测试环境用没问题。

Q:macOS 的 nc 和 Linux 的 nc 有差异吗?

A:有。macOS 的 nc 是 BSD 版本,Linux 常见的是 GNU 版本(nmap-ncat)。

  • 参数略有差异(比如 -w 超时行为)
  • BSD nc 不支持 -e 反向 shell 参数(macOS 默认)
  • Linux 的 ncat(nmap 自带的)功能最强,支持 SSL

日常用法两者通用,写脚本跨平台时注意下参数差异即可。

暴露的知识盲区小结

缺口 补齐方向
不知道 TCP 层和 HTTP 层的区别 学 OSI 七层模型,理解每层的职责
没有"分层排查"意识 以后遇到网络问题强制按层排查
只会浏览器/Postman 工具链 命令行工具 nc/curl/dig/traceroute 各练一遍

17. 思考题:TCP 通但服务不通

时间:2026-04-20

思考题原文

如果 nc -zv 测试显示 succeeded(端口通),但你启动 Tomcat 后项目还是报数据库连不上——此时问题可能出在哪几层?你会怎么分层排查?

用户回答

用户表示对网络层协议知识模糊,请求详解。这个盲区非常有价值——大多数前端工程师只接触 HTTP 层,底层网络栈是认知盲区,补齐这块能让面试和工作质量显著提升。

核心认知(一句话)

TCP 握手成功 ≠ 服务真的可用。TCP 层通了,还有"应用层协议 / 认证 / 权限 / 连接池 / 配置"等多层可能出错。

nc -zv 到底测了什么(精确版)

TCP 三次握手的完成,证明:

┌─────────────┐                          ┌─────────────┐
│   客户端    │                          │   服务端    │
│             │                          │             │
│             │  ──── SYN ────────▶      │             │
│             │  ◀─── SYN-ACK ────       │             │
│             │  ──── ACK ────────▶      │             │
│             │                          │             │
│             │  TCP 连接建立完成         │             │
└─────────────┘                          └─────────────┘

证明的事情(3 件):

  1. 路由可达:IP 包能到目标主机
  2. 端口有监听:目标端口有进程在 listen
  3. TCP 握手正常:没有被防火墙在传输层拒绝

完全没验证的事情:

  • 监听端口的程序是不是你期望的(可能被别的服务占用)
  • 应用层协议能不能正常交换(MySQL 协议、Redis RESP 协议)
  • 认证信息(账号密码)
  • 访问权限(IP 白名单、用户权限)
  • 应用层资源(连接池容量、数据库句柄)

OSI/TCP-IP 分层模型速记

作为后端工程师,你会经常听到"分层",这里给你一张速记表:

完整 OSI 七层        实用 TCP/IP 四层      代表协议           诊断工具
───────────────────────────────────────────────────────────────────
应用层  ┐                                  HTTP/FTP            curl
表示层  ├─ 合并为 ─── 应用层 ──────         MySQL/Redis         mysql/redis-cli
会话层  ┘                                  Dubbo/gRPC          专属客户端
───────────────────────────────────────────────────────────────────
传输层  ──────────── 传输层 ──────         TCP / UDP            nc -zv
───────────────────────────────────────────────────────────────────
网络层  ──────────── 网络层 ──────         IP / ICMP            ping / traceroute
───────────────────────────────────────────────────────────────────
数据链路层 ┐
           ├─ 合并为 ─── 链路层 ──────     以太网 / WiFi        ifconfig / ip link
物理层     ┘                              网线 / 电磁波         肉眼看灯

关键理解:每一层独立工作,上层不管下层怎么实现,下层不管上层传什么。所以:

  • 下层通了,上层不一定通
  • 上层不通时,要沿着层级逐层往下查

前端类比(建立直觉)

前端场景 对应的层
Fetch 请求、看 Network 面板 response 应用层(HTTP)
看 Response Headers 的 Content-Type 应用层协议细节
net::ERR_CONNECTION_REFUSED 传输层(TCP 握手失败)
net::ERR_NAME_NOT_RESOLVED 应用层(DNS 查询失败)
net::ERR_INTERNET_DISCONNECTED 链路层(没网)
Service Worker / HTTPS 证书 应用层 + TLS

前端开发 99% 时间在应用层跟 HTTP 打交道,所以你对 TCP 及以下层级不熟是完全正常的。后端切入才会深入学这些。

nc 通但服务不通的 7 个典型原因

按从常见到罕见排列:

① 账号密码错(最常见,60% 概率)

典型报错:
Access denied for user 'myslh'@'192.168.x.x' (using password: YES)

原因db.properties 里的密码和实际不符 排查

mysql -h 192.168.0.100 -P 3366 -u myslh -p
# 输入密码验证

② IP 白名单限制(第二常见,20% 概率)

典型报错:
Host '192.168.x.x' is not allowed to connect to this MySQL server

原因:MySQL 的账号权限表里 user 字段限制了可连接的 IP 范围 特点nc 通但 MySQL 应用层直接拒绝 排查:在 MySQL 里查看 SELECT user, host FROM mysql.user;

③ JDBC 驱动版本不兼容(10%)

典型报错:
Unsupported protocol version / Unknown authentication plugin 'caching_sha2_password'

原因

  • 老驱动(com.mysql.jdbc.Driver,MySQL 5.x)连新 MySQL 8 服务器
  • 或 MySQL 8 默认用新认证插件 caching_sha2_password,老驱动不支持

排查:看 pom.xml 里 mysql-connector-java 版本

④ JDBC URL 参数缺失(5%)

典型报错:
The server time zone value 'CST' is unrecognized or represents more than one time zone
Communications link failure due to SSL

原因:URL 里缺必要参数 正确格式

jdbc:mysql://192.168.0.100:3366/slh2?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8&useUnicode=true

⑤ 连接池耗尽(3%)

典型报错:
Could not get JDBC Connection; nested exception is com.mysql.jdbc.exceptions.jdbc4.CommunicationsException
或:HikariPool-1 - Connection is not available, request timed out after 30000ms

原因:MySQL 的 max_connections 达上限、或应用这边连接池满了没释放 排查

SHOW VARIABLES LIKE 'max_connections';
SHOW PROCESSLIST;  -- 看当前连接数

⑥ 权限不足(1%)

典型报错:
SELECT command denied to user 'myslh'@'x.x.x.x' for table 'xxx'

原因:账号能登录但没表操作权限 排查

SHOW GRANTS FOR 'myslh'@'%';

⑦ 中间件/防火墙深度包过滤(<1%,但难查)

典型报错:
Connection reset / 五花八门、无规律

原因:企业防火墙允许 TCP 握手但检测到 MySQL 协议后切断 特征nc 通 + 连接建立瞬间就断 排查:抓包(tcpdump)看看握手后数据流向

分层排查 SOP(资深做法)

场景:Tomcat 启动报 Cannot connect to MySQL

Step 1: 链路层  →  ifconfig                  确认网卡 up,有 IP
Step 2: 网络层  →  ping 192.168.0.100        确认主机可达
Step 3: 传输层  →  nc -zv 192.168.0.100 3366 确认端口开放
Step 4: 协议层  →  mysql -h ... -u ... -p    确认能进库(★ 新增这一步)
Step 5: 权限层  →  SHOW GRANTS / SELECT 1    确认能查数据
Step 6: 应用层  →  看 Tomcat 完整堆栈        定位是驱动还是配置

关键原则

  1. 每层独立验证:用"该层的工具",别跳层
  2. 从下往上:下层不通,上层永远不通
  3. 输出原文记录:报错原话往往比二次理解的描述更有信息量

三层对比

❌ 初级做法(瞎蒙):
   "连不上 → 重启 Tomcat → 改改配置 → 再重启"
   浪费时间、根因不明、问题反复

⚠️ 中级做法(有方向但不系统):
   "连不上 → 看看日志 → 猜是密码问题 → 改密码"
   能解决简单问题,但遇到白名单/驱动版本类问题会卡住

✅ 资深做法(分层排查):
   按 OSI 层级逐层用专用工具验证,每一步的结论都是"这一层 OK/不 OK"
   能在 5 分钟内定位到具体哪一层出问题
   永远不会"改了 A 结果问题出在 B"

你现在的实战下一步

既然 nc -zv 都通了(Step 3 ✅),继续做 Step 4——验证 MySQL 协议层:

# 如果没装 mysql 客户端:
brew install mysql-client

# 或用 docker 跑一次性客户端:
docker run --rm -it mysql:8 mysql -h 192.168.0.100 -P 3366 -u myslh -p slh2

如果 mysql 能进去:说明数据库应用层 OK,后续 Tomcat 报错是 Java 侧问题(驱动/URL/连接池)。 如果 mysql 进不去:报错原话会直接告诉你是密码错还是白名单问题。

面试话术

"后端服务连接类问题,我养成了按 OSI 分层排查的习惯。先用 ping 确认网络层、nc -zv 确认传输层、目标服务的专用客户端(mysql/redis-cli)确认协议层、再查认证和权限。这样每一步的结论都是明确的『这一层 OK/不 OK』,永远知道问题出在哪一层。遇到 nc 通但应用不通的情况,典型场景是密码错、IP 白名单、驱动版本不兼容,按概率倒序查效率最高。"

延伸讨论

Q:为什么 HTTP 问题很少遇到 TCP 层失败?

A:HTTP 背后的 TCP 握手被浏览器和服务器高度封装,失败时浏览器直接报 ERR_CONNECTION_REFUSED。而且 HTTP 一般是 80/443 这种常用端口,运维防火墙配置很成熟。数据库用的是 3366 这种"非标准"端口,白名单、防火墙规则经常出幺蛾子。

Q:抓包工具 tcpdump 和 Wireshark 什么时候用?

A:当你查到应用层还搞不定时。比如:

  • MySQL 连接建立后立刻断开,报错模糊
  • TLS 握手失败但不知道为什么
  • 怀疑中间件/代理在捣乱

抓包能看到完整的数据包内容,是最后的"显微镜"。Wireshark 有 GUI,Linux 服务器上只能用 tcpdump。

Q:为什么 ping 不通但 TCP 可能通,反之也可能?

A:

  • ping 不通 + TCP 通:服务器/中间防火墙 block 了 ICMP 协议(云服务商默认行为),但 TCP 放行
  • ping 通 + TCP 不通:主机活着但目标端口没服务,或防火墙单独 block TCP

所以 ping 只能证明"主机活着",不能证明"服务可用"。单独用 ping 诊断问题是不够的,要 ping + nc 组合

Q:分层思维除了排查网络问题,还有什么用?

A:这是通用工程思维,随处可用:

  • 前端渲染问题:JS 逻辑正确吗 → 组件 state 对吗 → CSS 生效吗 → DOM 结构对吗
  • 性能问题:业务逻辑慢吗 → 函数复杂度高吗 → 内存占用大吗 → GC 频繁吗
  • 权限问题:用户身份对吗 → 角色对吗 → 策略匹配吗 → 资源权限对吗

学会分层,你能把"模糊的大问题"切成"清晰的小问题",这就是工程能力的核心。

暴露的知识盲区小结

缺口 补齐方向
网络协议分层模糊 记住 TCP/IP 四层模型 + 每层诊断工具
认为 TCP 通 = 服务可用 建立"nc 只测握手,应用层另说"的心智
不会用分层思维排查 下次遇到问题强制自问"我现在排查到哪一层了"
只熟悉 HTTP(应用层) 了解 TCP/IP 协议栈,工作/面试都需要

18. Homebrew keg-only 机制

时间:2026-04-20

问题背景

用户 brew install mysql-client 装完后,终端输出提示:

mysql-client is keg-only, which means it was not symlinked into /opt/homebrew,
because it conflicts with mysql (which contains client libraries).

If you need to have mysql-client first in your PATH, run:
  echo 'export PATH="/opt/homebrew/opt/mysql-client/bin:$PATH"' >> ~/.zshrc

装完却不能直接 mysql 一把梭,为什么?

核心概念

keg-only 是 Homebrew 的一种包类型,表示"已安装但不自动加入 PATH",需要用户手动处理。

Homebrew 的两种包安装模式

普通包(Regular):
─────────────────────────────
  /opt/homebrew/Cellar/node/22.0.0/    ← 实际文件位置
                 │
                 │ 自动软链接
                 ▼
  /opt/homebrew/bin/node               ← PATH 里能直接用
  /opt/homebrew/bin/npm                ← 立刻能 node / npm


keg-only 包:
─────────────────────────────
  /opt/homebrew/Cellar/mysql-client/9.6.0/
                 │
                 │ ❌ 不创建软链接
                 │
  /opt/homebrew/bin/  ← 这里什么也没有

  使用方法:
  方案 A:用完整路径
         /opt/homebrew/opt/mysql-client/bin/mysql ...
  方案 B:手动加 PATH
         export PATH="/opt/homebrew/opt/mysql-client/bin:$PATH"

为什么要有 keg-only

避免命令名冲突。几种典型场景:

场景 1:多版本并存

brew install node@18
brew install node@20
brew install node@22

三个都自动加 PATH 的话,node 命令会冲突。所以 @18@20 会被标记为 keg-only,只有最新的 node 走普通模式。

场景 2:系统已有对应软件

macOS 自带 openssl(旧版 LibreSSL)
brew install openssl@3  → 更新版,keg-only

如果 brew 版本覆盖系统版本,可能破坏系统工具链(比如系统 Git 依赖旧 openssl)。所以 brew 的新版只安装不暴露。

场景 3:客户端/服务端分离

brew install mysql          → 完整 MySQL(服务端+客户端),普通包
brew install mysql-client   → 只客户端,keg-only

同时装两个就会冲突(都有 mysql 命令),所以后者 keg-only。你只装了 mysql-client,完全可以安全加 PATH。

常见的 keg-only 包

为什么 keg-only
mysql-client 和 mysql(服务端版)冲突
openssl@3 避免覆盖系统 openssl
libpq PostgreSQL 客户端,和 postgresql 冲突
node@16 / node@18 多版本共存
python@3.11 同上
ruby@3.1 同上
curl (Homebrew 版) 避免覆盖系统 curl
gnu-sed 避免覆盖 macOS 的 BSD sed

识别技巧:带版本号(@3@18)或带 -client 后缀的包,99% 是 keg-only。

三种使用方式

方式 A:用完整路径(一次性,最干净)

/opt/homebrew/opt/mysql-client/bin/mysql -h xxx -u xxx -p

适合场景:偶尔用一次,不想污染全局 PATH 优点:明确知道用的是哪个版本 缺点:命令长

方式 B:加到 PATH(持久,最方便)

echo 'export PATH="/opt/homebrew/opt/mysql-client/bin:$PATH"' >> ~/.zshrc
source ~/.zshrc

适合场景:经常使用 优点:全局可用 缺点:如果将来装了 mysql(服务端版),会产生冲突

方式 C:用 alias(折中)

echo 'alias mysql="/opt/homebrew/opt/mysql-client/bin/mysql"' >> ~/.zshrc
source ~/.zshrc

适合场景:只要某一两个命令,不要整个 bin 目录 优点:颗粒度细、冲突风险低 缺点:脚本里用不到 alias(alias 只在交互式 shell 生效)

前端类比(建立直觉)

Homebrew 的 keg-only 类似 npm 的:

npm 概念 Homebrew 对应
全局安装 npm install -g xxx 普通包 brew install xxx
本地安装 npm install xxx keg-only 包
npx xxx(指定用某个本地版本) 用完整路径调用 keg-only 命令
nvm use 18(切换版本) 手动改 PATH 指向 node@18

核心思想一致:把"可能冲突的东西"放在一边,用完整路径或显式切换来选择使用。

三层对比:Homebrew 用户的成熟度

❌ 初级:
   装完就用,一看 "command not found" 就懵,反复 brew reinstall
   不知道 keg-only 是什么,硬往 PATH 里塞所有东西

⚠️ 中级:
   能看懂 keg-only 提示,知道加 PATH 或用完整路径
   但不知道多版本共存怎么优雅管理

✅ 资深:
   - 理解 keg-only 的设计意图(避免冲突 + 多版本并存)
   - 用 direnv / asdf / mise 管理项目级的版本切换
   - 关键命令用 alias 避免污染 PATH
   - shell 配置文件(~/.zshrc)分层组织,容易维护

在本项目中的应用

你需要偶尔用 mysql 命令连数据库验证,最佳方案:

# 方案:加到 PATH(推荐,你不会装完整 mysql 服务端版)
echo 'export PATH="/opt/homebrew/opt/mysql-client/bin:$PATH"' >> ~/.zshrc
source ~/.zshrc

# 之后可直接用
mysql -h 192.168.0.100 -P 3366 -u myslh -p slh2

如果你担心将来可能装服务端 mysql 产生冲突,用 alias 更保险:

echo 'alias mysql="/opt/homebrew/opt/mysql-client/bin/mysql"' >> ~/.zshrc
echo 'alias mysqldump="/opt/homebrew/opt/mysql-client/bin/mysqldump"' >> ~/.zshrc
source ~/.zshrc

延伸讨论

Q:为什么 brew link 可以强制链接 keg-only 的包?

A:Homebrew 提供 brew link --force <pkg> 可以手动暴露 keg-only 包。

  • 用途:明知冲突但自己清楚后果时的"我知道我在干什么"
  • 风险:可能破坏系统工具链
  • 建议:除非真有必要,优先用 PATH 或 alias 方案

Q:多版本 Node 怎么优雅管理?

A:别用 brew install node@18,用专业工具:

  • nvm:Node 官方推荐
  • fnm:Rust 写的,更快
  • asdf / mise:多语言统一管理(Node + Python + Ruby)
  • 这些工具自带版本切换,不需要改 PATH

Q:keg-only 包占多少磁盘?

A:和普通包一样,该占多少占多少。keg-only 只是"不暴露",不是"精简版"。想省空间用 brew cleanup 清理旧版本。

Q:怎么知道一个包是不是 keg-only?

A:三种方法:

# 方法 1:查看包信息
brew info mysql-client | grep -i keg

# 方法 2:装完看提示
brew install xxx  # 提示里会说 "xxx is keg-only"

# 方法 3:看文件是否有软链接
ls -la /opt/homebrew/bin/ | grep mysql-client
# 如果没输出就是 keg-only

面试话术

"Homebrew 的 keg-only 机制是处理包冲突和多版本共存的设计。被标记的包安装后不自动加 PATH,需要手动用完整路径或加 PATH。典型场景是 openssl@3 避免覆盖系统 openssl、mysql-client 避免和服务端版 mysql 冲突、以及各种 @版本号 的多版本共存。生产实践中,多版本管理推荐用 nvm/asdf/mise 等专业工具,而非硬怼 PATH。"

暴露的知识盲区小结

缺口 补齐方向
不知道 Homebrew 的两种安装模式 看懂 keg-only 提示后的三种处理方案
不理解"软链接"概念 学习 Unix 文件系统的 symlink vs hardlink
以为装完命令就能直接用 PATH 机制:shell 只在 PATH 列的目录里找可执行文件
不知道如何优雅管理多版本 接触 asdf/mise 这类工具,替代手动 brew install @xxx

19. MySQL 生态 / Percona / MariaDB / 官方版的关系

时间:2026-04-20

问题背景

用户连上本项目数据库后,mysql 命令显示:

Server version: 5.7.22-22-log Percona Server (GPL), Release 22

用户看到 Percona Server 这个陌生名词——这不是官方 MySQL,而是它的一个分支。对前端工程师来说是新知识,而且在面试中能展现技术视野。

核心概念

MySQL 在业界有三个主流版本——官方 MySQL、Percona Server、MariaDB——都兼容相同的协议和 SQL 语法,但在性能、功能、治理模式上有差异。

三者的关系(一张图讲清)

        ┌──────────────────────────────────┐
        │  MySQL AB(瑞典公司,2008 年之前)│
        │  创始人:Monty Widenius            │
        └──────────────┬───────────────────┘
                       │ 2008 被 Sun 收购
                       ▼
        ┌──────────────────────────────────┐
        │     Sun Microsystems              │
        └──────────────┬───────────────────┘
                       │ 2010 被 Oracle 收购
                       ▼
        ┌──────────────────────────────────┐
        │          Oracle Corporation       │ ← 现在的 MySQL 官方归属
        └──────────────────────────────────┘

   社区因担心 Oracle 不开源,fork 出两个兄弟:

┌──────────────┐   ┌──────────────┐   ┌──────────────┐
│  MySQL 官方   │   │   Percona    │   │   MariaDB    │
│  (Oracle)    │   │    Server    │   │              │
│              │   │              │   │              │
│  商业化驱动   │   │  性能优化版   │   │  原作者领导   │
│  部分闭源     │   │  完全兼容     │   │  纯开源       │
│              │   │              │   │  功能扩展     │
└──────────────┘   └──────────────┘   └──────────────┘

三者的定位对比

维度 官方 MySQL Percona Server MariaDB
维护方 Oracle Percona(美国公司) MariaDB 基金会
协议兼容 原版 100% 兼容 MySQL 大部分兼容,部分语法差异
存储引擎 InnoDB XtraDB(InnoDB 增强版) InnoDB + Aria + TokuDB 等
性能特色 标准性能 高并发优化、XtraBackup 热备份 并行复制、动态列、存储引擎多
企业支持 收费 免费 + 可选付费支持 免费 + 基金会支持
社区氛围 商业化 工程师导向 开源原教旨
典型使用者 全球通用 互联网大厂、金融行业 欧洲政企、云厂商默认

为什么本项目选 Percona

❌ 如果用官方 MySQL 5.7:
   - 部分监控工具要钱(MySQL Enterprise Monitor)
   - InnoDB 在超高并发下有死锁概率
   - 热备份方案有限

✅ 选 Percona Server 5.7:
   - XtraDB 存储引擎(基于 InnoDB 优化):
     · 更好的高并发写入性能
     · 更多的诊断视图(比如 USER_STATISTICS 表)
   - XtraBackup:免费的热备份工具(官方版要付费)
   - 完全兼容 MySQL 协议,代码零改动

关键:对应用层完全透明。你的 JDBC 驱动、SQL 语句、工具链都不需要改。

面试考点:能说出差异就加分

普通回答(初级):

"我们用的 MySQL 5.7。"

资深回答:

"服务端是 Percona Server 5.7,它是 MySQL 的高性能 fork,完全兼容协议,但在 XtraDB 存储引擎、热备份工具 XtraBackup 上做了优化。我们选它主要是因为高并发场景下它的表现比官方版更稳,而且 XtraBackup 能做物理热备份,比 mysqldump 快一个数量级。"

这种能说出具体技术理由的回答,比直接说"因为公司选的"要强得多。

MariaDB 的独特之处

虽然本项目没用 MariaDB,但业界很多公司在用,值得了解一下:

MariaDB 的独特功能:
─────────────────────────────────────
✅ 存储引擎丰富:Aria(替代 MyISAM)、TokuDB(压缩比极高)、ColumnStore(列存)
✅ 动态列(Dynamic Columns):单列里存 JSON-like 结构
✅ 并行复制:主从同步性能更好
✅ 开源完全纯粹:所有功能都免费
❌ 部分场景和 MySQL 语法有差异:JSON 函数、某些系统表名
❌ 国内社区较弱:出问题 Stack Overflow 答案少

谁在用 MariaDB

  • 云厂商(AWS RDS、Google Cloud SQL 提供 MariaDB 选项)
  • 欧洲公司(反 Oracle 倾向)
  • Linux 发行版默认(CentOS/Rocky 默认预装 MariaDB 不是 MySQL)

三者如何识别(实战技能)

# 方法 1:连上后看版本号
mysql> SELECT VERSION();
+-------------------------+
| 8.0.36-0ubuntu0.22.04.1 |   ← 官方 MySQL
| 5.7.22-22-log           |   ← Percona Server(带 -log 后缀)
| 10.6.7-MariaDB          |   ← MariaDB(版本号 10.x,和 MySQL 完全不同)
+-------------------------+

# 方法 2:看一些 Percona 独有的表
mysql> SHOW TABLES FROM information_schema LIKE '%USER_STAT%';
# 如果有 USER_STATISTICS、CLIENT_STATISTICS → Percona

# 方法 3:mysql 客户端 banner
Server version: 5.7.22-22-log Percona Server (GPL)  ← 直接写了
Server version: 8.0.36                              ← 官方
Server version: 10.6.7-MariaDB-MariaDB              ← MariaDB

三层对比:对 MySQL 生态的认知深度

❌ 初级:
   "MySQL 就是 MySQL,各种版本都一样"
   面试时说不出项目用的是哪个分支

⚠️ 中级:
   知道 MySQL 和 MariaDB 不一样,但不知道 Percona
   能说出"我们用 xxx 版本",但说不出为什么

✅ 资深:
   - 清楚三大分支的历史、定位、技术差异
   - 知道项目选型的理由(性能?成本?合规?)
   - 能说出对应的诊断工具和特色功能
   - 面试时能主动带出"为什么选它而不是另一个"

延伸讨论

Q:Oracle 收购 MySQL 后真的变闭源了吗?

A:没有完全闭源,但确实有"商业化版本":

  • 社区版(Community Edition):完全开源免费,本项目用的就是这个的 fork
  • 企业版(Enterprise Edition):闭源收费,多出监控、备份、审计、加密等功能
  • 社区担心 Oracle 逐步削弱社区版,所以 fork 出 Percona 和 MariaDB 保底

Q:云数据库(如 AWS RDS、阿里云 RDS)用的是哪个?

A:大多数云厂商提供多选:

  • AWS RDS:MySQL / MariaDB / Aurora(AWS 自研的高度 MySQL 兼容版)
  • 阿里云 RDS:MySQL / MariaDB / PolarDB(阿里自研)
  • 云原生分支(Aurora/PolarDB)在存储层做了深度改造,性能远超原版,但只能在对应云上用

Q:选型时怎么评估?

A:

  • 兼容性最重要:老项目迁移就选协议兼容的(Percona 最安全)
  • 性能要求高 → Percona + XtraDB 或云原生(Aurora/PolarDB)
  • 成本敏感 + 完全开源 → MariaDB
  • 企业合规 + 官方支持 → Oracle MySQL Enterprise Edition
  • 新项目 + 云环境 → 云原生版本(PolarDB / Aurora),省心

Q:SQLite / PostgreSQL 是 MySQL 的分支吗?

A:不是。它们是完全独立的数据库系统:

  • SQLite:嵌入式文件数据库(像 JSON 文件,但是 SQL 接口)
  • PostgreSQL:独立血统的开源数据库,语法和 MySQL 不完全兼容
  • 三者对比:MySQL 易用、PostgreSQL 功能强、SQLite 轻量

在本项目中的体现

服务器:192.168.0.100:3366
版本:Percona Server 5.7.22-22
存储引擎:XtraDB(基于 InnoDB)
驱动:com.mysql.jdbc.Driver(MySQL 5.x 驱动,完全兼容)
连接数:开服以来累计 20000+(从 connection id 看出来)

这台 MySQL 承担了本项目的业务数据(slh2 库),后续你要改某个接口查询,最终落到这里。

面试话术

"我们生产环境用的是 Percona Server,它是 MySQL 的高性能 fork,完全兼容协议。选它的主要理由是 XtraDB 引擎在高并发写入下比官方 InnoDB 稳定,还有 XtraBackup 这个免费的物理热备份工具。业界还有 MariaDB 这个分支,更强调开源纯粹性,云厂商默认提供多,但我们选 Percona 是因为它对标 MySQL 更彻底,迁移和兼容性最稳。知道这些差异,在做技术选型和排查疑难杂症时能更精准。"

暴露的知识盲区小结

缺口 补齐方向
以为 MySQL 只有一个版本 记住三大分支的关系:MySQL / Percona / MariaDB
不知道 fork 是社区应对商业化的常见策略 软件史上的 fork 案例:OpenJDK、OpenSearch(ES fork)、Rocky Linux 等
没有"为什么选这个技术"的思考 以后面试 / 工作都主动问:为什么不是另一个?

20. 本项目数据源架构

时间:2026-04-20

问题背景

用户连上 slh2 主库后发现只有 9 张系统配置表,没看到业务表。查询 ds_info 表揭示本项目其实是多数据源架构,真正的业务数据分散在多个数据库里。

核心认知(一句话)

本项目不是"单库应用",而是"按功能拆分的多数据源应用"——配置、业务入口、日志、OA 分别独立成库,应用根据访问目的动态选择连哪个库。

ds_info 实际数据(真实架构)

数据源 id 地址 库名 dstype 建库日期 角色
confc 192.168.0.6:3366 confc room 2020-09-08 配置中心
entry 192.168.0.100:3366 slh2 entry 2021-09-03 入口主库
slh_log 192.168.0.143:3366 slhlog log 2017-11-10 日志库(gbk)
slh_oa 118.196.33.253:3366 dloa oa 2018-07-03 OA 库(公网 IP)

我的错误猜测与纠正

之前的错误推论

看到 DataSourceContext.getUnitDs() 这段代码时,我推断是"多租户分库"——每个租户(unit)一个独立数据库。

实际情况

ds_info 里没有任何租户数据源,只有 4 个按功能拆分的基础库。租户数据源的加载机制可能在别处(unit_info 表?或硬编码?)。

教训

再次证明第 14 章的原则:看见一段代码不等于理解整个系统。看到 getUnitDs() 就推断"多租户分库",是犯了"以偏概全"的错误。

资深的做法:看到局部代码后,先问"这段代码的数据来自哪里?"——去找定义和来源,不要急着推断整体架构。

三种相似概念的辨析

工程师经常混用以下三个词,面试常考其差异:

① 多数据源(Multi-DataSource)← 本项目的做法

应用 ──┬──▶ 配置库 (confc)
       ├──▶ 业务库 (entry)
       ├──▶ 日志库 (log)
       └──▶ OA 库 (oa)

特点:不同库存放不同"类型"的数据
目的:职责分离、性能隔离、资源分层

典型场景:读写分离、日志独立、跨系统集成

② 分库分表(Sharding)

应用 ──┬──▶ 订单库1(用户 ID 1-100万)
       ├──▶ 订单库2(用户 ID 100万-200万)
       └──▶ 订单库3(用户 ID 200万-300万)

特点:同一张逻辑表被切成多份物理表
目的:突破单库性能/容量上限

典型场景:淘宝订单表、微信消息表——单表数据超过亿级

③ 多租户(Multi-Tenancy)

应用 ──┬──▶ 租户A库(A 公司的全部业务数据)
       ├──▶ 租户B库(B 公司的全部业务数据)
       └──▶ 租户C库(C 公司的全部业务数据)

特点:不同租户的数据彻底物理隔离
目的:数据安全、合规、独立计费

典型场景:SaaS 平台(钉钉、飞书、Salesforce)

三者对比表

维度 多数据源 分库分表 多租户
拆分维度 数据类型 数据量/哈希 归属者
数据特点 异构 同构(同样的表结构) 同构(每个租户结构一样)
技术难度 低(配置层) 高(中间件+路由) 中(上下文切换)
代表框架 Spring AbstractRoutingDataSource ShardingSphere、MyCat Hibernate Multi-Tenancy
典型问题 跨库事务 跨分片查询、全局 ID 租户数据泄漏、资源抢占

本项目架构图

                  客户端请求
                      │
                      ▼
        ┌──────────────────────────┐
        │  Spring MVC 网关 (ec-slh2-server)│
        └────────────┬─────────────┘
                     │ @Dubbo RPC
         ┌───────────┴────────────┐
         ▼                        ▼
  ec-trade / ec-fin         ec-inv / ec-mdm
  (业务模块)              (业务模块)
         │                        │
         └────────────┬───────────┘
                      │ 动态数据源路由
      ┌───────┬───────┴──────┬─────────┐
      ▼       ▼              ▼         ▼
   confc   entry         slh_log    slh_oa
  (配置)(业务主库)    (日志)    (OA)
   192.168 192.168       192.168   118.196
   .0.6    .0.100        .0.143    .33.253

 (可能还有按 unit 分的业务库,待进一步验证)

为什么拆成 4 个库(设计取舍)

日志库独立(slh_log)

❌ 如果和业务库混在一起:
   - 日志写入频繁(每个请求一条),拖慢业务库事务
   - 日志表数据量大(亿级),一次全表扫描影响 InnoDB 缓存
   - 备份、归档策略不同(日志只需短期)

✅ 分到独立库:
   - 业务库专注核心事务
   - 日志库可以用独立硬件、独立备份策略
   - 清理历史日志不影响业务

配置中心库独立(confc)

❌ 和业务库混:
   - 配置读多写少,数据量小,和业务混一起浪费资源
   - 配置更新需要全网通知,和业务耦合

✅ 独立:
   - 可以用高可用 + 缓存策略优化(Redis 一级缓存)
   - 配置中心挂了不影响业务(可以用本地缓存兜底)

OA 库独立(slh_oa)

OA(办公自动化)通常是独立系统:
- 有自己的审批、工作流引擎
- 用户体系可能不同
- 通过跨系统数据同步

业务入口库(entry)

这个 entry 库(也就是你登录的 slh2)可能有两种模式

  • 模式 A:所有业务表都在这,你 SHOW TABLES 看到的 9 张只是冰山一角(可能有权限限制)
  • 模式 B:entry 库只存元数据和路由信息,真正业务表在别的库(按租户/模块再拆)

进一步验证方法

SHOW DATABASES;     -- 看这台 MySQL 还有哪些库
SELECT DATABASE();  -- 确认当前在哪个库

-- 看 entry 库里所有表(包括可能隐藏的)
SELECT table_name FROM information_schema.tables
WHERE table_schema = 'slh2';

-- 查找和租户相关的表
SELECT table_name FROM information_schema.tables
WHERE table_schema = 'slh2' AND table_name LIKE '%unit%';

关键发现:日志库用 gbk 编码

db.url=jdbc:mysql://192.168.0.143:3366/slhlog?characterEncoding=gbk&useSSL=false

历史遗留:日志库建于 2017 年,当时 MySQL 默认编码还没统一到 UTF-8(MySQL 8.0 才默认 utf8mb4)。改编码要重建所有表和迁移数据,成本高,就保留至今。

面试加分点

"我们日志库因为历史原因用 GBK 编码,业务库用 UTF-8。这对 Java 侧透明——JDBC 驱动会根据 characterEncoding 参数自动转码。只要不在应用层写死编码,运行时转码成本可忽略。"

关键发现:OA 库用公网 IP

db.url=jdbc:mysql://118.196.33.253:3366/dloa

118.196.33.253 是公网 IP(不是 192.168 内网)。这有几种可能:

可能 1:OA 系统部署在云服务器,通过公网访问
可能 2:多地部署,OA 机房和业务机房不在一起
可能 3:OA 是外包/采购的系统,架在外部

安全风险

  • MySQL 端口公网暴露 → 被扫描器持续攻击
  • 账号 myslh / CottonXu 一旦泄漏,全网可连
  • 如果有 IP 白名单就没事,如果没有就是高危配置

资深视角

❌ 初级反应:"哦,公网 IP,方便"
✅ 资深反应:"这是安全隐患 → 查是否有白名单 → 查是否有 VPN 网关 → 查审计日志"

三层对比:数据源拆分的认知深度

❌ 初级:
   "数据库就是一个地方存所有数据"
   全放一个库,性能出问题就加索引、加机器

⚠️ 中级:
   知道可以拆库,但概念混乱("分库" "多数据源" "多租户"混着用)
   能照着已有架构做维护,但设计新系统时思路混乱

✅ 资深:
   清楚"按类型拆(多数据源)" "按量拆(分库分表)" "按归属拆(多租户)"是三种不同的维度
   能根据业务特点选择合适的拆分策略,并说清楚每种策略的代价
   知道跨库事务、分布式一致性、ID 生成器等附带问题

延伸讨论

Q:多数据源如何在 Spring 里实现?

A:Spring 提供了 AbstractRoutingDataSource 抽象类:

public class DynamicDataSource extends AbstractRoutingDataSource {
    @Override
    protected Object determineCurrentLookupKey() {
        // 从 ThreadLocal 取当前该用哪个数据源
        return DataSourceContext.getCurrentDs();
    }
}

// 切换数据源
DataSourceContext.setCurrentDs("slh_log");
logService.insert(log);  // 自动走 log 库

原理:通过 AOP 或手动切换 ThreadLocal,让每个请求动态选择数据源。

Q:多数据源如何处理跨库事务?

A:

  • 短期:尽量避免跨库写入,能分业务事务就分(最终一致性)
  • 中期:用 MQ 做异步通知,业务库写完发消息,日志库消费消息
  • 长期:引入分布式事务框架(Seata、阿里 GTS、XA 协议)
  • 本项目:看起来是"各写各的",依赖业务流程保证最终一致

Q:日志库用 gbk 到底有多大影响?

A:日常几乎无感知。但要注意:

  • 日志里如果有特殊 Unicode 字符(emoji、古汉字),gbk 存不下会报错或乱码
  • 从日志库导出数据做分析时,记得用 gbk 打开
  • 迁移时是个大坑(数据量大 + 转码成本)

Q:配置中心库(confc)和 Nacos 有什么关系?

A:从 uconfig.xml 看到项目有 nacos:// 引用,说明两者都用

  • confc 库:存储应用启动时需要的基础配置(数据源、服务地址)
  • Nacos:运行时可变配置(功能开关、限流阈值)

分工逻辑:启动必需放数据库(强一致)+ 运行可变放 Nacos(动态推送)

Q:未来如果要做"多租户隔离",怎么改?

A:三种演进路径:

  • Schema 级隔离(中等成本):同一库不同 schema,每个租户一套表
  • 库级隔离(高成本):每个租户独立库(ds_info 加一列 unit_id)
  • 行级隔离(低成本):所有租户同库同表,通过 tenant_id 过滤(本项目可能就是这种)

看代码里 UserContext.mockContext(unitId) 的用法,本项目更像行级隔离——不同 unit 的数据在同一张表里,靠 unit_id 字段区分。

在本项目中的体现

// ec-inv-plugin/.../InvStockDayController.java
Set<Long> unitIds = DataSourceContext.getUnitDs().keySet();
unitIds.forEach(unitId -> {
    UserContext.mockContext(unitId);          // ← 切换"当前租户"上下文
    invStockDayService.initTodayStockDay(unitId, null);  // ← Service 根据 unitId 查询对应数据
});

这段代码遍历所有租户并分别执行。UserContext.mockContext(unitId) 是 ThreadLocal 设置,SQL 执行时会自动加 WHERE unit_id = ? 过滤(可能用 MyBatis 拦截器实现)。

面试话术

"我们项目用的是多数据源架构,按功能拆成四个库:配置中心、业务入口、日志、OA。这和分库分表、多租户是三个不同的概念——多数据源按数据类型拆、分库分表按数据量拆、多租户按归属者拆。我们选多数据源的主要理由是日志库和业务库性能特性差异大(写多读少 vs 读写均衡),放一起会互相拖累。Spring 的 AbstractRoutingDataSource + ThreadLocal 实现动态切换,对业务代码透明。"

暴露的知识盲区小结

缺口 补齐方向
混淆"多数据源/分库分表/多租户" 记清三者的拆分维度和典型场景
不知道 Spring 如何实现多数据源 学 AbstractRoutingDataSource + ThreadLocal 切换模式
以为看一段代码就能推断整体架构 看到关键代码后先问"数据从哪来",再下结论
不关注公网 IP 暴露的安全风险 建立"公网 IP = 必须有白名单"的安全直觉

21. 排查失误:扩大搜索范围时悄悄收缩了过滤条件

时间:2026-05-19 类型:Bug 修复 / 排查复盘(AI 自身失误)

现象

用户问"能不能在腾讯云数据库里查到登录账号 15222215011",我跑了一轮穷举搜索后得出错误结论"查不到",理由是 sc_user.mobile 是远程加密服务(bpcry)的密文、外部 SQL 无法重建密文。

但实际上,别人能查到——这条记录就老老实实存在 hs3d6_sts003.sc_user 里,且 mobile='15222215011' 是明文存的:

id            : 438256620
mobile        : 15222215011    ← 明文,没加密
mobile_cipher : (空)
user_name     : dcy
tenant_id     : 19053

第一反应(错误方向)

看到 sts001.sc_usermobile 字段的几条样本数据是 07341522985+861820271946700726231703 这种"不像国内手机号"的形式,立刻下了一个未经验证的结论:"这是密文,全表都加密了"。

然后顺着这个错误前提一路推下去:

  • MobileSensitiveUtil 源码 → 确认走远程加密服务
  • SspdEncryptUtil 源码 → 确认密钥不在代码库
  • 找反查索引表 → 没有
  • 得出"查不到"的结论

整个推理链是自洽的,但前提是错的——所以越推越远,越自信。

排查思路(事后回放)

用户提示"别人能查到"之后,我直接换了个思路:不再揣测加密,直接拿明摆着的事实去倒推。

SELECT * FROM hs3d6_sts003.sc_user WHERE id=438256620\G

一条 SQL 全字段查询,立刻看到 mobile=15222215011 是明文。

根因(两层)

第一层:查询条件正交性原则被破坏

对比我前后两轮 SQL:

-- 第一轮(只查了 sts001/002/003):两个字段都查
WHERE mobile='15222215011' OR user_name='15222215011'

-- 第二轮(扩大到 hs3d6/chaints/scits 等):只查了 user_name  ← 致命
WHERE user_name='15222215011'

为什么第二轮把条件改窄了?因为我中间插入了一个没有验证的假设:"反正 mobile 是密文,明文匹配不上,那查 mobile 没意义,省掉。"

→ 这个假设让我主动放弃了一个本应保留的过滤维度

直白说:扩大库范围(横向)的同时,收缩了字段范围(纵向)。两个维度本应正交,结果我用一个去抵消另一个,等于在做"假装扩大"。

第二层:用样本数据反推全表特征(统计学谬误)

我看到的"密文样本"其实来自 hs3d6_sts001(不同库),且只有 3 条 mobile<>'' 的样本。基于 3 条样本就给整个表(11 万条)+ 跨库(9 张 sc_user)下"全是加密"的结论,这是采样偏差 + 过度泛化

实际项目里,加密是个渐进迁移过程(看代码就知道):

  • mobile_src_is_cleared 环境开关 + 租户 MOBILE_ENCRYPT 参数共同控制
  • 不同租户、不同时期写入的数据,mobile 字段可能是明文/密文混存
  • mobile_cipher 是空 → 几乎可以肯定 mobile 是明文

我看到 mobile_cipher 这个字段存在,但没去查它是否为空,就跳过了。

修复方案(之后怎么查)

-- 正确做法:横向扩库 + 纵向保留所有可能字段,不要相互抵消
SELECT db, id, user_name, nick_name, mobile, mobile_cipher
FROM (
  SELECT 'hs3d6_sts001' db, id, user_name, nick_name, mobile, mobile_cipher FROM hs3d6_sts001.sc_user
  UNION ALL SELECT 'hs3d6_sts002', id, user_name, nick_name, mobile, mobile_cipher FROM hs3d6_sts002.sc_user
  UNION ALL SELECT 'hs3d6_sts003', id, user_name, nick_name, mobile, mobile_cipher FROM hs3d6_sts003.sc_user
  -- ... 其他库
) t
WHERE mobile='15222215011' OR user_name='15222215011';

相关代码位置

  • ec-mdm/ec-mdm-api/src/main/java/com/hzecool/mdm/encrypt/util/MobileSensitiveUtil.java:77-86 —— mobileSrcIsCleared() 开关判断,说明明/密文是租户级开关而非全局
  • ec-mdm/ec-mdm-api/src/main/java/com/hzecool/mdm/encrypt/util/SspdEncryptUtil.java:64-66 —— genCryBizUnitId()tenantId 作 bizUnitId,证明加密按租户隔离
  • .claude/skills/mobile-decrypt/SKILL.md —— 项目内置技能也明确说"明文 mobile,密文 mobile_cipher",两个字段并存

经验提炼(资深排查准则)

准则 1:扩大搜索范围时,字段维度必须保留或加强,绝不收缩

  • 横向扩(更多库/表)和纵向扩(更多字段/条件)是两个独立维度
  • 横向变了就保持纵向不变,反之亦然
  • 永远不要用"反正 X 字段没用"为由砍掉一个查询条件——除非已独立验证 X 确实没用

准则 2:样本不能代表总体——尤其是带"渐进迁移"特性的字段

  • 加密、脱敏、灰度、多版本数据格式 …… 这些场景下,同一字段的多条记录可能完全不同形态
  • 看到 3 条数据"长得像密文",最多得出"有些记录是密文",不能跳到"全部是密文"
  • 验证方法:SELECT COUNT(DISTINCT 长度), 长度, 一个样本 FROM tbl GROUP BY 长度,或直接看 mobile_cipher 是否为空

准则 3:信"事实"先于信"推理"

  • "别人能查到" = 事实
  • "我推理出查不到" = 推理
  • 两者冲突时,永远先怀疑推理链,从前提开始一条条核查,而不是再去找理由证明推理对

准则 4:直接看一条目标记录的全字段,胜过 10 次 schema 猜测

  • 当 schema 不熟时,最有效的动作不是 DESCRIBE table,而是 SELECT * FROM table WHERE id=<已知ID> \G
  • 真实数据形态一目了然,比从字段名+注释猜测准 10 倍

举一反三:这个反模式在前端的等价场景

场景 同样的反模式表现
调试 React 渲染问题 加了一堆组件后,反而简化console.log 的字段——以为"反正 props 不变",结果就是某个 prop 变了
排查接口报错 把范围从一个 API 扩大到所有调用方时,省略了 query string 的对比——以为"参数都一样",其实差一个
找 CSS 失效 扩大到多个页面排查,但只看 className 不看 computed style——以为"类名对了就行",结果是被覆盖

通用模式:"放宽场景维度的同时窄化观察维度"是侦查工作里最隐蔽的失误——它看起来像在扩大调查,实际在压缩信息量。

面试话术

"我有一次帮人在多分库环境里查一个用户的登录账号,第一轮在 3 个分库里用 mobile OR user_name 两个字段查,没命中;第二轮扩大到 9 个分库,但因为之前看到部分样本数据'像密文',就主观判断 mobile 字段查不到,把条件砍成只查 user_name。结果是错过了——目标记录就在第二轮扩大的库里,mobile 是明文。复盘出两条教训:第一,扩大搜索维度时,绝不能同时收缩过滤维度,这两个是正交的;第二,看到几条样本数据就给整个表下结论是采样偏差,尤其在'加密渐进迁移'这种字段形态混存的场景,必须 COUNT 或看相关字段(如 mobile_cipher 是否为空)来验证。后来我养成的习惯是:先 SELECT * FROM tbl WHERE id=<已知ID> 拿一条真实记录全字段看清楚,再设计批量查询。"

延伸讨论(资深之间能聊下去的方向)

  • "那这个 bug 怎么自动化避免?" → 工具层面:写个查询助手脚本,参数化"目标值 + 候选字段列表 + 候选库表列表",强制笛卡尔积覆盖;流程层面:每次扩大搜索后,把上一轮的所有条件作为最小集合保留。
  • "加密迁移期间,业务读写怎么保证一致?" → 双写:写入时同时更新 mobilemobile_cipher;读取时根据 mobile_src_is_cleared 开关决定走哪个;这是经典的"渐进式数据迁移"模式,类似前端的"功能开关 + 灰度发布"。
  • "如果有 1 亿条记录,这种扫描怎么办?" → 不能全表 WHERE mobile=,得有索引;mobile 加密后无法用普通 B+ 树索引(密文无序),所以业界常见做法是单独维护一张 mobile_hash → cipher 的反查表(用 HMAC 等确定性哈希),既保密又可查。本项目目前没这张表(已确认),说明手机号查询频次没到必须优化的量。
  • "租户级密钥隔离的代价?" → 跨租户运营查询变难(如客服按手机号查任意租户的用户),需要"主密钥能解所有"的特权通道;这是安全 vs 运营便利的经典 tradeoff。

暴露的知识盲区小结

缺口 补齐方向
不知道加密字段可能"明密混存" 看到加密字段,先查 cipher IS NULL 的占比
用小样本反推全表特征 任何"全部都是"结论必须 COUNT(*) 验证
扩大搜索时不自觉收缩条件 扩搜索范围前,把"上一轮的全部条件"写在显眼处当 checklist
没意识到"事实 vs 推理"冲突的处理顺序 事实永远先于推理,冲突时反推推理链而不是反驳事实