本文合并自 5 篇笔记,点击目录跳转。

目录


微服务治理:Nacos / Sentinel / OpenFeign / Spring Cloud Gateway

一句话速览:微服务治理的核心是”服务之间如何找到彼此(Nacos)、如何保护彼此不被打垮(Sentinel)、如何方便地互相调用(OpenFeign)、以及如何统一对外的门面(Gateway)”——本篇所有踩坑的根因都可以归结为一句话:配置了不等于生效了,治理类功能的失效几乎都是沉默的

涉及原始记录:07-面试问题/开发问题/01-依赖与配置问题.md P0-01、P1-01、P1-02;
07-面试问题/开发问题/02-框架与中间件集成问题.md P1-01、P1-02、P0-06

最近修订:2026-08-07

目录


技术点 1:Nacos —— 注册发现 + 配置中心

难度:⭐⭐ | 掌握要求:能画出注册/发现/配置推送三条链路,能说清临时实例与持久实例的区别

核心概念

Nacos 在项目里承担两个独立的角色,容易被混为一谈:

  1. 服务注册发现:每个微服务启动时把自己(IP+端口)注册到 Nacos,Feign/Gateway 通过服务名找到实际地址,不用写死 IP。
  2. 配置中心application.yml 之外的动态配置(比如 Sentinel 限流规则)存在 Nacos 里,服务启动时拉取、运行时监听变更。这是一个订阅-推送模型:客户端向 Nacos 建立长轮询/gRPC 长连接,Nacos 侧数据变化后主动推送。

原理深挖:注册发现的完整链路

sequenceDiagram
    participant S as 微服务实例
    participant N as Nacos Server
    participant C as 调用方(Feign/Gateway)

    S->>N: 1. 注册实例(IP+Port+元数据)
    S->>N: 2. 每5秒发送心跳(临时实例)
    C->>N: 3. 订阅服务名(长轮询/gRPC推送)
    N-->>C: 4. 推送健康实例列表
    Note over C: 本地缓存实例列表,定期刷新
    C->>S: 5. 按负载均衡策略直接调用
    Note over S,N: 心跳中断15s标记不健康,30s剔除

三个必须理解的设计点:

  • 临时实例 vs 持久实例:临时实例(默认,ephemeral=true)由客户端主动上报心跳,Nacos 超过 15 秒没收到心跳标记不健康、30 秒剔除,数据只存在内存(Distro 协议在 Nacos 集群节点间同步),是 AP 模型——允许短暂读到过期数据,但保证注册中心永远可用;持久实例(ephemeral=false,典型如 MySQL 这种”实例消失就是事故”的中间件)由 Nacos 主动探测,数据落盘并通过 Raft 保持集群一致,是 CP 模型。业务微服务几乎都用临时实例。
  • 客户端本地缓存:调用方不是每次 RPC 都去问 Nacos,而是把实例列表缓存在本地、由 Nacos 推送变更。这意味着”Nacos 上看到的实例列表”和”调用方实际路由到的列表”之间可能存在秒级延迟——服务下线后仍短暂有流量打到旧 IP 是正常的。
  • 配置的三元组定位:一份配置由 namespace + group + dataId 唯一确定。排查”配置没生效”时 90% 的问题是这三者之一对不上——客户端配的 namespace(比如用了环境隔离的 dev namespace)和 Nacos 控制台改的那份不在同一个命名空间,是最经典的乌龙。

我们踩过的坑

坑1:Open API 鉴权方式变更(P1-01)
Nacos 2.x 的 Open API 要求 accessToken 放在 Query 参数里,不是 Request Body。我们最初往 Body 里塞 token,直接被拒绝返回 403。这提醒我们:同一个中间件的大版本升级,连鉴权传参的位置都可能变,不能想当然照抄旧文档。

坑2:手写 SDK 客户端时鉴权信息被遗漏(P0-06,见后文 Sentinel 部分详细展开)
NacosDataSource 有多个构造函数重载,其中”看起来更简单”的 4 参数构造函数(serverAddr, group, dataId, parser)内部根本不支持传鉴权信息。这种问题的可怕之处在于:它不报错、不启动失败,只是订阅静默失败,日志只出现在 Nacos 客户端自己的日志里(ERROR c.a.n.client.config.impl.ClientWorker),业务代码完全无感知。

知识扩展:没踩过但必须知道的

  • Distro 协议:Nacos 自研的 AP 型数据同步协议,用于临时实例数据在集群节点间的最终一致同步。理解它是为了回答面试经典问题”Nacos 是 AP 还是 CP”——正确答案是两者都支持,由实例类型决定
  • 配置监听的失效模式:除了本篇踩过的鉴权遗漏,还有两类高频失效:① dataId 后缀与 file-extension 不匹配(声明了 yaml 却用 .properties 后缀的 dataId),内容解析失败但可能不抛异常;② 改了 Nacos 上的配置但应用没用 @RefreshScope@ConfigurationProperties(后者天然支持刷新),Bean 里的旧值不会变。
  • 服务雪崩时的保护阈值:Nacos 可以配置保护阈值(0~1),当健康实例占比低于阈值时,把不健康的实例也返回给调用方,防止”实例批量不健康 → 流量全部压到少数健康实例 → 连锁崩溃”。这是注册中心层面的兜底思维,和 Sentinel 的流量层面兜底是互补的。

必须记住的结论

  • Nacos 既是注册中心也是配置中心,这两个职责在排障时要分开想:服务发现不通通常是网络/端口/心跳问题,配置拉取不通通常是鉴权/namespace/dataId/group 不匹配问题。
  • 任何时候手写”XxxDataSource”这类基础设施客户端代码,第一反应就是检查有没有正确传鉴权信息,尤其是构造函数有多个重载时,优先选择接受 Properties 的那个,显式塞入 username/password,不要选看起来参数最少的那个。
  • 排查”配置好像没生效”类问题时,不要只看业务服务自己的日志,还要看中间件 SDK 自己打的日志(比如 Nacos 客户端日志),很多失败只在这一层暴露。
  • 记住临时实例的两个时间数字:15 秒标记不健康、30 秒剔除;记住配置定位三元组:namespace + group + dataId

技术点 2:Sentinel —— 限流熔断

难度:⭐⭐⭐ | 掌握要求:能说清两套规则体系的分裂、四种规则类型、三种熔断策略

核心概念

Sentinel 的规则体系比想象中更分裂,理解这一点是排查所有 Sentinel 问题的关键:

  • 业务层限流@SentinelResource 注解标记的方法,规则存在 FlowRuleManager 里,数据源自动装配用 spring.cloud.sentinel.datasource.xxx.nacos.rule-type=flow
  • 网关层限流SentinelGatewayFilter 拦截网关路由请求,规则类型是完全独立的 GatewayFlowRule,存在完全独立的 GatewayRuleManager 里。
  • 这两套规则表互相不认识对方spring-cloud-alibaba 官方针对 Nacos 数据源的自动装配只支持业务层的 rule-type(flow/degrade/param-flow/system),根本没有网关规则的选项——网关规则的 Nacos 订阅代码必须自己手写。

原理深挖:Sentinel 是怎么统计和判断的

Sentinel 的核心实现是一条责任链(ProcessorSlot Chain):每次受保护的调用依次经过 NodeSelectorSlot → ClusterBuilderSlot → LogSlot → StatisticSlot → AuthoritySlot → SystemSlot → FlowSlot → DegradeSlot。其中最关键的是 StatisticSlot——它用滑动时间窗口(LeapArray,默认把 1 秒切成 2 个 500ms 的窗口桶,支持秒级/分钟级两种采样)统计实时的 QPS、RT、异常数,后面的 FlowSlot/DegradeSlot 拿着这些统计数据跟规则比对。

flowchart LR
    A[受保护的调用进入] --> B[StatisticSlot
滑动窗口统计
QPS/RT/异常数] B --> C{FlowSlot
流控规则判断} C -->|QPS/线程数超阈值| D[抛出 FlowException
进入 fallback/blockHandler] C -->|通过| E{DegradeSlot
熔断规则判断} E -->|熔断器打开| F[抛出 DegradeException
走降级逻辑] E -->|通过| G[执行真实业务逻辑] G --> H[统计结果回写窗口]

四种规则类型各管一件事,面试高频:

规则类型 作用 典型阈值
flow(流控) 限制进入的流量 QPS 阈值、并发线程数;支持直接拒绝/排队(Warm Up)/匀速排队模式
degrade(熔断) 下游不稳定时切断调用,快速失败 慢调用比例(RT 超阈值的比例)、异常比例异常数三种策略
param-flow(热点参数) 对”同一个参数值”的请求限流(如某个热点商品 ID) 参数值维度的 QPS
system(系统) 保护整机不被打垮 系统 Load、CPU 使用率、总 QPS、总线程数

熔断器的工作模式是一个三态状态机:CLOSED(正常)→ 触发阈值 → OPEN(熔断,请求直接快速失败)→ 经过一个”探测窗口期” → HALF-OPEN(放少量请求试探)→ 成功则 CLOSED,失败则继续 OPEN。这和电路保险丝是同一个隐喻,也是”熔断”这个名字的由来。

我们踩过的坑

P0-06:网关限流从项目搭建起就没生效过,直到压测才发现

这是本项目优先级最高的一个真实缺陷,值得完整复盘一遍思路:

  1. 我们最初以为”网关限流”和”业务限流”用同一套 rule-type=flow 配置就够了。规则确实被正常加载了——但是加载进了 FlowRuleManager,而 SentinelGatewayFilter 检查请求时读的是 GatewayRuleManager。两张表毫不相干,规则形同虚设。
  2. 更隐蔽的是,这个问题长期不会被任何人发现:功能测试时只会验证接口能不能通、返回对不对,不会主动测”限流到底生不生效”。只有做压测(200 并发瞬时打进去看有没有 429)才会暴露。
  3. 定位到问题后修复也踩了坑:手写 NacosDataSource 加载 GatewayFlowRule 时用了不带鉴权的 4 参构造函数(见上面 Nacos 部分),又白白排查了一轮。

P1-02:Sentinel Dashboard 在 JDK 21 下不显示数据
这是一个已知的 Sentinel 1.8.6 在新版 JDK 上的 bug,Dashboard 的 Command Server 拉不到数据,但限流本身走独立链路,不受影响。教训是:可视化工具显示异常不代表功能异常,一定要用接口压测去验证功能层,不能光看 Dashboard 有没有数据就下结论。

P1-02(依赖问题文档):Nacos 数据源依赖缺失
sentinel-core/sentinel-spring-cloud-gateway-adapter 不包含 Nacos 数据源实现,必须单独引入 sentinel-datasource-nacos,否则启动直接 ClassNotFoundException。这个是”显式报错”型问题,反而是最好排查的一类。

