企业 Java 困局的复盘与迁移路线(历史栈)
【重要说明 · 本章属于"历史栈"】本章讲的 EJB、重量级应用服务器(WebLogic / WebSphere / WildFly)、JTA 全局事务 等内容,在当前主流工程实践中已经基本被淘汰或边缘化:
① Java EE 已更名为 Jakarta EE(Oracle 于 2017 年捐给 Eclipse 基金会,2019 年改名),从 Jakarta EE 9 起包名从 javax.* 强制改为 jakarta.*;
② EJB 已非主流,它解决过的那些问题(声明式事务、远程调用、池化、安全管理)现在由 Spring 的 @Transactional + REST + 连接池 + Spring Security 以更轻的方式承担;
③ 重量级应用服务器已不是默认选择,绝大多数新项目用"内嵌容器 + Spring Boot 打成可执行 jar"的方式部署。
所以本章的定位不是"教你怎么用",而是"帮你读懂和迁移遗留系统"——如果你接手了一个十几年前的老系统,或者面试被问到 EJB 与 Spring 的关系,这一章就是你要的东西。如果你是新手、准备做新项目,可以直接跳到下一章的总结,不必深究本章的 API 细节。
不要用一个新项目去学 EJB。它既没有学习价值上的性价比(同样的问题 Spring 解决得更简单),也没有就业价值(岗位上几乎见不到)。学习顺序应该是:Servlet/JSP 打底(本页第 1~10 章)→ Spring / Spring Boot → 需要时再回来看 EJB 的概念,那时你会发现"原来 Spring 就是把 EJB 那些笨重的东西做轻了"。本章存在的意义,是让你在面对遗留系统时知道它长什么样、怎么一步步搬出来,而不是让你回去用它。
EJB 为什么会输
先把当年的背景讲清楚,你才能理解"为什么一个官方标准会输给一个第三方框架"。EJB(Enterprise JavaBeans)是 Java EE 的核心规范,1998 年诞生,目标是让开发者不用关心事务、安全、并发、远程调用这些"企业级复杂度"。理想很美好,但它从设计上就背了三个包袱。
论三个包袱,一个比一个致命
① 重量级:必须跑在容器里。一个 EJB 的开发和测试,必须先有一个完整的应用服务器(WebLogic/WebSphere/JBoss)。启动一次几分钟,改一行代码要重新部署。开发体验是灾难级的——你要在"写代码"和"等部署"之间反复横跳。这直接导致了单元测试几乎不可能:一个业务类没法脱离容器跑起来。
② 容器绑定,无法脱离接口编程。EJB 2.x 时代,一个会话 Bean 需要写 Home 接口、Remote 接口、Bean 实现类,还要配 ejb-jar.xml,再让容器生成代理。业务逻辑被淹没在容器的要求里。更糟的是,一旦用了 EJB 的 API(比如 SessionContext),你的代码就和容器绑死了,想换框架等于重写。
③ 测试难,间接导致质量差。不能脱离容器测试 → 只能等部署到服务器上手工点页面 → 回归成本极高 → 团队干脆少改、少测 → 代码越来越不敢动。这是"技术选型影响工程文化"的经典案例。
| 当年的需求 | EJB 的解法 | 现在的解法 | 为什么后者赢了 |
|---|---|---|---|
| 声明式事务 | 容器管理事务(CMT),在 ejb-jar.xml 或注解上声明 | Spring @Transactional | 同样是声明式,但用不着容器,能脱离应用服务器跑单测 |
| 远程调用 | Remote 接口 + RMI/IIOP,靠容器生成代理 | REST(Spring MVC)+ OpenFeign / gRPC | 跨语言、能穿透防火墙、易调试(可以直接 curl) |
| 对象池 / 性能 | 无状态会话 Bean 的实例池由容器管理 | 应用本身就是无状态的,靠水平扩容;对象创建开销可忽略 | 池化的收益在现代 JVM 上微乎其微,复杂度和收益不成比例 |
| 安全 | 容器管理安全,声明式角色 | Spring Security(方法级 @PreAuthorize) | 更灵活,能和业务上下文结合,不绑定容器 |
| 定时任务 | EJB Timer Service | Spring @Scheduled / Quartz / 分布式调度平台 | 简单可控,在多实例下配置分布式锁更容易 |
| 消息驱动 | MDB(Message-Driven Bean) | @RocketMQMessageListener / @KafkaListener | 主流 MQ 都有自己的 Spring 集成,不依赖容器 |
看这张表你会发现一件事:EJB 的每一项能力,后来都有更轻、更自由的替代品。这不是说 EJB 当年设计得差——它是在"应用服务器是唯一部署形态"的年代做的最优解。但当部署形态变了(容器化、可执行 jar、云原生),绑定容器的设计就成了致命伤。
Java EE → Jakarta EE:包名迁移与规范现状
本页第 1 章已经讲过这段历史的来龙去脉,这里聚焦"迁移时你会遇到什么"。核心事实只有一个:从 Jakarta EE 9 开始,所有 javax.* 的 EE 包名变成了 jakarta.*,而且这是强制的、不兼容的。
包名迁移的对照:改的是 import,但牵扯的是整条依赖链
// ============ 老代码(Java EE 8 / Spring Boot 2.x,javax 时代)============
import javax.servlet.http.HttpServlet;
import javax.servlet.annotation.WebServlet;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.validation.constraints.NotNull;
import javax.inject.Inject;
import javax.transaction.Transactional;
import javax.annotation.Resource;
// ============ 新代码(Jakarta EE 9+ / Spring Boot 3.x,jakarta 时代)============
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.annotation.WebServlet;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.validation.constraints.NotNull;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import jakarta.annotation.Resource;
// 规律:javax 后面接 EE 子包名(servlet / persistence / ws / validation /
// inject / transaction / annotation / faces / mail / jms ...)的,全换成 jakarta
//
// ⚠️ 注意例外:以下 javax 包属于 Java SE,不参与改名,别一起替换了
// javax.sql.DataSource (JDBC 在 SE 里)
// javax.naming.* (JNDI 在 SE 里)
// javax.crypto.* (加密在 SE 里)
// javax.net.ssl.* (SSL 在 SE 里)
// 如果无脑全局替换 javax → jakarta,这几个会被改坏,编译直接崩
批量迁移的实操:用 OpenRewrite 自动改,而不是手工替换
<!-- pom.xml 里加 OpenRewrite 插件,跑一次自动迁移 -->
<plugin>
<groupId>org.openrewrite.maven</groupId>
<artifactId>rewrite-maven-plugin</artifactId>
<version>5.38.0</version>
<configuration>
<activeRecipes>
<!-- 官方提供的 Spring Boot 2 → 3 迁移配方 -->
<recipe>org.openrewrite.java.spring.boot3.UpgradeSpringBoot_3_2</recipe>
<!-- 它会顺带处理 javax → jakarta 的包名替换 -->
<recipe>org.openrewrite.java.migrate.jakarta.JavaxMigrationToJakarta</recipe>
</activeRecipes>
</configuration>
</plugin>
# 先看一眼会改哪些文件(dryRun 不落盘)
mvn rewrite:dryRun
# 确认无误后真正执行
mvn rewrite:run
# ---------- 为什么必须用工具而不是 IDE 全局替换 ----------
# 1) 它知道哪些 javax 属于 SE(javax.sql / javax.naming)不能改
# 2) 它会同步升级依赖版本(Spring Boot 2.x → 3.x 要一起动)
# 3) 它会处理配置文件里的类名(比如 MyBatis 的 typeHandler 全限定名)
# 4) 它会给出 diff,便于 review,而不是一次不可控的批量修改
# 5) 它还能处理 XML(web.xml 里的类名、Spring 的 XML 配置)
| 规范 | 包名 | 现状与迁移要点 |
|---|---|---|
| Servlet | jakarta.servlet.* | 仍然活跃,是 Spring MVC 的底座。迁移只需改 import + 换 Tomcat 10+。注意 Tomcat 9 与 10 不能混用同一份代码。 |
| JPA | jakarta.persistence.* | 仍然是主流(Hibernate 是其实现)。迁移改 import,同时 Hibernate 要升到 6.x。注意 Hibernate 6 的查询语法与类型映射有行为变化。 |
| JAX-RS | jakarta.ws.rs.* | 仍在用(Jersey、RESTEasy),但新项目更常直接用 Spring MVC。迁移改 import 即可。 |
| CDI | jakarta.inject.* / jakarta.enterprise.* | 规范仍在,但实践中大量被 Spring 的 DI 取代。老系统里 CDI 与 Spring 混用的场景最难迁移,要理清 Bean 的归属。 |
| Bean Validation | jakarta.validation.* | 非常活跃,注解用法不变(@NotNull、@Size)。迁移只需改 import + 升级 Hibernate Validator 到 8.x。 |
| JTA | jakarta.transaction.* | 规范还在,但使用场景大幅收窄(见后文"JTA → 本地事务/Seata")。 |
| EJB | jakarta.ejb.* | 规范仍存在(Jakarta EE 全平台要求),但新项目已几乎不用。老系统迁移时通常直接把 EJB 拆成 Spring Bean + REST 接口。 |
| JMS | jakarta.jms.* | 规范仍在,但主流做法是直接用 RocketMQ / Kafka 的客户端与 Spring 集成,不再依赖容器提供的 JMS 资源。 |
一、无脑全局替换 javax → jakarta。javax.sql.DataSource、javax.naming.*、javax.crypto.*、javax.net.ssl.* 都是 Java SE 的包,改了直接编译失败。正确做法:用 OpenRewrite 这类懂语义的工具,或者至少只替换已知的 EE 子包名。
二、只改代码没改依赖。classpath 里还留着 Boot 2.x 的 starter,里面引的是 javax 版本的类,于是你看到"javax.servlet.http.HttpServletRequest 找不到"或者更诡异的 NoSuchMethodError。包名迁移必须连依赖一起升:Spring Boot 3.x + Tomcat 10+ + Hibernate 6.x + Hibernate Validator 8.x。
三、漏改"字符串形式的类名"。IDE 的重构不会管这些地方:MyBatis 的 typeHandler 全限定名、Spring XML 里的 class= 属性、反射里写的类名字符串、persistence.xml、以及各种第三方配置。迁移后要全局搜一遍 javax. 这个字符串,而不只是搜 import。
从应用服务器到内嵌容器 + Spring Boot
这是老系统迁移中体感最明显的一步:从"把 war 包丢进 WebLogic,等启动五分钟"变成"java -jar app.jar,三秒起来"。这一步的价值不只是快,而是开发、测试、部署、扩容全部变了。
| 维度 | 重量级应用服务器(历史做法) | 内嵌容器 + Spring Boot |
|---|---|---|
| 打包形态 | war / ear 包,部署到容器的 deploy 目录 | 可执行 jar(内含 Tomcat/Jetty/Undertow),或薄 war 跑在外部容器 |
| 启动方式 | 启动应用服务器,再部署应用;启动常以分钟计 | java -jar,秒级启动 |
| 配置位置 | 服务器控制台 + 容器级 xml + 应用 xml,三处混杂 | application.yml + 环境变量 + 配置中心,集中且可版本管理 |
| 本地开发 | 要在本地装一个应用服务器 | 不需要,直接跑 main 方法;测试用 @SpringBootTest 可脱离容器 |
| 资源隔离 | 多个应用共享一个服务器(一个应用的 OOM 会影响别人) | 一个进程一个应用,故障隔离好,配合容器编排可独立扩缩容 |
| 运维成本 | 要专门的中间件运维,License 费用高 | 不需要商业 License,用 K8s 统一运维 |
| 什么时候还需要外部容器 | — | 公司有既定的容器平台要求、需要容器提供的集中式 JNDI/安全域、或者多个应用必须共享容器资源 |
迁移前后的对照:从 web.xml 到 Java Config / 注解
<!-- ============ 老做法:web.xml(应用的部署描述符)============ -->
<?xml version="1.0" encoding="UTF-8"?>
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" version="3.1">
<display-name>legacy-app</display-name>
<!-- Servlet 与路由映射 -->
<servlet>
<servlet-name>userServlet</servlet-name>
<servlet-class>com.example.web.UserServlet</servlet-class>
<init-param>
<param-name>pageSize</param-name>
<param-value>20</param-value>
</init-param>
</servlet>
<servlet-mapping>
<servlet-name>userServlet</servlet-name>
<url-pattern>/user/*</url-pattern>
</servlet-mapping>
<!-- 过滤器 -->
<filter>
<filter-name>encodingFilter</filter-name>
<filter-class>com.example.web.EncodingFilter</filter-class>
</filter>
<filter-mapping>
<filter-name>encodingFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
<!-- 数据源(JNDI 查找容器配置好的资源)-->
<resource-ref>
<res-ref-name>jdbc/UserDS</res-ref-name>
<res-type>javax.sql.DataSource</res-type>
<res-auth>Container</res-auth>
</resource-ref>
<session-config>
<session-timeout>30</session-timeout>
</session-config>
</web-app>
// ============ 新做法:Spring Boot 的注解 + Java Config,零 XML ============
@SpringBootApplication // 等价于"包扫描 + 自动配置 + Java Config 入口"
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
// Servlet 的现代替身:Controller(路由靠注解,不用 web.xml)
@RestController
@RequestMapping("/user")
public class UserController {
// 原来的 init-param 变成了配置项 + @Value 注入
@Value("${app.user.page-size:20}")
private int pageSize;
@GetMapping("/{id}")
public UserVO get(@PathVariable Long id) { /* ... */ return null; }
}
// Filter 的现代替身:注册成 Bean 即可,不用 web.xml 里的 filter-mapping
@Component
public class EncodingFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse resp,
FilterChain chain) throws ServletException, IOException {
req.setCharacterEncoding("UTF-8");
resp.setCharacterEncoding("UTF-8");
chain.doFilter(req, resp);
}
}
# application.yml:原来的 web.xml + 服务器控制台配置,现在集中在这一个文件
server:
port: 8080
servlet:
session:
timeout: 30m # 对应 web.xml 的 session-timeout
app:
user:
page-size: 20 # 对应原来 Servlet 的 init-param
JNDI 数据源到 DataSource Bean,JTA 到本地事务
这两个是"容器依赖"最深的两个点,也是老系统迁移时改动最需要小心的地方。它们共同的特点是:原来由应用服务器提供能力,现在要由应用自己声明。
JNDI 查找 → DataSource Bean(连接池由应用管理)
// ============ 老做法:从容器的 JNDI 命名空间里"查"一个数据源 ============
// 数据源是应用服务器在控制台配好的,应用只负责查找
public class LegacyDao {
private DataSource dataSource;
public LegacyDao() throws NamingException {
// 这段代码只有跑在应用服务器里才有意义,脱离容器直接报 NameNotFoundException
InitialContext ctx = new InitialContext();
this.dataSource = (DataSource) ctx.lookup("java:comp/env/jdbc/UserDS");
}
public String findName(Long id) throws SQLException {
try (Connection conn = dataSource.getConnection();
PreparedStatement ps = conn.prepareStatement("select name from users where id = ?")) {
ps.setLong(1, id);
try (ResultSet rs = ps.executeQuery()) {
return rs.next() ? rs.getString("name") : null;
}
}
}
}
// ============ 新做法:数据源是应用的一个 Bean,池化交给 HikariCP ============
@Configuration
public class DataSourceConfig {
@Bean
@ConfigurationProperties("spring.datasource.hikari")
public DataSource dataSource() {
// Spring Boot 会自动读取 spring.datasource.* 配置并创建 HikariDataSource
// 这里显式声明便于自定义(比如多数据源)
return DataSourceBuilder.create().type(HikariDataSource.class).build();
}
@Bean
public JdbcTemplate jdbcTemplate(DataSource ds) { return new JdbcTemplate(ds); }
}
# application.yml:连接信息在这里,不再依赖容器控制台
spring:
datasource:
url: jdbc:mysql://localhost:3306/mydb?characterEncoding=utf8&useSSL=false
username: app
password: ${DB_PASSWORD} # 密码从环境变量注入,别写死在配置文件里
hikari:
maximum-pool-size: 20 # 池大小由应用决定,要按数据库承载能力算,不是越大越好
connection-timeout: 3000
max-lifetime: 1800000
pool-name: UserPool
// 移交给 Spring 管理后,业务代码用 JdbcTemplate 或 MyBatis,不再自己管 Connection
@Repository
public class UserDao {
private final JdbcTemplate jdbc;
public UserDao(JdbcTemplate jdbc) { this.jdbc = jdbc; }
public String findName(Long id) {
// 连接由框架管理,异常也由框架翻译成 DataAccessException
return jdbc.queryForObject("select name from users where id = ?", String.class, id);
}
}
JTA 全局事务 → 本地事务(单库)或 Seata(跨库)
// ============ 老做法:JTA 管理跨多个资源的全局事务 ============
// JTA 能同时管"数据库 A + 数据库 B + JMS 队列",靠两阶段提交
public class LegacyService {
@Resource
private UserTransaction userTransaction; // 容器提供的 JTA 事务管理器
public void transfer(Long from, Long to, BigDecimal amount) throws Exception {
try {
userTransaction.begin(); // 手动开启全局事务
accountDaoA.deduct(from, amount); // 数据源 A(XA 连接)
accountDaoB.add(to, amount); // 数据源 B(XA 连接)
mqSender.send("transfer.done"); // JMS 资源(也参与两阶段提交)
userTransaction.commit();
} catch (Exception e) {
userTransaction.rollback();
throw e;
}
// 这段代码的问题:强依赖容器提供 UserTransaction;XA 性能差、连接不能复用;
// 而且脱离应用服务器后,这段代码根本跑不起来,也没法写单元测试
}
}
// ============ 新做法之一:单库场景,用 Spring 的本地事务就够了 ============
@Service
public class AccountService {
// 只写一个库,本地事务完全够用,简单、快、好测
@Transactional(rollbackFor = Exception.class)
public void transfer(Long from, Long to, BigDecimal amount) {
accountMapper.deduct(from, amount);
accountMapper.add(to, amount);
}
}
// ============ 新做法之二:真的跨库/跨服务,用分布式事务框架 ============
@GlobalTransactional(timeoutMills = 30000) // Seata,替代 JTA 的全局事务能力
public void transferAcrossServices(Long from, Long to, BigDecimal amount) {
accountClient.deduct(from, amount); // 服务 A(自己的库)
accountClientInB.add(to, amount); // 服务 B(另一个库)
// 失败时由补偿机制回滚,不再依赖 XA 的两阶段提交
}
// ============ 新做法之三:不需要一致性保证的,直接异步化 ============
@Transactional
public void transferAndNotify(Long from, Long to, BigDecimal amount) {
accountMapper.deduct(from, amount);
accountMapper.add(to, amount);
// 通知类动作放本地消息表,同一事务提交,后续异步投递(见 Spring Cloud 那册)
localMessageMapper.insert(new LocalMessage("TRANSFER_DONE", "..."));
}
论为什么 JTA 会被放弃?把账算清楚
① JTA 的代价:它要实现两阶段提交,意味着参与的资源都必须是 XA 感知的、连接在事务期间被独占、协调者成为单点。结果是:性能下降明显、资源利用率低、故障场景复杂(悬而未决的事务要人工处理)。
② 实际业务的需求:真正需要"强一致跨资源"的场景其实很少。绝大多数所谓"跨库操作"其实是"主流程 + 通知"的关系——只需要保证通知最终会被送达,不需要它和主流程在同一瞬间完成。
③ 新范式的取舍:承认"中间状态存在",然后用补偿或幂等消费把它收敛掉。这换来的是:主流程延迟由自己决定、故障不会全局阻塞、系统可以水平扩展。代价是从"数据库替你保证"变成"你要自己写补偿和幂等"。
④ 关于 JTA 的遗产:Jakarta EE 里 JTA 规范仍然存在,某些金融/电信的老系统也确实需要它。但在 Spring Boot 应用里,除了极少数场景(比如必须同时写多个 XA 数据源),基本不会用到。迁移时的标准动作是:先判断"是不是真的需要强一致",通常答案是不需要。
渐进式迁移:绞杀者模式
最后讲方法。老系统迁移最大的风险不是技术难度,而是"一次性大重写"的诱惑——"我们干脆重新写一遍吧,三个月就好了"。这句话是无数项目失败的起点。正确的方法是绞杀者模式(Strangler Fig Pattern):在外面搭一层新入口,把功能一个个搬过去,老的逐步缩小,直到完全被替换。
绞杀者模式的落地:反向代理 + 按路由逐步切流
# ============ 第一步:在旧系统前面加一层代理,先全部转发给老系统 ============
# Nginx 配置:此时所有请求都进老应用,行为完全不变,零风险
server {
listen 80;
server_name app.example.com;
location / {
proxy_pass http://legacy-app:8080; # 老应用,原封不动
proxy_set_header Host $host;
}
}
# ============ 第二步:新写一个模块(比如用户中心),按路径切一部分流量 ============
# 只把 /api/user/ 切到新服务,其余仍然走老应用
server {
listen 80;
server_name app.example.com;
# 已迁移:用户模块走新服务
location /api/user/ {
proxy_pass http://new-user-service:8081;
}
# 未迁移:其余全部继续走老应用
location / {
proxy_pass http://legacy-app:8080;
}
}
# ============ 第三步(进阶):按比例灰度,先给 5% 流量 ============
# 用 Nginx 的 split_clients 做按流量比例分流,出问题立刻调整比例
split_clients "${remote_addr}${request_uri}" $backend {
5% new-user-service;
* legacy-app;
}
server {
listen 80;
location /api/user/ {
proxy_pass http://$backend;
# 加个响应头便于对照:确认请求到底走了哪个系统
add_header X-Served-By $backend always;
}
}
// ============ 若无法改代理(比如直连老服务器),用 Spring Cloud Gateway 做网关层 ============
@Configuration
public class StranglerRoutes {
@Bean
public RouteLocator routes(RouteLocatorBuilder builder) {
return builder.routes()
// 已迁移的模块 → 新服务
.route("new-user", r -> r
.path("/api/user/**")
.filters(f -> f.addResponseHeader("X-Served-By", "new"))
.uri("lb://new-user-service"))
// 兜底:所有未迁移路径继续打到老应用
.route("legacy-fallback", r -> r
.path("/**")
.filters(f -> f.addResponseHeader("X-Served-By", "legacy"))
.uri("http://legacy-app:8080"))
.build();
}
}
| 迁移阶段 | 动作 | 验收标准 / 回滚方式 |
|---|---|---|
| 0. 建立观测 | 给老系统补上日志、指标、链路追踪,摸清真实的调用量分布 | 能回答"哪个接口最热、哪个模块最痛"。没有这步,后面都是盲搬。 |
| 1. 加代理层 | 在老系统前加 Nginx/网关,此时全部流量仍给老系统 | 行为零变化。回滚 = 摘掉代理。这一步是后续所有操作的安全带。 |
| 2. 选低风险模块 | 挑一个业务独立、调用量小、无强依赖的模块先迁(比如配置查询、公告) | 用低风险模块验证整个迁移流水线(开发规范、部署、监控、回滚)。 |
| 3. 双写 / 数据同步 | 新旧系统共享数据(老库只读,新服务写新库;或用 CDC 同步) | 数据一致性有校验任务比对,出现偏差立即告警。回滚 = 切回老路由,数据不丢。 |
| 4. 灰度切流 | 5% → 20% → 50% → 100%,每档观察关键指标(错误率、延迟、业务量) | 任何异常先降流量比例。回滚成本必须接近于零,这是能持续前进的前提。 |
| 5. 下线老模块 | 观察期结束(通常一到两个业务周期)后,摘掉老代码与老路由 | 确认无任何调用(看老系统日志)再删。不要急着删,留着不占多少成本,删错代价很高。 |
| 6. 重复 2~5 | 按模块循环推进,直到老系统只剩空壳 | 整个过程新老共存,任何时刻系统都是可用的。 |
原因一:需求会变,而你在追一个移动的目标。重写要半年,半年里业务需求改了无数次,你的目标是"复刻旧系统在开始那天的行为",等你上线时它早就过时了。
原因二:旧系统的行为里藏着大量"没人知道的规则"。那些奇怪的 if 分支、特殊客户的特殊逻辑、为了绕过某个 bug 打的补丁——它们不写在文档里,只存在于代码和老员工的记忆里。只有小步迁移时,才能在被业务投诉的那一刻把这些规则一条条挖出来。
原因三:你只有一次成功机会。大重写意味着"某个时间点全量切换"。那一天出任何问题,都是全站故障,而且没有退路(新老数据已经分叉)。绞杀者模式下,任何一次失误的影响范围都只是一小部分流量,切回去就恢复了。
原因四:项目会被叫停。大重写在前 80% 的时间里看不到任何产出(业务零收益),管理层压力一大就砍资源,最后留下一个半成品和一堆技术债。小步迁移每一步都有可交付的成果,更容易获得持续投入。
论迁移中最难的部分其实不是技术
① 是"两个系统同时存在"的这段时间。数据要同步、日志要能串起来、监控要覆盖两边、运维要知道出了问题该看哪个系统。很多人低估了共存的复杂度,把精力全放在"新系统怎么写"上。
② 是"哪些能改、哪些不能碰"的判断。老系统里有些代码看起来丑陋,但它是某个历史事故的补丁,动了就会出问题。迁移前必须把"业务规则"从"实现细节"里分离出来,前者要保留,后者可以重写。
③ 是组织耐心。绞杀者模式的美德是"没有激动人心的大日子",缺点也是"没有激动人心的大日子"——它看起来不像个"项目"。所以要在开始时就把路线图和衡量指标(每季度迁完哪几个模块)讲清楚,让对方知道在稳步前进。
① 本章是历史栈:EJB 与重量级应用服务器已非主流,学它是为了读懂遗留系统、答好面试题,不是用来做新项目。
② EJB 输在"重量级 + 容器绑定 + 测试难",而它解决过的每个问题,现在都有更轻的解法。
③ Java EE → Jakarta EE 的关键是包名 javax.* → jakarta.*;注意 javax.sql / javax.naming 属于 Java SE,不能改。
④ 包名迁移要用 OpenRewrite 这类语义工具,不要手工全局替换;且必须同步升级依赖(Boot 3.x + Tomcat 10+ + Hibernate 6.x)。
⑤ 部署形态的迁移是 war/ear + 应用服务器 → 内嵌容器 + 可执行 jar;配置从"三处分散"集中到 application.yml + 环境变量。
⑥ web.xml → 注解与 Java Config;JNDI 数据源 → 应用自己声明 DataSource Bean(HikariCP 池化)。
⑦ JTA 全局事务 → 先判断"是否真的需要强一致",单库用本地事务,跨服务用 Seata,通知类异步化。
⑧ 迁移用绞杀者模式:先加代理层,再一个模块一个模块地灰度切流,保证任何时刻都能零成本回滚。
⑨ 绝不做"一次性大爆炸重写":需求会变、隐藏规则会漏、只有一次成功机会、且容易被叫停。
章末面试题
1.(概念题)EJB 和 Spring Bean 是什么关系?为什么 Spring 取代了 EJB?
查看答案
答案:两者解决的是同一类问题:声明式事务、依赖注入、对象生命周期管理、安全管理。区别在实现方式:EJB 必须跑在应用服务器容器里,依赖容器提供事务管理器、安全上下文、实例池,业务代码与容器 API 绑定,无法脱离容器测试;Spring 把容器做成了普通的 Java 库,一个 main 方法就能启动,@Transactional 靠 AOP 代理实现,@Autowired 靠反射注入。所以 Spring 赢在"同样的能力,但不需要重量级容器",开发、测试、部署成本都低一个数量级。
2.(迁移题)把 Spring Boot 2.x 升到 3.x,为什么包名要改?有哪些 javax 包不该改?
查看答案
为什么改:Java EE 捐给 Eclipse 基金会后改名 Jakarta EE,因为"Java"是 Oracle 的商标。为避免与 Oracle 自己维护的 javax 包冲突,从 Jakarta EE 9 起所有 EE 规范包名强制改为 jakarta.*。不该改的(属于 Java SE,不参与改名):javax.sql.*(JDBC,如 DataSource)、javax.naming.*(JNDI)、javax.crypto.*、javax.net.ssl.*、javax.management.* 等。实操建议:用 OpenRewrite 的迁移配方,它会按语义区分;如果手工替换,务必逐个人工确认。
3.(架构题)老系统里有 JTA 管理的跨库事务,迁移到 Spring Boot 后你打算怎么处理?
查看答案
答案:先做业务判断,不要直接找 JTA 的替代品。第一步:确认是否真的需要强一致。很多所谓的跨库操作其实是"主流程 + 通知(发消息、写报表、加积分)",这类只需要最终一致。第二步按场景分流:① 只有一个库写入 → 直接用 @Transactional 本地事务;② 跨服务/跨库且必须一起成功 → Seata(AT/TCC),或者用本地消息表 + 幂等消费实现最终一致;③ 确实是资金对账这类强一致短事务 → 评估 Seata XA,但要接受性能下降。原则:不要为了"形式上和原来一样"而引入 JTA,先问业务到底能不能容忍中间状态。
4.(工程题)什么是绞杀者模式?它相比"重写一遍"的核心优势是什么?
查看答案
答案:绞杀者模式是在旧系统外面加一层代理入口,然后按模块逐个把功能迁到新系统,通过路由把对应流量切过去,未迁移的部分继续由旧系统承担,直到旧系统被完全替换(就像榕树把宿主树包住慢慢"绞杀")。核心优势:① 全程新旧共存,任何时刻系统都是可用的;② 每次只迁一个模块,出问题的影响面小、回滚成本接近零(切回老路由即可);③ 每一步都有可交付成果,能持续获得资源;④ 迁移过程中会被业务投诉逼出旧系统里"没人知道"的隐藏规则,比一次性重写更容易把这些规则挖出来。
5.(判断题)你接手一个 2010 年的老系统,老板说"干脆重新写一遍,三个月上线"。你怎么回应?
查看答案
答案:先表达理解(老系统确实难维护),再用三点说明风险:① 需求在变——三个月后业务需求已经和今天不同,重写等于追一个移动目标;② 隐藏规则会漏——老代码里那些奇怪的 if 分支往往是特殊客户/历史事故留下的补丁,文档里没有,只有小步迁移时被投诉才能发现;③ 只有一次机会——全量切换那天出问题就是全站故障,且数据已分叉、无法回退。建议给出替代方案:先用一到两周做现状梳理(调用量分布、模块依赖),选出 1~2 个低风险独立模块用绞杀者模式先迁,建立"开发 → 灰度 → 回滚"的完整流水线,用一次成功的迁移证明这条路可行,再按季度规划后续模块。把"三个月上线"变成"每季度都有模块上线"。