帆软报表(FineReport)是企业级报表开发中最常用的工具之一,但随着数据量增长和并发上升,看板加载慢、内存溢出、查询超时等问题频发。本文从模板设计、数据准备、服务器配置、缓存策略到集群部署,全方位拆解 FineReport 性能优化方案。


目录

  1. FineReport 性能瓶颈全景
  2. 数据准备层优化
  3. 模板设计层优化
  4. 图表与可视化优化
  5. SQL 查询优化专题
  6. 数据集与参数优化
  7. 缓存策略深度解析
  8. 服务器与 JVM 调优
  9. 引擎配置优化
  10. 大并发场景优化
  11. 大数据量处理方案
  12. 集群部署与负载均衡
  13. 监控与诊断体系
  14. 常见反模式与避坑
  15. 实战案例集锦

一、FineReport 性能瓶颈全景

1.1 请求全链路分解

1
2
3
用户请求 → Web服务器(Tomcat) → 设计器引擎 → 数据集执行 → 数据库查询

模板渲染 → 图表计算 → 分页处理 → HTML输出 → 浏览器渲染

每一层都可能成为瓶颈,优化需要从端到端全局分析

1.2 性能瓶颈分布(按影响程度排序)

优先级 瓶颈来源 占比 典型现象
P0 SQL 查询慢、数据集设计不当 45% 报表打开 10s+,数据加载中菊花转不停
P0 模板设计过于复杂 25% 大量单元格公式、行式引擎未开启
P1 JVM / 服务器配置不合理 12% 频繁 Full GC、内存溢出
P1 图表渲染数据量过大 8% 图表区域白屏/卡死
P2 未启用缓存 5% 相同查询反复访问数据库
P2 并发控制不足 3% 高峰时段全站崩溃
P3 网络与浏览器端 2% 页面加载慢但服务器负载低

1.3 快速诊断命令

1
2
3
4
5
-- 1. 查看当前正在执行的报表数据集查询
SELECT * FROM information_schema.processlist WHERE info LIKE '%SELECT%';

-- 2. 查看慢查询(在 FineReport 日志中搜索)
grep "数据集执行" /opt/tomcat/logs/catalina.out | grep -E "[0-9]{4,} ms"
1
2
3
4
5
6
7
8
# 3. 查看 JVM 内存使用
jstat -gcutil $(pgrep -f tomcat) 1000 10

# 4. 查看 Tomcat 线程池
curl -s http://localhost:8080/manager/status?XML=true | grep currentThreadCount

# 5. 查看报表模板耗时(开启 FineReport 性能插件)
grep "Cost:" /opt/tomcat/webapps/webroot/WEB-INF/log/finework.log

二、数据准备层优化

2.1 数据集设计原则

原则一:数据集只取需要的列

1
2
3
4
5
6
7
8
9
-- ❌ 坏习惯:SELECT *
SELECT * FROM order_detail WHERE create_time >= '${start_time}'

-- ✅ 好习惯:精确指定列
SELECT
order_id, customer_name, product_name,
quantity, unit_price, total_amount, create_time
FROM order_detail
WHERE create_time >= '${start_time}'

危害:FineReport 会将全部列读入内存,无用的 TEXT/BLOB 字段会严重拖垮性能。

原则二:数据集数量最小化

1
单个模板的数据集建议 ≤ 8 个
1
2
3
4
5
6
7
8
9
10
11
12
// ❌ 每个单元格一个数据集(10 列 = 10 次 DB 查询)
// A1: ds1, B1: ds2, C1: ds3 ...

// ✅ 合并为一次查询,用 JOIN 或一次查出
SELECT
a.field1, a.field2,
b.field3, b.field4,
c.field5, c.field6
FROM table_a a
LEFT JOIN table_b b ON a.id = b.a_id
LEFT JOIN table_c c ON a.id = c.a_id
WHERE a.create_date = '${date}'

原则三:大数据量用存储过程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
-- ✅ 复杂业务逻辑放在存储过程中预计算
DELIMITER $$
CREATE PROCEDURE sp_dashboard_data(
IN p_start_date DATE,
IN p_end_date DATE
)
BEGIN
-- 1. 创建临时表存放中间结果
DROP TEMPORARY TABLE IF EXISTS tmp_summary;
CREATE TEMPORARY TABLE tmp_summary AS
SELECT
category_id,
COUNT(*) AS order_cnt,
SUM(amount) AS total_amt
FROM orders
WHERE create_time BETWEEN p_start_date AND p_end_date
GROUP BY category_id;

-- 2. 关联维度表,一次性返回
SELECT
c.category_name,
t.order_cnt,
t.total_amt,
t.total_amt / c.target_amount * 100 AS completion_rate
FROM tmp_summary t
JOIN category_dim c ON t.category_id = c.id
ORDER BY t.total_amt DESC;

DROP TEMPORARY TABLE IF EXISTS tmp_summary;
END$$
DELIMITER ;

-- FineReport 中直接调用:
-- {CALL sp_dashboard_data('${start_date}', '${end_date}')}

2.2 数据源连接池配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<!-- webroot/WEB-INF/resources/datasource.xml -->

<!-- 核心参数配置 -->
<JNDIName>java:comp/env/jdbc/reportDB</JNDIName>

<!-- 初始连接数:系统启动时创建的连接 -->
<initialSize>10</initialSize>

<!-- 最小空闲连接(保证随时可用) -->
<minIdle>10</minIdle>

<!-- 最大活跃连接数(根据并发量计算) -->
<!-- 公式:最大并发用户数 × 每报表平均数据集数 ÷ 2 -->
<maxActive>100</maxActive>

<!-- 获取连接超时时间(ms),超时抛异常 -->
<maxWait>10000</maxWait>

<!-- 检查空闲连接的间隔时间(ms) -->
<timeBetweenEvictionRunsMillis>60000</timeBetweenEvictionRunsMillis>

<!-- 验证连接有效的查询 -->
<validationQuery>SELECT 1</validationQuery>

<!-- 空闲时检测连接 -->
<testWhileIdle>true</testWhileIdle>

<!-- 借出时检测(性能开销,高并发建议 false) -->
<testOnBorrow>false</testOnBorrow>

<!-- 归还时检测 -->
<testOnReturn>false</testOnReturn>

2.3 数据字典与码表缓存化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
-- ❌ 每次查询都 JOIN 字典表
SELECT
o.*,
d1.name AS status_name, -- 订单状态码表
d2.name AS source_name -- 来源渠道码表
FROM orders o
JOIN dict_status d1 ON o.status = d1.code
JOIN dict_source d2 ON o.source = d2.code

-- ✅ 方案 A:在 FineReport 中使用「数据字典」功能
-- 步骤:模板 → 模板数据集 → 数据字典 → 选择字典数据集
-- 字典数据集单独加载,应用层做映射

-- ✅ 方案 B:使用 FineReport 内置的「枚举字典」
-- 在单元格属性 → 形态 → 数据字典中设置,前端映射

-- ✅ 方案 C:字典表数据导入 Redis/内存缓存
-- 在 Java 中预加载字典到 static Map

2.4 数据预聚合与宽表设计

1
2
3
4
5
6
7
8
9
10
11
12
13
OLTP 模型(规范化)                  OLAP 宽表(反规范化)
┌──────────┐ ┌─────────────────────────┐
│ orders │ │ dw_order_fact │
├──────────┤ ├─────────────────────────┤
│ id │ │ order_id │
│ user_id │──→ users │ order_no │
│ status │ │ user_name (冗余) │
│ amount │ │ city (冗余) │
│ time │ │ amount │
└──────────┘ │ status_name (冗余) │
│ category_name (冗余) │
│ year/month/day (派生列) │
└─────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
-- ❌ 报表中多次 JOIN 多个维度表:每次执行 4-5 次 JOIN
-- ✅ 定时 ETL 生成宽表,报表直接单表查询