知识扩展:没踩过但必须知道的

  • 规则持久化的三种模式:① 原始模式——规则写在代码/配置文件里,重启不丢但改规则要重启;② Push 模式(本项目用)——规则推到 Nacos,客户端监听变更,重启后从 Nacos 拉回,Dashboard 上改的规则默认只推到内存、不会写回 Nacos,重启就丢(除非改造 Dashboard 的发布逻辑);③ Pull 模式——客户端轮询外部存储,实时性差,少用。
  • @SentinelResource 的两个回调别搞混blockHandler 处理”被 Sentinel 规则拦下”(FlowException/DegradeException),fallback 处理”业务代码自己抛的异常”。只配 fallback 不配 blockHandler,限流触发的 BlockException 会直接抛给调用方,看起来像没降级。
  • 熔断 vs 降级的语义:熔断(Circuit Breaking)是触发机制——“检测到下游不行,自动切断”;降级(Fallback)是应对手段——“切断之后给一个兜底响应”。两者常一起出现但不是一回事。
  • 集群流控:单机流控只管一个节点,10 个节点各限 100 QPS 实际总阈值是 1000 且不精确。需要精确的全局限流要部署 Token Server 做集群流控,本项目未使用,了解概念即可。

必须记住的结论

  • 业务限流用 @SentinelResource + FlowRuleManager,网关限流用 SentinelGatewayFilter + GatewayRuleManager,两者规则类型(FlowRule vs GatewayFlowRule)、数据源加载方式(官方自动装配 vs 手写代码)完全不同,不能混用同一份配置。
  • 网关层想要限流生效,Nacos 数据源必须自己写代码用 GatewayRuleManager.register2Property() 注册,spring-cloud-alibaba 不会替你做这件事。
  • “配置了规则”不等于”规则生效”,中间存在”规则加载到了错误的地方”这种沉默失败模式。任何限流/熔断类功能上线前,必须用真实流量(哪怕是简单的并发 curl/wrk)验证一次,不能只凭配置文件和日志”看起来没报错”就认为完成了。
  • Dashboard/控制台类可视化工具本身可能有兼容性 bug,功能是否生效永远以接口实际返回结果为准,不以控制台”有没有数据”为准。
  • 记住三种熔断策略的名字和区别:慢调用比例 / 异常比例 / 异常数;记住熔断三态:CLOSED → OPEN → HALF-OPEN

技术点 3:OpenFeign —— 服务间调用

难度:⭐⭐ | 掌握要求:能讲清动态代理原理、熔断降级三要素、fallback 与 fallbackFactory 区别

核心概念

Feign 让跨服务调用写起来像调用本地接口。它的实现原理一句话说透:启动时扫描 @FeignClient 接口,用 JDK 动态代理生成实现类,把方法调用翻译成 HTTP 请求——方法上的注解(@GetMapping 等)决定 URL 和 HTTP 动词,参数注解决定参数放 query 还是 body,返回值由解码器反序列化。服务名到真实地址的转换交给 LoadBalancer 组件(问 Nacos 要实例列表,按策略挑一个)。

flowchart LR
    A[业务代码调用
houseFeignClient.freeze] --> B[JDK 动态代理
拦截方法调用] B --> C[编码器: 方法注解+参数
→ HTTP 请求模板] C --> D[LoadBalancer
livin-house → 实例列表
→ 选中一个 IP:Port] D --> E[HTTP Client 发出请求] E --> F[解码器: 响应 JSON
→ 返回值对象]

要让熔断降级生效,需要三件事同时满足,缺一不可:

  1. 引入 spring-cloud-starter-alibaba-sentinel
  2. 配置打开 feign.sentinel.enabled=true默认是关闭的,这是最容易漏的一步)
  3. @FeignClientfallbackFactory 而不是更简单的 fallback

我们踩过的坑

P1-02(依赖问题文档):FallbackFactory 不生效
livin-leaselivin-house 冻结房源,house 异常后本该走降级,结果直接抛 500 到调用方。三个必要条件里漏了 feign.sentinel.enabled=true 这一条,导致 Sentinel 根本没有接管这个 Feign 调用,自然不会触发降级。

fallbackfallbackFactory 的区别也值得记住:fallback 拿不到具体的异常原因,只能返回一个固定的兜底值;fallbackFactory 通过 FallbackFactory<T> 接口能拿到触发降级的 Throwable,可以针对性记录日志、区分是超时还是熔断还是业务异常。项目里统一用 fallbackFactory,方便后续排查降级原因。

知识扩展:没踩过但必须知道的

  • 超时配置的默认值陷阱:Feign 默认连接超时 10s、读超时 60s——读超时 60 秒对在线接口来说几乎等于”没有超时保护”,下游一旦慢响应,调用方线程会被长时间占住,可能引发调用方的线程池耗尽(这是雪崩的典型传导路径)。实践中应显式配置 feign.client.config.default.connectTimeout/readTimeout,读超时一般设 2~5 秒。
  • 默认不重试:Feign 默认 Retryer.NEVER_RETRY,失败即抛异常。开启重试要小心——非幂等的写接口重试可能造成重复执行(比如重复扣款),这是”重试”和”幂等”必须一起考虑的原因(幂等设计详见 10-JVM与并发编程.md)。
  • 第一刀慢的问题:Feign 首次调用要初始化连接池、做懒加载,第一次调用明显偏慢,健康检查/预热流量可以先打一发避免用户吃到。
  • 拦截器的正确用途RequestInterceptor 是传递跨服务上下文的官方通道——本项目 Seata 的 xid、以及”网关注入的用户 Header 继续往下一跳服务透传”都是在这层做的。凡是”调用链上的每个服务都需要的信息”,都应该通过拦截器统一注入,而不是在每个 Feign 方法上加参数。

必须记住的结论

  • Feign + Sentinel 熔断降级生效需要”依赖 + 开关配置 + 注解写法”三件事同时成立,任何一个环节漏了,表现都是”降级没生效,异常直接抛出去了”,容易被误判成”Sentinel 没配对”而忽略掉 feign.sentinel.enabled 这个开关。
  • 优先用 fallbackFactory,能拿到异常原因,对排障更友好。
  • 记住 Feign 的两个隐性默认值:默认不重试、读超时 60 秒过长——生产配置必须显式覆盖读超时。
  • 跨服务上下文(用户身份、事务 xid、traceId)的统一传递通道是 RequestInterceptor

技术点 4:Spring Cloud Gateway —— 统一入口

难度:⭐⭐ | 掌握要求:能讲清路由三要素、过滤器执行顺序、网关为什么是非阻塞模型

核心概念

