☰
Spring Boot+Redis+Docker自研实时监控告警系统实战
2026/10/17 3:43:24 网站建设 项目流程

想搭一套能真正用起来的实时监控告警系统,又不想被商业监控产品的私有化部署和按节点收费劝退,用 Spring Boot+Redis+Docker 这条技术路线自己攒一个,是相当务实的方案。我做了一个叫 InsightFlow 的系统,核心职责是对内部多个服务的接口耗时、错误率、请求量做实时统计,命中规则后通过 Webhook 推到工作群,从开发到容器化部署整体花了大约两周,后续又在大半年线上运行里陆续修掉了一批典型问题。

这个系统解决的是很具体的事:服务实例一多,靠人肉盯日志和看板已经不可靠,必须有一套能自动发现问题、及时通知、还能恢复正常监控的机制。全文按我实际落地顺序来写,先讲为什么不用现成方案,再讲指标链路、告警状态机、Docker 部署,最后是线上跑了大半年后的排障记录。适合正在扩服务、又不想在监控上投入过高的后端开发、运维以及个人开发者参考。

1. 为什么我没有直接买一套监控,而是选择自研

1.1 商业方案与开源全家桶的账,算清楚就不纠结了

很多团队第一反应是采购商业监控产品,但算完账基本就冷静了。商业产品按节点和指标量收费,我们内部服务有十几个,每个服务又拆了多实例,一年下来是一笔不小的固定成本。更麻烦的是私有化部署通常对环境和硬件有要求,数据要送到厂商平台又涉及敏感数据出内网的问题,告警规则想调整还要走工单或等版本迭代。

开源监控首选的自然是 Prometheus 那套组合。我承认它是优秀的方案,但结合实际场景,我们当时的状态是:业务指标为主,不只要看 CPU、内存这些基础设施指标,更关注"下单接口 5 分钟内的错误率"、"支付回调 P99 耗时"这类业务 API 指标。Prometheus 的拉取模型和服务发现更适合基础设施层,要让 Java 服务把业务指标暴露成 /metrics 端点再让 Prometheus 抓取,需要引入客户端库、配置各种 exporter,等于又增加一批需要维护的组件。团队本身以 Java 为主,日常开发都在 Spring Boot 里,延展链路和告警逻辑最好能用代码维护。

这里不是要分个高下,而是选型和团队技术栈、被监控对象强相关。我们需要的核心能力只有四个:接收指标写入、窗口聚合计算、规则匹配触发、通知与恢复。这些用 Spring Boot 做控制面、Redis 做实时数据面和计算面,完全能覆盖。

我把几个维度列了个对比表,方便同样在做选型的同学参考:

对比维度商业监控产品Prometheus 全家桶自研 Spring Boot + Redis
数据模型黑盒时序模型,适合拉取可按业务任意设计 key 和结构
采集模式需接 SDK拉取为主上报为主,适合业务 API
告警可编程性受限规则文件,相对受限完全代码可控,可上状态机
部署复杂度低但受平台限制组件多,链路较长一个 Compose 文件搞定
长期成本按量付费,越用越贵组件维护成本开发一次,维护成本低

1.2 我给"好用"定义了四个硬指标

在动手之前,我先给这个系统定了几条需求,避免写着写着跑偏。第一是实时性要够,关键指标从上报到出告警要在 10 秒内,不能让通知比故障晚到太久。第二是告警要分级别,轻微抖动和严重故障不能都往群里扔。第三是必须有防告警风暴的机制,能静默、能去重、能恢复,而不是一直刷屏。第四是部署要足够简单,一台新环境执行一次docker compose up -d就能把整套监控跑起来,数据能自动清理,不需要人去手动删 Redis 里的过期 key。

这几条后来成了整个系统设计的主线。包括后续所有踩坑和演进,回头看都围绕这四条在展开:实时性对应了 Lua 脚本做原子聚合,分级和防风暴对应了状态机设计,部署简单对应了多阶段构建和 Compose 编排,自动清理对应了 Redis 的 TTL 体系。如果一开始没有把"好用"定义清楚,很容易把监控系统做成一个只会发数字、没法真正辅助排障的工具。