-- 示例:每日凌晨生成当日汇总宽表
CREATE TABLE dw_dashboard_daily AS
SELECT
DATE(o.create_time) AS stat_date,
u.city,
u.user_level,
c.category_name,
COUNT(DISTINCT o.id) AS order_cnt,
COUNT(DISTINCT o.user_id) AS user_cnt,
SUM(o.amount) AS total_amount,
AVG(o.amount) AS avg_amount,
SUM(o.amount) / COUNT(DISTINCT o.user_id) AS arpu
FROM orders o
JOIN users u ON o.user_id = u.id
JOIN product_sku s ON o.sku_id = s.id
JOIN product_category c ON s.category_id = c.id
WHERE o.create_time >= CURDATE() - INTERVAL 1 DAY
GROUP BY DATE(o.create_time), u.city, u.user_level, c.category_name;

三、模板设计层优化

3.1 行式引擎 —— 大数据量的救星

这是 FineReport 最重要的性能开关之一,必须了解!

行式引擎 vs 普通引擎对比

特性 普通引擎 行式引擎
加载方式 全部数据加载到内存 逐行流式读取
内存占用 高(随数据量线性增长) 极低(近乎恒定)
适合场景 数据量 < 5000 行 数据量 ≥ 5000 行
分页支持 不支持 支持大分页
聚合函数 支持 不支持 SUM/AVG 等
单元格扩展 支持 有限支持
图表联动 支持 不支持
导出

开启方式:模板 → 模板属性 → 报表引擎 → 勾选「启用行式引擎」

1
2
3
4
5
数据量阈值建议:
< 2000 行 → 普通引擎即可
2000-5000 → 根据复杂度决定
5000-10W → 强烈推荐行式引擎
> 10W → 必须行式引擎 + 数据库端分页

行式引擎下的注意事项

1
2
3
4
5
6
7
8
9
10
11
12
// ❌ 以下功能在行式引擎下不可用:
// - SUM()、AVG() 等单元格聚合公式
// - 图表(chart)
// - 条件属性中的「新值」
// - 折叠展开树

// ✅ 解决方案:
// 1. 聚合计算在 SQL 中完成:
SELECT category, SUM(amount) AS total FROM orders GROUP BY category

// 2. 图表单独一个数据集(数据量通常较小,可不用行式引擎)
// 3. 复杂报表拆分为:列表部分(行式引擎)+ 汇总图表(普通引擎)

3.2 单元格公式优化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// ❌ 大量单元格公式(每个单元格独立计算)
// A1: =SUM(B1{B1 > 0})
// B1: =IF(A1 > 100, A1 * 0.9, A1)
// C1: =VLOOKUP(B1, ds2.select(), 2, false)
// —— 10 列 × 5000 行 = 50000 次计算!

// ✅ 优化策略 1:SQL 端计算,减少报表公式
SELECT
order_id,
amount,
CASE WHEN amount > 100 THEN amount * 0.9 ELSE amount END AS discount_amount,
(SELECT name FROM dict WHERE code = status) AS status_name
FROM orders

// ✅ 优化策略 2:使用「公式条件属性」而非单元格公式
// 在单元格属性 → 条件属性中设置,仅在满足条件时计算

// ✅ 优化策略 3:减少跨单元格引用
// ❌ =SUM({C2}) 引用整个 C2 列 —— 大数据量极慢
// ✅ 在 SQL 中用 GROUP BY + SUM 替代

公式性能排名(从快到慢)

1
2
3
4
5
6
1. 简单运算符 (+, -, *, /)       ← 最快
2. IF / SWITCH ← 快
3. INARRAY / GREPARRAY ← 中等
4. MAP / JOINARRAY ← 较慢
5. VLOOKUP / HIERARCHY ← 慢
6. SELECT() 数据集函数 ← 最慢(会重新查 DB!)
1
2
3
4
5
6
7
8
9
// ⚠️ SELECT() 函数的致命性能问题
// ❌ 每个单元格执行一次数据库查询 —— 回表地狱!
=sql("datasource", "SELECT name FROM users WHERE id = " + A1, 1, 1)

// ✅ 替代方案 1:先把数据一次性加载到内存
// 新建数据集 ds_users = SELECT id, name FROM users
// 然后单元格用:=VALUE("ds_users." + COLINDEX("name", "id", A1))

// ✅ 替代方案 2:数据关联放在 SQL 中完成

3.3 分页策略

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!-- 方案 1:数据库端分页(推荐,适用任何数据库) -->
<!-- 在数据集中使用分页参数 -->

<!-- MySQL 分页 -->
SELECT * FROM orders
WHERE create_time >= '${start_time}'
ORDER BY id
LIMIT ${pageSize} OFFSET (${pageNumber} - 1) * ${pageSize}

<!-- 方案 2:FineReport 行式引擎分页 -->
<!-- 模板属性 → 分页 → 每页显示行数 → 50 -->
<!-- 配合行式引擎,自动分层加载 -->

<!-- 方案 3:客户端无限滚动(配合 JS) -->
<!-- 首次加载第 1 页,滚动到底部 AJAX 加载下一页 -->

3.4 模板拆分策略

1
2
3
4
5
6
7
8
9
10
11
12
13
单一巨型模板(❌)          拆分后的模板组(✅)
┌──────────────────┐ ┌──────────────┐ ← 主看板(关键指标)
│ 主 KPI 指标 │ │ KPI 卡片区 │
│ 趋势图表 ×4 │ │ 核心指标趋势 │
│ 明细列表 5000行 │ └──────────────┘
│ 排行 TOP 10 │ ┌──────────────┐ ← 明细页(行式引擎)
│ 地图分布 │ │ 明细列表 │
│ 交叉透视表 │ │ 条件筛选 │
│ 钻取子报表 │ └──────────────┘
└──────────────────┘ ┌──────────────┐ ← 图表分析页
│ 图表 ×4 │
│ 排行 ×3 │
└──────────────┘

拆分原则

  • 每种查询类型独立一个模板(列表用行式引擎,图表用普通引擎)
  • KPI 卡片单独加载(数据量小、查询快,首屏体验好)
  • 通过超链接或 Tab 切换关联子模板
  • 子模板可共用数据集(通过参数传递)

3.5 懒加载与异步加载

1
2
3
4
5
6
7
8
9
10
11
12
13
// 方案 1:在模板中设置「延迟加载」
// 模板属性 → 页面设置 → 加载方式 → 异步加载

// 方案 2:使用 JS 动态创建 iframe 加载子报表
// 主模板的「加载后」事件
setTimeout(function() {
var detailFrame = document.createElement('iframe');
detailFrame.src = '/webroot/decision/view/report?viewlet=detail.cpt&orderId=' + orderId;
document.getElementById('detail-container').appendChild(detailFrame);
}, 500); // 500ms 后异步加载明细

// 方案 3:首屏只加载可见区域,滚动加载更多
// 配合「报表分页加载」+ 滚动事件监听

四、图表与可视化优化

4.1 图表数据量控制

图表的性能瓶颈主要在前端渲染数据点数量和数据集返回大小。