Gateway 在本项目里承担两个职责:路由转发(把 /api/house/** 转发到 house 服务)和统一鉴权(JWT 全局过滤器)。这两个职责如果配置上打架,会互相影响。

原理深挖:一个请求在网关里经历了什么

Gateway 不是传统的 Servlet 应用,它构建在 Spring WebFlux + Netty 之上,是非阻塞 I/O 模型——少量线程(默认 CPU 核数个 EventLoop)就能处理大量并发连接,线程不会因为等待下游响应而阻塞挂起。这就是为什么网关这类”I/O 密集、几乎无业务计算”的组件适合用 WebFlux,而传统 Spring MVC(每请求一个线程)做网关在高并发下线程数会先爆。

一个请求的处理链路由三个核心概念组成:

flowchart TD
    A[客户端请求] --> B&#123;Route Predicate
路由谓词匹配&#125; B -->|/api/house/** 匹配| C[命中 livin-house 路由] B -->|都不匹配| D[404] C --> E[GlobalFilter 链
按 order 从小到大执行] E --> F[JwtAuthGlobalFilter
验签/注入用户Header] F --> G[GatewayFilter 链
路由级过滤器] G --> H[NettyRoutingFilter
真实转发到下游服务] H --> I[下游响应后
过滤器链逆序执行 post 逻辑]
  • Route(路由):转发规则的基本单位,包含 ID、目标 URI、谓词集合、过滤器集合。
  • Predicate(谓词):判断请求是否命中该路由的条件(Path/Method/Header/Query 等),可组合。
  • Filter(过滤器):分 GlobalFilter(对所有路由生效,如 JWT 鉴权)和 GatewayFilter(只对绑定路由生效)。过滤器有 pre/post 两阶段:请求转发前按 order 从小到大执行 pre 逻辑,下游响应返回后逆序执行 post 逻辑——这和 Servlet Filter 的”链式进、链式出”是一致的。排查”为什么我的过滤器没执行/顺序不对”时,先看 order 值。

我们踩过的坑

P1-01:白名单遗漏支付回调接口
第三方支付平台的异步回调请求不会带 JWT token(它是外部系统发起的,不是登录用户),但 Gateway 的全局 JWT 过滤器默认会拦截所有请求,回调路径必须显式加入白名单:

1
2
3
4
5
6
7
8
private static final List<String> WHITE_LIST = List.of(
"/api/auth/sms/send",
"/api/auth/login/**",
"/api/payment/callback/**", // 支付回调(第三方异步通知),必须放行
"/api/house/list",
"/api/house/{houseId}/detail",
"/api/search/**"
);

这类问题的通用规律:任何”非用户主动发起、无法携带登录态”的请求(第三方回调、内部服务间调用、健康检查探针)都需要显式加入白名单,否则会被统一鉴权拦截。

P2-01:Knife4j 聚合文档路由冲突
Gateway 同时配置路由转发和 swagger-ui 聚合,两者对 /v3/api-docs 路径处理冲突,需要在 Gateway 单独配置 springdoc.swagger-ui.urls 显式列出每个子服务的文档地址,而不是依赖自动聚合。这是一个相对边缘的问题,了解即可。

P0-06:网关限流规则加载(见上面 Sentinel 部分)

知识扩展:没踩过但必须知道的

  • 白名单匹配方式的坑List.contains 全等匹配和 Ant 风格路径匹配(/api/auth/login/**)不是一回事,混用时要确认匹配工具类支持通配符(AntPathMatcher),否则配了 /** 实际按全等比较,等于白配——这又是一个”配置了不生效”的沉默失败高发区。
  • 网关层的超时与重试:Gateway 转发本身也有超时(spring.cloud.gateway.httpclient.response-timeout),下游长时间不响应时网关的表现(504 还是自定义兜底)取决于这里;Retry GatewayFilter 可以配重试,但和 Feign 重试一样,只对幂等的 GET 类请求安全
  • 网关不是银弹:所有流量过网关意味着网关是单点放大器——网关抖动影响全部业务。生产上网关本身要多实例部署 + 前置 SLB/Nginx,本项目单机部署是演示环境取舍,面试被问到要能说出来。

必须记住的结论

  • Gateway 是统一鉴权入口,也是所有”无法带登录态”请求的例外白名单管理入口。新增任何对外的 webhook/回调接口,第一件事就是检查网关白名单。
  • Gateway 身兼路由转发、鉴权、限流三个职责,排查网关相关问题时要先明确是哪个职责出的问题,不要混着看。
  • 记住过滤器执行规律:pre 阶段按 order 升序,post 阶段逆序;全局过滤器对所有路由生效,路由过滤器只对绑定路由生效。
  • Gateway 基于 WebFlux/Netty 非阻塞模型,少量线程扛高并发——这是它区别于 Tomcat+MVC 传统网关方案的核心优势。

术语表

术语 含义
临时实例(Ephemeral Instance) Nacos 中靠客户端心跳保活的服务实例,AP 模型,默认类型
Distro 协议 Nacos 自研的 AP 型数据同步协议,用于临时实例数据集群内最终一致
namespace/group/dataId Nacos 中唯一定位一份配置(或服务分组)的三元组
滑动时间窗(LeapArray) Sentinel 统计实时指标的环形数组结构,把统计周期切成多个窗口桶
熔断三态 CLOSED(正常)→ OPEN(熔断快速失败)→ HALF-OPEN(试探恢复)
blockHandler / fallback @SentinelResource 中分别处理”被规则拦截”和”业务异常”的两个回调
JDK 动态代理 Feign 的实现基础:运行期为接口生成实现类,拦截方法调用转成 HTTP 请求
Route / Predicate / Filter Gateway 路由三要素:转发目标、匹配条件、加工逻辑
pre / post 阶段 过滤器在请求转发前、响应返回后的两个执行阶段,post 逆序执行
白名单 网关鉴权中显式放行、不做登录校验的路径清单

分布式事务与消息队列:Seata / RocketMQ

一句话速览:Seata AT 用 undo_log 快照让分布式事务”自动可回滚”,RocketMQ 用异步消息让服务解耦——两者共同的教训是:它们的失效方式都是沉默的(事务没回滚不报错、订阅被覆盖不报错),可靠性必须靠机制和验证来保证,不能靠”没异常”来推断。

涉及原始记录:07-面试问题/开发问题/02-框架与中间件集成问题.md P0-01、P0-05;
07-面试问题/验证问题/01-业务逻辑问题.md P0-01

最近修订:2026-08-07

目录


技术点 1:Seata AT 模式 —— 分布式事务

难度:⭐⭐⭐ | 掌握要求:能画出两阶段提交流程,说清 undo_log、全局锁、写隔离

核心概念

Seata AT 模式的本质是:业务代码不用手写补偿逻辑,Seata 自动记录 SQL 执行前后的数据快照(存在 undo_log 表),一旦全局事务需要回滚,就用快照生成反向 SQL 自动执行

原理深挖:AT 模式的两阶段提交到底做了什么

Seata 有三个角色:TC(Transaction Coordinator,Seata Server,全局事务的大脑)、TM(Transaction Manager,标注 @GlobalTransactional 的发起方)、RM(Resource Manager,每个参与事务的服务,管理本地分支事务)。

sequenceDiagram
    participant TM as lease服务(TM)
    participant TC as Seata Server(TC)
    participant RM1 as lease库(RM)
    participant RM2 as house库(RM)

    TM->>TC: 1. 开启全局事务,获得 xid
    Note over TM,RM1: 一阶段:本地提交
    TM->>RM1: 2. 执行本地SQL(携带xid)
    RM1->>RM1: 记录 before/after 快照到 undo_log
向TC注册分支事务、获取全局锁 RM1->>RM1: 本地事务直接提交(释放本地锁) TM->>RM2: 3. Feign调用house(xid随Header透传) RM2->>RM2: 同样记录undo_log、注册分支、本地提交 alt 全部成功 TM->>TC: 4a. 全局提交 TC->>RM1: 二阶段:异步删除undo_log即可 TC->>RM2: 二阶段:异步删除undo_log即可 else 任一失败 TM->>TC: 4b. 全局回滚 TC->>RM1: 二阶段:用undo_log生成反向SQL补偿 TC->>RM2: 二阶段:用undo_log生成反向SQL补偿 end

AT 模式区别于 XA 等传统两阶段协议的关键设计,也是面试必考点:

  • 一阶段本地直接提交:RM 在一阶段就把本地事务提交了(不是 hold 住锁等二阶段),本地锁占用时间极短,这是 AT 性能远好于 XA 的原因。代价是中间态对其他事务可见——所以隔离性要靠全局锁补。
  • 全局锁(Global Lock):RM 在一阶段提交前会向 TC 申请被修改行的全局锁。另一个全局事务想改同一行,也必须先申请全局锁——拿不到就等待/失败。这样保证”两个全局事务不会同时改同一行”,即 AT 默认提供读未提交之上的写隔离
  • 回滚前先校验:二阶段回滚时,RM 会用 undo_log 的 after 镜像和当前数据比对(脏写校验),如果不一致(说明全局事务提交后数据又被别人改了),自动回滚会失败,需要人工介入——这是兜底中的兜底。
  • undo_log 表是每个参与库的”本地资源”:这就是为什么必须在每个服务的数据库各建一份,而不是全局共享一张。

要让这套机制跑起来,四个条件必须同时满足:

  1. 引入 seata-spring-boot-starter
  2. 每个参与分布式事务的服务的数据库都要有 undo_log(这张表是每个库自己的,不是全局共享一张)
  3. 全局事务上下文(xid)要能跨服务传递——这是通过 Feign 调用时自动在请求头里带上 xid 实现的,需要注册 Seata 的 SeataFeignClientInterceptor
  4. @GlobalTransactional 必须标注在事务的发起方法(比如”创建租约”这个入口方法),不是标注在被调用的下游方法上

我们踩过的坑

P0-01:跨服务事务未生效,残留脏数据

现象:livin-lease 创建租约时用 Feign 调 livin-house 冻结房源,标了 @GlobalTransactional,house 侧异常后 lease 侧数据却没回滚。

根因是条件 2 和条件 3 都没满足:house 服务的库里没建 undo_log 表,Feign 也没注册 Seata 拦截器。这两个问题的报错都不明显——不满足条件不会让事务”报错”,只会让事务”不生效”(回滚请求发出去了,但因为没有 undo_log 记录,没有东西可以回滚)。

必须记住的结论

  • undo_log 表要在每一个参与全局事务的服务库里都建一份,不是建一次就够。新增一个会参与分布式事务的服务时,第一件事是检查这张表在不在。
  • @GlobalTransactional 要标在事务发起的入口方法上(谁开启全局事务,谁负责收尾),不要标在被 Feign 调用的下游方法上。
  • Seata 相关的”没回滚成功”类问题,优先检查 undo_log 表是否存在、Feign 拦截器是否注册,这两处不满足时不会有明显的异常堆栈,只会看到”回滚了但数据还在”这种沉默失败。
  • AT 模式一阶段本地直接提交,性能好但中间态可见,隔离性靠全局锁保证;二阶段提交是异步删 undo_log(很快),回滚才用快照补偿(较慢)——“正常路径快、异常路径慢”是 AT 的设计取向。

关联坑:验证阶段发现的 tenant_id 为空问题(业务逻辑问题 P0-01)

这个问题看起来是 Seata 的锅,实际是验证方式错了:直接绕过 Gateway 调 lease 服务内部接口,导致 UserContextHolder.getUserId() 拿不到用户 ID(这个值本来是 Gateway 解析 JWT 后通过 Header 透传的),tenant_id 字段插入了 NULL,进而影响 Seata 回滚 SQL 的执行。

这条记录放在这里是想强调一个更通用的教训:看到”分布式事务没回滚干净”,不要第一反应就是排查 Seata 配置,先确认测试数据本身是不是正常业务流程产生的。绕过网关直连业务端口是本项目里反复出现的一种”伪造出来的假故障”来源,详见 09-认证鉴权与网关架构.md

知识扩展:分布式事务方案全景对比

AT 不是唯一选项,面试常考横向对比:

方案 一致性 性能 业务侵入 适用场景
Seata AT 强一致(全局锁) 较好(一阶段直接提交) 低(注解即可) 关系型库、短事务,本项目选择
TCC(Try-Confirm-Cancel) 强一致 好(资源预留粒度细) 高(每个操作写三个方法) 资金类核心链路,需精细控制资源
Saga 最终一致 中(每个操作写正向+补偿) 长流程、跨系统(如订单→物流→通知)
本地消息表 / MQ 事务消息 最终一致 最好 允许短暂不一致的异步场景(最常用)

记住判断思路:要实时强一致选 AT/TCC,允许秒级不一致优先选 MQ 最终一致方案(实现简单、性能最好)。实际大厂系统里最终一致方案的使用频率远高于强一致方案。


技术点 2:RocketMQ —— 消息队列

难度:⭐⭐⭐ | 掌握要求:能说清存储结构、刷盘策略、消息不丢的三个环节、重试与死信、幂等消费

核心概念

rocketmq-spring-boot-starter 的消费者模型有一个容易被忽视的底层约束:每一个 consumerGroup 在客户端侧对应一个独立的 DefaultMQPushConsumer 实例,一个实例只能维护一份订阅关系(订阅一个 topic)。这个约束在写代码时完全体会不到——@RocketMQMessageListener(topic=..., consumerGroup=...) 这个注解看起来只是声明式配置,不会有任何编译期或启动期的提示告诉你”这个 group 已经被占用了”。

原理深挖:Broker 架构与消息存储

flowchart LR
    P[Producer] -->|同步/异步/单向发送| B[Broker]
    NS[NameServer
轻量级路由注册中心] <-.->|Broker注册/客户端查路由| B NS <-.-> P NS <-.-> C B --> CL[CommitLog
所有topic消息顺序追加写] CL --> CQ[ConsumeQueue
每个topic每个queue的索引
offset+size+tag] CL --> IF[IndexFile
按key/messageId查询] CQ --> C[Consumer
消费进度记录在Broker]

理解存储结构是理解 RocketMQ 一切行为的基础:

  • CommitLog:所有 topic 的消息都顺序追加到同一个物理文件族——顺序写磁盘性能接近内存,这是 RocketMQ 高吞吐的根本原因。
  • ConsumeQueue:逻辑消费队列,每个 topic 的每个 queue 一份,只存”消息在 CommitLog 的偏移量 + 长度 + tag 哈希”,消费者实际读的是它。写消息时先落 CommitLog,再异步分发构建 ConsumeQueue/IndexFile。
  • 刷盘策略SYNC_FLUSH(同步刷盘,消息落盘才返回成功,最可靠最慢)vs ASYNC_FLUSH(异步刷盘,先写 PageCache 就返回,性能高,机器断电可能丢最后几百毫秒数据)。金融级用同步,一般业务用异步。
  • 主从复制SYNC_MASTER(同步双写,Master 和 Slave 都写完才返回)+ ASYNC_FLUSH 是常见的”可靠性/性能”折中组合。Broker 配置里 brokerRoleflushDiskType 两个参数决定可靠性水位。

原理深挖:消息”不丢”要守住哪几个环节

消息从生产者到消费者,丢失风险分布在三个环节,每个环节有对应的手段——这是”RocketMQ 如何保证消息不丢”这道面试题的标准拆解:

  1. 发送环节:用同步发送 + 检查 SendResult.SEND_OK;重要消息开启失败重试。
  2. 存储环节SYNC_FLUSH 或至少 SYNC_MASTER 同步复制到 Slave,防单机断电/磁盘损坏。
  3. 消费环节消费成功后再提交 offset。RocketMQ 消费者默认是”业务处理返回成功才提交消费进度”,如果处理中宕机,Broker 会把这条消息重新投递(重试)——所以”至少一次”投递语义下,消费者幂等是义务,不是可选项

关于重试,还要记住这条链:消费失败 → 进入重试队列(%RETRY% + consumerGroup 命名)→ 按延迟等级退避重试(默认 16 次,从 10s 逐级到 2h)→ 仍失败进入死信队列(%DLQ% + consumerGroup 命名),死信消息不再自动投递,需要人工或专门的任务兜底处理。

我们踩过的坑

P0-05:consumerGroup 复用导致订阅被覆盖,消息静默丢失

新写 RefundResultConsumer 时图省事,直接复制了已有的 PaymentResultConsumer,只改了 topicconsumerGroup 没改:

1
2
3
4
5
6
// 错误:两个不同 topic 的监听器用了同一个 consumerGroup
@RocketMQMessageListener(topic = "livin-payment-result", consumerGroup = "livin-lease-consumer")
public class PaymentResultConsumer implements RocketMQListener<PaymentResultMessage> { /* 省略实现 */ }

