
简介这是一份配合 Grafana 的 simpod-json-datasource 插件使用的 JSON 代理实现面向需要将 Oracle、MongoDB 接入 Grafana 统一监控面板的运维与开发人员。它通过自定义数据源代理解决了 Grafana 原生无法直接连接这两类数据库的问题可部署在 Java 环境中作为数据查询桥接层。压缩包共 26 个文件以 7 个 Java 源文件、7 个 class 文件、7 个 XML 配置和 2 个 YAML 配置为主附带 Maven 的 pom.xml 与 HELP.md 说明文档整体仅 36KB结构轻量清晰。内置 src、main、test、target 等标准目录读者可直接查看源码理解查询映射逻辑也可基于 Maven 构建部署再结合 simpod 插件快速实现 Oracle/MongoDB 数据源配置。目前已有 160 人学习/下载适合正在搭建 Grafana 多数据源监控体系的开发者参考。1. grafana-json-proxy 是什么用一层 Java JSON 服务补齐数据源缺口grafana-json-proxy 解决的是一个很具体的尴尬想在一个面板里同时看 MongoDB 业务流水和 Oracle 历史告警但 Grafana 对这两类存储的原生支持要么是实验插件要么版本一升级就断供。它的做法很直接——用 Java 起一个轻量 JSON 代理服务伪装成 Grafana 的 Simple JSON 数据源。面板发来的查询请求由代理翻译成 MongoDB 聚合管道或 Oracle 绑定变量 SQL再包装成时间序列 JSON 返回。对 Grafana 来说它只是个普通 HTTP 后端对底层库来说查询是可控、带参数的。适合被数据源插件卡脖子、又要自定义聚合逻辑的工程师。这篇按实际拆包的顺序把协议、实现、配置、踩坑逐条过一遍。2. 看不懂协议就做不对代理拆开 Simple JSON 的三个端点和时间参数做代理之前先把 Simple JSON 插件和代理服务的分工讲清楚。插件不负责连数据库它只做三件事把你的后端地址暴露成一个数据源、把面板的查询上下文原样 POST 过来、把返回 JSON 渲染成曲线。真正干活的逻辑全在代理里协议边界就是开发的起点。2.1 /search、/query、/annotations一个管下拉菜单一个管喂数据一个管事件标线Simple JSON 数据源插件对后端只提三个要求/search、/query、/annotations全部是 POST。/search负责返回指标名列表用于面板编辑时的下拉菜单填充。返回格式有两种一是纯字符串数组[cpu,mem]二是对象数组[{text:CPU 使用率,value:cpu}]。text是面板上显示的名字value会被插件原样塞进/query请求里的targets[].target字段。如果模板变量类型选的是 DataSource/search的返回值还会直接决定下拉框内容。对应的 Java DTO 长这样public class MetricOption { public String text; // 下拉框里显示的名字 public String value; // 实际传给 /query 的 target 值 }逻辑说明text只用来展示value才是代理真正关心的查询标识。如果你的后端不区分别名直接返回字符串数组最省事——插件对这两种格式都认但对象数组能让你把内部指标名和展示名解耦。我第一版只实现了/queryGrafana 的 Save Test 直接报 404。插件探活会先请求/和/search这两个端点不响应数据源根本存不进去。/query是核心端点一次完整的请求体大概长这样{ panelId: 1, range: { from: 1736755200000, to: 1736762400000, raw: {from: now-2h, to: now} }, interval: 1m, intervalMs: 60000, maxDataPoints: 1920, targets: [ {target: avg_latency, type: timeserie, refId: A} ] }参数说明range.from和range.to是查询窗口不同版本的插件可能传 epoch 毫秒数字也可能传 RFC3339 字符串这个双格式问题会在第 4 章展开intervalMs是面板根据图表宽度算出的推荐采样间隔maxDataPoints是这一宽度能容纳的最大点数targets是数组一个面板拖了三个指标它就传三个 target每个 target 的refId在面板内唯一。响应是时间序列数组public class TimeSeries { public String target; // 图例上显示的名字 public ListObject[] datapoints; // 每个元素是 [value, epochMillis] }datapoints里每个元素是两个元素的数组值和毫秒时间戳。值允许为null表示该桶没有数据曲线会断开时间戳必须是 Long 类型的毫秒传成秒会出现整条曲线挤在 1970 年的名场面。/annotations是事件标线返回[{text:变更说明,time:1736755200000,tags:[deploy]}]不需要可以返回空数组。如果 Oracle 里有变更记录表投影成事件标线比堆曲线直观得多——但注意time也必须是 epoch 毫秒。2.2 时间范围怎么解析从面板上下文到后端查询边界的翻译时间解析是代理里最容易出错的一层因为 Grafana 传时间有四种形态epoch 毫秒数字、RFC3339 字符串带 Z 结尾、rangeRaw里的相对时间now-1h、now/d以及个别旧版本插件传的秒级时间戳。我一般用一个独立类把解析收敛起来public class TimeRange { public static long parseEpoch(Object t) { if (t instanceof Number) { return ((Number) t).longValue(); } String s t.toString(); if (s.matches(^\\d{13}$)) { // 纯数字字符串也要兜住 return Long.parseLong(s); } return Instant.parse(s).toEpochMilli(); // 形如 2024-01-13T08:00:00.000Z } }逻辑说明instanceof Number覆盖数字形态正则^\\d{13}$覆盖了某些网关把数字序列化成字符串的情况最后才走Instant.parse。这个顺序很重要——先判类型再判字符串能避免DateTimeParseException把代理打挂。面板上选择的绝对时间本身就是 UTC代理原样转成 epoch 毫秒就行千万不要再减 8 小时显示时区的换算归 Grafana 面板管。相对时间now-1h不会出现在range.from里它只在rangeRaw出现。如果range.from拿不到绝对值才需要退回按rangeRaw加当前时间计算。这个兜底分支我建议也写进TimeRange类因为 Grafana 某些内嵌页面比如告警预览发过来的请求就是只有raw没有绝对值的。解析完的fromMs/toMs直接传给下层查询服务MongoDB 的new Date(fromMs)和 Oracle 的setTimestamp都用这两个值保证整个链路口径一致。2.3 最小可运行骨架Spring Boot 控制器从请求到响应的完整回路协议和服务结构绑定之后最小骨架就是一个 Spring Boot 工程加两个控制器方法。依赖只需要spring-boot-starter-web、MongoDB Java Driver、Oracle JDBC 驱动或者用 HikariCP 管理连接池。先是请求 DTOpublic class QueryRequest { public Range range; public MapString, Object rangeRaw; // 相对时间兜底用 public String interval; public long intervalMs; // 分桶粒度核心参数 public int maxDataPoints; public ListTarget targets; public static class Range { public Object from; // 可能是 Number 也可能是 String public Object to; } public static class Target { public String target; // 指标名来自 /search 的 value public String type; // 固定 timeserie public String refId; // 面板内唯一 } }字段说明intervalMs和maxDataPoints是后端分桶和限流的直接依据比面板上的字符串interval可靠因为1m这种字符串在不同版本里存在大小写和单位差异。控制器实现RestController public class ProxyController { PostMapping(/search) public ListMetricOption search(RequestBody(required false) MapString, Object body) { return metricService.listMetrics(); } PostMapping(/query) public ListTimeSeries query(RequestBody QueryRequest req) { long fromMs TimeRange.parseEpoch(req.range.from); long toMs TimeRange.parseEpoch(req.range.to); ListTimeSeries out new ArrayList(); for (QueryRequest.Target t : req.targets) { out.add(queryService.query(t.target, fromMs, toMs, req.intervalMs)); } return out; } }逻辑说明/search的RequestBody(required false)是必须的——实测某些版本探活时发的是空 POSTrequired true会直接 400 导致数据源保存失败/query遍历targets每个 target 返回一个TimeSeries顺序和请求保持一致面板才能把曲线和图例对应上。另外强烈建议加一个全局异常处理器把异常包装成{message: ...}返回而不是 Spring 默认的错误页面。Simple JSON 插件对非 200 响应处理不友好包装成 200 message 能直接把错误显示在面板上排障效率完全不同RestControllerAdvice public class QueryExceptionHandler { ExceptionHandler(Exception.class) public MapString, Object handle(Exception e) { MapString, Object body new HashMap(); body.put(message, e.getMessage()); return body; } }到这里一个能通过 Save Test、能返回空曲线的代理就立住了。下一步是把这个空壳填成真正查 MongoDB 和 Oracle 的适配器也就是第 3 章的内容。3. 把两类数据源接进来MongoDB 聚合桶和 Oracle 绑定变量查询的落地写法协议通了之后工作量集中在两个底层查询适配。代理的价值不是把 SQL 或聚合原样转发而是把 Grafana 的intervalMs、maxDataPoints翻译成分桶参数保证面板宽度和返回点数匹配。说得直白点面板要 1920 个点你后端别给两万个。3.1 在 Grafana 侧注册数据源四个配置项决定生死先把 Grafana 侧的配置理顺代理写得再好这边接不起来也白搭。安装插件用grafana-cli plugins install grafana-simple-json-datasource装完必须重启 grafana-server。然后在 Add data source 里选 Simple JSON 类型URL 填代理地址关键配置项整理成一张表配置项推荐设置说明URLhttp://localhost:8080必须是 Grafana 服务器能访问到的地址不能填开发机的 localhostAccessServer由 Grafana 服务端转发请求避免浏览器端跨域HTTP MethodPOST/search、/query、/annotations三个端点全部走 POSTTime interval1m面板最小时间粒度和刷新频率对齐太小的 interval 会产生大量桶配置完点 Save Test插件会先请求/再做一次/search探活代理返回非 JSON 就会红字报错。如果代理加了认证建议用 Custom HTTP Headers 传X-API-Key由代理的拦截器校验——别用 Basic AuthSimple JSON 插件对 401 的处理非常别扭经常把认证失败显示成数据源不可用。这里有个血泪经验开发时面板跑在本地浏览器代理也跑在本地URL 填localhost:8080一切正常部署后 Grafana 在服务器上代理也在服务器上但习惯性还是填了localhost结果服务器上的 Grafana 请求自己的 8080 端口如果代理没监听到对应端口就全是 connection refused。排查了半天最后发现是 URL 写对了服务却没起——这类问题在第 5 章会讲怎么快速定位。3.2 MongoDB 场景按 intervalMs 切桶聚合成时间序列假设有一个事件流水集合events字段是tsBSON Date、host字符串、latencydouble面板要看每分钟平均延迟。错误做法是查出全量文档在 Java 内存里分桶——数据量一大每次面板刷新都是灾难。正确做法是把聚合下推给 MongoDB按 Grafana 给的intervalMs切桶public TimeSeries queryMongo(String metric, long fromMs, long toMs, long intervalMs) { long bucketMs Math.max(intervalMs, 1000L); // 防止 intervalMs0 时除零 Document groupId new Document($toLong, new Document($subtract, Arrays.asList( new Document($toLong, $ts), new Document($mod, Arrays.asList( new Document($toLong, $ts), bucketMs))))); ListDocument pipeline Arrays.asList( new Document($match, Filters.and( Filters.gte(ts, new Date(fromMs)), Filters.lte(ts, new Date(toMs)))), new Document($group, new Document(_id, groupId) .append(avgLatency, new Document($avg, $latency))), new Document($sort, new Document(_id, 1))); ListObject[] points new ArrayList(); for (Document doc : db.getCollection(events).aggregate(pipeline)) { long ts doc.getLong(_id); // 桶起点毫秒 Double value doc.getDouble(avgLatency); // 桶内平均延迟 points.add(new Object[]{value, ts}); } return new TimeSeries(avg_latency, points); }参数说明$toLong把 BSON Date 转成毫秒数$subtract配合$mod取整到桶起点——原理是先用ts / bucketMs得到桶序号再用ts - 余数复原起点毫秒。bucketMs直接用 Grafana 传来的intervalMs可以保证返回点数与面板宽度匹配取Math.max(intervalMs, 1000L)是兜底防零。这个管道需要 MongoDB 4.0 以上。如果线上是 5.0可以直接用$dateTrunc更直观new Document($dateTrunc, new Document(date, $ts) .append(unit, minute) .append(binSize, bucketMs / 60000L))$dateTrunc的binSize参数是单位倍数按分钟传时就传bucketMs / 60000。这套逻辑的边界在于如果面板里还选了host条件就把 host 值从 target 里解析出来往$match里追加Filters.eq(host, value)。target 的命名约定上我建议用mongo.avg_latency.hostweb-01这种带前缀的格式后面第 6 章会展开讲路由但注意一定要对 host 值做白名单校验只允许字母数字和连字符否则变量内容可能被拼进聚合管道——虽然 MongoDB 的聚合不是 SQL 注入那么直接但会让查询行为变得不可控。3.3 Oracle 场景PreparedStatement 绑定变量与返回列映射Oracle 侧的场景通常是历史告警表ALARM_LOG(event_time TIMESTAMP, alarm_type VARCHAR2, ...)面板要按小时统计告警数并且按alarm_type分多条曲线。查询必须用 PreparedStatement 绑定变量一方面防注入另一方面让 Oracle 共享池能复用执行计划public TimeSeries queryOracle(String alarmType, long fromMs, long toMs, long intervalMs) { long intervalSeconds Math.max(intervalMs / 1000L, 60L); // 最小 1 分钟桶 String sql SELECT FLOOR((CAST(event_time AS DATE) - DATE 1970-01-01) * 86400 / ?) AS bucket, COUNT(*) AS cnt FROM ALARM_LOG WHERE event_time BETWEEN ? AND ? AND alarm_type ? GROUP BY FLOOR((CAST(event_time AS DATE) - DATE 1970-01-01) * 86400 / ?) ORDER BY bucket; ListObject[] points new ArrayList(); try (PreparedStatement ps conn.prepareStatement(sql)) { ps.setLong(1, intervalSeconds); ps.setTimestamp(2, new Timestamp(fromMs), Calendar.getInstance(TimeZone.getTimeZone(UTC))); ps.setTimestamp(3, new Timestamp(toMs), Calendar.getInstance(TimeZone.getTimeZone(UTC))); ps.setString(4, alarmType); ps.setLong(5, intervalSeconds); try (ResultSet rs ps.executeQuery()) { while (rs.next()) { long bucket rs.getLong(bucket); long ts bucket * 1000L; // 桶起点毫秒 long cnt rs.getLong(cnt); points.add(new Object[]{(double) cnt, ts}); } } } return new TimeSeries(alarm_ alarmType, points); }参数说明CAST(event_time AS DATE) - DATE 1970-01-01算出秒数除以intervalSeconds取整得到桶序号最后乘回 1000 就是桶起点毫秒。DATE 1970-01-01这个字面量不带时区配合 JDBC 连接串里的serverTimezoneUTC才能保证桶边界和 Grafana 传进来的 UTC 时间一致。绑定变量里intervalSeconds要绑两次因为SELECT和GROUP BY里都出现了?。如果面板拖了多个alarm_type每个类型当成独立 target 查询即可更推荐的做法是用 Grafana 模板变量做下拉框选类型target传oracle.alarm_cnt.typeORA-00600代理解析后走绑定变量。alarm_type里混中文或特殊字符时绑定变量完全没压力但拼接 SQL 轻则 ORA-00911 重则被注入。Oracle 侧的性能边界要说清楚30 天范围加小时桶只有 720 个点查询很轻但如果面板要秒级粒度建议在 Oracle 侧建汇总表或物化视图别让代理每次全表扫。JDBC 写裸查询就够了这个场景引入 MyBatis 反而增加配置复杂度。4. 避坑清单五个高频翻车点从时间格式到连接池代理写通不难难在 Grafana 面板真正用起来之后的那些玄学问题。下面五条是我和同事实际踩过的每条按现象、原因、解决讲清楚。4.1 时间参数双格式与 UTC 时区偏移现象 1面板曲线整体偏移 8 小时报警事件时间和数据库记录对不上MongoDB 里的 BSON Date 是对的Oracle 里查出来的记录也是对的但图就是平移了。 原因链路里有两次时区换算。JVM 默认时区是东八区时Instant.parse解析出的 epoch 毫秒本身正确但 JDBCsetTimestamp和 MongoDBnew Date()都会受本地时区影响如果 JDBC 连接串没指定serverTimezoneOracle 的 SESSIONTIMEZONE 会跟着服务器走查询边界就平移了。 解决全链路统一 UTC。MongoDB 连接串不转时区BSON Date 本身就是 UTCOracle JDBC URL 加?serverTimezoneUTCJVM 启动参数加-Duser.timezoneUTC。显示时区的换算交给 Grafana 面板代理不要做第二次时区修正。现象 2面板历史数据大面积空白只有最近几分钟有值打开浏览器 Network 看/query请求range.from是 epoch 毫秒数字。 原因代码里对range.from直接调Instant.parse()遇到数字抛DateTimeParseException异常被上层吞掉后走了兜底值查询窗口坍缩成一个小范围。 解决用第 2 章的TimeRange.parseEpoch双格式解析。我后来把这个分支写成了单测数字和字符串各跑一条防止 Grafana 或插件升级后又翻一次车。4.2 maxDataPoints 导致的 JSON 爆炸与面板卡死现象有人把面板时间范围拖到 15 天浏览器直接卡死/query响应 200但 body 有几十 MB。 原因代理忽略了maxDataPoints把 15 天的原始记录全量返回。Grafana 渲染端一下子拿到几万个点性能直接崩掉。 解决两层保护。第一聚合分桶必须吃intervalMs它在面板宽度变窄或时间范围拉大时会自动放大第二兜底限制返回点数private static final int MAX_POINTS 5000; long bucketMs Math.max(intervalMs, (toMs - fromMs) / MAX_POINTS);逻辑说明(toMs - fromMs) / MAX_POINTS算出的就是不超 5000 点的最小桶宽和intervalMs取较大值保证任何拖拽都不会爆点数。这条是离线压测时发现的——某次有人把大盘拖到 30 天代理直接把内存打满从那以后这行兜底就一直在代码里待着。4.3 Oracle 侧的两个隐蔽问题注入与连接池耗尽现象 1面板某变量填了11;--/query直接报 ORA-00933日志里能拼出完整 SQL 文本。 原因早期版本把 target 直接字符串拼接进 SQL变量变成了注入点。 解决全链路 PreparedStatement 绑定变量同时在 target 入口加白名单校验if (!t.target.matches(^[a-zA-Z0-9_\\-]{1,64}$)) { throw new IllegalArgumentException(非法的指标名: t.target); }逻辑说明白名单只允许字母、数字、下划线和连字符最长 64 位。不合法直接抛message而不是带着特殊字符进 JDBC。现象 2仪表盘整体刷新时随机几个 Panel 报Connection is not available手动再刷新又恢复。 原因Grafana 一次刷新会对同一数据源并发打十几个/querySpring Boot 默认 HikariCP 的maximum-pool-size是 10连接被占满拿不到连接就抛异常。 解决按并发规模调池参数spring: datasource: hikari: minimum-idle: 5 maximum-pool-size: 30 connection-timeout: 3000参数说明maximum-pool-size设 30 对常见 20 面板并发够用connection-timeout设 3 秒让失败快速暴露而不是把面板拖住等连接。MongoDB 侧也记得在连接串里给maxPoolSize100低版本 driver 的默认值偏保守。这个坑的隐蔽之处在于它只在并发刷新时出现单条 curl 永远测不出来必须模拟批量请求。5. 验证与调试用 curl 把代理服务从黑匣子变成白盒代理最讨厌的问题是“面板说查询失败但不知道失败在哪”。不要一上来就对着 Grafana UI 排查——直接拿 curl 打代理把黑匣子变成白盒。5.1 先造一个最小 /query 请求手动确认 datapoints 结构绕开 Grafana 和插件直接验证代理逻辑用下面这个最小请求curl -s -XPOST http://localhost:8080/query -H Content-Type: application/json -d { panelId: 1, range: {from: 1736755200000, to: 1736762400000}, interval: 1m, intervalMs: 60000, maxDataPoints: 1920, targets: [{target: mongo.avg_latency, type: timeserie, refId: A}] } | jq .参数说明range直接传 epoch 毫秒省掉字符串解析的干扰项intervalMs用 60000 对应 1 分钟桶targets[].target要和/search里返回的 value 对得上。拿到响应后用 jq 检查前几个点curl -s -XPOST http://localhost:8080/query -H Content-Type: application/json -d {...} | jq .[0].datapoints[:3]期望输出[[12.3,1736755200000],...]这种结构。如果时间戳出现在 1970 年附近基本是单位问题——fromMs被当成了秒而不是毫秒需要回头核对parseEpoch的返回值。接着做探活curl -s -XPOST http://localhost:8080/search -H Content-Type: application/json -d {} curl -s http://localhost:8080//search返回 JSON 数组、/返回任意 JSON 才能通过 Save Test。注意接口响应如果不是 JSON插件一律按失败处理。5.2 结合日志和返回码定位三类高频异常跑完上面三连大多数问题已经能定位。我把高频场景整理成一张表现象可能原因定位方法500 DateTimeParseExceptionrange.from格式未兼容日志打印 from 原文对照双格式解析200 但 datapoints 为空match/WHERE 条件范围错误打印 fromMs/toMs 和库里第一条记录的 ts 对比Grafana 报 query data error 但 curl 正常返回结构不符合插件校验用 jq 检查 target 和 datapoints 的类型最后一种最常见也最坑。插件对返回结构校验非常严格value必须是数字时间戳必须是毫秒 Long传成字符串在 curl 里看着没问题面板上就是报错。用 jq 显式验类型curl -s -XPOST http://localhost:8080/query -H Content-Type: application/json -d {...} | jq type, .[0].datapoints[0][0] | type期望输出是array和number任一不是这个结果就是结构问题。同时建议在代理里给每次 query 打一行关键参数日志log.info(query target{} window{}~{} bucketMs{} maxPoints{}, t.target, fromMs, toMs, bucketMs, maxDataPoints);配合 curl 复现日志和响应一对照十分钟内基本能定位。我自己的习惯是本地起一个 8080 实例Grafana 直接连它面板操作加抓包两边同步看比对着线上日志猜快得多。6. 进阶技巧动态字段映射与按标签路由多套数据源最后这套资源里最有价值的部分是工程骨架里带的一套按前缀路由的查询分发机制。target 命名约定为数据源前缀.指标名.标签值例如mongo.avg_latency.hostweb-01、oracle.alarm_cnt.typeORA-00600。控制器里按前缀分发PostMapping(/query) public ListTimeSeries query(RequestBody QueryRequest req) { ListTimeSeries out new ArrayList(); for (QueryRequest.Target t : req.targets) { String[] parts t.target.split(\\., 2); switch (parts[0]) { case mongo: out.add(mongoService.query(parts[1], req)); break; case oracle: out.add(oracleService.query(parts[1], req)); break; default: throw new IllegalArgumentException(未知数据源前缀: parts[0]); } } return out; }逻辑说明split(\\., 2)只拆第一段前缀表放配置文件里新增数据源不用改控制器。模板变量把选中的 host 拼进 target 值代理解析host之后的字段经白名单校验后转成Filters.eq(host, value)——这样图表多一个下拉框就能切主机切地区不用每台机器单独建图。多数据源混排时同一个面板的曲线可能分别来自 MongoDB 和 Oracle时间口径统一为 epoch 毫秒就完全没问题。验证方法上我建议每个新数据源上线前都跑一遍 curl 三连外加一次 30 天范围、maxDataPoints1920的压测确认返回点数不超过 5000、响应时间在 3 秒以内。第一次上线时因为没做点数兜底被同事一个 30 天拖拽把代理内存打满从那以后每次加数据源我都强制走一遍这个流程。希望帮到你。本文还有配套的精品资源点击获取