一个BI项目要上线,最怕的从来不是代码写不出来,而是那句“在我机器上跑得好好的”。BI系统和普通业务系统不太一样:它有定时跑批、有大结果集导出、有早会那半小时的集中访问,还要同时应付运营看板和财务对账两拨人的不同查询习惯。把这套东西塞进 Docker,再拉上 Spring Boot、Redis、RabbitMQ 组一套 BI 项目部署方案,看着像标准四件套,实际每一环的配置都能在某个凌晨给你上一课。
这篇内容聊的就是这件事:怎么把一个 BI 项目从“能跑起来”推到“敢让别人用”。面向的是刚接手 BI 后端部署的开发、要独立完成一次容器化上线的运维,以及那些代码写得没问题、但一碰中间件配置就心里发虚的同行。文中所有参数、编排写法、排查路径都来自这类项目的通用实践,你可以直接照着改,但别照抄——每家的查询量和报表复杂度差太多。另外说明一句,下文有些内容属于基于常见场景的合理补充,比如分析型数据库的接入、压测模型的搭建,原文没有细说,我按一个合格从业者在这个位置上最可能采用的方案来写。
1. BI项目部署的整体架构与选型逻辑
1.1 一套BI系统实际要背负多少东西
先把BI系统的负载画像摆清楚,后面所有选型才有依据。普通业务系统的请求特征是“多而轻”——一次请求几条SQL,返回几百字节。BI系统正好反过来,是“少而重”:一个看板首屏可能触发七八个聚合查询,每个都要扫几百万行;一次导出可能生成几十万行数据、几十兆的文件;跑批任务半夜起来算T+1指标,一跑两小时。这三类负载混在同一个 JVM 里,如果不想办法隔开,用户体验就是“打开看板转圈,导出按钮点下去浏览器卡死”。
更要命的是BI系统的时间分布极不均匀。早上八点半到九点半是集中访问高峰,其余时间基本空闲。你按峰值配机器,平时浪费;按均值配机器,早会必崩。这就是为什么BI项目特别需要 Redis 挡在前面、需要 RabbitMQ 把重活挪到后面,也是为什么要用 Docker 把每一层都固定下来,避免“昨天还好今天就不行”的环境漂移。
我在实际项目里遇到过最典型的情况是:报表导出功能和看板查询共用同一个线程池,某天有人导出了一张三十万行的明细,直接把 Tomcat 的线程占满,整个系统的看板全部超时。这个问题的根子不在代码,在架构上没有把“快查询”和“慢任务”物理隔开。所以这套四件套的分工必须提前想明白,不能等出了事再补。
1.2 四个组件各自的岗位职责
很多人把 Docker 理解成“打包工具”,其实在一个 BI 部署里,它更重要的角色是环境契约。它保证开发机上跑通的 JVM 版本、时区、字符集、字体库,到生产上一模一样。BI 项目尤其依赖字体——导出 PDF 报表时少了中文字体,出来的全是方框,这种问题靠文档沟通永远沟通不清,靠镜像固化一次解决。
Spring Boot 在这套组合里是调度中枢:接收查询请求、组装查询计划、调 Redis 拿缓存、往 RabbitMQ 丢任务、最后把结果拼给前端。它的核心价值不是“快速开发”,而是自动配置和健康检查端点,让容器编排有东西可探。
Redis 的定位要说得更准确一点:它不是“缓存”,是BI系统的前置减压层。它至少承担三件事——字典和权限这类几乎不变的热数据、看板聚合结果这类算一次能用几分钟的温数据、以及分布式锁和限流这类控制类数据。三者混在一个实例里不是不行,但一定要用不同的 key 前缀和不同的淘汰策略区分开,否则一次缓存雪崩会把权限数据一起冲掉。
RabbitMQ 解决的是时间维度上的解耦。导出、跑批、指标重算这类任务,用户不需要立刻拿到结果,那就没必要占用 Web 线程。丢进队列,后台消费者慢慢处理,处理完写回结果表或者推个通知。这样做的好处不只是“不卡”,更重要的是任务可重试、可限流、可观测——队列积压了多少条,一眼就能看出来,比翻日志猜要高效得多。
| 组件 | 在BI项目中的实际职责 | 配错的典型后果 |
|---|---|---|
| Docker | 固化运行时环境、隔离资源、编排启动顺序 | 字体缺失、时区错乱、启动竞态 |
| Spring Boot | 请求入口、查询编排、健康检查、优雅停机 | 停机丢任务、健康检查误判 |
| Redis | 热数据缓存、结果缓存、分布式锁与限流 | 缓存击穿打穿数据库、大key阻塞 |
| RabbitMQ | 异步导出、跑批解耦、削峰、失败重试 | 消息堆积、重复消费、任务丢失 |
1.3 端口与资源规划先落地
动手写 Dockerfile 之前,先把端口和资源分配表定下来。这一步看着琐碎,实际能省掉后面一大半“端口冲突”“内存不够”的麻烦。原则是:宿主机端口只暴露必要的入口,中间件一律走内网,不进宿主机端口映射;每个容器显式声明内存上限,不让任何一个组件把整台机器吃光。
| 容器名 | 镜像基础 | 内网端口 | 宿主机映射 | 内存上限 | 数据卷 |
|---|---|---|---|---|---|
| bi-app | temurin:17-jre | 8080 | 8080 | 2G | 日志目录 |
| bi-redis | redis:7-alpine | 6379 | 不映射 | 1G | redis-data |
| bi-rabbit | rabbitmq:3.12-management | 5672 / 15672 | 15672(仅内网调试) | 1G | rabbit-data |
| bi-nginx | nginx:alpine | 80 | 80 | 256M | 静态资源 |
这里有个容易忽略的点:Redis 的内存上限不是随便填的。它要和maxmemory配置对齐,容器给 1G,Redis 的maxmemory就该设到 768M 左右,留出约 25% 给 fork、复制缓冲和内存碎片。如果容器 1G、Redis 也配 1G,一旦触发 RDB 持久化的 fork,容器就会被 OOM Killer 干掉,日志里只留一行 Killed,排查起来极其难受。这个经验值不是拍脑袋,是踩过之后才记住的。
2. Docker镜像与编排的落地细节
2.1 Dockerfile分层:让构建缓存真正生效
Spring Boot 应用打镜像最常见的浪费,是把整个 fat jar 一次性 COPY 进去。结果是改一行代码,镜像重新构建,几百兆的依赖层全部重来,一次构建五分钟起步。正确做法是利用 Spring Boot 的分层 jar 特性,把依赖、快照依赖、应用代码拆成独立层,只让变化的部分重新构建。
# 阶段一:构建 FROM maven:3.9-eclipse-temurin-17 AS builder WORKDIR /build COPY pom.xml . RUN mvn -B dependency:go-offline COPY src ./src RUN mvn -B clean package -DskipTests \ && java -Djarmode=layertools -jar target/*.jar extract --destination /build/layers # 阶段二:运行 FROM eclipse-temurin:17-jre-jammy RUN groupadd -r app && useradd -r -g app app \ && apt-get update && apt-get install -y --no-install-recommends \ fontconfig fonts-noto-cjk tzdata \ && rm -rf /var/lib/apt/lists/* ENV TZ=Asia/Shanghai \ LANG=C.UTF-8 \ JAVA_OPTS="-XX:MaxRAMPercentage=70.0 -XX:+UseG1GC -XX:+ExitOnOutOfMemoryError" WORKDIR /app COPY --from=builder /build/layers/dependencies/ ./ COPY --from=builder /build/layers/spring-boot-loader/ ./ COPY --from=builder /build/layers/snapshot-dependencies/ ./ COPY --from=builder /build/layers/application/ ./ USER app EXPOSE 8080 ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS org.springframework.boot.loader.JarLauncher"]这段 Dockerfile 有三处值得单独说一下。fonts-noto-cjk是为了 PDF 报表导出,没有它中文字符会渲染成方块,而且这个错误在本地开发机上永远复现不了,因为你的 Mac 和 Windows 自带字体。MaxRAMPercentage=70.0而不是常见的 75 或 80,是因为 BI 应用的堆外内存占用比普通服务大——文件流、Netty 缓冲、POI 处理 Excel 时的临时对象都走堆外,留 30% 给这些才不至于被容器杀掉。ExitOnOutOfMemoryError则是一个兜底策略,宁可让容器退出被重启,也不要让一个内存已经错乱的 JVM 继续对外提供服务,返回半截数据比返回错误更危险。
多阶段构建的另一个好处是最终镜像里没有 Maven 和源码,体积能压到 300M 以内,推送和拉取都快。别小看这一点,CI 流水线上一分钟和一分钟的差距,攒一周就是半天。
2.2 docker-compose编排中的依赖与健康检查
depends_on是新手最容易误解的配置。它默认只保证“容器启动了”,不保证“服务可用了”。MySQL 容器启动到能接受连接可能有十几秒,RabbitMQ 从启动到队列可用可能二十秒,这段时间里应用连上去就是一堆Connection refused。解决办法是把健康检查写全,然后用condition: service_healthy把等待关系表达清楚。
services: redis: image: redis:7-alpine container_name: bi-redis command: > redis-server --appendonly yes --maxmemory 768mb --maxmemory-policy allkeys-lru --requirepass ${REDIS_PASSWORD} volumes: - redis-data:/data networks: [bi-net] healthcheck: test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"] interval: 10s timeout: 3s retries: 5 start_period: 10s deploy: resources: limits: memory: 1g rabbitmq: image: rabbitmq:3.12-management-alpine container_name: bi-rabbit hostname: bi-rabbit environment: RABBITMQ_DEFAULT_USER: ${MQ_USER} RABBITMQ_DEFAULT_PASS: ${MQ_PASS} RABBITMQ_VM_MEMORY_HIGH_WATERMARK: 0.6 volumes: - rabbit-data:/var/lib/rabbitmq networks: [bi-net] healthcheck: test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"] interval: 15s timeout: 10s retries: 5 start_period: 30s app: image: bi-app:${TAG:-latest} container_name: bi-app depends_on: redis: { condition: service_healthy } rabbitmq: { condition: service_healthy } environment: SPRING_PROFILES_ACTIVE: prod REDIS_HOST: redis RABBIT_HOST: rabbitmq ports: - "8080:8080" networks: [bi-net] stop_grace_period: 40s restart: unless-stopped healthcheck: test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"] interval: 20s timeout: 5s retries: 3 start_period: 60s networks: bi-net: driver: bridge volumes: redis-data: rabbit-data:start_period这个参数很多人不写,结果应用还在启动过程中,健康检查连续失败三次就判定不健康,编排层直接判定部署失败。BI 应用启动时要加载大量元数据和缓存预热,给 60 秒是保守但不夸张的设置。另一个细节是stop_grace_period: 40s,Docker 默认只给 10 秒,Spring Boot 优雅停机配置了 30 秒的等待窗口,10 秒根本不够,正在处理的导出任务会被直接腰斩。
RABBITMQ_VM_MEMORY_HIGH_WATERMARK: 0.6也值得解释:RabbitMQ 内存超过阈值会阻塞生产者,这个机制本来是保护自己,但如果容器内存上限 1G、阈值又按默认的 0.4 算,实际可用只有 400M,稍微积压一点就开始阻塞。设成 0.6 是给队列留出更多缓冲,同时不至于把容器撑爆。
2.3 网络、数据卷与配置分离
网络这块没什么玄机,自定义 bridge 网络让容器之间用服务名互相访问,比links或者host网络干净得多。要强调的是中间件绝不映射到宿主机端口。RabbitMQ 的 15672 管理界面很好用,但把它暴露在公网等于把家门钥匙挂在门口——默认账号密码、默认 vhost、没有速率限制,任何人都能进去清空队列。调试需求用 SSH 隧道或者临时映射到 127.0.0.1 解决,别图省事写-p 15672:15672。
配置分离这块,docker-compose.yml里只放结构性配置,密码和密钥一律走.env文件,并且.env必须进.gitignore。很多事故不是技术问题,是有人把.env提交到了代码仓库。更稳妥的做法是用secrets或者部署时注入环境变量,.env只作为本地开发的便利。
数据卷的选择要分清场景。Redis 开了 AOF,数据落盘,用命名卷没问题;但要注意 BI 的缓存数据大部分是可以重建的,AOF 带来的磁盘 IO 开销值不值,得看用途。如果 Redis 只做纯缓存,appendonly no反而更合适,重启丢缓存、回源重建,比慢慢重放 AOF 更快。但如果 Redis 里放了分布式锁或者任务状态,那就必须开持久化,否则重启后锁状态丢失,可能出现并发重复执行。我一般会把这两类数据分到两个 Redis 实例,缓存实例不持久化、状态实例持久化,物理隔离最省心。
3. Spring Boot与中间件对接的关键点
3.1 Redis在BI场景里的三类用途与键设计
Redis 用得好不好,八成看键设计。BI 系统里我通常分三个命名空间,用冒号分隔,前缀固定下来,后面所有代码都遵守:
bi:meta:{tenantId}:{dictType} 元数据与字典,TTL 1h bi:board:{boardId}:{queryHash} 看板结果,TTL 5m bi:lock:export:{taskId} 导出任务锁,TTL 10m bi:limit:api:{userId}:{minute} 接口限流计数,TTL 60s看板结果的键里带一个queryHash,是根据查询条件、时间范围、用户权限算出来的摘要。这个设计解决了一个很实际的问题:同一个看板,不同用户因为数据权限不同,看到的数字不一样,如果只按boardId缓存,就会串数据。加上权限哈希之后,缓存命中率会下降一些,但正确性有保证——在BI系统里,数据串了比慢一点严重得多。
序列化方式必须统一。默认的 JDK 序列化会把对象写成二进制,占用大且不可读,线上排查时redis-cli get出来一堆乱码。改成 JSON 序列化,牺牲一点性能换来可观测性,很值。同时要显式配置StringRedisSerializer作为 key 的序列化器,否则 key 前面会带一串类型前缀,用 SCAN 匹配的时候特别别扭。
注意:BI 系统极易产生大 key。一个“全国门店销售明细”的缓存如果直接存成单个 key,几十兆的大小会在读取时阻塞 Redis 主线程,表现为整个应用的缓存集体超时。凡是结果集可能超过一万行的,不要进缓存,或者按分页拆成多个 key。
缓存穿透和击穿的处理不用上太复杂的方案。穿透用空值缓存加短 TTL 挡住;击穿用互斥锁重建,只让一个线程去算,其余线程短暂等待。这两招加起来不到二十行代码,比引入布隆过滤器简单得多,效果也够用。
3.2 RabbitMQ的队列、交换机与报表任务拆分
BI 的异步任务按耗时和失败处理方式可以分三类,队列也建议对应拆成三个,别用一个大队列混着跑。混跑的问题是:一个卡住的任务会堵住后面所有的任务,头部阻塞在消息队列里同样存在。
| 队列名 | 承载任务 | 消费并发 | 失败策略 | 超时 |
|---|---|---|---|---|
| bi.export.queue | 报表导出、Excel/PDF生成 | 2 | 重试2次后进死信 | 10min |
| bi.batch.queue | T+1指标计算、跑批 | 1 | 重试3次后告警 | 60min |
| bi.notify.queue | 站内信、邮件通知 | 4 | 重试5次后丢弃 | 30s |
交换机用 direct 类型,按 routing key 精确路由。为什么不统一用 topic 或者 fanout?因为 BI 的任务类型边界很清晰,用不着模糊匹配,direct 的路由表更小、更快,出问题时也更容易推理。
@Configuration public class RabbitConfig { public static final String EXPORT_EXCHANGE = "bi.task.exchange"; public static final String EXPORT_QUEUE = "bi.export.queue"; public static final String EXPORT_KEY = "task.export"; public static final String DLX_EXCHANGE = "bi.dlx.exchange"; @Bean public DirectExchange taskExchange() { return ExchangeBuilder.directExchange(EXPORT_EXCHANGE).durable(true).build(); } @Bean public Queue exportQueue() { return QueueBuilder.durable(EXPORT_QUEUE) .deadLetterExchange(DLX_EXCHANGE) .deadLetterRoutingKey("dlx.export") .maxLength(20000) .build(); } @Bean public Binding exportBinding() { return BindingBuilder.bind(exportQueue()).to(taskExchange()).with(EXPORT_KEY); } @Bean public RabbitTemplate rabbitTemplate(ConnectionFactory factory) { RabbitTemplate template = new RabbitTemplate(factory); template.setMandatory(true); template.setConfirmCallback((cd, ack, cause) -> { if (!ack) { // 记录到本地补偿表,由定时任务重投 } }); return template; } }maxLength(20000)是一个保护性配置,防止队列无限增长把内存吃光。超过上限后旧消息会被丢弃或者转到死信,具体行为取决于overflow策略,默认是丢头部。mandatory(true)配合 confirm 回调,解决的是“消息发出去了但没进队列”的问题——交换机找不到匹配队列时会静默丢弃,没有这个回调你永远不知道任务丢了。
消费者端有两个配置必须显式设定。一是prefetch=1,BI 的导出任务动辄几十秒,一个消费者预取一堆消息毫无意义,只会让其他消费者闲着。二是手动 ACK,配合幂等表。BI 任务的重复执行代价不小——同一张报表导两次,用户收到两封邮件,或者计数指标被累加两遍,问题就很麻烦。幂等的做法很简单:任务表加唯一索引(任务ID + 状态),消费者开始处理前先尝试插入一条“处理中”记录,插入失败就直接 ACK 丢弃。
提示:BI 跑批任务的执行时间经常超过 RabbitMQ 的
consumer_timeout(默认 30 分钟)。如果你有一个跑两小时的指标计算任务,要么调到更长的超时,要么把任务拆成子任务分段处理,否则连接会被 broker 判定为无响应并强制关闭,任务白跑。
3.3 连接池、线程池与超时参数怎么算
这一块是最容易被“默认值”坑到的地方。Spring Boot 的默认 HikariCP 连接池是 10 个连接,对于 BI 系统动不动几十个并发查询的场景明显不够;但也不是越大越好,连接数超过数据库能扛的并发,等待时间反而更长。
连接池大小的经验公式是连接数 ≈ CPU核数 × 2 + 有效磁盘数。一台 8 核的数据库服务器,池子设置 20 左右就够了。真正决定吞吐的不是连接数,是查询本身的效率——BI 的慢查询优化不了,配 100 个连接也只是把阻塞从应用层挪到数据库层。
spring: datasource: hikari: maximum-pool-size: 20 minimum-idle: 5 connection-timeout: 3000 idle-timeout: 600000 max-lifetime: 1200000 keepalive-time: 300000 redis: timeout: 2000ms lettuce: pool: max-active: 16 max-idle: 8 min-idle: 2 max-wait: 1000ms rabbitmq: listener: simple: acknowledge-mode: manual prefetch: 1 concurrency: 2 max-concurrency: 4 connection-timeout: 10s server: shutdown: graceful tomcat: threads: max: 200 min-spare: 20 spring.lifecycle.timeout-per-shutdown-phase: 30sconnection-timeout: 3000缩短是有意的。默认 30 秒意味着一旦数据库连接耗尽,请求会挂 30 秒才报错,这 30 秒里 Tomcat 线程一直被占着,很快整个应用都无响应。3 秒快速失败,让上游能尽快感知并降级。max-lifetime: 1200000(20 分钟)要略小于数据库或中间网络的空闲连接超时,避免拿到已经被对端关掉的死连接。
线程池这块,BI 系统最关键的是把快查询和慢导出分开。给看板查询配一个 50 线程的池子,给 Excel 生成配一个 4 线程的池子,两个池子互不干扰。导出的池子小是故意的——并行生成太多 Excel,内存和时间都不划算,而且会给数据库带来无谓压力。
超时链路上还有一个隐藏的坑:Tomcat 请求超时、Spring MVC 异步超时、HTTP 客户端超时、数据库查询超时、MQ 消费超时,这五个值必须是递减的,否则外层已经超时返回了,内层还在傻乎乎地跑,白白消耗资源。我一般这样设:Tomcat 120 秒、查询 60 秒、MQ 消费 10 分钟(导出专用队列)、Redis 2 秒。
4. 部署实操全流程
4.1 环境初始化与目录约定
动手之前先把宿主机的基础参数调整好,这些是一次性的,但漏了会在后面以各种奇怪的方式冒出来。vm.max_map_count在某些中间件下会用到,文件句柄数默认 1024 对于连接多的场景不够,时区必须和业务对齐否则报表日期全错。
# 文件句柄与网络参数 echo "fs.file-max = 1000000" >> /etc/sysctl.conf echo "* soft nofile 65535" >> /etc/security/limits.conf echo "* hard nofile 65535" >> /etc/security/limits.conf sysctl -p # 时间同步(BI系统的定时任务极度依赖准确时间) timedatectl set-timezone Asia/Shanghai # 目录约定 mkdir -p /opt/bi/{config,logs,data,backup} chmod 750 /opt/bi目录约定的价值在于,出问题时你知道去哪找东西。我把配置放config、应用日志放logs、中间件数据挂data、每日备份放backup,四者分开。有次线上 Redis 被误操作清空,因为data目录每天凌晨有 RDB 备份,十分钟就恢复了。如果数据和日志混在一个目录里,那次至少要排查一小时。
.env文件的内容大致是这样,注意不要写默认密码:
TAG=20240115 REDIS_PASSWORD=<强密码> MQ_USER=bi_app MQ_PASS=<强密码> DB_URL=jdbc:mysql://db-host:3306/bi?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai DB_USER=bi_reader DB_PASS=<强密码>4.2 首次启动与逐层验证
启动顺序不要图快,一层一层验证。直接用docker compose up -d一把梭也能起来,但出问题时你面对的是四个容器的日志混在一起,排查成本翻倍。
第一步,先起中间件,确认健康检查通过:
docker compose up -d redis rabbitmq docker compose ps # 状态应为 healthy docker compose logs -f rabbitmq | head -50第二步,验证 Redis 可读写、密码生效:
docker compose exec redis redis-cli -a "$REDIS_PASSWORD" ping # 期望返回 PONG docker compose exec redis redis-cli -a "$REDIS_PASSWORD" info memory | grep used_memory_human第三步,验证 RabbitMQ 的队列和交换机已经声明成功。应用启动时会自动创建队列,所以这一步要等应用起来之后做:
docker compose exec rabbitmq rabbitmqctl list_queues name messages consumers # 期望看到 bi.export.queue 等三个队列,consumers 与配置一致第四步,应用启动后打健康检查端点,不要只看 HTTP 200,要看里面每个组件的状态:
curl -s http://localhost:8080/actuator/health | python3 -m json.tool返回里status: UP只代表整体,如果 Redis 连不上而配置了management.health.redis.enabled=true,这里会明确显示redis: {status: DOWN}。这就是为什么要暴露 actuator 的 detail 级别信息,只看一个 UP 是没有意义的。
第五步才是前端静态资源和反向配置,Nginx 里要注意proxy_read_timeout必须大于后端的最长查询时间。默认 60 秒,一个导出请求跑 90 秒,Nginx 会先断开,用户看到 504,而后端还在勤勤恳恳地生成文件。
4.3 压测模型与容量估算
BI 系统的压测不能只压一个接口。合理的做法是分三组场景:看板首屏查询(占 70%)、明细分页查询(20%)、导出任务提交(10%),按这个比例混合打流。
并发数的估算用利特尔法则:并发数 = 目标QPS × 平均响应时间。假设业务要求早晚高峰支持 20 QPS 的看板查询,实测平均响应 800ms,那么理论并发是 20 × 0.8 = 16。再考虑 30% 的余量,压测时从 20 个线程起步,逐步加到 50,观察响应时间的拐点。拐点出现的那个并发数,就是这套配置的实际上限,超过它响应时间会陡然上升而不是缓慢增长。
Redis 的内存估算也要算一算。假设有 200 个看板,每个看板 5 种常见筛选组合,每种结果平均 50KB,那就是 200 × 5 × 50KB = 50MB;加上字典和权限数据 20MB,再加 30% 的碎片和开销,总量在 100MB 左右。这个量级用一个 1G 的实例绰绰有余。但如果有人把明细数据缓存进 Redis,量级瞬间就到 GB 级,所以前面说的“大结果集不进缓存”是一条硬规矩。
RabbitMQ 的堆积估算看两个数:单条消息大小 × 积压条数,和消费者处理速度。假设导出任务消息 2KB,峰值积压 5000 条,也就是 10MB,内存完全不是问题。真正的风险是消费者处理速度跟不上——单任务平均 40 秒,2 个消费者,一小时只能处理 180 个,如果高峰一小时提交 300 个导出,积压会持续增长。这种情况要么加消费者(但受数据库和内存限制),要么在入口做限流,让用户排队而不是让队列无限涨。
5. 常见问题与排查实录
5.1 典型故障速查表
| 现象 | 高概率原因 | 排查命令 | 处理办法 |
|---|---|---|---|
| 应用启动即退出 | 等待中间件超时 | docker compose logs app | 检查健康检查是否通过 |
| 容器状态 Exited(137) | 内存超限被 OOM Kill | docker inspect --format='{{.State.OOMKilled}}' bi-app | 调 JVM 堆比例或加内存 |
| 缓存集体失效 | 触发 maxmemory 淘汰 | redis-cli info stats看 evicted_keys | 增大内存或缩短 TTL |
| 导出任务不执行 | 队列消费者未注册 | rabbitmqctl list_queues name consumers | 检查监听器与 ACK 模式 |
| 看板首次极慢、之后正常 | 缓存未预热 | 看 Redis 命中率 | 加启动预热或定时预热 |
| 报表中文变方块 | 镜像缺中文字体 | 容器内 `fc-list | grep -i cjk` |
Exited(137)这个状态码新手常误判成“进程被杀”,其实是 128 + 9,也就是 SIGKILL,绝大多数情况是 cgroup 内存超限。判断方法很简单,docker inspect里看OOMKilled字段,true 就是内存问题。这时候不要急着加内存,先看 JVM 的MaxRAMPercentage是不是设太高,把堆吃满了但堆外还要用,两边一挤就爆了。
5.2 中间件的坑:缓存与消息
Redis 这边最常见的问题是连接数泄漏。表现是运行几小时后突然所有缓存操作超时,日志里一堆Could not get a resource from the pool。根因通常是有代码在try里拿了连接,异常路径没释放;或者用了RedisTemplate的 pipeline 但没关。排查方式看redis-cli info clients里的connected_clients是否持续上涨,涨到maxclients就全挂了。
第二个坑是键过期引发的集中回源。给一万个键设了同样的 1 小时 TTL,一小时后同时失效,瞬间几万个请求打到数据库。解决办法是在 TTL 上加随机抖动,baseTtl + random(0, baseTtl * 0.2),一行代码的事,能避免绝大多数雪崩。
RabbitMQ 这边最典型的是消息重复消费。网络抖动、消费者超时、ACK 丢失,任何一种都会导致 broker 重投。BI 场景下重复消费的后果很直观:用户收到两封“报表已生成”的邮件,或者指标被累加两次导致数字翻倍。前面提的幂等表是最可靠的方案,别指望“消息只投一次”,这个保证在分布式环境里不存在。
第二个坑是unacked 消息堆积。rabbitmqctl list_queues里看到messages_ready是 0,但队列就是不消化,说明消息全在messages_unacknowledged状态——消费者拿到了但没 ACK。原因可能是消费逻辑卡在一个慢查询上,或者手动 ACK 的代码写在了一个没被执行的分支里。排查时看消费者的堆栈,jstack抓一下就知道卡在哪。
注意:RabbitMQ 的
consumer_timeout默认 30 分钟,超时后 broker 会主动关闭 channel 并重投消息。如果你的跑批任务需要两小时,务必把这个参数调大,否则会陷入“执行—超时—重投—再执行”的死循环,指标算出来是错的。
5.3 容器内存、日志与重启策略
容器化的一个副作用是,很多在物理机上靠top就能发现的问题,现在藏起来了。JVM 看到的是容器内存限额(需要-XX:+UseContainerSupport,JDK 10 之后默认开启),但如果镜像基础偏老或者参数覆盖了,JVM 可能按宿主机内存算堆大小,一启动就把容器撑爆。
诊断步骤分三步:先看容器的内存限额docker stats,再看 JVM 实际堆上限(启动日志里会打,或者jcmd <pid> VM.flags),最后看 GC 日志。三者对不上就是参数问题。我习惯在启动参数里固定加上-Xlog:gc*:file=/app/logs/gc.log:time,uptime:filecount=5,filesize=20M,GC 日志轮转好,出问题时有据可查。
日志这块,BI 系统的日志量比普通服务大得多,一次慢查询可能打几百行 SQL。如果不做轮转,几天就能把磁盘写满,然后所有容器一起挂。Docker 的json-file驱动要配max-size和max-file,应用层的 logback 也要配滚动策略,双保险:
logging: file: name: /app/logs/bi-app.log logback: rollingpolicy: max-file-size: 100MB max-history: 15 total-size-cap: 2GB重启策略用unless-stopped而不是always,区别在于手动停了之后不会被自动拉起来,维护时更可控。但要注意,restart策略救不了启动就崩的情况——容器会疯狂重启,日志被刷得飞快。遇到这种,先docker compose stop停下来,改完再起,不要在重启循环里排查。
5.4 上线后最该盯的三个指标
部署完成不等于结束。上线第一周,我建议只盯三个数:缓存命中率、队列积压深度、P99 响应时间。这三个指标能覆盖八成的体验问题。
缓存命中率低于 70%,说明 TTL 设计或者键的粒度有问题,可能缓存了太多一次性查询,反而增加了开销。队列积压持续上涨,说明消费能力跟不上生产速度,短期靠限流,长期要拆分任务或者加消费者。P99 响应时间远超平均值,说明有少量特别慢的查询在拖累整体,这时候去翻慢查询日志比看监控图更直接。
这三个指标我一般会做成一个简单的定时脚本,每五分钟采集一次写进日志文件,出问题时grep一下就能看到趋势,不需要上完整的监控体系。当然,规模再大一点就该上正经的监控了,但起步阶段别过度设计。
最后分享一个实际用下来很省事的做法:把整个部署目录(compose 文件、Dockerfile、.env 模板、启停脚本、备份脚本)做成一个独立的仓库,版本号和镜像 tag 对齐。回滚的时候不需要回忆“上次改了什么”,git checkout到上一个 tag,docker compose up -d就回去了。这套流程看着笨,但在真出事的时候,笨办法往往是最快的。