@RocketMQMessageListener(topic = "livin-refund-result", consumerGroup = "livin-lease-consumer")
public class RefundResultConsumer implements RocketMQListener<RefundResultMessage> { /* 省略实现 */ }

Spring 容器按 Bean 加载顺序依次向这个 group 注册订阅,后注册的会覆盖先注册的。最终这个 consumerGroup 实际只订阅了其中一个 topic。表现是:MQ 生产者这边显示消息发送成功(SendResult.SEND_OK),但消费者那边完全没有任何反应——没有异常、没有报错日志、启动也完全正常,是这次踩坑记录里隐蔽性最强的问题之一。

排查方法很关键,值得记住:普通的应用日志排查不出这个问题,必须用 RocketMQ 自带的运维工具确认某个 group 实际订阅了哪些 topic:

1
mqadmin consumerConnection -n 127.0.0.1:9876 -g livin-lease-consumer

修复方式很简单:一个 consumerGroup 只订阅一个 topic,新增消费者时必须用独立的 group 名:

1
2
@RocketMQMessageListener(topic = "livin-refund-result", consumerGroup = "livin-lease-consumer-refund")
public class RefundResultConsumer implements RocketMQListener<RefundResultMessage> { /* 省略实现 */ }

必须记住的结论

  • 一个 consumerGroup 只能订阅一个 topic,新建消费者时永远给一个新的、专属的 consumerGroup 名字,不要复制粘贴已有消费者时漏改这一项
  • 这类”注册被覆盖”的问题,应用层日志看不出任何异常,唯一可靠的排查手段是用 mqadmin consumerConnection -g <group> 直接问 MQ 服务端”这个 group 现在到底订阅了什么”。
  • 设计消息 Topic 时,同一个业务模块如果有多种消息语义(比如”收款结果”和”退款结果”),优先拆成独立的 Topic + 独立的 consumerGroup,而不是共用一个 Topic 靠消息体里的字段做分支判断——这样从命名上就不容易发生 group 复用的失误,下游消费者的订阅关系也更清晰可查。
  • 消息不丢要守住三个环节:发送确认、刷盘/主从复制、消费成功后再提交 offset;消费失败的处理链是 重试队列(16 次退避)→ 死信队列(人工兜底)

知识扩展:没踩过但必须知道的

  • 消费幂等:RocketMQ 是”至少一次”投递,网络抖动、消费超时、Rebalance 都可能导致同一条消息被投递多次。标准做法是消费前查”这条消息是否已处理过”(业务唯一键 + DB 唯一索引 / Redis setnx 去重表),幂等详细设计见 10-JVM与并发编程.md
  • 顺序消息:全局顺序只有一个 queue(吞吐量剧降),局部顺序(同一订单ID的消息进同一 queue)靠发送方用 MessageQueueSelector 按 key 哈希选队列 + 消费方单线程消费该 queue 实现。”支付成功先于退款成功被消费”这类诉求用局部顺序即可。
  • 事务消息:解决”本地事务和发消息的原子性”(先扣款成功才能发出”支付成功”消息)。机制是半消息 + 回查:先发一条对消费者不可见的半消息 → 执行本地事务 → 根据事务结果 commit/rollback 该消息;如果结果不明,Broker 会回查生产者的本地事务状态。它是实现最终一致性的标准武器,也是 Saga/本地消息表之外的第三条路。
  • 延迟消息:RocketMQ 内置 18 个延迟等级(1s~2h),常用于”30 分钟未支付自动关单”类场景。注意延迟等级是固定的,不能任意指定时间。
  • 广播 vs 集群消费:集群消费(默认)下一条消息只被 group 内一个实例消费;广播消费下每个实例都收到全量——本项目的”缓存清理广播”如果用 MQ 实现就该用广播模式,这是 03-缓存与数据一致性.md 里状态广播问题的另一种解法。

术语表

术语 含义
TC / TM / RM Seata 三角色:事务协调器(Server)、事务管理器(发起方)、资源管理器(参与方)
undo_log AT 模式记录数据前后镜像的表,二阶段回滚的依据,每个参与库各一份
全局锁(Global Lock) Seata AT 对被修改行的跨全局事务互斥锁,保证写隔离
两阶段提交 一阶段本地事务直接提交并注册分支;二阶段全局提交(删 undo_log)或回滚(快照补偿)
TCC Try-Confirm-Cancel,把每个业务操作拆成预留/确认/取消三个方法的分布式事务模式
Saga 长事务模式,每个正向操作配一个补偿操作,任一失败按逆序补偿
NameServer RocketMQ 的轻量级路由注册中心,Broker 注册、客户端查路由
CommitLog / ConsumeQueue RocketMQ 存储核心:全量消息顺序写文件 / 按 topic+queue 组织的消费索引
同步/异步刷盘 消息写入磁盘后才返回 / 写入 PageCache 即返回,可靠性与性能的取舍
重试队列 / 死信队列 %RETRY% + group 命名(最多重试16次)/ %DLQ% + group 命名(重试耗尽后的兜底队列)
事务消息 半消息+本地事务+状态回查机制,保证”本地事务成功才发出消息”
Rebalance 消费者组内实例数量变化时,queue 消费权的重新分配(期间可能重复消费)

缓存与数据一致性:多级缓存 / 布隆过滤器 / 状态广播

一句话速览:缓存的价值是”用内存换数据库压力”,代价是引入了一致性问题——本篇的主线是两条:① 缓存自身的三大经典风险(穿透/击穿/雪崩)各有标准解法;② 在分布式场景下,任何状态变更只清本地缓存等于没清,必须广播

涉及原始记录:07-面试问题/开发问题/02-框架与中间件集成问题.md P0-02、P0-03;
07-面试问题/验证问题/01-业务逻辑问题.md P0-02、P2-01(数据与环境问题)

最近修订:2026-08-07

目录


技术点 1:布隆过滤器 —— 防缓存穿透

难度:⭐⭐ | 掌握要求:能说清”只能加不能删””假阳性无假阴性”两个特性及其后果

核心概念

布隆过滤器解决的问题是:当查询一个根本不存在的 ID 时,不要让这个请求打到 DB(比如恶意扫描 houseId=99999999),它是查询链路的第一道关卡,用极小的内存代价快速判断”这个 ID 一定不存在”或”这个 ID 可能存在”。

原理深挖:它凭什么用极小的内存做判断

布隆过滤器的底层是一个位数组 + k 个哈希函数:加入一个元素时,用 k 个哈希函数算出 k 个位置,把这些位置的二进制位置为 1;查询时同样算 k 个位置,只要有一位是 0,这个元素就绝对没加入过;全是 1 则”可能”加入过——因为别的元素可能恰好也把这些位置成了 1(哈希碰撞),这就是误判(假阳性)的来源。

两个关键特性必须记住,它们决定了布隆过滤器的适用边界:

  • “不存在 = 100% 准确,存在 = 有一定概率误判”(无假阴性、有假阳性)。误判率由位数组大小、哈希函数个数、元素数量共同决定,Guava 的 BloomFilter.create(funnel, expectedInsertions, fpp) 里那个 fpp 参数就是你能容忍的误判率。
  • 只能”添加”,不能”删除”单个元素——因为置为 1 的位可能被多个元素共享,贸然清零会误伤其他元素。要支持删除得用计数布隆过滤器(Counting Bloom Filter)或定期重建。

这个特性决定了它只适合用来防”查询一个从来没有出现过的 ID”,不适合用来判断”这个 ID 当前有效还是已失效”。

项目里布隆过滤器是两层的:本地 Guava BloomFilter(纯 JVM 内存,重启即清空)+ Redisson 分布式 RBloomFilter(数据在 Redis,跨节点共享,重启不丢)。

我们踩过的坑

P0-03:忘记做启动预热,服务重启后存量数据全部被误杀

BloomFilterConfig 类的注释写着”启动时从 DB 加载所有有效 houseId”,但代码里从来没有真正实现这段逻辑。布隆过滤器的数据只在 publishHouse 这个写入接口被调用时才会追加

1
2
3
4
5
6
public Long publishHouse(House house, Long landlordId) {
houseMapper.insert(house);
houseBloomFilter.add(house.getId()); // 只有这里会写入布隆过滤器
localHouseBloomFilter.put(house.getId());
return house.getId();
}

本地 Guava 过滤器是纯内存结构,服务一重启就是全新的空过滤器。所有”重启前已经存在、但重启后没有被重新调用过写入接口”的房源,查询详情接口的第一步 mightContain 判断就会返回 false,直接短路返回 null,根本走不到后面的 Caffeine/Redis/DB 任何一层

1
2
3
4
5
6
public HouseDetailVO getHouseDetail(Long houseId) {
if (!localHouseBloomFilter.mightContain(houseId)) {
return null; // 存量房源被误杀在这一步
}
// ... 后续多级缓存逻辑,根本走不到
}

修复方式是新增一个 ApplicationRunner,在服务启动阶段主动查一遍 DB 把全部有效 ID 灌入过滤器:

1
2
3
4
5
6
7
8
9
10
11
12
13
@Component
public class BloomFilterWarmUpRunner implements ApplicationRunner {
@Override
public void run(ApplicationArguments args) {
List<Long> ids = houseMapper.selectList(
new LambdaQueryWrapper<House>().select(House::getId)
).stream().map(House::getId).toList();
ids.forEach(id -> {
houseBloomFilter.add(id);
localHouseBloomFilter.put(id);
});
}
}