1
2
3
4
5
6
7
// 图表数据量经验上限
折线图/柱状图 → 数据点 ≤ 2000(超出建议采样)
饼图 → 分类 ≤ 50(超出合并为"其他"
散点图 → 数据点 ≤ 10000
地图 → 区域 ≤ 500
漏斗图 → 数据点 ≤ 20
仪表盘 → 无限制(数据量固定)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
-- ✅ 数据采样或 TOP N 来减少点数
-- 方案 A:等距采样
SELECT * FROM (
SELECT *, ROW_NUMBER() OVER (ORDER BY time) AS rn
FROM metrics WHERE time >= '${start}'
) t WHERE rn % 10 = 0 -- 每 10 条取 1 条

-- 方案 B:取 TOP 10 + 其他
SELECT category, total FROM (
SELECT category, total, 1 AS sort_order
FROM category_stats ORDER BY total DESC LIMIT 10
UNION ALL
SELECT '其他' AS category, SUM(total) AS total, 2 AS sort_order
FROM category_stats
WHERE category NOT IN (SELECT category FROM category_stats ORDER BY total DESC LIMIT 10)
) t ORDER BY sort_order, total DESC

4.2 图表数据源复用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// ❌ 每个图表一个独立数据集 → N 个图表 = N 次查询
// 图表 1 数据集: SELECT category, SUM(amount) FROM orders WHERE time >= '${start}' GROUP BY category
// 图表 2 数据集: SELECT category, COUNT(*) FROM orders WHERE time >= '${start}' GROUP BY category
// 图表 3 数据集: SELECT category, AVG(amount) FROM orders WHERE time >= '${start}' GROUP BY category

// ✅ 合并为一个数据集,图表按需取列
// 统合数据集:
SELECT
category,
SUM(amount) AS total_amount,
COUNT(*) AS order_count,
AVG(amount) AS avg_amount
FROM orders
WHERE create_time >= '${start}'
GROUP BY category

// 图表 1 分类:=ds_main.category,系列:=ds_main.total_amount
// 图表 2 分类:=ds_main.category,系列:=ds_main.order_count
// 图表 3 分类:=ds_main.category,系列:=ds_main.avg_amount

4.3 ECharts / 第三方图表集成优化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// FineReport 支持嵌入 ECharts 图表,性能比原生图表更好

// 1. 在模板中插入「网页框」
// 2. 网页框指向一个静态 HTML 或动态生成的 ECharts 页面

// ✅ ECharts 大数据量优化配置
option = {
// 开启采样(折线图)
series: [{
type: 'line',
sampling: 'lttb', // 使用 LTTB 采样算法
large: true, // 大数据量模式
largeThreshold: 500, // 超过 500 点启用
progressive: 400, // 渐进式渲染
progressiveThreshold: 3000
}],
// 使用 dataset 模式(比 series.data 节省内存)
dataset: {
source: dataArray // 二维数组
}
};

// ✅ 使用 Web Worker 处理数据
var worker = new Worker('chart-data-worker.js');
worker.postMessage({ rawData: rawData });
worker.onmessage = function(e) {
chart.setOption({
dataset: { source: e.data }
});
};

4.4 原生图表 vs 第三方图表

场景 推荐方案 理由
简单柱状/折线/饼图 FineReport 原生图表 配置快,无需额外开发
数据点 > 2000 ECharts + LTTB采样 原生图表大数据量卡顿
高度自定义样式 ECharts / Highcharts 原生图表样式自由度低
实时刷新(秒级) ECharts + WebSocket 原生刷新慢,重渲染开销大
地图可视化 ECharts + 自定义 GeoJSON 原生地图功能有限
3D 图表 ECharts GL / Three.js 原生不支持

五、SQL 查询优化专题

详见上一篇 SQL优化完整指南,此处聚焦 FineReport 特有的 SQL 优化场景。

5.1 参数化查询防 SQL 注入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-- ❌ 拼接参数:SQL注入风险 + 无法缓存执行计划
SELECT * FROM orders WHERE user_id = '${user_id}'
-- 如果 user_id = "1' OR '1'='1" → 全表泄漏!

-- ✅ 使用 FineReport 参数占位符
-- 方式 1:默认参数绑定(推荐)
SELECT * FROM orders WHERE user_id = '${user_id}'
-- FineReport 默认会对字符串参数加引号转义

-- 方式 2:使用预处理参数(推荐,需要 FineReport 10.0+)
SELECT * FROM orders WHERE user_id = ?
-- 参数设置 → 勾选「使用预处理」

-- 方式 3:模板参数 + 存储过程(最安全)
{CALL sp_get_orders(?)}

5.2 避免在 WHERE 中使用 FineReport 函数

1
2
3
4
5
6
7
-- ❌ FineReport 公式包裹导致索引失效
SELECT * FROM orders WHERE YEAR(create_time) = ${year}

-- ✅ 计算在 FineReport 参数中完成
-- FineReport 参数:start_date = DATE($year + "-01-01")
-- end_date = DATE($year + "-12-31")
SELECT * FROM orders WHERE create_time >= '${start_date}' AND create_time <= '${end_date}'

5.3 IN 参数处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
-- 场景:FineReport 多选下拉框传参 → 多个值用逗号分隔

-- ❌ 直接拼接(有注入风险且性能差)
SELECT * FROM orders WHERE city IN ('${cities}')

-- ✅ 方案 A:使用 FIND_IN_SET(值少时可用)
SELECT * FROM orders WHERE FIND_IN_SET(city, '${cities}')

-- ✅ 方案 B:动态构建 IN 子句(FineReport 10.0+)
-- 数据集参数 → cities → 勾选「允许多值」
-- SQL:
SELECT * FROM orders WHERE city IN (${cities})
-- FineReport 自动将 "北京,上海,深圳" 转为 "'北京','上海','深圳'"

-- ✅ 方案 C:临时表(值很多时)
-- FineReport 端拼接参数插入临时表,再 JOIN
CREATE TEMPORARY TABLE tmp_cities (city VARCHAR(50));
-- 循环插入 ${cities} 的每个值
SELECT o.* FROM orders o JOIN tmp_cities t ON o.city = t.city;

5.4 分页优化(配合行式引擎)

1
2
3
4
5
6
7
8
-- ❌ 大 OFFSET 深分页
SELECT * FROM orders ORDER BY id LIMIT 10000, 50
-- 扫描 10050 行,丢弃 10000 行

-- ✅ 游标分页(配合排序字段)
SELECT * FROM orders WHERE id > ${last_id} ORDER BY id LIMIT 50
-- 始终只扫描 50 行
-- FineReport 中设置:上一页最后 id → 传入参数 ${last_id}

六、数据集与参数优化

6.1 参数联动优化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 场景:省 → 市 → 区 三级联动下拉框
// ❌ 级联查询每次触发子数据集刷新,N 次请求

// ✅ 优化方案:
// 方案 A:全部数据一次加载,前端过滤(数据量 < 5000 时)
// 数据集:SELECT province, city, district FROM area_dict
// 各下拉框用「数据字典 → 公式」过滤:
// 省下拉:=UNIQUEARRAY(ds_area.province)
// 市下拉:=UNIQUEARRAY(VALUE("ds_area.city", ds_area.select(province, province = $province)))
// 区下拉:=UNIQUEARRAY(VALUE("ds_area.district", ds_area.select(district, province = $province && city = $city)))

// 方案 B:懒加载 + 缓存(推荐)
// 省 → 首次加载查 DB
// 市 → 选择省后异步加载,结果缓存到 session
// 区 → 选择市后异步加载,结果缓存到 session

6.2 控件默认值不触发查询

1
2
3
4
5
6
7
8
9
// ❌ 页面加载时所有控件默认值触发数据集查询
// → 6 个下拉框 = 6 次 DB 查询

// ✅ 设置「不自动查询」
// 数据集属性 → 查询模式 → 选择「手动查询」或「首次不查询」
// 只有点击「查询」按钮才执行

// 或者:控件属性 → 事件 → 添加「初始化后」
// 仅在用户主动变更时触发

6.3 自定义参数面板

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ❌ FineReport 默认参数面板:每个参数一次查询,不可控

// ✅ 自定义查询面板
// 1. 使用 HTML 组件自定义界面
// 2. 所有参数收集完毕后一次性查询
// 3. 添加防抖(输入框)

// 加载后事件
var timer = null;
document.getElementById('search-input').addEventListener('input', function() {
clearTimeout(timer);
timer = setTimeout(function() {
_FR.doClick('查询'); // 300ms 防抖
}, 300);
});

七、缓存策略深度解析

7.1 FineReport 缓存层级

1
2
3
4
5
6
7
8
9
10
11
┌─────────────────────────┐  1. 浏览器缓存
│ HTTP 304 / localStorage │ 静态资源、模板文件
├─────────────────────────┤
│ 2. 模板结果缓存 │ 整个模板 HTML 缓存,相同参数直接返回
├─────────────────────────┤
│ 3. 数据集缓存 │ 数据集查询结果缓存(最常用)
├─────────────────────────┤
│ 4. Redis / 外部缓存 │ 跨节点共享(集群必用)
├─────────────────────────┤
│ 5. 数据库缓存 │ MySQL Buffer Pool / 查询缓存
└─────────────────────────┘

7.2 模板结果缓存配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!-- webroot/WEB-INF/resources/cache.xml -->

<!-- 模板结果缓存 -->
<cacheConfig>
<!-- 缓存类型:memory / redis / ehcache -->
<cacheType>memory</cacheType>

<!-- 最大缓存条目数 -->
<maxEntries>1000</maxEntries>

<!-- 缓存过期时间(秒) -->
<timeToLive>600</timeToLive>

<!-- 空闲过期时间(秒) -->
<timeToIdle>300</timeToIdle>
</cacheConfig>
1
2
3
4
5
6
7
8
9
10
11
12
13
// 在模板中指定此模板的缓存时间
// 模板 → 模板属性 → 其他 → 缓存配置
// 缓存时间:600 秒(10 分钟)
// 缓存范围:按参数缓存(不同参数各自缓存一份)

// ✅ 适合缓存的场景:
// - 日报/周报(数据非实时更新)
// - 字典/码表数据集
// - 不频繁变化的 KPI 看板

// ❌ 不适合缓存的场景:
// - 需要实时数据的大屏
// - 用户个性化数据(不同用户看到不同数据)

7.3 数据集缓存

1
2
3
4
5
6
7
8
9
10
11
12
// 方式 1:在设计器中配置
// 数据集 → 缓存设置 → 勾选「开启缓存」
// 缓存时间:3600 秒(1 小时)

// 方式 2:使用「服务器数据集」
// 服务器 → 服务器数据集 → 新建
// 服务器数据集全局共享,所有模板复用
// 适合:公司组织架构、产品分类等全局维表

// 方式 3:定时任务预热缓存
// 定时调度 → 新建任务 → 选择模板 → 设置执行频率
// 凌晨 6:00 执行一次,缓存预热

7.4 Redis 缓存集成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!-- 集群环境下必须使用 Redis 缓存 -->
<!-- webroot/WEB-INF/resources/redis.properties -->

<!-- Redis 配置 -->
redis.host=192.168.1.100
redis.port=6379
redis.password=your_password
redis.database=0
redis.maxTotal=200
redis.maxIdle=50
redis.minIdle=10
redis.maxWaitMillis=5000
redis.testOnBorrow=true

<!-- 缓存时间配置 -->
redis.expire.seconds=1800
1
2
3
4
5
<!-- cache.xml 中启用 Redis -->
<cacheConfig>
<cacheType>redis</cacheType>
<timeToLive>1800</timeToLive>
</cacheConfig>

7.5 缓存更新策略

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 方案 A:定时失效 + 被动更新(Cache-Aside)
// FineReport 定时调度 → 执行更新程序 → 更新 DB → 删除 Redis 缓存 key
// 用户下次访问 → 缓存未命中 → 查 DB → 写缓存

// 方案 B:主动更新(Write-Through)
// 业务系统更新数据库后 → 发送 MQ 消息 → FineReport 监听并更新缓存

// 方案 C:兜底策略
// 缓存失效时,使用「互斥锁」防缓存击穿
public ReportData getCachedReport(String templateId, Map<String, String> params) {
String cacheKey = "report:" + templateId + ":" + params.hashCode();
ReportData data = redis.get(cacheKey);
if (data != null) return data;

// 加锁防止击穿
String lockKey = "lock:" + cacheKey;
if (redis.setnx(lockKey, 1, 30)) {
try {
data = generateReport(templateId, params); // 查 DB 生成报表
redis.setex(cacheKey, 600, data);
} finally {
redis.del(lockKey);
}
} else {
Thread.sleep(100);
data = redis.get(cacheKey); // 其他线程生成完,直接读
}
return data;
}

八、服务器与 JVM 调优

8.1 JVM 参数优化(最关键的服务器层配置)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
# FineReport 默认使用 Tomcat,JVM 配置在 catalina.sh / catalina.bat

# ==================== 堆内存配置 ====================
# 最小堆 = 最大堆(避免动态扩缩容的 GC 开销)
-Xms8g -Xmx8g

# ==================== 新生代配置 ====================
# 新生代大小(一般设为堆的 3/8 ~ 1/2)
-Xmn3g
# 或使用比率:-XX:NewRatio=2(老年代:新生代 = 2:1)

# ==================== GC 算法 ====================
# 优先使用 G1GC(Java 8u40+, FineReport 推荐)
-XX:+UseG1GC
# G1 期望最大暂停时间(ms)
-XX:MaxGCPauseMillis=200
# G1 堆区域大小(2 的幂次,如 4M, 8M, 16M)
-XX:G1HeapRegionSize=8m
# 并行 GC 线程数
-XX:ParallelGCThreads=8
-XX:ConcGCThreads=2

# ==================== 元空间(Metaspace) ====================
# FineReport 加载大量模板类,需要充足的元空间
-XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize=512m

# ==================== GC 日志(排查内存问题必备) ====================
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:/opt/tomcat/logs/gc.log
-XX:+UseGCLogFileRotation
-XX:NumberOfGCLogFiles=10
-XX:GCLogFileSize=50M

# ==================== 其他优化 ====================
# 禁用显式 GC(防止 System.gc() 触发 Full GC)
-XX:+DisableExplicitGC
# 优先使用堆内存(减少直接内存分配)
-XX:+AlwaysPreTouch
# 字符串去重(G1GC 支持,减小元空间)
-XX:+UseStringDeduplication
# 线程栈大小
-Xss512k

JVM 内存分配建议

服务器总内存 JVM 堆内存 说明
8G 4G - 5G 小型部署,< 50 并发
16G 8G - 10G 中型部署,50-200 并发
32G 16G - 20G 大型部署,200-500 并发
64G+ 24G - 32G 注意:堆 > 32G 时指针压缩失效,需权衡

8.2 Tomcat 线程池配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<!-- conf/server.xml -->

<Connector port="8080"
protocol="org.apache.coyote.http11.Http11Nio2Protocol"

<!-- ====== 核心配置 ====== -->
<!-- 最大线程数 -->
maxThreads="500"
<!-- 最小空闲线程 -->
minSpareThreads="50"
<!-- 连接超时(ms) -->
connectionTimeout="20000"
<!-- 最大连接数(等待+处理中的总连接上限) -->
maxConnections="10000"

<!-- ====== 性能优化 ====== -->
<!-- 启用压缩(减少网络传输) -->
compression="on"
compressionMinSize="2048"
compressableMimeType="text/html,text/xml,text/plain,text/css,text/javascript,application/json,application/javascript"

<!-- 禁用 DNS 查询(提升速度) -->
enableLookups="false"

<!-- 接受队列长度 -->
acceptCount="200"

<!-- 异步超时 -->
asyncTimeout="30000"
/>

<!-- AJP 连接器(如果使用 Apache/Nginx + AJP) -->
<Connector port="8009"
protocol="AJP/1.3"
maxThreads="500"
connectionTimeout="20000"
packetSize="65536"
/>

8.3 Tomcat Session 优化

1
2
3
4
5
6
7
8
9
10
<!-- conf/context.xml -->

<Context>
<!-- Session 超时时间(分钟) -->
<SessionTimeout>30</SessionTimeout>

<!-- Cookie 配置 -->
<CookieProcessor className="org.apache.tomcat.util.http.Rfc6265CookieProcessor"
sameSiteCookies="lax" />
</Context>
1
2
3
4
5
<!-- 集群环境使用 Redis 共享 Session -->
<!-- webroot/WEB-INF/web.xml -->
<session-config>
<session-timeout>30</session-timeout>
</session-config>

8.4 Nginx 反向代理层优化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
# /etc/nginx/conf.d/finereport.conf

upstream finereport_backend {
# 负载均衡策略:ip_hash(保持 Session 一致性)
ip_hash;

server 192.168.1.101:8080 weight=5 max_fails=3 fail_timeout=30s;
server 192.168.1.102:8080 weight=5 max_fails=3 fail_timeout=30s;
server 192.168.1.103:8080 weight=5 max_fails=3 fail_timeout=30s;

# 长连接(减少 TCP 握手)
keepalive 64;
}

server {
listen 80;
server_name report.yourcompany.com;

# 上传大小限制(报表模板导入/图片上传)
client_max_body_size 50m;

# Gzip 压缩(静态资源)
gzip on;
gzip_min_length 1k;
gzip_comp_level 6;
gzip_types text/plain text/css text/javascript
application/json application/javascript
application/xml text/xml image/svg+xml;
gzip_vary on;
gzip_proxied any;

# 缓存静态资源(CSS/JS/图片)
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf)$ {
proxy_pass http://finereport_backend;
expires 7d;
add_header Cache-Control "public, immutable";
}

# 报表访问路径
location /webroot/decision/ {
proxy_pass http://finereport_backend;

# 请求头传递
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;

# 超时配置(报表查询可能耗时较长)
proxy_connect_timeout 60s;
proxy_send_timeout 300s;
proxy_read_timeout 300s;

# 长连接
proxy_http_version 1.1;
proxy_set_header Connection "";

# 缓冲配置
proxy_buffering on;
proxy_buffer_size 16k;
proxy_buffers 32 16k;
proxy_busy_buffers_size 32k;
proxy_max_temp_file_size 256m;
}
}