2. 指标从埋点到 Redis 的数据链路设计

2.1 上报协议:批量上报比单点上报更合适

InsightFlow 对外提供一个 HTTP 上报接口/api/v1/metrics/report,接收方是所有被监控服务的客户端 SDK。SDK 不会一条一条地发数据,那会把服务端打成筛子,而是在本地做聚合,默认 10 秒上报一批,业务量大的服务可以调整到 30 秒。上报格式是 JSON,大致长这样:

{ "service": "order-service", "instanceId": "order-svc-02", "reportedAt": "2024-01-15T10:30:05Z", "metrics": [ { "name": "http.request.count", "type": "counter", "value": 120, "occurredAt": 1705307400 }, { "name": "http.request.error_count", "type": "counter", "value": 3, "occurredAt": 1705307400 }, { "name": "http.request.cost", "type": "histogram", "value": 180, "occurredAt": 1705307400 } ] }

name是指标名,type区分 counter、gauge、histogram 三种,occurredAt是这批数据对应的时间戳,服务端会按这个时间戳落窗口,而不是按接收时间,这样即使客户端上报稍微延迟,也不至于把数据写错窗口。接口层我用拦截器统一做了三件事:校验 appId 的 token,校验时间戳不能是未来时间或超过 5 分钟之前的旧数据,限制单次 body 大小不超过 1MB。这三道防护别看简单,上线后帮我挡掉了不少客户端埋点代码的 bug,比如某个服务时钟漂移发来一堆未来时间的数据,直接被拦掉,没有污染统计。

2.2 Redis 里的数据结构选型:ZSet 配 Hash,而不是 List

第一版我走的是非常朴素的方案:每类指标一个 Redis key,用 Hash 按分钟窗口累加。比如req.count这个 Hash,field 是分钟时间戳,value 是该分钟内请求量,服务端拿到上报后执行HINCRBY req.count 202401151030 120。计数类指标这样没问题,但耗时和错误率这类需要算占比和分布的就麻烦了。

后来引入 ZSet 处理窗口内的明细事件。ZSet 的 member 存唯一标识,score 存毫秒时间戳,我可以使用ZREMRANGEBYSCORE清理过期数据,再用ZCOUNT统计窗口内数量。为什么不用 List?List 只能从两端弹数据,中间过期数据没法高效删除,窗口滑动的数据裁剪做不干净。而 ZSet 天然支持按 score 范围做裁剪和统计,对滑动窗口来说就是最合适的基础结构。

我当时实际使用的 key 结构大概是这样:

insight:req.count -> Hash (field: 分钟窗口, value: 请求量) insight:err.count -> Hash (field: 分钟窗口, value: 错误量) insight:cost.hist.{5min} -> Hash (field: 耗时桶, value: 计数) insight:window:{service} -> ZSet (member: 请求ID, score: 毫秒时间戳)

注意这里的演进过程。早期我确实用了 ZSet 存每个请求的耗时明细,准确性没问题,但内存消耗很大,后面 5.5 节会讲我怎么改成直方图桶。如果你现在就要动手做,直接按"本地分桶 + Hash 桶计数"起步就好,ZSet 只保留必要的事件样本,不需要把每一条原始请求都塞进去。

2.3 写入并发与限流:pipeline 是起点,Lua 才是终点

上报接口会被多个服务并发调用,高峰期单分片能到上千 QPS。一开始我用 Lettuce 的 pipeline 把一批 HINCRBY、ZADD 合并发送,效果不错。但 pipeline 有个隐患:一批命令里如果夹杂一个慢命令,比如误用了 KEYS,整条 pipeline 都会卡住,后续上报全部排队。这个坑我最后在 5.1 节里详细讲,这里先说结论:批量写入的终态是 Lua 脚本,把多个 Redis 命令在脚本里原子执行,减少网络往返,也避免 pipeline 被单一慢命令拖累。

服务端还做了租户维度的限流,一个简单的令牌桶脚本控制每个 appId 每秒上报次数,超限直接返回 429。客户端 SDK 收到 429 会丢弃这批数据而不是重试,这是有意设计的。监控系统本身就是为故障场景服务的,如果服务端已经快撑不住了,客户端还拼命重试,只会加重故障,不如放弃一些精度换取可用性。