关联的验证问题(业务逻辑问题 P0-02):验证阶段直接用 SQL 插入测试房源数据(绕过 publishHouse 接口),同样会导致布隆过滤器里没有这条数据,查询被误判为不存在。这不是 bug,而是验证方式违反了”数据必须通过接口产生”的前提

必须记住的结论

  • 凡是”注释写了但代码没实现”的预热/初始化逻辑,必须假设它根本不存在,尤其是涉及内存结构(本地缓存、本地过滤器)这种重启即失效的东西,一定要亲自确认有没有启动阶段的预热代码。
  • 本地内存态的过滤器/缓存,只要服务重启就是空的,上线新版本、扩容加节点、任何形式的重启都会触发这个问题,所以预热逻辑不是可选项,是这类结构的必需品。
  • 排查”明明数据库里有数据,但接口说不存在”这类问题时,如果链路里有布隆过滤器,第一步永远是确认过滤器是不是刚重启过、有没有做预热。
  • 测试数据必须通过业务接口创建,不能走 SQL 直接插入——这条规则在本项目里因为布隆过滤器、ES 同步、缓存预热等多个旁路逻辑的存在而变得格外重要,绕过接口写库约等于制造出一份”看起来存在但对系统不可见”的脏数据。
  • 布隆过滤器的两个特性要能脱口而出:无假阴性、有假阳性;只增不删

技术点 2:多级缓存 —— Caffeine(本地)+ Redis(分布式)

难度:⭐⭐⭐ | 掌握要求:能画出查询链路,说清回填机制、广播失效、DCL 双检锁

核心概念

多级缓存的分层逻辑是:本地缓存(Caffeine)解决同一节点的重复请求,不用每次都走网络;分布式缓存(Redis)解决跨节点共享、以及本地缓存过期后的兜底。一次查询命中的位置决定了这次请求的延迟:本地缓存命中最快(微秒级,无网络开销),Redis 命中次之(有一次网络往返),DB 命中最慢。

flowchart TD
    A[查询请求 houseId] --> B&#123;布隆过滤器
mightContain?&#125; B -->|一定不存在| C[直接返回 null
挡住穿透] B -->|可能存在| D&#123;Caffeine 本地缓存命中?&#125; D -->|命中| E[返回 微秒级] D -->|未命中| F&#123;Redis 命中?&#125; F -->|命中| G[回填 Caffeine
再返回 毫秒级] F -->|未命中| H[DCL 双检锁
查 DB] H --> I[回填 Redis + Caffeine
再返回]

多级缓存要真正发挥效果,有一个容易被忽视的细节:命中了下层缓存之后,要回填到上层缓存。如果只是”Redis 命中就直接返回”,没有把结果写回本地 Caffeine,那么本地缓存永远只在”写入时”才会有数据,之后过期了就再也不会被填充,多级缓存事实上退化成了单级。

原理深挖:两个组件为什么这样分工

  • Caffeine 的淘汰策略是 W-TinyLFU:传统的 LRU(最近最少使用)有个缺陷——突发的一波扫描式访问(比如定时任务全量遍历)会把真正的热点数据挤出缓存。W-TinyLFU 引入频率统计(用 Count-Min Sketch 近似记录访问频次),新候选元素要和受害者元素比频率才能换入,抗”突发流量污染”能力显著强于 LRU。这是 Caffeine 成为本地缓存首选的核心原因。
  • Redis 的高性能来自三个设计:① 纯内存操作;② 单线程处理命令(避免锁竞争和线程切换开销,单线程不是瓶颈因为瓶颈在网络 IO 和内存;6.0 后 IO 读写用了多线程但命令执行仍单线程);③ IO 多路复用(epoll)。理解”单线程”能解释两个实践原则:不要在 Redis 里执行 O(N) 的大 key 操作(会阻塞所有其他命令)、Lua 脚本/事务是原子执行的(因为天然没有并发交错)。

DCL 双检锁:防”缓存失效瞬间大量请求同时打到 DB”

当热点 key 过期,大量并发请求同时发现缓存未命中,如果都去查 DB 就是缓存击穿。DCL(Double-Checked Locking)模式:第一个线程拿到锁查 DB 并回填,其他线程拿到锁后再检查一次缓存(此时已被回填),直接返回。这个模式和单例模式的 DCL 是同一个思想,但注意分布式的正确姿势——本地锁只能挡本节点,多节点下要么接受”每节点各回源一次”(通常可接受),要么用 Redisson 分布式锁(详见 10-JVM与并发编程.md)。

我们踩过的坑(阶段8验证记录,未单独建 P 编号,属于本项目自查发现的优化点)

getHouseDetail 命中 Redis 缓存的分支(包括双检锁 DCL 分支)只是把 Redis 里的 JSON 反序列化后直接返回,没有执行 houseLocalCache.put(houseId, vo) 这一步回填。结果是:Caffeine 缓存一旦因为 TTL 到期失效,之后同一节点的所有请求都会退化成”每次都要走一次 Redis 网络 IO”,而不是”第一次走 Redis,之后重新在本地缓存住”。

修复很简单,在 Redis 命中的分支补上回填这一行代码即可。这个问题的价值不在于修复难度,而在于提醒自己:写多级缓存代码时,”命中下层就返回”和”命中下层就返回、并且回填上层”是两种完全不同的效果,写代码时容易只想到前者

数据一致性问题:状态变更必须广播,不能只清本地缓存

P0-02:房源冻结/解冻只清了当前节点的缓存,没有通知其他节点和 ES

这是缓存一致性里最容易犯、也是本项目里代价最大的一类错误。freezeHouse(房源出租后冻结,防止重复出租)和 unfreezeHouse(退租后解冻)这两个方法,只调用了 evictCache(houseId)

1
2
3
4
5
6
public void freezeHouse(Long houseId) {
house.setStatus(OCCUPIED);
houseMapper.updateById(house);
evictCache(houseId); // 只清了当前节点自己的 Caffeine/Redis 缓存
// 漏了这一步:houseEventProducer.publishStatusChange(houseId, "ONLINE", "OCCUPIED");
}

evictCache 只能清掉当前处理这个请求的这个节点的本地缓存,对以下两类东西完全无能为力:

  1. 其他节点的本地 Caffeine 缓存(多节点部署下,A 节点冻结了房源,B 节点的本地缓存对此一无所知)
  2. ES 索引HouseSearchSyncConsumer 只监听消息队列的状态变更事件,没有事件就不会有任何动作)

后果是:已经出租的房源,在其他节点的缓存里、以及搜索引擎里,会一直显示”可租”的旧状态,直到缓存自然过期(这里配置的 TTL 长达 2 小时+随机因子)。用户能搜索到、点进去看到一个实际已经租出去的房源,这是真实影响业务正确性的问题,不是性能优化类的小问题。

修复方式是让状态变更的方法都去广播一个事件,让所有关心这个状态的下游(跨节点缓存清理、ES 同步)都能收到通知去做自己的事:

1
2
3
4
5
6
public void freezeHouse(Long houseId) {
house.setStatus(OCCUPIED);
houseMapper.updateById(house);
evictCache(houseId);
houseEventProducer.publishStatusChange(houseId, "ONLINE", "OCCUPIED"); // 补上广播
}

必须记住的结论

  • 多级缓存要做到”命中下层就回填上层”,不然多级缓存名存实亡
  • 在分布式/多节点场景下,”清除自己的缓存”永远不等于”数据保持一致”。任何状态变更,只要有可能被其他节点的缓存、或者搜索引擎等旁路系统缓存过,就必须通过消息广播的方式通知所有关心的下游,而不能只操作当前进程能直接访问到的那份缓存。
  • 排查”缓存不一致”问题的标准动作:先确认这个状态变更的入口方法,除了改 DB 和清本地缓存之外,有没有对外广播事件;再确认所有监听这个事件的消费者,有没有把这次变更的所有状态值都覆盖到(比如本项目里 HouseSearchSyncConsumer 最初只处理了 OFFLINE/ONLINE 两种状态,忘了处理 OCCUPIED,也是同一类疏漏)。
  • 单机部署(单节点)测不出”没有广播状态变更”这类问题,因为当前节点自己清了缓存,看起来完全正常。这类 bug 只有在多节点部署,或者引入了另一个独立系统(如 ES)依赖同一份状态时才会暴露,这也是为什么这个问题一直隐藏到阶段8做搜索验证时才被发现。
  • 记住两个组件的设计要点:Caffeine 用 W-TinyLFU(频率优先,抗扫描污染);Redis 命令执行是单线程(别放大 key 操作、Lua 天然原子)。

技术点 3:缓存三大经典问题 —— 穿透 / 击穿 / 雪崩

难度:⭐⭐ | 掌握要求:三者定义、区别、标准解法要能背出来(面试必考)

项目实际踩过的是”穿透”(布隆过滤器),另外两个属于同主题必须掌握的扩展知识:

问题 定义 本项目对应 标准解法
穿透 查询一定不存在的数据,缓存永远不命中,请求全部打到 DB(恶意攻击场景) 布隆过滤器防恶意 houseId 扫描 ① 布隆过滤器前置拦截;② 空值也缓存(短 TTL)
击穿 某个热点 key 过期瞬间,大量并发同时回源 DB DCL 双检锁分支就是为此设计 ① 互斥锁/DCL(只放一个请求回源);② 热点 key 逻辑过期(不物理过期,异步刷新)
雪崩 大量 key 在同一时刻集中过期,或 Redis 整体宕机,DB 被打垮 TTL 配置”2 小时+随机因子“就是防这个 ① TTL 加随机抖动;② 多级缓存兜底;③ Redis 高可用 + 熔断降级

记忆口诀:穿透是”查不存在的”,击穿是”一个热点的过期了”,雪崩是”一大片同时过期了”。三者解法里反复出现的思想是”拦截(布隆过滤器)、互斥(锁)、分散(随机 TTL)、兜底(多级缓存/降级)”。


技术点 4:缓存与数据库的一致性模式

难度:⭐⭐⭐ | 掌握要求:能说清为什么”先更新DB再删缓存”是主流、延迟双删解决什么、最终方案是什么

项目用的是 Cache-Aside 模式但没有深入讨论过”为什么是删缓存而不是更新缓存”,这是面试高频深挖点,必须补全:

sequenceDiagram
    participant W as 写请求
    participant DB as 数据库
    participant C as 缓存
    participant R as 读请求

    W->>DB: 1. 先更新数据库
    W->>C: 2. 再删除缓存(不是更新!)
    Note over R,C: 后续读请求缓存未命中
