第 23 章 综合实战:把全套串成一个能上线的服务(并收尾全书)
你走到这章,前面 22 章的零件都齐了:第 15 章知道 Tomcat 和 RedisTemplate 是怎么”凭空”进容器的,第 16 章知道一次请求过了几道门,第 17 章知道连接池和事务边界,第 18 章知道 SQL 在你手里,第 19 章知道缓存三大坑,第 20 章知道 Kafka 怎么异步解耦,第 22 章知道 traceId、Micrometer、Actuator 这套可观测三件套。
但零件和能跑的服务之间,还差一件最关键的事:把它们拼起来,并且知道拼完之后线上炸了去哪下手。这一章不教新概念,只干一件事——带你把一个真实可上线的服务从 0 搭到上线,再把前面所有知识串成你自己的排错肌肉记忆。
23.1 选需求:为什么是短链服务
先别急着写代码,先想清楚”练什么”。你要挑一个需求,它能逼你把每一层都用上,又别复杂到三天写不完。我选短链服务(short link),理由很实在:
- 它有 HTTP 入口(发短链、跳长链、查计数)—— 用上第 16 章 Web 层。
- 它要持久化长链映射和点击数—— 用上第 17/18 章的 MyBatis + HikariCP + 事务。
- 它的”短码查长链”是典型读多写少、热点集中—— 用上第 19 章 Redis 缓存和三大坑。
- 它的”点击计数”允许异步、允许最终一致—— 用上第 20 章 Kafka 解耦。
- 它上线后你要能回答”刚才那次跳转慢在哪”—— 用上第 22 章 MDC + Micrometer + Actuator。
短链服务就三个核心接口,先钉死契约:
// 1. 发短链:长 URL -> 短码
POST /api/shorten body: {"longUrl":"https://...","userId":1001}
// 2. 跳转:短码 -> 302 到长链(这是高频读,必须走缓存)
GET /{shortCode}
// 3. 计数:查某短链累计点击(允许短暂不一致)
GET /api/stats/{shortCode}问题 1: 短码怎么生成?长链存哪、短码存哪、点击数存哪?
短码用「发号器 + 62 进制」最简单:一个自增 id 转成 a-zA-Z0-9 的短串,冲突概率为零,还不用算 hash。长链和短码的映射落 MySQL(url_map 表:short_code 唯一索引、long_url、user_id、create_time)。点击数别每次写 MySQL——热点短链一秒几万次点击,直接打库会把它打挂,所以点击数走 Redis INCR,再异步靠 Kafka 落库(呼应第 19 6 节”缓存与 DB 一致性靠 MQ 兜底”)。
【思考】点击数这种数据,放进 Redis 用
INCR计数、异步落库,会不会丢数?和”余额不能进缓存”那条纪律冲突吗?
参考答案
直接答案:会丢,但是”可接受地丢”。 点击数属于第 19 6 节说的”能容忍最终一致窗口、不一致代价小”的数据——运营看报表差几十个点击无所谓,它和”余额/库存”那种”不一致就资损”的数据是两类。所以点击数进 Redis 不违反纪律,因为纪律本来就是”按业务 SLA 选一致性级别”,不是”一律不进缓存”。
展开看风险点:Redis INCR 后如果实例宕机、且这条增量还没被 Kafka 消费者落库,那部分点击就丢了。这是设计取舍,不是 bug。要更稳,可以做两件事:① Kafka 发的是”点击事件”原始流,消费者用 INSERT ... ON DUPLICATE KEY UPDATE count = count + 1 原子累加,DB 是真相源;② 本地再叠一层 Caffeine(第 19 7 节多级缓存思路)挡住 Redis 宕机时的瞬时冲击。
和余额的区别:余额若用 INCR 再异步落库,宕机瞬间丢的是真钱,且”显示有钱实际没钱”是资损——所以余额的扣减必须落在 DB 事务里(第 17 章的 UPDATE ... WHERE 原子更新)。点击数落库失败只是”少一个 UV 统计”,业务不疼。判断标准就一句:这份数据不一致时,代价谁扛?
更深一层:这题考的是你有没有把第 19 章”一致性是业务 SLA 问题”真正内化。很多人学缓存只背”先更 DB 再删缓存”,但一到真实设计就僵化。短链服务的点击数,正确的架构恰恰是”Redis 挡写 + Kafka 异步落库”,它故意放弃了强一致,因为点击数不值得为强一致付出同步写 DB 的 RT 代价。你能分清”哪份数据配强一致、哪份配最终一致”,才说明缓存那章学透了。
23.2 从 0 搭一条龙:讲决策,不贴全量代码
我按层讲”为什么这么装、踩了什么雷”,不把完整 pom.xml 和每个类都贴出来——那会变成 API 文档,而你已经会看 javadoc 了。
23.2.1 Spring Boot 起项目(第 15 章)
pom.xml 里加一组 Starter,项目就能起内嵌 Tomcat:
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId> <!-- 自动装配 Tomcat + DispatcherServlet + Jackson(15章) -->
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId> <!-- 自动装配 SqlSessionFactory + Mapper 扫描(18章) -->
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId> <!-- 默认 Lettuce 客户端(19章) -->
</dependency>
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId> <!-- KafkaTemplate + @KafkaListener(20章) -->
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId> <!-- 健康探针 + 指标端点(22章) -->
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId> <!-- Micrometer 暴露 Prometheus 格式(22章) -->
</dependency>
</dependencies>你看,一个 spring-boot-starter-web 背后自动装配了 Tomcat、DispatcherServlet、Jackson(第 15 章讲的 WebMvcAutoConfiguration/DispatcherServletAutoConfiguration/JacksonAutoConfiguration);mybatis-spring-boot-starter 自动装配了 SqlSessionFactory 并扫描你的 Mapper 接口(第 18 章)。你没写一行 new DispatcherServlet(),它在运行时就出现了。
问题 2: 这些 Bean 到底谁 new 的?你为什么改一个配置它有时不生效?
这正是第 15 章的核心——它们由自动配置类靠 @ConditionalOnClass 探测 classpath 后反射创建,塞进 ApplicationContext 这个大 Map。你想覆盖默认(比如自定义 RedisTemplate 的序列化器,第 19 章反复强调”别用 JDK 默认序列化”),就写个 @Configuration 类,靠 @ConditionalOnMissingBean 让默认让位。忘了那个条件、或顺序不对,你的配置就不生效——排查靠第 15 章的 --debug 和 /actuator/beans。
【思考】站在 Go 老哥视角,这套”加个依赖就自动有 Tomcat”的能力,最让你不安的是什么?
参考答案
直接答案:最不安的是”真相不在代码里”。 你在 Go 里 r := gin.Default(); r.Run(),这行背后干了什么 go to definition 看得一清二楚;Spring Boot 里 Tomcat 是运行时靠 classpath 探测 + 条件注解反射出来的,你 git blame 找不到任何 new Tomcat()。第 00 章卡点五说的”运行时 DI 把编译期确定性换成运行时灵活性”,在这里兑现成账单:灵活是真灵活(换容器改个依赖就行),但排错时你得先恢复可观测性(第 15 章的 Actuator、第 22 章的 trace)。
Go 对照:Go 没有运行时 classpath,没有注解元数据,做不到这种”运行时自动装配”。Go 的等价物是 wire 在编译期生成 InitializeApp()(第 15 章题 4 讲过),或者你直接在 main() 里 r.Use(...) 显式拼。代价是改依赖要重新生成/编译,收益是 wire_gen.go 一眼看全装配关系,没有黑箱。
更深一层:这不是”Java 比 Go 差”,是两种”约定”的分野——显式约定 vs 隐式约定。Spring Boot 的约定藏在几百个自动配置类的条件里,Go 的约定藏在项目模板和生成代码里。你学到现在应该能欣赏两边:写业务时感谢 Spring Boot 省事,排错时感谢 Go 的 go to definition 不骗你。
23.2.2 Web 层:Controller + 参数绑定 + 统一异常(第 16 章)
跳转接口是高频读,参数就是一个短码路径段:
@RestController
public class RedirectController {
private final UrlService urlService;
public RedirectController(UrlService s) { this.urlService = s; } // 构造器注入,非 @Autowired
@GetMapping("/{shortCode}") // 路径段直接绑成方法参数
public ResponseEntity<Void> redirect(@PathVariable String shortCode) {
String longUrl = urlService.resolve(shortCode); // 走缓存+DB(19章)
if (longUrl == null) throw new BizException("SHORT_CODE_NOT_FOUND");
return ResponseEntity.status(302).header("Location", longUrl).build(); // 302 跳转
}
}@PathVariable 是 ArgumentResolver 在运行时把路径段填进来的(第 16.4 章),你方法签名直接是业务类型,不用自己 c.Param("shortCode")。统一异常用 @ControllerAdvice 兜住 BizException,返回统一 JSON——但记住第 16.3 章的教训:认证这种入口级逻辑别依赖它兜,该硬截断就直接写响应。
23.2.3 数据层:MyBatis + HikariCP + 事务(第 17/18 章)
Mapper 接口写 SQL,热点查询走 #{} 占位符(第 18.4 章,绝不用 ${} 拼用户输入):
@Mapper
public interface UrlMapper {
@Select("SELECT long_url FROM url_map WHERE short_code = #{code}") // #{} 预编译防注入
String selectLongUrl(@Param("code") String code);
@Update("UPDATE url_map SET click_count = click_count + 1 " // DB 原子累加,防丢失更新(17.9)
+ "WHERE short_code = #{code}")
int incrementClick(@Param("code") String code);
}配置里把 HikariCP 的 maxLifetime 设成小于 MySQL wait_timeout(第 17.4 章的重灾区),并开 leakDetectionThreshold 防连接泄漏。DataSource 是 HikariCP 这个实现,连接池职责在第 17 章讲过——和 Go 的 sql.DB 一身兼两职不同,Java 这块是 DataSource 池化 + JdbcTemplate/MyBatis 管资源关闭分开的。
23.2.4 缓存层:热点短码走 Redis,防三大坑(第 19 章)
resolve 用 Cache-Aside:先查 Redis,未命中查 DB 并回填,带 TTL 且加随机抖动防雪崩(第 19 5 节)。序列化器用 GenericJackson2JsonRedisSerializer(第 19.3 章,覆盖默认的 JDK 序列化)。Lettuce 高并发必须关 shareNativeConnection 并配池(第 19.2 章案例 ③),否则单连接成瓶颈。
23.2.5 异步层:点击发 Kafka 事件(第 20 章)
点击用 Kafka 解耦”计数落库”和”主流程”。生产者发事件,消费者手动提交 offset + 幂等消费:
// 生产者:跳转时发点击事件,主流程不Block
kafkaTemplate.send("click-event", shortCode, // topic=click-event
new ClickEvent(shortCode, System.currentTimeMillis()));
// 消费者:MANUAL ack + 幂等(用 shortCode+事件时间去重)
@KafkaListener(topics = "click-event", groupId = "click-counter")
public void onClick(ConsumerRecord<String, ClickEvent> rec,
Acknowledgment ack) { // 手动提交 offset
ClickEvent e = rec.value();
if (dedupCache.contains(e.key())) { ack.acknowledge(); return; } // 幂等:重复事件直接跳过
urlMapper.incrementClick(e.shortCode()); // DB 原子累加(17.9)
dedupCache.add(e.key());
ack.acknowledge(); // 处理完才提交,崩了不丢位点
}手动提交 offset(配 spring.kafka.listener.ack-mode=MANUAL)意味着”处理成功才提交”,消费者崩溃时这条消息会被重新投递——所以消费逻辑必须幂等,否则重复计数。这和 Go 的 sarama 手动 CommitOffset 是同一个心智(第 20 章会并排讲)。
23.2.6 可观测层:MDC + Micrometer + Actuator(第 22 章)
每个请求进来时,在 Filter 里生成 traceId 塞进 SLF4J 的 MDC,日志里就能串起一条链路(第 22 章):
// 在 Filter(MDC 是 ThreadLocal,请求结束要 clear,否则串号)
MDC.put("traceId", UUID.randomUUID().toString().substring(0, 8)); // 8位足够定位
try { chain.doFilter(req, res); }
finally { MDC.clear(); } // 必须清,否则线程复用串号用 Micrometer 给跳转接口埋 RT:
Timer redirectTimer = MeterRegistry.get("shortlink.redirect.latency"); // 注入 MeterRegistry
redirectTimer.record(() -> { /* resolve 逻辑 */ }); // 自动算 P50/P95/P99Actuator 开 /actuator/health(K8s 就绪探针)、/actuator/metrics、/actuator/prometheus(给 Prometheus 抓)。这套东西是线上排错的眼睛——下一节三个案例,全靠它。
23.3 案例一:@Transactional 自调用,配额被超发
现象:短链服务给每个用户每天发了创建配额(daily_quota = 1000)。上线后运营发现,某用户一天居然创建了 1043 个短链,超了 43 个。日志没报错,接口都返回成功。
排查过程:
- 配额扣减逻辑写在
UrlService.create里,调了本类的deductQuota:
@Service
public class UrlService {
@Transactional
public String create(ShortenCmd cmd) {
this.deductQuota(cmd.userId()); // 自调用!this 指向原始对象,绕过代理
return doCreate(cmd);
}
@Transactional // 期望:扣减在一个事务里原子完成
public void deductQuota(long userId) {
int used = quotaMapper.selectUsedToday(userId);
if (used >= 1000) throw new BizException("QUOTA_EXCEED"); // "判断+扣减"非原子
quotaMapper.incrUsed(userId); // UPDATE used = used + 1
}
}this.deductQuota(...)里的this是原始对象,不是 Spring 代理(第 14/17.9 章的铁律),@Transactional根本没生效——selectUsedToday和incrUsed各跑在自动提交的连接上,不是同一事务。- 更糟的是”先查后改”:并发下两个请求同时
SELECT到 used=999,都通过判断,都UPDATE成 1000,于是超发。本质是第 17.9 章的”丢失更新”。
根因:自调用绕过 AOP 代理,事务没开;且配额扣减用”读-改-写”而非 DB 原子更新,并发下丢失更新。
修复:把 deductQuota 拆到独立 QuotaService,让 UrlService 注入它走代理;并改 DB 原子更新:
@Service
public class QuotaService {
@Transactional
public void deduct(long userId) {
// UPDATE used = used + 1 WHERE user_id = ? AND used < 1000,返回受影响行数
int rows = quotaMapper.incrIfUnderLimit(userId, 1000);
if (rows == 0) throw new BizException("QUOTA_EXCEED"); // 0 行 = 已超,原子拦截
}
}UrlService 注入 QuotaService 后调用,事务走代理生效;WHERE used < 1000 把”判断+扣减”交给 MySQL 原子完成,并发安全。
教训:① @Transactional 自调用失效是头号坑,凡”一个方法调本类另一个 @Transactional 方法”先怀疑它(第 17.9 章)。② 配额/库存这类”读-改-写”绝不能用”先 SELECT 再 UPDATE”在应用层算,必须 DB 原子 UPDATE ... WHERE 条件(第 17.9 章)。③ 在 Go 里 tx := db.Begin(); qs.deduct(tx, ...) 传的是显式事务,没有”代理被绕过”这回事——你转 Java 后,必须把”谁调谁、走不走代理”刻进肌肉记忆。
23.4 案例二:上线后 RT 突增,指标揪出连接打满
现象:短链服务上线第二天,跳转接口 RT 从 3ms 飙到 400ms,但 Redis 和 MySQL 单独连上去都不慢,其他服务正常。告警群炸了。
排查过程(按第 22 章”指标→日志”):
- 看 Actuator 的
shortlink.redirect.latency指标:P99 涨了 100 倍,但shortlink.redirect.cache.hit命中率从 98% 掉到 40%——说明大量请求没走缓存、漏到了 DB。 - 看 HikariCP 指标(
/actuator/metrics/hikaricp.connections.active或spring.metrics暴露的):活跃连接数持续等于maximumPoolSize,且pending线程堆积。和第 17.5 章的案例一同一个栈——线程卡在HikariPool.getConnection上等连接。 - 结合命中率掉,推断:有一类请求没走 Redis 直接查了 DB,把连接池占满,所有请求跟着排队。
- 看慢查询日志,发现一条
SELECT long_url FROM url_map WHERE short_code = ?居然走了全表扫描——因为它的short_code字段上忘了建唯一索引。热点短码本该命中 Redis(3ms),但索引缺失让”缓存未命中回源 DB”的那一次查询也从 0.5ms 变成 800ms,连接被这条慢 SQL 占住,雪崩。
根因:热点 key 的回源 SQL 缺索引,回源变慢 → 连接池被慢查询占满 → 所有请求排队 → RT 全面飙升。缓存命中率掉只是表象,真凶是”回源 SQL 没走索引 + 没缓存兜底”。
修复:① 给 short_code 加唯一索引,回源查询回到亚毫秒;② resolve 加 Redis 缓存且 TTL 设合理(第 19.1 章 Cache-Aside),让 98% 流量根本不碰 DB;③ HikariCP maximumPoolSize 按”DB max_connections / 实例数”算,并开 leakDetectionThreshold 兜底。
教训:RT 突增先别猜 JVM GC,先看”缓存命中率 + 连接池活跃数 + 慢 SQL”三件套(第 22 章的可观测 + 第 17 章的连接池 + 第 18 章的慢 SQL)。可观测性不是装饰,是排错的眼睛——没有 cache.hit 和 hikaricp.connections.active 这两个指标,你会在线下瞎猜半天。
23.5 案例三:热点 key 击穿,DB 被一记重拳打挂
现象:某个短链(某顶流主播的带货链接)突然爆了,零点前后那一秒,跳转接口 5xx 飙升,MySQL 连接池瞬间耗尽。这个短码的 Redis key 恰好设了零点过期。
排查过程:
- 时间线对上了:key 的 TTL 在流量高峰点过期。过期的那一瞬,几万 QPS 同时未命中、同时去查 DB 重建——第 19.4.2 章的”缓存击穿”,查的是真实存在的热点 key,只是恰好过期。
- 和第 23.4 章不同:这里不是”缺索引慢”,而是”瞬时并发回源把 DB 连接打光”。HikariCP 指标显示活跃连接瞬间打满、pending 暴涨。
根因:热点 key 过期瞬间,重建缓存的 DB 查询被成千上万并发同时触发,DB 连接池被占满,整服务雪崩。
修复:让”重建缓存”同一时刻只一个人干,其他人等结果(第 19.4.2 章的 SET NX 互斥锁):
public String resolve(String code) {
String key = "short:" + code;
String cached = stringRedisTemplate.opsForValue().get(key);
if (cached != null) return cached; // 命中直接返回
// SET NX PX:只有抢到锁的线程去重建,其他人等,避免几万请求同时打 DB
Boolean locked = stringRedisTemplate.opsForValue()
.setIfAbsent("lock:" + key, "1", Duration.ofSeconds(10)); // 10s 防死锁
if (Boolean.TRUE.equals(locked)) {
try {
String url = urlMapper.selectLongUrl(code); // 只有一个线程查 DB
stringRedisTemplate.opsForValue().set(key, url, Duration.ofHours(1));
return url;
} finally {
stringRedisTemplate.delete("lock:" + key); // 重建完释放
}
}
try { Thread.sleep(20); } catch (InterruptedException ignored) {}
return resolve(code); // 没抢到,稍等重试,此时多半已回填
}更稳的根治:热点 key 不设 TTL,改后台定时刷新(逻辑过期思路,第 19.4.2 章)——让它永不过期,由任务线程主动更新,从根上消除”过期瞬间”这个窗口。
教训:击穿的本质是”热点数据失效瞬间,重建成本被并发放大”(第 19 章题 1)。SET NX 互斥锁或 Go 的 singleflight 都是在”重建”动作上加互斥,把 N 次 DB 查询压成 1 次。判断它是击穿还是穿透,看缓存命中率:击穿是”命中率瞬间跳水又回弹”,穿透是”命中率长期接近 0%“(第 19 章题 1)。
23.6 排错大案例收口:RT 突增的四步定位法
前面三个是”单点事故”,这节串起第 08 章排错法 + 第 22 章可观测 + 第 21 章读源码,演示一次完整定位。场景:跳转接口 RT P99 从 5ms 缓涨到 300ms,持续不退,但 Redis、DB 连接数、CPU 都正常。
第一步:指标(第 22 章可观测)。先看 Micrometer 指标分解——shortlink.redirect.latency 涨,但 cache.hit 没掉、hikaricp.connections.active 没满。说明瓶颈不在缓存、不在连接池。再看细分:shortlink.redirect.latency 里按 shortCode 维度切片,发现只有”按点击量排序”的那类请求慢。指标把范围缩小到”某一类特定请求”。
第二步:链路(第 22 章 trace)。从 Prometheus 拉一条慢 trace,看到耗时全卡在 UrlMapper.selectTopClicked 这一步的 DB 调用,耗时 280ms。链路定位到具体 SQL、具体 Mapper 方法,不用翻全代码。
第三步:日志(第 08 章)。把这条 trace 的 traceId 丢进日志系统,捞出对应 SQL:SELECT ... FROM url_map ORDER BY ${sortField} DESC LIMIT 10。注意——它用了 ${sortField}。
第四步:源码(第 21 章读源码)。打开 UrlMapper.xml,发现排序字段是 ${sortField} 直接拼进去的(第 18.4 章的雷区),而 sortField 来自前端传入、只做了白名单但漏了一个值。漏网的那个值让 ORDER BY 拼上了 create_time 之外的列,该列没索引,触发全表排序。第 21 章教你的读源码方法——顺着 Mapper 接口 namespace + id 找到 XML、再读 DefaultSqlSource 怎么拼 SQL——在这里直接定位到根因。
根因:${} 拼了未走索引的排序字段,导致全表排序慢查询。和第 18.4 章案例 ① 同源,只是这回不是注入、是”漏白名单导致的隐式慢 SQL”。
修复:排序字段收紧白名单(第 18.4 章的 Map<前端值, 真实列> 枚举法),只允许建了索引的列;慢 SQL 加索引兜底。
教训(四步法固化):① 指标定”大方向”(哪类请求慢、慢在哪一层);② 链路定”具体 SQL/方法”;③ 日志定”具体入参/SQL 文本”;④ 源码定”为什么这么拼”。这四步和第 08 章的”从异常栈到根因”一脉相承,只是前面多了一层可观测性把”盲猜”变成”有数据支撑的猜”。你现在排错不再是”看日志碰运气”,而是”指标→链路→日志→源码”的流水线——这正是第 21 章读源码能力 + 第 22 章可观测能力合体的成果。
23.7 全书主线收口:五个别扭点,现在你怎么解
第 00 章说 Go 程序员学 Java 有五个别扭点。你走到这章,回头看这五个,应该已经从”别扭”变成”知道它在 trade 什么”。
别扭一:心智模型差异(并发 + 错误)。你带着 goroutine/channel 来,第一次见 new Thread OOM、见异常控制流(try/catch 能跨层切断传播)都很懵。现在你懂了:Java 传统线程是 1:1 内核线程,所以才有线程池(第 10/11 章);Java 21 虚拟线程是 JVM 来扮演 Go runtime(第 12 章)。错误方面,checked exception 强制你面对失败,但 catch {} 吞异常比 Go 的 if err != nil { return } 更危险——你已建立”要么处理要么包装上抛”的纪律(第 08 章)。
别扭二:生态规模(classpath / Jar Hell)。你习惯 go.mod 一处定死版本。Java 的 classpath 寻址 + Maven 仲裁让你第一天撞墙(第 00 章卡点一)。现在你掌握 mvn dependency:tree 看冲突、NoSuchMethodError 三分钟定位(第 05/07 章),知道 Shade/ClassLoader 隔离是 Java 为”运行时寻址”付的账。
别扭三:隐式魔法(框架运行时 DI)。你最别扭的是”找不到 new 在哪”(第 00 章卡点五)。现在你知道 Spring 容器就是个 Map + 注解填充规则,@Transactional 是 AOP 代理、@ControllerAdvice 是声明式兜底、Mapper 是 JDK 动态代理(第 14/16/18 章)。隐式不再是黑箱,而是”靠 Actuator/--debug 恢复可观测性”的代价。
别扭四:构建部署(Maven + fat jar + JVM)。你习惯单二进制 go build。Java 要 Maven 多模块、spring-boot-maven-plugin 打 fat jar、java -jar 起内嵌 Tomcat(第 05/15 章)。现在你懂内嵌 Tomcat 其实是 Java 向 Go 的”单二进制自包含”靠拢(第 15 章题 3),K8s 部署模型两者同构。
别扭五:排错(JVM 可调 + 运行时黑箱)。你习惯 pprof + 二进制自包含。Java 的几百个 JVM 参数、运行时黑箱曾让你无从下手。现在你有第 08 章排错 SOP、jstack/jmap/Arthas、第 13 章 GC 推理框架、第 22 章可观测三件套——排错从”看日志碰运气”升级成”指标→链路→日志→源码”流水线。
一句话收口:五个别扭点不是 Java 的缺陷,是它”运行时灵活性 + 生态厚度”这笔交易的账单。你付得起,是因为你学会了用工具把可观测性补回来。
23.8 Go 对照表:同一需求,两套拼装
把短链服务这套栈,和你的 Go 拼装并排摆平。这不是比高低,是让你清楚”两套价值观各自 trade 了什么”。
| 概念 | Go(gin + gorm + go-redis + zap + Prometheus + sarama) | Java(Spring MVC + MyBatis + RedisTemplate + SLF4J + Micrometer + Kafka) |
|---|---|---|
| HTTP 框架 | gin 显式 r.GET 注册路由,管道你拼 | DispatcherServlet 运行时调度,路由靠注解 |
| SQL 映射 | sqlx/gorm 手写 SQL + struct tag 扫 | MyBatis Mapper 接口 + #{} 占位符 |
| 连接池 | sql.DB 内建池(SetMaxOpenConns) | HikariCP(DataSource 实现,参数独立配) |
| 缓存 | go-redis 内建池 + singleflight 防击穿 | RedisTemplate + Lettuce 池 + SET NX 互斥锁 |
| 消息 | sarama/kafka-go 手动 CommitOffset | KafkaTemplate + @KafkaListener MANUAL ack |
| 事务 | db.Begin() 显式传 tx,边界看得见 | @Transactional AOP 代理,自调用会失效 |
| 可观测 | zap + prometheus 手动埋点 | SLF4J MDC + Micrometer + Actuator 端点 |
| 组装 | wire 编译期生成,代码即真相 | Spring 运行时 DI,靠工具恢复可观测性 |
| 部署 | 单二进制 go build | fat jar + JVM(java -jar) |
并排看”发短链”这块核心逻辑,体会 DNA 差异:
// Go:依赖显式传入,事务显式传 tx,缓存/消息都在眼前
func (h *Handler) Shorten(c *gin.Context) {
var cmd ShortenCmd
c.ShouldBindJSON(&cmd) // 参数绑定,你显式调
tx := h.db.Begin() // 事务显式开启
code := h.svc.Create(tx, cmd) // 传 tx,边界清楚
h.redis.INCR("click:"+code) // 缓存/计数,调用点可见
h.kafka.Send("click-event", code) // 异步事件,显式发
c.JSON(200, gin.H{"code": code})
}// Java:注解驱动,事务/缓存靠框架,调用点干净但"真相在运行时"
@RestController
public class ShortenController {
@PostMapping("/api/shorten")
public String shorten(@RequestBody ShortenCmd cmd) { // @RequestBody 自动绑
return urlService.create(cmd); // 事务/@Cacheable/Kafka 都在 UrlService 内部被代理触发
}
}加 traceId 这块,Go 和 Java 思路也同构,只是载体不同:
// Go:context 传 traceId,zap 的字段带进去,显式
ctx := context.WithValue(c.Request.Context(), "traceId", tid)
h.logger.Info("redirect", zap.String("traceId", tid))// Java:MDC 是 ThreadLocal,Filter 里 put,日志 pattern 里 %X{traceId} 自动带出
MDC.put("traceId", tid); // SLF4J,日志格式里写 %X{traceId} 即可串链路(22章)【思考】拼完整套你会发现:Go 是”显式拼装、代码即真相”,Java 是”框架隐式装配、可观测性靠工具补”。那到底谁高级?
参考答案
直接答案:没有谁高级,只有取舍不同。 Go 的显式把”装配关系”写在代码里,go to definition 看得全,代价是样板多、换实现要改代码;Java 的隐式把”装配/条件/横切”藏在运行时,换来灵活(换 jar 换实现、不用重编译)和少写样板,代价是可观测性要靠 Actuator/--debug 补回来。
展开看 trade:短链服务在 Go 里,你一眼能看到 Shorten 里 db.Begin 到 kafka.Send 的每一步,排错时调用栈就是真相;在 Java 里,urlService.create 一个方法背后可能叠了事务代理、@Cacheable 代理、Kafka 发送,你 git blame 看不到这些”横切”在哪生效——但它们确实在生效。这不是”Java 骗你”,是”声明式把横切逻辑从每个 handler 抽出来做成管道节点”(第 16.1 章),和第 14 章 AOP 同源。
更深一层:你从 Go 过来最大的收获,不是”会用 Spring”,而是能清醒地说出”我现在用的是显式还是隐式、各自 trade 了什么”。下次选型时你会问:“这个服务要频繁换实现吗?要插件化吗?团队大吗?“——要,Java 的隐式划算;要极致可追踪、要小团队快糙猛,Go 的显式省心。两套价值观没有高下,只有”匹配场景”。这也呼应第 00 章卡点五和第 14/15 章那条”显式 vs 隐式”主线:你现在已经能按场景选型,而不是盲信某一侧。
23.9 进阶路线:给 Go 程序员的下一步
你已能看懂开源项目、能排错、能构建、能上线。接下来往深走四条线,都是”知其所以然”的延续:
① JVM 深入(第 04/13 章延伸)。字节码(javap -c 看 invokevirtual/invokedynamic)、类加载双亲委派、GC 三件套(Young/Old/Metaspace)、G1 vs ZGC 的取舍、JMH 微基准。目标是:看到 GC 日志能推该调什么,而不是背参数。GraalVM Native Image 也值得看——它把 Java 提前编译成单二进制,正是向 Go 部署模型靠拢的极端形态。
② 并发与 Netty(第 12 章延伸)。虚拟线程解决”每请求一线程”的天花板后,真正的网络编程深水区是 Netty:Reactor 模型、ChannelPipeline、零拷贝 ByteBuf、粘包拆包。它和 Go 的 netpoll/epoll 思路同源但表达不同。学会它,你才懂为什么 Redis 客户端 Lettuce 基于 Netty、为什么 Kafka 客户端能异步非阻塞。
③ Spring / MyBatis / Kafka 源码(第 21 章方法)。用第 21 章教的”从自动配置类入口顺藤摸瓜”读 DispatcherServlet.doDispatch、SqlSessionFactoryBuilder、KafkaListenerAnnotationBeanPostProcessor。读源码不是背实现,是验证你脑子里那张”调用图”对不对——比如 @Transactional 自调用失效,你读过 TransactionInterceptor 就知道为什么 this 绕得过去。
④ 把 Java 的”框架思维”反哺回 Go(第 15 章题 4)。最值钱的一步:用 wire 给你的 Go 项目做”显式版 Spring Boot”——约定目录结构、pkg/bootstrap 预装配 gin+gorm+zap、build tag 区分 dev/prod 的 provider set。你甚至可以把 @ControllerAdvice 的”统一异常”用一个 gin.Recovery 中间件 + 统一错误结构反哺回去。学了 Java 的隐式,回头写 Go 会更自觉地”把横切逻辑抽成中间件/拦截器”——这就是第 00 章说的”双向收益”。
23.10 本章核心结论
如果这一章你只看这一段:
- 综合实战的价值是”拼装 + 排错”,不是堆新概念——短链服务把 Web(16)/DB(17)/MyBatis(18)/Redis(19)/Kafka(20)/可观测(22) 六层拧成一条能上线的链路,每一层都用上了。
@Transactional自调用失效是头号坑:this调本类方法绕过代理,事务没开;配额/库存用 DB 原子UPDATE ... WHERE 条件防丢失更新(第 17.9 章)。- RT 突增先看三件套:缓存命中率 + 连接池活跃数 + 慢 SQL;可观测指标是排错的眼睛,不是装饰(第 22 章)。
- 缓存三大坑都在短链服务现身:命中率掉可能是穿透/击穿,热点 key 过期瞬间用
SET NX互斥锁或逻辑过期重建(第 19 章)。 - 完整排错是”指标→链路→日志→源码”四步:指标定大方向、链路定具体 SQL、日志定入参、源码定根因,串起第 08/21/22 章。
- 五个别扭点(心智/生态/隐式/部署/排错)不是 Java 缺陷,是”运行时灵活 + 生态厚”的账单,你付得起是因为学会了用工具补可观测性。
- Go 显式 vs Java 隐式没有高下,只有取舍:按”是否要换实现、团队规模、可追踪需求”选型,而非盲信某侧(第 00/14/15 章主线)。
- 进阶四条线:JVM 深入、Netty 并发、框架源码、反哺 Go——尤其把 Spring 的横切思维用
wire/中间件反哺回你的 Go 项目,是双向收益。
23.11 深度思考题
题 1:短链服务的”发短链”接口,要不要也走 Kafka 异步?读写路径该怎么分?
【思考】创建短链是写操作,它能不能像”点击计数”那样丢给 Kafka 异步做,让接口立刻返回?边界在哪?
参考答案
直接答案:不能整个异步,但能”拆一半”。 创建短链要”立刻返回短码给用户”,而短码生成依赖 DB 写入后的自增 id(或发号器),所以”写 DB 拿短码”必须在主流程同步完成、返回给用户;而”后续衍生动作”(如通知、统计、同步给搜索引擎)可以异步发 Kafka。点击计数则相反——它不要求立刻返回结果,纯衍生,所以整条链路异步。
展开:判断标准是”这次请求的响应是否依赖这次写的结果”。创建短链:响应里的 code 依赖写库结果,必须同步;点击计数:响应就是个 302,计数成不成功不影响跳转,可异步。把”响应依赖的写”异步化,会得到”用户点了创建、半天拿不到短码”的体验灾难。
更深一层:这是”同步关键路径 + 异步衍生路径”的经典切分,也是第 20 章 Kafka”削峰解耦”的适用边界——MQ 解耦的是”衍生副作用”,不是”业务主链路的真相写入”。你能分清”哪段是主链路、哪段是副作用”,才说明消息队列那章学透了。
题 2:案例三里你用 SET NX 互斥锁防击穿。如果 Redis 本身挂了,这把锁还有意义吗?多级缓存该怎么兜?
【思考】互斥锁依赖 Redis 活着。Redis 宕机时,所有请求既拿不到缓存、也抢不到锁,会怎样?
参考答案
直接答案:Redis 挂了,互斥锁和缓存一起失效,请求会全涌向 DB——这正是第 19.5 章”雪崩”里”Redis 实例宕机”那一型。单靠 Redis 无解,要靠多级缓存 + 高可用兜底。 本地 Caffeine 挡在 Redis 前(第 19.7 节),Redis 挂时本地还能扛住大部分热点读;Redis 用主从 + 哨兵或 Cluster,单节点挂能切、能分片。
展开:互斥锁只在”Redis 活着但 key 过期”时有效,它防的是”击穿”(单 key 失效),不是”Redis 整体宕机”。Redis 宕机属于”雪崩”的另一种触发,得用不同手段:① 本地多级缓存(Caffeine/BigCache)吃掉大部分读,DB 只承接未命中;② Redis 高可用(哨兵自动切主);③ 限流降级(Resilience4j/Sentinel),缓存全失时拒绝部分请求保核心。三者叠加,单点故障才不至于拖垮全链。
更深一层:任何”把缓存当唯一快路径”的设计,都要问”缓存挂了怎么办”。缓存是 DB 的副本、是加速层,不是真相源——所以雪崩的根治不在”让 Redis 永不当机”(做不到),而在”Redis 挂了还有本地兜、DB 还有熔断护”。这和第 19 章”一致性靠 MQ、可用性靠多级”是一套逻辑。
题 3:第 23.6 节那次排错,如果项目没有接 Micrometer/Prometheus,你还能怎么定位?
【思考】可观测性是”有工具”时的奢侈。如果这是个老项目、只有普通日志,四步法里哪几步还能走?
参考答案
直接答案:指标和链路这两步会退化,但你仍能走”日志 + 源码”。 没有 Micrometer,你没法看 P99 切片、没法按 shortCode 维度拆;没有 trace,你没法把一次慢请求串成链路。但你可以:① 在 resolve 里手动 log.info("resolve cost={}", ms) 打耗时(第 22 章 MDC 保底能做);② 开 MySQL 慢查询日志(slow_query_log)抓出那条 280ms 的 SQL;③ 顺着 SQL 的表名/字段反查到 UrlMapper.xml(第 21 章读源码)。只是更慢、更靠手动,且看不到”按维度切片”的全局视图。
展开:可观测性的价值,是让你从”事后捞日志猜”变成”事前有仪表盘看”。老项目没接 Micrometer,你排同一次问题要多花几倍时间翻日志、比对慢查询——这也是第 22 章强调”新项目第一件事就把可观测接上”的原因。它不创造信息,它把本来就存在的信息(每次请求的耗时、每条 SQL 的文本)组织成”可查询、可切片”的形态。
更深一层:排错能力的天花板,取决于你”恢复现场”的能力。工具好,恢复现场快;工具差,你靠日志 + DB 慢查询日志 + 源码阅读也能恢复,只是慢。所以第 08 章的排错方法论(异常栈、Arthas watch/trace、jstack)是工具之上的底层能力——可观测性是锦上添花,不是取代它。
题 4(开放题,无标准答案):如果让你用 Go 重写这个短链服务,你会照抄 Java 这版的分层吗?还是按 Go 的习惯重新切?
【思考】分层(Controller/Service/Mapper)是 Java Spring 的惯例。Go 没有这套强制结构,你重写时会怎么组织?
参考答案
方向提示(非唯一解):大概率不会照抄 “Controller/Service/Mapper 三层 + 接口一对多实现” 的 Java 骨架。Go 更常见的是 internal/ 下按领域分包(如 url/、quota/),每个包暴露一个 Service 结构体,handler 直接调;Mapper 那层退化成 sqlx 的查询函数或 gorm 调用,写在 url/repo.go 里;事务用 db.Begin() 显式传。但”横切逻辑”(日志、trace、统一错误)你会用 gin 中间件做,而不是 @ControllerAdvice——本质上你是在用 Go 的方式重述同一套关注点分离。
更深一层:分层不是 Java 的专利,是”关注点分离”的共性需求。Java 用接口+注解把分层做得”重”(每个 Service 一个接口、一个 Impl),是因为要为 AOP 代理和运行时可替换实现让路(第 00 章题 3);Go 用包+结构体把分层做得”轻”,因为不需要运行时代理。你重写时会自然丢掉 Java 的”仪式感”,但保留”handler 不写 SQL、SQL 不写业务”的边界——那才是分层真正值钱的东西,和语言无关。学完 Java 再写 Go,你会更自觉地守住这条边界,这本身就是第 00 章说的”双向收益”。
下一章预告
全书完。
《Go 程序员的 Java 修炼之路》共 24 章(第 00 章开篇至第 23 章收官)已完结。你拿到的是一条完整的路:从”为什么别扭”(第 00 章)到”能看懂、能排错、能构建、能上线”(第 23 章),中间每一层都拆过、对照过、踩过坑。
总复习地图(按十大块):
| 块 | 章节 | 你该有的能力 |
|---|---|---|
| 语言 | 01-03 | 读懂任何 Java 字面意思,看懂 Java 17 与现代老项目差异,泛型擦除不慌 |
| 构建 | 04-07 | Maven/Gradle 依赖树、Jar Hell、NoSuchMethodError 三分钟定位 |
| 排错 | 08 | 异常栈→根因 SOP,Arthas/jstack/jmap,日志纪律 |
| 并发 | 10-13 | JMM、线程池、虚拟线程、GC 推理框架、火焰图 |
| Spring | 14-15 | IoC/AOP、@Transactional 失效边界、自动装配原理、写 Starter、排查配置 |
| Web | 16 | 一次请求过几道门,参数绑定,统一异常,Filter vs Interceptor |
| 数据 | 17-18 | JDBC/HikariCP/事务、MyBatis #{}/${}、动态 SQL、Mapper 代理 |
| 缓存 | 19 | Cache-Aside、三大坑、一致性靠 MQ、Lettuce 池化 |
| 消息 | 20 | Kafka 三板斧、手动 offset、幂等消费,Go sarama 对照 |
| 可观测 | 22 | MDC traceId、Micrometer 指标、Actuator 探针,指标→链路→日志→源码 |
进阶路线(下一步去哪):
- JVM 深入:字节码、
javap -c、GC 三件套、G1/ZGC、JMH、GraalVM Native Image(第 04/13 章延伸)。 - 并发与 Netty:虚拟线程之上,学 Reactor/
ByteBuf/ChannelPipeline,懂 Lettuce/Kafka 客户端的异步底座(第 12 章延伸)。 - 框架源码:用第 21 章方法读
DispatcherServlet、SqlSessionFactory、KafkaListener后置处理器,验证你脑子里的调用图。 - 反哺 Go:用
wire做显式版 Spring Boot,把@ControllerAdvice/@Transactional的横切思维用中间件反哺回 Go 项目(第 15 章题 4)。
最后一句实在话:下一步不是再读一章,而是去读一个真实开源项目的源码、改它、给它提 PR。你现在的装备,够你打开任何一个 Spring 项目,顺着 AutoConfiguration.imports 摸进去,搞清楚它到底”自动”了什么、哪里踩了书里讲过的坑。去动手吧。