3. 阈值判断与告警状态机:绝不是写几个 if else 那么简单

3.1 窗口聚合交给 Lua 脚本,而不是在 Java 内存里算

告警判断最自然的想法,是 Spring Boot 里写个定时任务,从 Redis 取出数据在 Java 内存里算。但多实例部署时每个实例都会执行一遍任务,结果相同还要处理重复通知。更关键的是"取数据、计算、判断"这个组合在 Java 侧不是原子的,窗口边缘的数据可能被两个实例各算一次,也可能漏算一次。

我的做法是把统计逻辑写成 Lua 脚本,在 Redis 服务端原子执行。比如计算错误率的脚本核心逻辑:

local req = redis.call('HGET', KEYS[1], ARGV[1]) local err = redis.call('HGET', KEYS[2], ARGV[1]) if req == false or req == '' then req = 0 end if err == false or err == '' then err = 0 end req = tonumber(req) err = tonumber(err) if req == 0 then return -1 end return err / req

Lua 脚本在 Redis 里是单线程原子执行的,脚本本身很短,毫秒级就能跑完,对 Redis 主线程影响可接受。同时我把"裁剪过期窗口、统计窗口值、判断是否达到阈值"三段逻辑都放进同一个脚本里,保证一次调用完成全部工作,这就是链路实时性的关键。

3.2 告警规则模型与分级

规则存在 MySQL,用 SQL 迁移脚本管理版本,这样规则就是代码的一部分,CR 评审、回滚都方便。规则的核心字段包括:rule_id、服务匹配模式、指标名、窗口大小、阈值、比较符、持续时长、告警级别、静默时间。

我们团队实际配置的几条典型规则:

规则描述窗口阈值持续时长级别
错误率超限5 分钟5%1 个窗口WARN
错误率严重超限5 分钟15%3 个连续窗口CRITICAL
P99 耗时过高5 分钟800ms5 个连续窗口CRITICAL
请求量环比下降10 分钟50%2 个连续窗口WARN

WARN 和 CRITICAL 的通知策略不同。WARN 只推工作群,CRITICAL 除了工作群还会额外触发一条高优先级 Webhook。这里没有真的接入电话路由,但保留了通道,后面想接只改一个适配器的事。

3.3 防告警风暴:状态机里必须有静默、去重、恢复

告警状态机是我觉得整个系统里收益最高的一部分。它只有三个状态:OK、TRIGGERED、RECOVERED。

转换逻辑是:某条规则连续 N 个窗口满足条件时进入 TRIGGERED,进入时推一次告警通知;持续在 TRIGGERED 状态期间,默认 30 分钟内不重复推送同一条规则;连续两个窗口恢复正常后进入 RECOVERED,推送恢复通知。整个过程用alert:{ruleId}:{service}:{metric}这个 key 保存状态和最近通知时间,再配合一个带过期时间的锁,防止并发情况下重复推送。

状态机里最容易被忽略的是"无数据"这个中间态。统计窗口取不到任何数据时,脚本统一返回 -1。早期我犯过一个严重错误,把 -1 当成正常值处理,导致凌晨 Redis 抖动时恢复通知疯狂误报。后来约定:-1 代表无数据,该窗口不参与判断,只有连续两个有数据的窗口都是正常,才允许从 TRIGGERED 转到 RECOVERED。这个改动直接把恢复消息的误报率降到了原来的零头。

我画了一张状态转换表,方便理解和排查:

原状态触发条件新状态动作
OK连续 N 个窗口满足阈值TRIGGERED推告警通知
TRIGGERED仍在告警中,但未到重推间隔TRIGGERED不推送
TRIGGERED仍在告警中,且超过重推间隔TRIGGERED重推告警通知
TRIGGERED连续两个窗口正常且有数据RECOVERED推恢复通知
任意状态窗口无数据保持原状态不动作
TRIGGERED超过静默期且再次连续满足TRIGGERED按新周期重新推送

4. Docker 化部署的完整过程与细节