回源DB读到新值并回填
  • 为什么是”删缓存”而不是”更新缓存”:① 懒加载思想——如果这个 key 短期内没人读,更新缓存是浪费;② 写场景复杂时(一个缓存值由多表 join 组装),更新缓存成本高且容易算错,删掉让下次读重新组装最稳。
  • 为什么是”先更 DB 再删缓存”而不是反过来:先删缓存再更 DB,中间窗口内一个读请求会把旧值重新回填进缓存,这个脏缓存会存活整个 TTL。先更 DB 再删缓存,脏窗口只有”读请求恰好在删缓存前读到旧值”的极短时间,且要求读比写慢才出问题,概率低得多。
  • 残余的不一致窗口怎么补延迟双删——更新 DB 前先删一次缓存,更新完隔几百毫秒再删一次,把窗口内被回填的脏数据清掉。工程上更彻底的做法是订阅 binlog(Canal/MQ)异步删缓存,把缓存操作和主业务流程解耦,本项目的状态广播消息本质上就是这个思路的手工版。
  • 强一致是做不到的:只要”DB 和缓存两次写”不是原子的,就必然存在不一致窗口,所有方案都是把窗口缩小到业务可接受的范围。追求强一致的场景应该不走缓存直接读 DB,或用分布式锁串行化读写——成本极高,先想清楚业务是否真的需要。

术语表

术语 含义
假阳性 / 假阴性 布隆过滤器”可能存在”的误判 / “一定不存在”的误判(布隆过滤器无假阴性)
W-TinyLFU Caffeine 的淘汰算法,用频率统计抵御突发扫描流量对热点的污染
回填(Backfill) 命中下层缓存后把数据写回上层缓存的动作,多级缓存生效的关键
DCL 双检锁 拿锁后再检查一次缓存的模式,防止缓存击穿时重复回源
穿透 / 击穿 / 雪崩 查不存在的数据 / 热点 key 集中回源 / 大量 key 同时失效
Cache-Aside 旁路缓存模式:读——先查缓存、未命中回源并回填;写——先更 DB 再删缓存
延迟双删 更新 DB 前后各删一次缓存,缩小脏数据窗口的工程技巧
逻辑过期 热点 key 不设物理 TTL,value 里带过期时间,到期后异步线程刷新,避免击穿

数据库与并发控制:MyBatis-Plus 乐观锁 / 逻辑删除 / 并发扣减 / MySQL 原理

一句话速览:并发写安全的终极答案只有一句话——把”校验 + 修改”放进同一条原子 SQL 的 WHERE 条件里,让数据库行锁替你守住底线;应用层的乐观锁、Java 锁都只是辅助。数据库排查的第一工具是 EXPLAIN 和慢查询日志,不是猜。

涉及原始记录:07-面试问题/开发问题/02-框架与中间件集成问题.md P1-03;
07-面试问题/验证问题/01-业务逻辑问题.md P1-02

最近修订:2026-08-07

目录


技术点 1:乐观锁(@Version)与逻辑删除(@TableLogic)叠加使用的坑

难度:⭐⭐⭐ | 掌握要求:能说清两个拦截器叠加的风险、手写 SQL 为什么更可控

核心概念

这两个 MyBatis-Plus 特性分别解决不同的问题:

  • 乐观锁:并发更新同一行数据时,靠一个 version 字段做 CAS(Compare And Swap)判断——读的时候记住当前 version,更新时带上这个 version 作为 WHERE 条件,如果这行数据在读和写之间被别人改过(version 已经变了),这次更新就会失败(影响行数为 0),需要业务代码感知并重试或报错。
  • 逻辑删除:不真的执行 DELETE,而是给数据打一个 is_deleted 标记,MyBatis-Plus 会自动在所有查询/更新语句上追加 is_deleted=0 的条件,让”已删除”的数据在业务上看起来不存在。

这两个特性都是通过 MyBatis-Plus 的拦截器自动往 SQL 里拼条件实现的,问题就出在”自动拼接”这个环节:两个拦截器叠加时,生成的最终 SQL 会同时带上 version=?is_deleted=0 两个条件,理论上没问题,但在配合 Seata AT 模式做分布式事务时,行锁的处理会更复杂。

我们踩过的坑

P1-03:并发更新偶发报”version 不匹配”,但实际没有真正的并发冲突

livin-house 的 TimeSlot(预约时间槽)表同时用了 @Version 乐观锁和 @TableLogic 逻辑删除。排查发现问题是 MyBatis-Plus 自动生成的 UPDATE 语句、叠加 Seata AT 模式对整行加的锁,两层机制叠加后在部分场景下会导致乐观锁版本号判断出现误判——本来没有真正的并发写冲突,却被判定为冲突。

解决思路不是去深挖框架内部机制打补丁,而是收窄乐观锁的使用范围:把 version 字段的比较从”依赖 MyBatis-Plus 自动生成 SQL”改成完全手写 SQL,把所有需要的条件显式写清楚,不依赖框架的自动拼接行为:

1
2
3
UPDATE t_time_slot
SET booked_count = booked_count + 1, version = version + 1
WHERE id = #{id} AND version = #{version} AND booked_count < max_count

这条 SQL 本身也顺带解决了另一个问题——booked_count < max_count 作为 WHERE 条件,把”人数是否超额”的判断下推到数据库层做原子判断,而不是先在 Java 代码里查出当前人数、判断没超再发起更新(那样在并发场景下存在”查的时候没超,更新的时候已经超了”的竞态窗口)。

必须记住的结论

  • 框架自动生成 SQL 的便利性和”多个自动拼接机制叠加时行为可预测性”之间存在权衡。当乐观锁 + 逻辑删除 + 分布式事务锁三者叠加,出现难以定位的诡异报错时,与其花大量时间去理解框架内部具体是怎么冲突的,不如直接对最关键的并发场景手写 SQL,把所有条件显式化,牺牲一点框架便利性换取行为可预测。
  • 涉及库存/名额类的并发扣减场景(预约名额、优惠券库存、下单库存),标准做法是把”数量是否足够”的校验直接写进 UPDATE 语句的 WHERE 条件里WHERE booked_count < max_count),让数据库用行级锁保证这个判断和更新是原子的,而不是在应用层先查询后判断再更新——后者无论加不加乐观锁字段,都存在”两次数据库交互之间”的竞态窗口。
  • 乐观锁的本质是”检测冲突后交给业务处理”,不是”防止冲突发生”;而下推到 SQL 层的条件更新是”从数据库层面直接杜绝了不合法的更新执行”,两者不是互斥的,但在同一个字段上不要既指望乐观锁自动重试、又依赖 WHERE 条件兜底,容易在排查时把两层逻辑搞混。

技术点 2:字段级参数校验的必要性

难度:⭐ | 掌握要求:NOT NULL 是最后防线不是校验手段、400 vs 500 的语义

核心概念

Java 实体类的字段类型(比如 Integer floor)本身不会阻止空值传到持久层,只有当持久层执行插入/更新,遇到数据库列的 NOT NULL 约束时才会报错。如果不在接口入参层做校验,这个”必填但漏传”的错误会一路穿透到 DB 层才暴露,报错信息也是数据库异常而不是业务语义的提示。

我们踩过的坑

验证问题 P1-02:漏传字段导致 500 而不是友好的 400

t_house 表的 floor(楼层)、total_floor(总楼层)字段是 NOT NULL 且无默认值,但 House 实体上没有加任何校验注解。前端一旦漏传,请求能顺利通过 Controller,一路走到 Mapper 执行 INSERT 时才被数据库拒绝:

1
java.sql.SQLException: Field 'floor' doesn't have a default value

这类问题返回给前端的是 500(服务器内部错误),而不是 400(请求参数错误)——语义上是错的:这明明是客户端传参有问题,不是服务端出了故障。

修复是加上 Bean Validation 注解,配合 Controller 方法参数上的 @Valid,让 Spring 在进入业务逻辑之前就完成校验、统一返回友好的 400:

1
2
3
4
5
@NotNull(message = "楼层不能为空")
private Integer floor;

@NotNull(message = "总楼层不能为空")
private Integer totalFloor;

必须记住的结论

  • 数据库的 NOT NULL 约束是最后一道防线,不是校验手段。所有对用户可见的接口,必填字段应该在实体类或 DTO 上用 @NotNull/@NotBlank 等注解显式声明,配合 Controller 的 @Valid,让校验失败在进入业务逻辑之前就以 400 的形式返回,不要让数据库异常直接冒泡成 500 抛给客户端。
  • 看到接口返回 500 并且异常栈显示是 SQLException/约束冲突之类的问题,通常说明上层缺少了本该有的参数校验,修复思路不是”处理这个 SQL 异常”,而是”把校验往前移”。

技术点 3:MySQL 索引与慢查询排查 —— 必补的地基

难度:⭐⭐⭐ | 掌握要求:B+树结构、最左前缀、覆盖索引、EXPLAIN 关键列

本篇前半部分讲的是”应用层怎么用数据库”,这部分补”数据库本身是怎么工作的”——不理解索引结构,就无法解释为什么有些查询慢。

B+ 树索引:为什么是它

InnoDB 的索引是一棵 B+ 树:非叶子节点只存索引键值和指针(所以一层能存上千个节点,3~4 层就能支撑亿级行——树高决定磁盘 IO 次数,这是选 B+ 树的根本原因),叶子节点存数据且用双向链表串起来(所以范围查询 BETWEEN/> 只需定位起点后顺序扫叶子,天然高效)。

  • 聚簇索引(主键索引):叶子节点直接存整行数据,表数据本身就是按主键组织的一棵 B+ 树。
  • 二级索引(普通索引):叶子节点存的是”索引键值 + 主键值”。用二级索引查非索引列时,要拿主键再回聚簇索引查一次——这就是回表
  • 覆盖索引:查询要的列全部包含在二级索引里,不用回表,是成本最低的优化手段之一(EXPLAIN 的 Extra 列显示 Using index)。

最左前缀原则

联合索引 (a, b, c) 的 B+ 树是先按 a 排序、a 相同按 b 排、再按 c 排。因此只有查询条件从索引最左列开始连续匹配aa+ba+b+c)才能用上索引;跳过 a 直接查 b 则整棵树无序可循,只能全扫。范围查询(>/LIKE '%xx')之后的列也无法继续用索引——因为范围列之后的部分在树上不再有序。

慢查询排查标准动作

  1. 开启慢查询日志(slow_query_log + long_query_time),先拿到”到底哪条 SQL 慢”的事实。
  2. 对可疑 SQL 执行 EXPLAIN,重点看四列:type(至少要 range/ref,看到 ALL 全表扫描就要警惕)、key(实际用了哪个索引,NULL 就是没用)、rows(估算扫描行数)、ExtraUsing filesort/Using temporary 是性能杀手信号,Using index 是好消息)
  3. 对症下药:缺索引加索引、索引失效改写法(对列做函数运算、隐式类型转换、前导通配符 LIKE 都会让索引失效)、大分页改游标式翻页。

技术点 4:事务隔离级别、MVCC 与锁 —— 必补的地基

