从拖死交易到大屏丝滑:TiDB HTAP下Java报表隔离的“3道叹息之墙”与500行避坑代码

📅 2026/8/10 12:36:19
从拖死交易到大屏丝滑:TiDB HTAP下Java报表隔离的“3道叹息之墙”与500行避坑代码
关注墨瑾轩带你探索编程的奥秘超萌技术攻略轻松晋级编程高手技术宝库已备好就等你来挖掘订阅墨瑾轩智趣学习不孤单即刻启航编程之旅更有趣一、引子那场差点让我引咎辞职的“实时大屏”惨案时间拨回几年前的一个双十一预热期。那时候我们刚把核心业务从MySQL分库分表迁移到TiDB。老板不知道在哪看了篇软文跑来找我“老墨啊我看TiDB支持HTAP那咱们搞个实时销售大屏吧直接查TiDB别搞什么离线数仓了我要看秒级延迟的”我当时脑子一热觉得TiDB有TiFlash列存引擎兜底AP查询应该不影响TP行存TiKV吧于是我让Java团队写了个报表服务直接连上了同一个TiDB集群的同一个TiDB Server节点。大促当天晚上8点流量洪峰来了。老板站在大屏前看着实时跳动的GMV笑得合不拢嘴。然后大屏卡住了。紧接着我的手机被监控告警短信震得差点从手里飞出去【P0】核心交易链路RT响应时间从 20ms 飙升至 3500ms【P0】TiDB Server CPU 使用率 99%活跃连接数打满【P0】订单创建接口大面积超时失败率 45%发生了什么事后看火焰图和慢查询日志真相让人吐血报表服务为了算“各省份实时客单价”跑了几个带GROUP BY和JOIN的大SQL。虽然优化器选了TiFlash但TiFlash的MPP大规模并行处理引擎在计算时需要把大量中间结果通过网络在TiDB Server和TiFlash节点之间传输。网络带宽被大查询吃满了导致核心交易的TiKV请求走的是同一个网络平面被严重阻塞。更惨的是Java报表服务没有做连接池隔离和超时控制大查询卡住了Java线程池被占满连接池被耗尽甚至拖垮了同一个JVM里的其他微服务。老板的大屏没看到高潮用户的订单付不了款。从那以后我悟出了一个血泪教训HTAP的“混合”指的是“数据”的混合绝不是“资源”和“链路”的混合如果你不在物理层、逻辑层、代码层做极其严苛的隔离AP查询就是那个随时会掐死TP交易的“内鬼”。二、正片TiDB HTAP 隔离的“三道叹息之墙”要把Java报表服务这头“吞吐怪兽”关进笼子你必须砌三道墙。任何一道墙漏风生产环境都会教你做人。第一道墙TiDB底层的“物理与资源隔离”这是最底层、最硬核的防线。你必须明确告诉TiDB“报表查询只准用特定的资源敢碰交易链路的资源老子打断你的腿。”1. 引擎级隔离强制走 TiFlash别让它去骚扰 TiKVTiDB的优化器有时候很“自作聪明”。它看你的报表SQL如果觉得数据量不大或者统计信息过期了可能会选择走TiKV行存而不是TiFlash列存。大查询走TiKV等于在高速公路上开拖拉机不仅自己慢还把后面的法拉利TP交易全堵死。-- -- 强制报表查询走 TiFlash 引擎-- 【干嘛的】通过 SQL Hint强制优化器使用 TiFlash 列存引擎执行查询-- 【为啥非得这么写】-- 如果不加 Hint优化器可能会因为统计信息不准选择走 TiKV。-- TiKV 是行存为高并发小事务TP优化大查询扫几百万行-- 会把 TiKV 的 CPU 和 IO 打满直接导致核心交易超时。-- 【不这么写会怎么死】-- 报表查询回退到 TiKVTP 链路 RT 飙升大促期间直接 P0 故障。-- 【更骚的写法】-- 可以在 Session 级别全局设置SET tidb_isolation_read_engines tiflash;-- 但建议在 Java 代码里用 Hint 精准控制避免误伤其他正常查询。-- SELECT/* read_from_storage(tiflash[t_order, t_order_detail]) */-- 【核心 Hint】指定 t_order 和 t_order_detail 这两张表必须从 tiflash 读取-- 注意表名必须和 FROM/JOIN 后面的别名或表名完全一致-- 如果用了别名 t_order o这里就得写 tiflash[o]o.province,SUM(o.total_amount)ASgmv,COUNT(DISTINCTo.user_id)ASuvFROMt_order oJOINt_order_detail dONo.order_idd.order_idWHEREo.create_time2026-07-01 00:00:00GROUPBYo.province;2. 资源组隔离Resource Control给报表戴上“紧箍咒”从 TiDB v7.1 开始引入了Resource Control资源管控功能。这是 HTAP 隔离的“核武器”。你可以创建一个“资源组”限制报表服务能使用的 CPU、IO 和 RURequest Unit请求单元。-- -- 创建并绑定资源组Resource Group-- 【干嘛的】给报表服务分配一个“低优先级、有上限”的资源配额-- -- 1. 创建资源组CREATERESOURCEGROUPrg_report_service RU_PER_SEC2000-- 【RU_PER_SEC】每秒允许消耗的 RU 数量-- 【为啥设 2000】-- 假设集群总 RU 是 10000交易链路需要 7000。-- 给报表服务硬上限 2000防止它把集群资源吃光。-- 如果报表查询超过这个速率会被限流Throttling排队等待。-- 【不这么写会怎么死】-- 不设上限一个写得烂的笛卡尔积 SQL 能把整个集群的 RU 瞬间打满。PRIORITYLOW;-- 【PRIORITY】优先级设为 LOW-- 【为啥设 LOW】-- 当系统资源CPU/IO紧张时TiDB 会优先保障 HIGH/MEDIUM 优先级-- 通常是核心交易的请求。LOW 优先级的请求会被延后处理。-- 这就是“丢车保帅”的底层逻辑。-- 2. 将报表服务的数据库用户绑定到该资源组-- 【前提】Java 报表服务必须使用独立的数据库账号连接 TiDB-- 千万别和交易服务共用一个账号否则没法做用户级的资源隔离ALTERUSERreport_user%RESOURCEGROUPrg_report_service;-- 【干嘛的】以后 report_user 登录的所有会话自动受 rg_report_service 限制。第二道墙Java 连接池与路由的“逻辑隔离”底层资源限制住了接下来是 Java 应用层。很多团队的 Java 报表服务和交易服务用的是同一个 HikariCP 连接池连的是同一个 TiDB Server 节点。这就像让重卡和私家车走同一条车道重卡一抛锚私家车全得死。1. 多数据源与连接池物理隔离在 Spring Boot 中必须为报表服务配置独立的数据源和连接池。// // Java 报表服务数据源配置 (Spring Boot HikariCP)// 【干嘛的】为报表服务创建独立的连接池限制最大连接数防止拖垮数据库// ConfigurationpublicclassReportDataSourceConfig{BeanConfigurationProperties(spring.datasource.report.hikari)publicHikariDataSourcereportDataSource(){HikariConfigconfignewHikariConfig();// 【核心配置 1】独立的 JDBC URL// 建议如果条件允许报表服务连接专门的 TiDB Server 节点只读节点// 而不是和 TP 交易混用同一个 TiDB Server。config.setJdbcUrl(jdbc:mysql://tidb-report-node:4000/your_db?useSSLfalse);config.setUsername(report_user);// 使用绑定了资源组的独立账号config.setPassword(your_password);// 【核心配置 2】连接池大小限制死死卡住config.setMaximumPoolSize(20);// 【为啥设 20】// 报表查询通常是长连接、大事务。如果连接池设太大比如 100// 100 个大查询同时跑TiDB Server 的内存瞬间就会被 OOM 杀掉。// 20 个连接配合 TiDB 的 Resource Control刚好能榨干分配的 RU// 又不会引发雪崩。// 【不这么写会怎么死】// 默认 maximumPoolSize 是 10或者有人手贱设成 200。// 设 10 不够用设 200 会把 TiDB 内存打爆Error 8001。config.setMinimumIdle(5);// 【为啥设 5】保持少量空闲连接避免突发报表请求时频繁建连。// 【核心配置 3】连接超时与生命周期config.setConnectionTimeout(3000);// 【ConnectionTimeout】从连接池获取连接的超时时间3秒// 【为啥设 3000】// 如果连接池满了等待 3 秒拿不到连接直接抛异常// 绝不能让 Java 线程在这里无限期阻塞否则 Tomcat 线程池会被耗尽。config.setMaxLifetime(1800000);// 【MaxLifetime】连接最大生命周期30分钟// 【为啥必须设】// TiDB 的 Server 端有连接超时清理机制。如果 Java 端连接生命周期// 大于 Server 端Java 拿着一个已经被 Server 掐死的“死连接”去发 SQL// 就会报 Connection is closed 的幽灵 Bug。// 必须比 TiDB 的 wait_timeout默认 15 分钟小一点或者保持一致。// 【更骚的配置】连接泄漏检测config.setLeakDetectionThreshold(60000);// 【LeakDetectionThreshold】连接泄漏检测阈值60秒// 【干嘛的】// 如果一个连接被借出后60 秒还没归还没 close// HikariCP 会在日志里打印 ERROR并打印出是哪行代码借走的。// 这是抓“连接未关闭”Bug 的终极神器returnnewHikariDataSource(config);}}2. 路由隔离用 TiProxy 给流量“打标”如果你用的是 TiDB v7.5 或者 TiDB Cloud强烈建议引入TiProxy官方代理组件。TiProxy 可以在连接层做路由把报表服务的连接强制路由到专门扩容出来的 TiDB Server 节点只读节点上。# # TiProxy 路由配置示例 (proxy.toml)# 【干嘛的】在网络层把 TP 和 AP 流量物理隔离# [proxy]# 监听端口addr 0.0.0.0:6000[proxy.router]# 【核心路由规则】基于用户名路由# 当 report_user 登录时强制路由到带有 report 标签的 TiDB 节点rules [{user report_user,match_type exact,target_labels [report_node]},{user trade_user,match_type exact,target_labels [trade_node]}]# 【为什么不用 LVS/HAProxy 做路由】# 因为 LVS 是四层代理它不懂 MySQL 协议不知道当前登录的是哪个用户。# TiProxy 是七层代理能解析 MySQL 握手包根据用户名做精准路由。# 这样即使报表大查询把 report_node 的 CPU 打满了# trade_node 上的核心交易依然稳如老狗。第三道墙Java 代码层的“线程与降级隔离”底层限了 RU连接池限了连接数你以为就万事大吉了错最不可控的永远是写 Java 代码的那帮兄弟。如果报表服务没有做线程池隔离和熔断降级一个大查询卡住依然能引发 JVM 级别的雪崩。1. 线程池隔离别让报表拖死 Tomcat很多新手写报表接口直接在RestController里同步调用 ServiceService 里跑大 SQL。大 SQL 跑了 30 秒Tomcat 的工作线程就被占用了 30 秒。并发来个 200 次Tomcat 直接假死。// // Java 报表服务线程池隔离与异步执行// 【干嘛的】把报表大查询扔到独立的线程池绝不阻塞 Tomcat 主线程// ServicepublicclassReportService{// 【核心配置】自定义报表专用线程池// 【为啥不用 Async 默认的线程池】// Spring 默认的 Async 线程池是 SimpleAsyncTaskExecutor每次新建线程// 或者 ThreadPoolTaskExecutor 默认配置核心线程数 8队列无界。// 无界队列是万恶之源大查询一多队列堆积几万个任务OOM 教你做人。privatefinalThreadPoolExecutorreportExecutornewThreadPoolExecutor(10,// corePoolSize: 核心线程数 1020,// maximumPoolSize: 最大线程数 20// 【为啥设 20】和 HikariCP 的 maximumPoolSize 保持一致// 如果线程数 连接数多出来的线程全在等连接白白浪费 CPU 上下文切换。60L,TimeUnit.SECONDS,// keepAliveTime: 非核心线程空闲 60 秒回收newLinkedBlockingQueue(100),// 【workQueue】有界队列容量 100// 【为啥必须有界】// 当并发请求超过 20 个线程的处理能力时任务进队列。// 如果队列满了100个触发拒绝策略。// 绝不允许无限堆积导致 JVM OOM。newThreadFactoryBuilder().setNameFormat(report-pool-%d).build(),// 【ThreadFactory】给线程起个名字// 【为啥必须起名】// 线上 CPU 飙高时用 jstack 导出线程快照// 看到 report-pool-1 就知道是报表服务在作妖// 而不是看着一堆 pool-1-thread-1 怀疑人生。newThreadPoolExecutor.CallerRunsPolicy()// 【RejectedExecutionHandler】拒绝策略// 【为啥选 CallerRunsPolicy】// 当队列满了由调用者Tomcat 线程自己执行。// 这相当于一种“反压Backpressure”机制。// Tomcat 线程被阻塞就不会再接收新的 HTTP 请求// 从而保护了 JVM 不被压垮。// 【更骚的写法】// 自定义拒绝策略直接返回 系统繁忙请稍后再试 的 JSON// 体验更好但需要改 Controller 层的返回值处理。);AutowiredprivateReportMapperreportMapper;/** * 异步执行报表查询 */publicCompletableFutureReportDTOgetProvinceGmvAsync(Stringdate){// 【核心】使用 CompletableFuture 将任务提交到独立线程池returnCompletableFuture.supplyAsync(()-{// 这里执行大 SQLreturnreportMapper.selectProvinceGmv(date);},reportExecutor);}}2. 超时与熔断Resilience4j 兜底就算你做了线程池隔离如果 TiDB 真的卡了报表查询一直不返回线程池里的 20 个线程还是会被占满。必须加超时控制并且引入熔断机制// // Java 报表服务超时控制与熔断降级 (Resilience4j)// 【干嘛的】当数据库响应慢时快速失败保护系统不被拖死// ServicepublicclassReportResilienceService{AutowiredprivateReportServicereportService;// 【核心配置】定义熔断器privatefinalCircuitBreakercircuitBreakerCircuitBreaker.of(reportCB,CircuitBreakerConfig.custom().failureRateThreshold(50)// 失败率超过 50% 触发熔断.waitDurationInOpenState(Duration.ofSeconds(10))// 熔断后保持 10 秒.slidingWindowSize(10)// 滑动窗口大小 10 次请求.build());// 【核心配置】定义超时限制privatefinalTimeLimitertimeLimiterTimeLimiter.of(reportTL,TimeLimiterConfig.custom().timeoutDuration(Duration.ofSeconds(5))// 【timeoutDuration】超时时间 5 秒// 【为啥设 5 秒】// 大屏报表是给人看的超过 5 秒没出来老板早就刷新页面了。// 与其让 SQL 在后台跑 30 秒浪费资源不如 5 秒直接掐断// 【不这么写会怎么死】// 不设超时一个烂 SQL 跑 5 分钟线程池被占满后续请求全死。.build());/** * 带熔断和超时的报表查询 */publicCompletableFutureReportDTOgetGmvWithFallback(Stringdate){// 将熔断器和超时器组合起来CircuitBreakerRegistrycbRegistryCircuitBreakerRegistry.of(circuitBreaker);TimeLimiterRegistrytlRegistryTimeLimiterRegistry.of(timeLimiter);// 使用 Resilience4j 的 Decorators 包装异步任务returnDecorators.ofSupplier(()-reportService.getProvinceGmvAsync(date)).withCircuitBreaker(cbRegistry.circuitBreaker(reportCB)).withTimeLimiter(tlRegistry.timeLimiter(reportTL),reportService.getExecutor()).withFallback(Arrays.asList(TimeoutException.class,CallNotPermittedException.class),throwable-{// 【Fallback 降级逻辑】// 【干嘛的】当超时或熔断时返回一个“兜底数据”或“缓存数据”// 【为啥必须降级】// 大屏不能白屏就算实时数据查不出来也要返回昨天晚上的离线快照数据。// 老板看到数据没变最多骂一句“今天数据怎么不涨”// 但如果你给他弹个 500 Internal Server Error他会让你的绩效变 0。log.warn(报表查询超时或熔断降级返回缓存数据: {},throwable.getMessage());returngetCacheGmv(date);}).get();}}三、尾声架构设计的本质是“不信任”写到这里烟灰缸又满了咖啡也彻底凉透了。咱们来做个总结升华。很多年轻架构师在引入 TiDB HTAP 这种“银弹”时总是抱着一种“岁月静好”的幻想“既然官方说 HTAP 能自动隔离 TP 和 AP那我就直接连上去查呗优化器会帮我搞定的。”兄弟记住老哥这句话在分布式系统里永远不要相信“自动”永远不要相信“默认”。架构设计的本质就是“不信任”。不信任优化器所以你要用/* read_from_storage(tiflash) */强制走列存。不信任资源调度所以你要用Resource Control给报表戴上 RU 的紧箍咒。不信任网络所以你要用TiProxy把 TP 和 AP 的流量物理分流。不信任 Java 代码所以你要用 HikariCP 有界连接池、自定义线程池、Resilience4j 熔断器把报表服务死死锁在笼子里。HTAP 是个好东西它消灭了数据同步的延迟。但如果你不做好隔离它也会顺便消灭你的核心交易。 最后送个彩蛋血泪教训当年有个兄弟隔离做得很完美大屏也很流畅。但他忽略了一件事TiFlash 的副本延迟。TiFlash 的数据是从 TiKV 异步同步过去的Raft Learner。在双十一写入洪峰时TiKV 写入太快TiFlash 同步不过来产生了几秒甚至十几秒的延迟。老板看着大屏上的 GMV突然问了一句“为什么这 10 秒的数据没涨”记住如果你的业务对“实时性”要求是绝对的秒级比如金融风控HTAP 的 TiFlash 可能不适合你你得走 Flink 实时流计算。如果业务能容忍5~10 秒的延迟比如销售大屏那 TiDB HTAP 绝对是你的神。在架构选型时认清业务的“容忍度”比盲目追求“黑科技”重要一万倍。好了天亮了。这篇几千字的硬核长文算是把我这十几年在 HTAP 和 Java 隔离上踩过的坑、流过的血都倒干净了。如果你觉得有用转发给你们公司的架构师和 DBA 看看。别再用默认的 HikariCP 和 Tomcat 线程池去跑大查询了服务器真的会哭的。