九、引擎配置优化

9.1 FineReport 引擎参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<!-- webroot/WEB-INF/resources/run.xml -->

<runConfig>
<!-- 1. 打印/导出超时时间(ms) -->
<exportTimeout>300000</exportTimeout>

<!-- 2. 单元格最大计算次数(防止死循环) -->
<cellCalcMaxCount>10000000</cellCalcMaxCount>

<!-- 3. 报表计算超时时间(ms) -->
<reportTimeout>600000</reportTimeout>

<!-- 4. 最大数据行数(超出截断,防止 OOM) -->
<maxDataRow>100000</maxDataRow>

<!-- 5. 导出 Excel 每 sheet 最大行数 -->
<excelMaxRow>200000</excelMaxRow>

<!-- 6. CSV 导出编码 -->
<csvEncode>UTF-8</csvEncode>
</runConfig>

9.2 调度任务优化

1
2
3
4
5
6
7
8
9
10
11
// ❌ 所有报表在同一时间点执行
// 凌晨 0:00 所有日表同时计算 → 数据库瞬间高峰期

// ✅ 错峰调度
// 00:00 - 简单汇总表(1-2 min)
// 00:05 - 复杂关联表(3-5 min)
// 00:15 - 大型分析报表(5-10 min)
// 每批次间隔 3-5 分钟