难度:⭐⭐⭐ | 掌握要求:四个隔离级别解决什么问题、MVCC 快照读原理、间隙锁

四个隔离级别与三种读现象

隔离级别 脏读 不可重复读 幻读 说明
读未提交 RU 几乎不用
读已提交 RC Oracle/多数 PG 默认
可重复读 RR 基本解决(快照读+间隙锁) InnoDB 默认
串行化 读写都加锁,性能差,极少用

三个读现象的定义要能脱口而出:脏读——读到别人未提交的数据;不可重复读——同一事务内两次读同一行结果不同(被别人改了并提交);幻读——同一事务内两次范围查询,多出了新插入的行。

MVCC:RR 隔离级别是怎么实现的

InnoDB 的 MVCC(多版本并发控制)三要素:

  1. 隐藏列:每行数据带 trx_id(最后修改它的事务ID)和 roll_pointer(指向 undo log 里的旧版本)。
  2. undo log 版本链:每次修改把旧值存入 undo log,形成”当前值 → 旧值 → 更旧值”的链。
  3. ReadView:事务执行快照读时生成一个”可见性视图”,规则是”只看得见在我开始前已提交的事务的修改”。沿版本链找到第一个可见版本返回。

关键区别:RC 是每条 SELECT 都生成新 ReadView,RR 是事务第一次快照读时生成、之后复用——所以 RR 下同一事务内反复读结果一致。注意 MVCC 只管”快照读”(普通 SELECT);”当前读”(SELECT ... FOR UPDATEUPDATEDELETE)永远读最新已提交数据并加锁,这两个概念不分清,面试必挂。

行锁与间隙锁

  • 记录锁(Record Lock):锁住索引记录本身。
  • 间隙锁(Gap Lock):锁住索引记录之间的”间隙”,防止其他事务在间隙里插入新行——这是 RR 级别解决幻读的关键武器,只在 RR 下存在(RC 没有间隙锁)。
  • 临键锁(Next-Key Lock):记录锁 + 前面间隙的组合,左开右闭区间,RR 下 InnoDB 加锁的默认单位。
  • 死锁:两个事务互相持有对方要的锁。排查工具:SHOW ENGINE INNODB STATUS 里的 LATEST DETECTED DEADLOCK 段会给出完整持锁/等锁链路。预防手段:固定访问顺序、减小事务粒度、给 WHERE 条件加索引(无索引的 UPDATE 会退化成锁更多行甚至锁表,这是”加索引也是并发控制手段”的原因)。

技术点 5:并发控制手段全景对比

难度:⭐⭐ | 掌握要求:能按场景选出正确手段并说出理由

把本篇和 03 篇、10 篇提到的并发手段放在一起对比,形成选型直觉:

手段 机制 优点 缺点 适用场景
原子 UPDATE(条件下推) UPDATE ... WHERE 条件,行锁保证原子 最简单、一次 IO、最强保障 只能表达简单条件 库存扣减、名额抢占,首选
乐观锁 @Version CAS 思想,冲突时更新失败 无锁等待、高并发读友好 冲突方要自己重试/报错;高冲突下重试风暴 冲突概率低的更新(个人资料等)
悲观锁 SELECT ... FOR UPDATE 读时直接加行锁 语义直白,串行化安全 锁持有期间阻塞并发、可能死锁 冲突高、临界区必须串行的场景
Java 锁(synchronized/ReentrantLock) JVM 内互斥 无 DB 开销 只挡得住单 JVM,分布式下形同虚设 单机内保护共享内存结构
分布式锁(Redisson) 跨进程互斥(Redis setnx+看门狗) 跨节点有效 引入网络依赖、要处理锁超时/续期 跨节点必须互斥且 DB 条件表达不了的复杂临界区

记忆主线:能用数据库原子 SQL 解决的,不要用锁;能用乐观锁的,不用悲观锁;单机锁解决不了分布式问题。详见 10-JVM与并发编程.md 对分布式锁的展开。


术语表

术语 含义
乐观锁 / @Version 用版本号 CAS 检测并发冲突的机制,冲突时更新失败交业务处理
逻辑删除 / @TableLogic 用标记位代替物理删除,MyBatis-Plus 自动追加 is_deleted=0 条件
条件下推 把校验条件写进 UPDATE 的 WHERE,用行锁保证”判断+修改”原子
聚簇索引 / 二级索引 叶子节点存整行数据的主键索引 / 叶子存主键值、需回表的普通索引
回表 / 覆盖索引 二级索引查不到的列回聚簇索引再查一次 / 索引本身覆盖全部查询列无需回表
最左前缀 联合索引必须从最左列开始连续匹配才能被利用的原则
MVCC 多版本并发控制:隐藏列 + undo 版本链 + ReadView 实现无锁快照读
快照读 / 当前读 普通 SELECT 走 MVCC 读历史版本 / 加锁读永远读最新已提交数据
间隙锁 / 临键锁 RR 级别锁索引间隙防幻读 / 记录+间隙的左开右闭锁单位
EXPLAIN type=ALL 全表扫描信号,慢查询排查中最需要警惕的访问类型

搜索引擎:Elasticsearch + IK 中文分词

一句话速览:ES 的核心是倒排索引——“词 → 文档列表”的映射让全文检索从全表扫描变成查字典;但围绕它的绝大多数线上问题都不是”搜不到”本身,而是索引结构(Mapping)被错误创建、数据同步不完整、插件生命周期没管好这三类工程问题。

涉及原始记录:07-面试问题/开发问题/02-框架与中间件集成问题.md P1-04、P1-05;
07-面试问题/验证问题/03-基础设施与中间件问题.md P1-01、P1-02、P1-03;
07-面试问题/验证问题/02-数据与环境问题.md P1-04

最近修订:2026-08-07

目录


技术点 0:ES 底层原理速通 —— 倒排索引 / 写入流程 / 分片

难度:⭐⭐⭐ | 掌握要求:能画出倒排索引结构、写入流程(refresh/flush)、query then fetch

这部分没有对应踩坑记录,但它是理解后面所有坑(为什么 mapping 抢跑会出事故、为什么数据”写进去立刻搜不到”)的地基,放在最前面。

倒排索引:ES 快的根本原因

传统数据库的索引是”文档 → 包含哪些词”(正排),查”哪些文档包含’精装修’”只能全表扫描 LIKE。ES 反过来建索引——倒排索引(Inverted Index)是”词 → 包含它的文档ID列表”

flowchart LR
    subgraph 文档
        D1["doc1: 豪华精装修公寓"]
        D2["doc2: 精装修近地铁"]
        D3["doc3: 毛坯大平层"]
    end
    subgraph 倒排索引
        T1["精装 → [1, 2]"]
        T2["修 → [1, 2]"]
        T3["公寓 → [1]"]
        T4["地铁 → [2]"]
        T5["毛坯 → [3]"]
    end
    D1 --> T1 & T2 & T3
    D2 --> T1 & T2 & T4
    D3 --> T5

搜索”精装修”时,分词器把查询词也拆开,直接查词项字典拿到文档 ID 列表做交集——时间复杂度从 O(全表) 变成 O(词项数),这就是”查字典 vs 翻全书”的区别。词项字典本身用 FST(有限状态转移机)压缩存储,支持前缀快速定位。

写入流程与”近实时”搜索

ES 一条写入请求经历的路径,解释了为什么 ES 叫 Near Real-Time(近实时,NRT) 而不是实时:

flowchart TD
    A[写入请求] --> B[写入内存缓冲区
+ 追加 translog 防丢] B --> C&#123;refresh
默认每秒一次&#125; C --> D[生成新的 segment
进入 OS 文件缓存
**此刻起可被搜索到**] D --> E&#123;flush
translog 满或定时&#125; E --> F[segment 真正 fsync 落盘
清空 translog] F --> G[后台 segment merge
小段合并成大段]

三个机制各管一件事,面试要能分开讲:

  • refresh(默认 1s):把内存数据生成新 segment 放进文件系统缓存,让数据可被搜索。这就是”写入后立刻搜索可能查不到”的原因,也是 POST /index/_refresh 强制刷新的用途(本项目验证搜索功能时如果用写入后立刻查询,应该了解这个参数)。
  • translog:每次写入先追加到 translog,防节点宕机丢内存数据——类似 Redis 的 AOF、MySQL 的 redo log,是同一种”WAL(预写日志)”思想。
  • flush 与 segment merge:segment 是不可变文件(immutable),删除只是打标记(.del 文件),后台合并才真正清理。segment 不可变这个设计带来了缓存友好、无需锁、并发读高效等好处,是 Lucene 的核心取舍。

分片与两阶段查询

  • 分片(Shard):索引被水平拆成多个主分片(primary shard),每个分片是一个独立的 Lucene 索引,可以分布在不同节点上实现水平扩展;副本分片(replica)提供高可用和读扩展。主分片数建索引时定死,不能改(要改只能 reindex),副本数可以随时调——这是建索引规划时最重要的一个决策。
  • Query Then Fetch:一次搜索分两步——① Query 阶段:协调节点把查询广播到所有相关分片,每个分片返回”本分片得分最高的 from+size 个文档ID”;② Fetch 阶段:协调节点归并排序出最终 TopN,再按 ID 去分片取完整文档。这解释了为什么深度分页(from+size 很大)性能差:每个分片都要算并返回大量候选。深分页的正确姿势是 search_afterscroll
  • 相关性评分:现代版本默认 BM25(TF-IDF 的改进版,引入词频饱和和文档长度归一化),match 查询按得分排序,term 精确匹配查询默认不关心评分。

技术点 1:Mapping —— 索引字段类型决定查询行为

难度:⭐⭐ | 掌握要求:text vs keyword 的区别、动态映射的风险、mapping 不可变

核心概念

ES 的字段类型分两大类,查询行为完全不同,这是所有 ES 排查问题的第一认知基础:

  • text 类型:写入时会被分词器拆分成多个 token 存索引,适合全文检索(搜索”精装修”能匹配到”豪华精装修公寓”)。
  • keyword 类型:写入时整个字符串作为一个不可分割的值存索引,适合精确匹配、排序、聚合(比如城市名”上海”必须完整匹配,不能被拆开)。

如果不显式定义 mapping,ES 会在第一次写入数据时自动推断字段类型(动态映射,dynamic mapping),推断规则对字符串默认是 text(外加一个 .keyword 子字段)。这个自动推断的行为是很多”数据存在但搜不到”问题的根源。

我们踩过的坑

验证问题 P1-02:city 字段被自动推断成 text,导致 TermQuery 精确匹配失效