4.1 多阶段构建与镜像瘦身

Docker 部署的第一个目标是镜像足够小、构建足够快。我用多阶段构建,第一阶段用 Maven 镜像完成编译,第二阶段用 JRE 镜像作为运行环境。通过这种方式,镜像从最初随便打包的 800MB 降到了 180MB 左右,磁盘和网络传输成本都友好很多。

Dockerfile 大意:

FROM maven:3.9-eclipse-temurin-17 AS builder WORKDIR /build COPY pom.xml . RUN mvn dependency:go-offline -DskipTests COPY src ./src RUN mvn package -DskipTests FROM eclipse-temurin:17-jre WORKDIR /app ENV TZ=Asia/Shanghai RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone COPY --from=builder /build/target/insightflow.jar app.jar EXPOSE 8080 ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]

这里有一个构建缓存的细节。先把 pom.xml 单独 COPY 进去执行依赖下载,再 COPY 源码,这样源码变更时 Docker 会命中依赖层缓存,不需要每次重复下载全部依赖。发布新版本的时间从五分钟级降到一分钟级。

4.2 Compose 编排:Redis 持久化与健康检查

编排我用 docker-compose,两个服务就够了:应用服务和 Redis。Compose 文件关键配置:

services: redis: image: redis:7-alpine command: ["redis-server", "--appendonly", "yes", "--appendfsync", "everysec", "--maxmemory", "2gb", "--maxmemory-policy", "allkeys-lru"] healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 5s timeout: 3s retries: 10 volumes: - redis-data:/data app: build: . mem_limit: 1g cpus: "0.5" environment: SPRING_PROFILES_ACTIVE: prod SPRING_DATA_REDIS_HOST: redis TZ: Asia/Shanghai JAVA_OPTS: "-Xms256m -Xmx768m -Duser.timezone=Asia/Shanghai" depends_on: redis: condition: service_healthy ports: - "8080:8080" volumes: - ./logs:/app/logs volumes: redis-data:

Redis 的持久化策略我选择appendonly yes+appendfsync everysec。如果完全不持久化,Redis 一重启所有窗口数据和告警状态全部清零,监控系统等于失忆,正在告警的故障瞬间"假恢复",这是监控场景不能接受的。everysec是性能和安全的折中,最多丢一秒数据。

健康检查被很多人忽略,我这里单独说。depends_on只保证容器启动顺序,不保证服务可用。Spring Boot 应用启动时如果 Redis 还没就绪,会直接连接失败。加了 healthcheck +condition: service_healthy才算真正解决了启动依赖问题。

4.3 优雅停机、滚动发布与日志容量

应用侧把server.shutdown设为 graceful,配合 Docker stop 的 30 秒等待,下线时先停止接收新请求,再处理存量请求,保证监控数据不丢。更新版本时用docker compose up -d --build,服务先后起,告警链路不会出现真空期。

日志容量是个容易被忽视的坑。默认 json-file 驱动不限制日志文件大小,服务跑一个月日志能吃满磁盘。我的处理是 compose 里限制 max-size 和 max-file,同时应用内部用 Logback 按天滚动保留 14 天,双保险。

5. 线上运行大半年后,我处理过的五个典型问题

5.1 Redis 连接池被一个 KEYS 命令拖垮

现象是有段时间上报接口偶发超时,查 Redis 慢日志发现有个执行了 2 秒多的 KEYS 命令。根因是我在某个版本上线了"列出全部指标 key"的功能,直接用了KEYS insight:*,生产环境有上百万个 key,这个命令会阻塞 Redis 主线程,所有客户端连接排队直到超时。

解决方法是彻底禁用 KEYS。统计 key 列表改成用一个专门的 ZSet 做注册表,每次新建指标 key 时往注册表里 ZADD 一下,需要列全部 key 时直接 ZRANGE。同时代码里所有 KEYS 全部换成 SCAN。教训就一句话:生产环境 Redis 里的 KEYS 命令,和定时炸弹没有区别。

5.2 多实例部署导致告警重复推送