// ✅ 依赖管理
// 设置任务依赖链,基础表先执行,结果表后执行

9.3 平台参数配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# webroot/WEB-INF/resources/config.properties

# 登录并发控制
login.concurrent.max=200

# 平台操作超时
platform.timeout=1800

# 导出队列大小
export.queue.size=100

# 在线用户限制
session.max=500

# 模板预加载(启动时提前编译)
template.preload=true
template.preload.list=/dashboard/index.cpt,/dashboard/sales.cpt

十、大并发场景优化

10.1 并发场景分类

场景 并发量 特点 策略
管理看板 5-20 高级管理者定时查看 定时预计算 + 缓存
业务报表 50-200 业务人员工作时段集中访问 限流 + 缓存 + 分组
大屏展示 1-10 长时间展示,定时刷新 缓存 + 增量更新
对外公开报表 500-5000 不可控,峰值高 CDN + 静态化 + 限流

10.2 限流策略

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 方案 A:Nginx 层限流
// nginx.conf
http {
// 限制每 IP 每秒请求数
limit_req_zone $binary_remote_addr zone=report:10m rate=10r/s;

// 限制并发连接数
limit_conn_zone $binary_remote_addr zone=addr:10m;

server {
location /webroot/decision/ {
// 每 IP 每秒最多 10 个请求,突发最多 20 个
limit_req zone=report burst=20 nodelay;
// 每 IP 最大 5 个并发连接
limit_conn addr 5;

proxy_pass http://finereport_backend;
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
// 方案 B:应用层限流(Guava RateLimiter)
// 自定义 FineReport 插件:GlobalRequestFilter
import com.google.common.util.concurrent.RateLimiter;

public class ReportRateLimiter {
// 每秒 100 个请求
private static final RateLimiter LIMITER = RateLimiter.create(100);

public static boolean tryAcquire() {
return LIMITER.tryAcquire(5, TimeUnit.SECONDS);
}
}

10.3 排队机制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 大报表异步导出排队
// 前端代码
function exportLargeReport() {
$.ajax({
url: '/webroot/decision/view/export',
data: { reportId: 'xxx', format: 'excel' },
success: function(res) {
if (res.status === 'queued') {
// 进入排队,轮询获取结果
var taskId = res.taskId;
pollExportStatus(taskId);
}
}
});
}

function pollExportStatus(taskId) {
var timer = setInterval(function() {
$.get('/webroot/decision/view/export/status?taskId=' + taskId, function(res) {
if (res.status === 'completed') {
clearInterval(timer);
window.open(res.downloadUrl);
} else if (res.status === 'failed') {
clearInterval(timer);
alert('导出失败,请重试');
}
});
}, 3000);
}

10.4 读写分离 + 只读副本

1
2
3
4
5
6
7
8
9
10
11
12
# FineReport 数据源配置:读写分离
# 使用 MySQL 主从 + 读写分离中间件(ShardingSphere/MyCat)

# 报表只读数据源 → 指向从库
readonly.datasource.url=jdbc:mysql:replication://master:3306,slave1:3306,slave2:3306/report_db
readonly.datasource.username=report_readonly
readonly.datasource.password=xxx

# 注意:
# 1. 所有报表数据集使用只读数据源
# 2. 填报/录入功能使用主库数据源
# 3. 处理好主从延迟(>>> 第十四章)

十一、大数据量处理方案

11.1 数据量分级策略

级别 数据量 策略
小数据量 < 1 万行 普通引擎,全量加载,交互丰富
中等数据量 1-10 万行 行式引擎 + 分页查询
大数据量 10-100 万行 行式引擎 + 数据库分页 + 游标
海量数据量 > 100 万行 预聚合 + ES/ClickHouse/StarRocks

11.2 预聚合方案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
-- 方案 A:MySQL 汇总表
CREATE TABLE report_daily_stats (
stat_date DATE NOT NULL,
dim1_id INT NOT NULL, -- 维度组合
dim2_id INT NOT NULL,
metric1 DECIMAL(15,2),
metric2 DECIMAL(15,2),
metric3 INT,
PRIMARY KEY (stat_date, dim1_id, dim2_id)
) ENGINE=InnoDB;

-- 每天凌晨跑 ETL 增量更新
INSERT INTO report_daily_stats
SELECT
DATE(order_time) AS stat_date,
category_id AS dim1_id,
city_id AS dim2_id,
SUM(amount) AS metric1,
COUNT(*) AS metric2,
COUNT(DISTINCT user_id) AS metric3
FROM orders
WHERE DATE(order_time) = CURDATE() - INTERVAL 1 DAY
GROUP BY stat_date, dim1_id, dim2_id;

11.3 异构数据源方案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
传统架构                              优化架构
┌─────────┐ ┌─────────────┐
│ MySQL │ ← 报表直接查询 │ MySQL │ ← 业务库、OLTP
│ (OLTP) │ 大数据量 → 超时/OOM │ (OLTP) │
└─────────┘ └──────┬──────┘
│ Binlog / ETL
┌──────▼──────┐
│ ClickHouse │ ← 报表/分析查询(OLAP)
│ / StarRocks │ 列式存储,查询 ms 级
└──────┬──────┘

┌──────▼──────┐
│ FineReport │
│ 数据源指向 │
│ ClickHouse │
└─────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
-- ClickHouse 宽表示例(列式存储,压缩比高)
CREATE TABLE report_order_wide (
stat_date Date,
order_id UInt64,
user_name String,
category_name String,
city String,
amount Decimal(12, 2),
quantity UInt32,
status UInt8
) ENGINE = MergeTree()
PARTITION BY toYYYYMM(stat_date)
ORDER BY (stat_date, category_name, city)
SETTINGS index_granularity = 8192;

-- 查询 1000W 行数据,ClickHouse 响应时间 < 100ms
SELECT
category_name,
city,
SUM(amount) AS total_amount,
COUNT(DISTINCT user_name) AS user_cnt
FROM report_order_wide
WHERE stat_date BETWEEN '2024-01-01' AND '2024-01-31'
GROUP BY category_name, city
ORDER BY total_amount DESC
LIMIT 20;

11.4 导出优化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// ❌ 10W+ 行导出:同步导出 → 内存溢出 / 超时

// ✅ 异步导出流程
// 1. 前端提交导出请求 → 返回任务 ID
// 2. 后台分页查询数据库(每页 5000 行)
// 3. 流式写入 Excel(SXSSFWorkbook / EasyExcel)
// 4. 写入完成后上传至 OSS/MinIO
// 5. 通知用户下载(站内信 / 邮件)

// Java 伪代码
public void asyncExport(String taskId, String templateId, Map<String, Object> params) {
int pageSize = 5000;
int pageNum = 1;
int totalRows = getTotalRows(params);

SXSSFWorkbook workbook = new SXSSFWorkbook(100); // 内存中只保留 100 行
Sheet sheet = workbook.createSheet();
int rowIdx = 0;

while ((pageNum - 1) * pageSize < totalRows) {
List<Map<String, Object>> batch = queryPage(pageNum, pageSize, params);
for (Map<String, Object> row : batch) {
// 写 Excel 行...
rowIdx++;
}
pageNum++;
workbook.setCompressTempFiles(true);
}

// 上传到 OSS
String ossUrl = ossClient.upload(taskId + ".xlsx", workbook);
notifyUser(taskId, ossUrl);
}

十二、集群部署与负载均衡

12.1 集群架构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
                ┌──────────────┐
│ Nginx LB │ ← 入口
│ (SSL 终结) │
└──┬───┬───┬──┘
│ │ │
┌───────────┼───┼───┼───────────┐
│ │ │ │ │
┌──────▼──┐ ┌─────▼───▼──▼─────┐ │
│ Redis │ │ 共享 NAS/OSS │ │
│ Session │ │ 模板文件/附件 │ │
│ Cache │ └──────────────────┘ │
└──────▲──┘ │
│ │
┌──────┴──────────────────────┐ │
│ MySQL (主从) │ │
└─────────────────────────────┘ │
│ │ │ │
┌──────▼──┐ ┌───▼────┐ ┌─▼──────┐ │
│ FR 节点1│ │FR 节点2│ │FR 节点3│ │
│ Tomcat │ │Tomcat │ │Tomcat │ │
└─────────┘ └────────┘ └────────┘

12.2 集群关键配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!-- 1. 开启集群模式(webroot/WEB-INF/resources/cluster.xml) -->
<clusterConfig>
<enable>true</enable>

<!-- 节点 ID(每个节点不同) -->
<nodeId>node-192-168-1-101</nodeId>

<!-- 集群通信协议 -->
<protocol>tcp</protocol>

<!-- 节点列表 -->
<nodes>
<node>192.168.1.101:7800</node>
<node>192.168.1.102:7800</node>
<node>192.168.1.103:7800</node>
</nodes>

<!-- 心跳间隔 -->
<heartBeatInterval>5000</heartBeatInterval>

<!-- 节点失效检测超时 -->
<nodeTimeout>30000</nodeTimeout>
</clusterConfig>
1
2
3
4
5
6
7
8
9
<!-- 2. 模板同步 -->
<!-- 方案 A:NAS 共享存储 -->
<!-- 所有节点的 .../webroot/WEB-INF/reportlets/ 挂载到同一个 NAS 目录 -->

<!-- 方案 B:使用 FineReport 内置同步 -->
<clusterConfig>
<syncTemplate>true</syncTemplate>
<syncInterval>300</syncInterval> <!-- 5 分钟同步一次 -->
</clusterConfig>
1
2
3
4
5
<!-- 3. 缓存同步(必须使用 Redis) -->
<cacheConfig>
<cacheType>redis</cacheType>
<!-- 所有节点共享 Redis,避免缓存不一致 -->
</cacheConfig>

12.3 集群环境下的注意事项

问题 说明 解决方案
Session 共享 不同节点登录状态不互通 Redis Session 共享 / ip_hash
模板一致性 节点间模板版本不一致 NAS 共享 / 自动同步
缓存一致性 节点 A 更新了缓存,节点 B 未感知 Redis 集中缓存
定时任务重复 多个节点同时执行同一调度 分布式锁(Redis SETNX)
文件导出 导出到节点本地,用户找不到文件 统一 OSS/MinIO
日志聚合 各节点日志分散 ELK / Loki 集中收集

十三、监控与诊断体系

13.1 FineReport 内置监控

1
2
3
4
5
6
7
-- 系统管理 → 性能监控 → 查看:

-- 1. 当前在线用户
-- 2. 正在执行的报表
-- 3. 慢查询 SQL(执行时间 > 3s)
-- 4. 内存使用趋势
-- 5. 线程池使用情况
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# FineReport 日志分析
# 日志路径:/opt/tomcat/logs/

# 1. 搜索执行超时的报表(> 10s)
grep -E "Cost: [0-9]{5,}" /opt/tomcat/logs/catalina.out

# 2. 搜索 OOM 错误
grep "OutOfMemoryError" /opt/tomcat/logs/catalina.out

# 3. 搜索数据库连接池耗尽
grep "Cannot get a connection" /opt/tomcat/logs/catalina.out

# 4. 统计各模板的访问次数
grep "view/report" /opt/tomcat/logs/localhost_access.log | awk '{print $7}' | sort | uniq -c | sort -rn | head -20

# 5. 统计慢模板 TOP 10
grep "Cost:" /opt/tomcat/logs/catalina.out | awk -F'Cost:' '{print $2}' | sort -rn | head -10

13.2 JVM 监控

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 1. 实时监控 GC
jstat -gcutil $(pgrep -f tomcat) 1000

# 输出样例:
# S0 S1 E O M CCS YGC YGCT FGC FGCT GCT
# 0.00 98.72 45.31 62.15 95.23 92.11 1234 12.345 5 2.345 14.690
# ↑新生代 ↑老年代 ↑元空间 ↑YGC次数 ↑YGC时间 ↑FGC次数 ↑总GC时间

# 2. 堆内存快照(排查内存泄漏)
jmap -dump:live,format=b,file=/tmp/heap.hprof $(pgrep -f tomcat)
# 用 MAT (Memory Analyzer Tool) 分析 heap.hprof

# 3. 线程栈(排查死锁/线程阻塞)
jstack $(pgrep -f tomcat) > /tmp/thread_dump.txt

# 4. 查看 JVM 启动参数
jinfo -flags $(pgrep -f tomcat)

13.3 自定义监控指标

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// FineReport 插件:自定义性能拦截器
// 继承 FineReport 的 GlobalRequestFilter
public class PerformanceFilter implements GlobalRequestFilter {

@Override
public void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain) {
long start = System.currentTimeMillis();
String templateId = request.getParameter("viewlet");

try {
chain.doFilter(request, response);
} finally {
long cost = System.currentTimeMillis() - start;

// 上报到监控系统(Prometheus / Grafana / 自建)
if (cost > 3000) {
logger.warn("[SLOW] template={}, cost={}ms, params={}",
templateId, cost, request.getQueryString());
}

// 记录到 Prometheus(如集成 Micrometer)
reportLatencyHistogram.observe(cost / 1000.0);
}
}
}

13.4 Prometheus + Grafana 监控看板

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# Prometheus 监控指标(通过 JMX Exporter 暴露)
# jmx_exporter.yml
rules:
# Tomcat 线程池
- pattern: 'Catalina<type=ThreadPool, name="http-nio-8080"><>(\w+):'
name: tomcat_threadpool
labels:
connector: http-nio-8080

# JVM 内存
- pattern: 'java.lang<type=Memory><(\w+)>(\w+)'
name: jvm_memory_$1_$2

# FineReport 报表执行次数/耗时
- pattern: 'com.fr<type=ReportMetrics><>(\w+): (\d+)'
name: finereport_$1

# Grafana 监控大屏推荐面板:
# 1. QPS / 响应时间 P50/P95/P99
# 2. JVM 堆内存使用率 + GC 频率
# 3. 活跃连接数 / 线程池使用率
# 4. 数据库慢查询次数
# 5. 各模板访问量 TOP 10 + 平均响应时间

十四、常见反模式与避坑

反模式 1:所有数据放在一个数据集

1
2
3
4
5
6
7
8
// ❌ 一个数据集查 50 列给整个模板用
// → 网络传输大、内存占用高、无法针对性优化索引

// ✅ 按功能拆分数据集
// 数据集 1(KPI 卡片):SUM/COUNT 聚合值,4 列
// 数据集 2(明细列表):列表需要的 10 列 + 分页
// 数据集 3(图表用):分类 + 聚合值,3 列
// 数据集 4(码表/字典):code + name,2 列,开启缓存

反模式 2:条件属性 vs 公式不分

1
2
3
4
5
6
7
8
// ❌ 每个单元格都写公式判断条件
// =IF(A1 > 100, "red", IF(A1 > 50, "orange", "black"))
// → 10W 个单元格 = 10W 次 IF 计算

// ✅ 使用条件属性
// 选中列 → 右键 → 条件属性 → 添加
// 条件:当前值 > 100 → 字体颜色 = 红色,背景色 = 浅红
// 性能远高于单元格公式(仅当条件满足时才触发)

反模式 3:填报 + 查询混在一个模板

1
2
3
4
5
6
7
// ❌ 同一个模板既有大量填报控件,又展示分析图表
// → 控件渲染慢 + 提交时校验全部数据

// ✅ 填报和分析分离
// - 分析看板:纯查询,优化查询性能
// - 填报模板:专注录入,可适当简化查询
// - 需要填报+展示的 → 用主子表,独立数据源

反模式 4:每个单元格轮询数据库

1
2
3
4
5
6
7
8
// ❌ 通过 SELECT() 函数在单元格中实时查 DB
=A1 + sql("ds", "SELECT stock FROM inventory WHERE sku_id = " + A1, 1, 1)
// 每个单元格一次网络往返 → 1000 行 = 1000 次 DB 查询

// ✅ 数据在 SQL 中 JOIN 好,一次性返回
SELECT o.*, i.stock
FROM orders o
LEFT JOIN inventory i ON o.sku_id = i.sku_id

反模式 5:图表不设数据上限

1
2
3
4
5
6
// ❌ 折线图直接绑定全量明细数据集(100W 行)
// → 图表组件卡死、浏览器内存爆炸

// ✅ 在 SQL 层聚合或采样
// 日粒度数据直接用聚合值(最多 365 个点/年)
// 分钟粒度超过 2000 点自动采样

反模式 6:忽略模板预加载

1
2
3
4
5
6
7
8
# ❌ 模板访问前不预编译
# → 用户首次访问等待模板编译,体验极差

# ✅ 配置模板预加载
# config.properties
template.preload=true
template.preload.list=/dashboard/index.cpt,/dashboard/sales.cpt,/report/daily.cpt
# 或使用定时调度,每天凌晨自动访问一次(预热)

反模式 7:Tomcat 默认参数跑生产

1
2
3
4
5
6
7
8
# 默认配置:
# maxThreads=200, -Xms128m -Xmx256m
# → 并发一上来直接崩溃

# 生产必改:
# -Xms8g -Xmx8g -XX:+UseG1GC
# maxThreads=500
# connectionTimeout=20000

反模式 8:报表导出无限制

1
2
3
4
5
6
7
// ❌ 允许用户导出 200W 行 Excel
// → 单次导出吃满内存 → 整个节点 OOM

// ✅ 限制导出行数 + 异步化
// 1. 前端显示行数 > 10W 时提示拆分导出
// 2. 后端强制截断(maxDataRow)
// 3. 超过 5W 行走异步导出队列

十五、实战案例集锦

案例 1:电商运营看板 —— 从 15s 到 0.3s

原始场景:运营大屏,包含 4 个 KPI 卡片、3 个趋势图、1 个订单明细列表、1 个 TOP10 排行。

1
2
3
4
5
6
7
原始配置:
- 1 个大数据集(220 列,关联 8 张表)
- 全部图表和列表共享这个数据集
- 某天全量数据(150W 行)
- 普通引擎,无分页

加载时间:15s(超时风险极高)

优化方案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
-- Step 1:拆分数据集,各司其职
-- ds_kpi:KPI 卡片(4 个聚合值)
SELECT
SUM(amount) AS total_gmv,
COUNT(DISTINCT user_id) AS uv,
COUNT(*) AS order_cnt,
SUM(amount) / COUNT(DISTINCT user_id) AS arpu
FROM orders WHERE DATE(create_time) = CURDATE();

-- ds_trend:趋势图(最大 30 个点)
SELECT DATE(create_time) AS dt, SUM(amount) AS gmv
FROM orders WHERE create_time >= CURDATE() - INTERVAL 30 DAY
GROUP BY dt ORDER BY dt;

-- ds_rank:排行(10 行)
SELECT category_name, SUM(amount) AS total
FROM order_detail WHERE DATE(create_time) = CURDATE()
GROUP BY category_name ORDER BY total DESC LIMIT 10;

-- ds_detail:明细列表(行式引擎 + 分页)
SELECT id, customer_name, product_name, quantity, amount
FROM orders WHERE DATE(create_time) = CURDATE()
ORDER BY id LIMIT 50;
1
2
3
4
5
6
7
8
9
// Step 2:开启行式引擎(仅明细列表)
// 模板属性 → 报表引擎 → 开启行式引擎
// 每页 50 行

// Step 3:参数默认今天 + 不自动查询
// 页面加载只执行 ds_kpi(最快),其他数据集手动触发

// Step 4:KPI 卡片独立缓存 5 分钟
// 数据集缓存 → 300s

优化效果

指标 优化前 优化后
首屏加载 15s 0.3s
KPI 面板 随主查询 0.1s(缓存命中)
明细列表 一次性 150W 分页 50 行/页
内存占用 4.2G 800M
并发能力 < 10 > 100

案例 2:财务月报 —— 30s 超时到 1.2s

问题:月报涉及多表关联(订单、退款、费用、结算),30+ 个汇总指标,执行 30 秒超时。

分析

  • 4 张核心表关联(每张 200W-500W 行)
  • 多个子查询嵌套
  • 实时关联计算

优化方案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
-- 方案:ETL 预计算月度汇总表
CREATE TABLE fin_monthly_summary (
stat_month CHAR(7) NOT NULL COMMENT '统计月份 YYYY-MM',
dept_id INT NOT NULL,
biz_line_id INT NOT NULL,

-- 收入指标
order_amount DECIMAL(18,2) DEFAULT 0 COMMENT '订单金额',
order_cnt INT DEFAULT 0 COMMENT '订单数',
return_amount DECIMAL(18,2) DEFAULT 0 COMMENT '退款金额',
return_cnt INT DEFAULT 0 COMMENT '退款单数',

-- 毛收入
net_amount DECIMAL(18,2) DEFAULT 0 COMMENT '净收入',

-- 费用指标
shipping_fee DECIMAL(18,2) DEFAULT 0 COMMENT '运费',
platform_fee DECIMAL(18,2) DEFAULT 0 COMMENT '平台费用',

-- 利润
gross_profit DECIMAL(18,2) DEFAULT 0 COMMENT '毛利润',
profit_rate DECIMAL(6,4) DEFAULT 0 COMMENT '利润率',

PRIMARY KEY (stat_month, dept_id, biz_line_id)
) ENGINE=InnoDB;

-- 每月 1 号凌晨 3:00 执行
INSERT INTO fin_monthly_summary
SELECT ... FROM ... GROUP BY ...;
1
2
3
优化前:实时 JOIN 4 张大表 → 30s
优化后:查询预计算汇总表 → 0.8-1.2s
月度数据误差:T+1(次日更新),可接受

案例 3:大屏展示 —— 自动刷新的资源消耗

场景:双 11 实时大屏,每 5 秒自动刷新,8 小时不间断展示。

问题

  • 每次都重新计算全量数据
  • 高峰期数据库压力巨大
  • 浏览器内存持续增长(页面不关闭)

优化方案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 方案 A:增量更新(只刷新变化的部分)
// 第一次:全量加载
// 后续:只传递增量 delta

// 在 FineReport 中使用「定时刷新 + 增量数据集」
// 数据集配置:
SELECT * FROM realtime_metrics
WHERE update_time >= '${last_refresh_time}'

// last_refresh_time 参数每次刷新时更新
// 未变化的数据使用上次缓存版本

// 方案 B:WebSocket 推送(替代轮询)
// 后端数据变化时主动推送,而非前端轮询
var ws = new WebSocket('wss://report-api.yourcompany.com/ws/dashboard');
ws.onmessage = function(event) {
var delta = JSON.parse(event.data);
// 只更新变化的指标
document.getElementById('gmv').innerText = delta.gmv;
document.getElementById('uv').innerText = delta.uv;
chart.setOption({ dataset: { source: delta.chartData } });
};

// 方案 C:多级缓存(Redis + 本地)
// 第一层:Java 本地缓存(Caffeine,TTL = 3s)
// 第二层:Redis 缓存(TTL = 10s)
// 第三层:数据库查询

优化效果

指标 轮询方案 WebSocket + 增量方案
每秒 DB 查询 100次 0次(按变化推送)
网络传输 100MB/h 5MB/h
浏览器内存 持续增长到 2G+ 稳定在 300M

案例 4:集群环境下的数据不一致

场景:3 节点集群,用户修改筛选条件后发现数据不一致。

问题

  • 节点 A 缓存了”本月”数据
  • 节点 B 缓存了”上月”数据
  • 由于 ip_hash 可能路由到不同节点

解决方案

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 方案:
# 1. Redis 集中缓存 → 所有节点共享缓存
# 2. 缓存 key 包含参数 hash → 不同参数不同缓存
# 3. 缓存更新 → 发布 Redis Pub/Sub 通知所有节点

# Jedis 配置
redis:
host: 192.168.1.100
port: 6379
maxTotal: 200
cacheExpire: 600

# 缓存 key 命名规则
# report:{templateId}:{paramsMD5}
# 例:report:dashboard_sales:a1b2c3d4e5f6...

附录 A:FineReport 优化速查清单

日常开发检查

  • 数据集是否避免了 SELECT *?
  • 单个模板数据集是否 ≤ 8 个?
  • 大数据量数据集是否开启了行式引擎?
  • 图表数据是否做了 TOP N 或采样?
  • 单元格公式是否能用条件属性替代?
  • 是否避免了在单元格中使用 sql() 函数?
  • 参数字段是否设置了默认值以减少空查询?
  • 数据字典是否重用了服务器数据集?
  • 是否设置了合理的数据集缓存时间?

上线前检查

  • SQL 是否通过了 EXPLAIN 检查(type ≥ ref)?
  • 是否模拟了生产数据量进行压力测试?
  • JVM 堆内存是否 ≥ 4G,是否启用 G1GC?
  • Tomcat maxThreads 是否 ≥ 300?
  • 是否配置了 Nginx 反向代理和 Gzip 压缩?
  • 是否配置了慢查询监控和告警?
  • 大数据量模板是否设置了导出行数限制?
  • 集群环境是否配置了 Redis 缓存?
  • 是否配置了模板预加载?

附录 B:常用命令速查

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# ==================== JVM 诊断 ====================
# GC 实时监控(每秒刷新)
jstat -gcutil $(pgrep -f tomcat) 1000

# 查看 JVM 参数
jinfo -flags $(pgrep -f tomcat)

# 导出堆转储
jmap -dump:live,format=b,file=/tmp/heap.hprof $(pgrep -f tomcat)

# 线程栈分析
jstack $(pgrep -f tomcat) > /tmp/threads.txt

# ==================== Tomcat 诊断 ====================
# 线程池使用情况
curl -s http://localhost:8080/manager/status?XML=true | grep -E "currentThread|currentThreadsBusy"

# ==================== FineReport 日志 ====================
# 慢模板 TOP 10
grep "Cost:" /opt/tomcat/logs/catalina.out | sort -t: -k2 -rn | head -10

# 访问量 TOP 10
grep "view/report" /opt/tomcat/logs/localhost_access.log | awk '{print $7}' | sort | uniq -c | sort -rn | head -10

# 错误日志
grep -E "ERROR|Exception" /opt/tomcat/logs/catalina.out | tail -50

# ==================== 数据库诊断 ====================
# 查看正在执行的报表 SQL
mysql -u root -p -e "
SELECT id, user, host, db, time, state, SUBSTRING(info, 1, 100) AS query_preview
FROM information_schema.processlist
WHERE command != 'Sleep' AND info LIKE '%SELECT%'
ORDER BY time DESC
"

# 查看表锁等待
mysql -u root -p -e "SELECT * FROM information_schema.innodb_lock_waits"

# 查看慢查询
mysql -u root -p -e "SHOW VARIABLES LIKE 'slow_query%'"

本文基于 FineReport 10.0/11.0 版本编写,结合实际项目中上百个看板的优化经验总结而成。建议收藏作为报表开发日常参考手册,并在每个报表上线前对照检查清单逐项确认。