完整的问题链条,是理解这个坑最好的方式:

  1. 第一次启动 search 服务时,IK 分词器插件还没装,代码里定义好的索引 mapping(含自定义分词器配置)创建失败。
  2. 但如果这时候恰好有数据尝试写入 ES(比如调用了 /search/sync),ES 会自动创建一个索引并自动推断 mapping,把 city 这类字符串字段识别成了 text 类型。
  3. 代码里查询用的是 TermQuery(精确匹配,不会对查询词分词,也期望字段本身没有被分词)。但 city 字段是 text 类型,写入”上海”时已经被标准分词器拆开存储,TermQuery 拿着完整的”上海”去匹配被拆碎的索引,自然匹配不上,结果是数据明明存在,搜索却返回 0 条

修复方式是删除这个被自动推断出来的错误索引,让代码里正确定义了 keyword 类型的 ElasticsearchIndexInitializer 重新创建索引:

1
2
curl -X DELETE "http://localhost:9200/livin_houses"
# 重启 search 服务,触发按代码定义的 mapping 重新建索引

必须记住的结论

  • 凡是要做精确匹配/排序/聚合的字段(状态、城市、类型这种枚举/短字符串),必须显式定义成 keyword,绝不能依赖 ES 自动推断
  • 索引的 mapping 必须由代码显式定义并且保证先于任何数据写入生效,一旦被自动推断”抢跑”创建了错误的 mapping,唯一的补救方法是删除索引重建(或 reindex 到新索引),没有办法”修改”已存在字段的类型——这是由”倒排索引建好后不可变”的底层结构决定的。
  • 排查”数据存在但搜不到”的第一反应:GET /{index}/_mapping 看一眼实际的字段类型,跟代码预期的是否一致,这比看业务代码快得多。

技术点 2:IK 中文分词器 —— 中文全文检索的前提

难度:⭐⭐ | 掌握要求:ik_max_word vs ik_smart、为什么索引用细粒度查询用粗粒度

核心概念

ES 官方镜像不自带中文分词能力,默认的 standard 分词器是按空格/标点切分的,对中文基本等于按单字拆分,效果很差。IK 分词器是最常用的中文分词插件,提供两种分词粒度:ik_max_word(细粒度,尽可能多地拆分)和 ik_smart(粗粒度,尽量拆成完整语义的词)。

为什么要两种粒度:索引时用 ik_max_word 拆得越细,能被匹配到的角度越多(召回率高);搜索时用 ik_smart 拆得越整,结果越精准(准确率高)。”索引粗、查询细”则两头不占。这是中文搜索场景的经典搭配:index 用 ik_max_word,search 用 ik_smart(mapping 里分别用 analyzersearch_analyzer 指定)。

我们踩过的坑

验证问题 P1-01:插件缺失导致索引创建直接失败

索引 mapping 里定义了用 ik_max_word 作为自定义 analyzer 的 tokenizer,但插件没装,报错非常明确:

1
Custom Analyzer [ik_max_word_analyzer] failed to find tokenizer under name [ik_max_word]

这类”依赖的组件缺失”报错很明确,反而不难排查,装上插件重启即可:

1
2
3
docker exec livin-es bin/elasticsearch-plugin install --batch \
"https://release.infinilabs.com/analysis-ik/stable/elasticsearch-analysis-ik-8.13.3.zip"
docker restart livin-es

开发问题 P1-05:插件安装在容器可写层,容器重建后会丢失(这是本项目影响最持久的一个基础设施配置疏漏)

真正麻烦的地方在于:docker exec 装插件是装在容器的可写层里,不是持久化数据卷。只要容器被”重建”(不是简单的 restart,而是 docker compose up --force-recreate 或者删掉容器重新创建),装好的插件就会消失,回到”插件缺失”的状态,之前建好的索引也会因为找不到 tokenizer 变成 red(不可用)状态。

这个坑之所以隐蔽,是因为**”重启”和”重建”看起来是同一类操作,但对容器可写层的影响完全不同**:docker restart 保留容器实例和它的可写层,装过的插件还在;docker compose up --force-recreate(或者删除容器重新 up)是创建一个全新的容器实例,可写层从镜像原始状态重新开始,之前手动装的东西全部消失。

彻底的解决方案是把插件目录也做成持久化数据卷,让插件的生命周期脱离容器实例本身:

1
2
3
4
5
6
7
8
9
volumes:
es-data:
es-plugins: # 插件目录单独持久化,不随容器重建丢失

services:
elasticsearch:
volumes:
- es-data:/usr/share/elasticsearch/data
- es-plugins:/usr/share/elasticsearch/plugins

必须记住的结论

  • 任何通过 docker exec 手动安装/修改的东西(装插件、改配置文件),如果没有对应挂载数据卷,都只存在于这个容器实例的生命周期里,容器一旦被重建就会丢失。这不是 ES 特有的问题,是所有 Docker 容器的通用特性,装完插件后要下意识问自己一句”这个目录持久化了吗”。
  • docker restart(重启现有容器)和 docker compose up --force-recreate / 删除重建(创建新容器)对可写层的影响完全不同,运维/部署脚本里要清楚区分这两种操作。
  • 中文全文检索项目,IK 插件是硬依赖,必须在环境搭建阶段就确认好持久化方案,不要等到某次容器重建后功能突然失效才去排查。
  • 记住分词搭配:索引用 ik_max_word(保召回),搜索用 ik_smart(保准确)

技术点 3:数据同步的完整性 —— 消息驱动同步容易踩的坑

难度:⭐⭐ | 掌握要求:消息体只是通知不是数据源、失败要重试不要写残缺数据

核心概念

用消息队列驱动”业务库变更 → 同步到 ES”是常见架构(属于最终一致性的数据同步,思想同 02 篇的 MQ 最终一致方案),但消息体本身通常只包含”发生了什么变化”的最小信息(比如 houseId + 新状态),而不是完整的业务数据。消费者拿到消息后,往往需要反查一次完整数据再写入 ES,而不能只用消息体里的字段拼一个残缺的文档。

我们踩过的坑

开发问题 P1-04:消费者”简化实现”导致索引文档字段严重缺失

HouseSearchSyncConsumer 的注释写着”简化实现:直接用 payload 中的数据(实际项目需要完整数据)”——这是一句诚实的自我记录,但代码一直没有按注释的提醒去补全。结果是 ES 文档里只有消息体带的 houseId/status/updatedAt 三个字段,索引 mapping 定义的 title/city/price/location(GEO坐标)全部是空的。由于搜索查询用这些字段做过滤条件,效果是搜索功能”能跑但结果不可预期”——不报错,但 city 过滤、GEO 距离过滤、全文检索全部失效。

修复方式是让消费者收到消息后,通过 Feign 反查一次完整的房源详情,再组装出与索引 mapping 完全对齐的文档:

1
2
3
4
5
6
7
8
// 修复后:先反查完整数据,再写入 ES
public void onMessage(Long houseId) {
HouseDetailDTO detail = houseFeignClient.getDetail(houseId).getData();
if (detail == null) {
throw new RuntimeException("查询房源详情失败,触发MQ重试"); // 不写入残缺数据
}
// 用 detail 的完整字段组装 ES 文档,包括 lng/lat 合并成 geo_point
}

这里还有一个值得记住的设计细节:查询失败时选择抛异常触发 MQ 重试,而不是”忍着写一份残缺数据进去”。宁可这条消息暂时没被消费成功(后续重试),也不要让不完整的数据进入索引——半成品数据比”暂时没同步”更难排查,因为它看起来像是”已经同步了”。

验证问题 P1-04:status 字段大小写不一致

搜索代码硬编码只认 status=ONLINE(大写),如果同步过去的数据是 online 或者其他大小写形式,同样会导致”数据存在但搜不到”。这类问题本质上是多个系统间对同一个枚举值的约定没有强制保证一致,业务库里存的状态值和搜索代码里过滤用的字符串必须字面一致。

必须记住的结论

  • 消息驱动的数据同步,消息体只是”变更通知”,不是”完整数据源”。消费者收到通知后应该反查权威数据源拿到完整数据,而不是直接用消息体里的字段拼装。
  • 代码注释里写的”简化实现,后续需要完善”,本质上是显式记录的技术债务,如果这个模块会被下游依赖用来做关键判断(这里是搜索的可用性),这类简化实现要优先偿还,不要等到功能验证时才暴露。
  • 反查数据失败时,raise 触发重试 比 “写入残缺/默认数据” 更安全——半成品数据造成的问题往往比”暂时缺失”更难定位。
  • 跨系统共享的枚举值(状态字段这种),要保证所有写入方和查询方对大小写、具体取值的约定完全一致,最好是通过枚举类型约束,而不是到处手写字符串常量。

技术点 4:GEO 地理位置查询

难度:⭐⭐ | 掌握要求:geo_point 格式、验证距离过滤的正确姿势

核心概念

ES 原生支持 geo_point 类型字段和基于此的距离过滤查询(GeoDistanceQuery),本项目用它实现”搜索我附近 N 公里内的房源”。geo_point 字段的写入格式通常是 {"lat": ..., "lon": ...} 或者 "lat,lon" 字符串,需要跟经纬度原始字段(lng/lat)分开维护,同步时要显式拼装成这个格式。

底层实现值得一提:ES 的地理索引是把地球表面递归划分成网格(GeoHash/Quadtree 思想),把”圆形距离范围”近似成”若干网格的并集”,从而把地理范围查询转化成对网格编码的 term 查询——这就是为什么 geo_distance 能复用倒排索引做到毫秒级,而不是逐文档算球面距离。

我们踩过的坑

这部分没有独立的踩坑记录(阶段8验证时是在修复 P1-04 消费者简化实现的同时一并补上的),但值得记住实现要点:同步到 ES 的文档中,除了保留原始的 lng/lat 字段用于展示,还要单独拼一个 locationgeo_point 字段供 GeoDistanceQuery 使用,两者字段名和数据类型不能混淆。验证 GEO 查询是否真的按距离过滤,最简单的方法是拿一个明显很远的坐标(比如用北京坐标查上海房源,限定 1km 范围)确认返回 0 条,而不是只验证”附近坐标能搜到”——后者不能排除”实际上没做距离过滤、只是全量返回”的可能性。


术语表

术语 含义
倒排索引 “词 → 文档ID列表”的映射结构,全文检索高效的根本原因
NRT(近实时) ES 写入默认 1 秒 refresh 一次才可被搜索,不是严格实时
refresh / flush 生成新 segment 使其可搜索 / 把 segment 真正落盘并清空 translog
translog ES 的预写日志(WAL),防节点宕机丢失内存中的新数据
segment Lucene 的不可变索引文件,删除是打标记,后台 merge 才真正清理
分片(Shard) 索引的水平拆分单位;主分片数建索引后不可改,副本数可调
Query Then Fetch 两阶段查询:各分片先返回候选ID,协调节点归并后再取完整文档
BM25 ES 默认的相关性评分算法,TF-IDF 的改进版
text / keyword 分词全文检索字段 / 整值精确匹配字段
ik_max_word / ik_smart IK 分词器的细粒度(索引用)/ 粗粒度(查询用)模式
geo_point / geo_distance 地理坐标字段类型 / 按距离范围过滤的查询