现象是某次故障触发时工作群被刷了三条一模一样的告警。第一反应是状态机锁没生效,排查后发现告警状态确实有锁,但"Lua 计算窗口、判断规则、推送通知"这三步不是原子的,两个实例几乎同时完成了计算和判断,都认为自己应该推送。

解决方式是把整个告警扫描任务包在 Redis 分布式锁里,setIfAbsent(lockKey, uuid, 30秒)拿锁,执行完成释放锁。锁的过期时间设成任务最长执行时间的两倍,这样即使任务异常卡死,锁也会自然过期,不会被永久占住。改完后重复推送再没出现过。

5.3 恢复消息疯狂误报

这个坑前面提到过。某天凌晨 Redis 短暂抖动,监控群半夜刷了上百条恢复消息。排查链路还原下来:Redis 连接断开导致统计数据缺失,旧逻辑把"无数据"当成"恢复正常",于是所有 TRIGGERED 状态的任务统统发了恢复通知。

修复有两层。第一层是语义统一,脚本里明确约定 -1 表示无数据,无数据窗口不参与任何状态判断。第二层是状态机加最小翻转保护,进入 TRIGGERED 后至少静默 10 分钟,避免规则因为瞬时抖动在告警和恢复之间反复横跳。这两层叠加之后,恢复通知的误报基本清零。

5.4 阈值总在误报和漏报之间摇摆

刚开始 P99 耗时阈值设 300ms,结果正常业务波动就能触发告警,天天狼来了。调高到 800ms 之后,确实不误报了,但线上真实故障有一半以上又漏报。单指标单阈值的静态方案在这个场景下走不通。

后来改成动态基准:每小时用 Lua 脚本计算上一小时同期的 P95 耗时作为基准值,规则里增加baseline_offset字段,实际阈值等于基准值乘以系数加固定偏移。这个改动让有效告警率从不足 30% 提升到了 65% 以上。动态阈值的本质是让告警规则适应业务的日常波动,而不是用一个拍脑袋的绝对值套所有时段。

5.5 内存容量膨胀,最终改成直方图桶

上线几个月后,Redis 内存从 500MB 一路涨到 1.8GB。排查时发现主要是 ZSet 里存的耗时明细太多,高峰期一分钟几万条请求全部落到了 Redis,再大的内存也不够烧。直接扩容只是拖延问题,因为业务量还在涨。

解决方式是把客户端 SDK 改成按固定耗时桶聚合,比如 [0-50, 50-100, 100-300, 300-800, 800-2000, 2000+] 毫秒六档,本地聚合成桶计数再上报。服务端只更新 Hash 里对应桶的计数。内存占用直接降了 80% 以上,P99 用桶边界插值估算,误差在可接受范围。这次改造的收益不仅是内存,上报数据量也大幅下降,对链路整体压力都是利好。

我把这五个问题的现象、根因、解决方案汇总成一张表,方便快速检索:

问题现象根因解决方案
KEYS 拖垮 Redis上报接口超时KEYS 扫描百万级 key 阻塞主线程改为注册表 ZSet + SCAN
告警重复推送同一条告警推三次计算与触发之间非原子Redis 分布式锁包住扫描任务
恢复消息误报凌晨刷屏上百条无数据当正常处理脚本返回 -1,无数据窗口不判断
阈值误报漏报告警质量极差静态阈值不适应业务波动动态基准 + baseline_offset
内存膨胀Redis 内存 1.8GBZSet 存了全部明细客户端直方图分桶后上报

做完这套系统再回看,最有价值的其实不是代码本体,而是围绕告警质量持续迭代的过程。如果让我重新做一遍,我会把统计链路和告警状态机拆成两个独立的 Spring Boot 容器,统计重吞吐、告警重可靠,混在一起会互相干扰。另外给后来者一个最实在的建议:不要一开始追求完美架构,先用 Hash+ZSet 把端到端跑通,再逐步加直方图、动态基线、静默机制。监控系统最大的难点从来不是技术选型,而是你愿不愿意根据真实告警反馈持续调规则、调阈值。InsightFlow 现在每天依然在跑,但真正值钱的是那几十条被线上故障反复打磨过的告警规则,它们才是监控系统沉淀下来的经验本体。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询