资讯详情 原生Servlet+MySQL财务系统:手写DAO与事务管理实战
📅 2026/10/6 3:16:29
简介本资源是一套基于Java EE技术栈的企业级财务管理系统完整开发实践包面向Java Web初学者与课程设计学生解决企业账务处理、报表生成、预算控制及资产成本分析等核心财务管理需求。压缩包共14个文件含3个MP4项目部署与模块操作视频覆盖登录、部门/职工/工资管理、资产与费用模块等、3张PNG系统界面截图登录页、信息管理页等、2份DOC/DOCX中期报告与任务书、1份PPT答辩材料、1份SQL数据库脚本、1份TXT使用说明及1个源码ZIP包总大小117.82MB。已有88人学习下载。读者可直接运行调试原生Servlet后端代码导入MySQL数据库并对照视频完成全流程部署论文文档提供规范的设计思路与实现逻辑界面截图与结构化资料便于快速理解系统模块划分与业务流程是掌握Java Web企业应用开发的典型教学案例。1. 原生Servlet MySQL 财务系统为什么不用Spring Boot反而要手写DAO、自己管连接池、连事务都得手动commit这不是一个“教学Demo”而是一套在中小型企业真实跑过三年的财务模块原型——没有Spring、没有MyBatis、没有Hibernate只有javax.servlet.*、com.mysql.cj.jdbc.Driver、手写的BaseDAO、用ThreadLocal兜底的事务上下文以及一份被反复压测调优过的MySQL建表脚本。它解决的不是“怎么学Java Web”而是“当客户只给5万预算、要求3周上线、服务器是2核4G旧物理机、DBA不配合建视图、连Druid监控都不让装”时怎么把凭证录入、科目余额、往来对账、月结报表这四块硬骨头稳稳落地。适合两类人一是刚从培训班出来、简历上写着“熟悉SSM但没碰过裸写JDBC”的应届生想补上那层被框架遮住的血肉逻辑二是老开发在维护一套十年前的老财务系统突然被要求加个“银行回单自动匹配”功能发现Spring事务注解在旧代码里根本不起作用——这时候你得知道Connection.setAutoCommit(false)到底在哪一行执行、PreparedStatement的setObject(1, value, Types.DATE)和setDate(1, sqlDate)在MySQL 5.7下行为差异有多大、甚至ResultSetMetaData.getColumnTypeName(i)返回VARCHAR还是TEXT会影响后续JSON序列化。本文不讲MVC分层理论只讲你打开IDEA新建一个Dynamic Web Project后第一行该敲什么、第17行必须加什么try-catch、第42行不加finally{conn.close()}第二天就会收到OOM报警。2. 从零搭起Servlet-Mysql财务骨架不依赖任何框架但每一步都经生产验证2.1 创建最小可运行Web项目结构别急着写Servlet先搞定web.xml和编译路径很多新手卡在第一步新建Dynamic Web Project后web.xml里连servlet标签都没配就去写HttpServlet子类结果访问404。这不是代码问题是部署契约没对齐。原生Servlet的生命周期由容器Tomcat通过web.xml或注解驱动而财务系统这类业务密集型应用必须用web.xml显式声明——因为你要控制加载顺序、初始化参数、甚至配置error-page统一处理SQL异常。以下是经过Tomcat 8.5/9.0实测的最小web.xml核心片段?xml version1.0 encodingUTF-8? web-app xmlnshttp://xmlns.jcp.org/xml/ns/javaee xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd version4.0 !-- 全局初始化参数指定数据库配置文件位置 -- context-param param-namedb.config.path/param-name param-value/WEB-INF/db.properties/param-value /context-param !-- 监听器在应用启动时初始化数据库连接池 -- listener listener-classcom.finance.util.DBConnectionPoolListener/listener-class /listener !-- Servlet映射财务核心模块必须显式声明避免注解扫描冲突 -- servlet servlet-nameVoucherServlet/servlet-name servlet-classcom.finance.servlet.VoucherServlet/servlet-class /servlet servlet-mapping servlet-nameVoucherServlet/servlet-name url-pattern/voucher/*/url-pattern /servlet-mapping !-- JSP页面默认编码财务系统中文字段多此处漏设会导致乱码 -- jsp-config jsp-property-group url-pattern*.jsp/url-pattern page-encodingUTF-8/page-encoding scripting-invalidfalse/scripting-invalid /jsp-property-group /jsp-config /web-app关键点说明context-param用于传递外部配置路径比硬编码更安全也方便测试环境切换DBConnectionPoolListener是自定义监听器后文详述它在ServletContextEvent触发时完成连接池初始化这是避免Servlet首次请求时因建连超时导致500的关键url-pattern/voucher/*/url-pattern采用路径前缀而非精确匹配为后续扩展/voucher/list、/voucher/save、/voucher/print留出空间jsp-config中page-encoding必须设为UTF-8否则科目名称“应收账款”存进MySQL变成“???”且这种乱码在MySQL客户端看是正常的因为客户端也用了UTF8MB4但在Java层String.getBytes(UTF-8)长度计算会出错影响凭证摘要截断逻辑。项目结构必须严格遵循Java EE规范FinanceSystem/ ├── src/ │ ├── com/finance/servlet/ # 所有Servlet入口 │ ├── com/finance/dao/ # 数据访问对象含BaseDAO │ ├── com/finance/entity/ # POJO字段名与MySQL列名严格一致非驼峰 │ ├── com/finance/util/ # 工具类日期格式化、金额精度处理、连接池 │ └── db.properties # 数据库配置放src根目录编译后落WEB-INF/classes ├── WebContent/ │ ├── WEB-INF/ │ │ ├── web.xml # 上面的配置文件 │ │ ├── db.properties # 运行时实际读取的配置覆盖src版 │ │ └── lib/ # mysql-connector-java-8.0.33.jar等依赖 │ ├── voucher/ │ │ ├── list.jsp # 凭证列表页 │ │ └── edit.jsp # 凭证编辑页 │ └── index.jsp └── build.xml # Ant构建脚本替代Maven轻量可控注意db.properties在src/和WEB-INF/下各存一份运行时优先读取WEB-INF/下的版本这样运维可直接修改生产配置而不需重编译。lib/目录下只放mysql-connector-java-8.0.33.jarMySQL 5.7推荐8.0.x驱动严禁放commons-dbcp、c3p0等旧连接池jar——这些库在高并发凭证提交时会出现连接泄漏我们用自研轻量池见2.3节。2.2 设计财务核心实体与MySQL建表语句科目、凭证、明细三张表的字段取舍逻辑财务系统不是CRUD练习字段设计直接受会计准则约束。比如“凭证号”不能用AUTO_INCREMENT必须按“年月流水号”生成如2024050001因为审计要求凭证号连续且不可跳号“金额”字段必须用DECIMAL(12,2)而非FLOAT否则0.10.2!0.3会引发借贷不平衡校验失败。以下是经某代账公司生产环境验证的三张核心表DDL-- 科目表一级到四级科目支持辅助核算如客户、部门 CREATE TABLE account_subject ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, code VARCHAR(32) NOT NULL COMMENT 科目编码如100101, name VARCHAR(128) NOT NULL COMMENT 科目名称, level TINYINT NOT NULL COMMENT 科目级次1一级,2二级..., parent_id BIGINT DEFAULT NULL COMMENT 上级科目ID, is_leaf TINYINT NOT NULL DEFAULT 0 COMMENT 是否末级科目0否,1是, debit_balance TINYINT NOT NULL DEFAULT 0 COMMENT 借方余额方向0贷方,1借方, ass_type VARCHAR(16) DEFAULT NULL COMMENT 辅助核算类型CUSTOMER/DEPARTMENT/PROJECT, PRIMARY KEY (id), UNIQUE KEY uk_code (code), KEY idx_parent (parent_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT会计科目表; -- 凭证主表存储凭证头信息 CREATE TABLE voucher_header ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, voucher_no VARCHAR(32) NOT NULL COMMENT 凭证编号格式YYYYMMNNNN, date DATE NOT NULL COMMENT 记账日期, period VARCHAR(10) NOT NULL COMMENT 会计期间如202405, description VARCHAR(512) DEFAULT NULL COMMENT 摘要, status TINYINT NOT NULL DEFAULT 0 COMMENT 状态0未审核,1已审核,2已记账, created_by VARCHAR(64) NOT NULL COMMENT 制单人, created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 创建时间, updated_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 更新时间, PRIMARY KEY (id), UNIQUE KEY uk_voucher_no (voucher_no), KEY idx_period_status (period, status), KEY idx_date (date) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT凭证主表; -- 凭证明细表一笔凭证可有多行分录 CREATE TABLE voucher_detail ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, header_id BIGINT NOT NULL COMMENT 关联凭证主表ID, subject_code VARCHAR(32) NOT NULL COMMENT 科目编码, subject_name VARCHAR(128) NOT NULL COMMENT 科目名称冗余避免联表, debit_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT 借方金额, credit_amount DECIMAL(12,2) NOT NULL DEFAULT 0.00 COMMENT 贷方金额, ass_value VARCHAR(128) DEFAULT NULL COMMENT 辅助核算值如客户ID, ass_type VARCHAR(16) DEFAULT NULL COMMENT 辅助核算类型, remark VARCHAR(255) DEFAULT NULL COMMENT 备注, PRIMARY KEY (id), KEY idx_header (header_id), KEY idx_subject (subject_code), KEY idx_ass (ass_type, ass_value) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT凭证明细表;字段设计血泪经验voucher_no用VARCHAR而非BIGINTMySQL的AUTO_INCREMENT无法满足“每月重置流水号”需求且字符串便于前端展示2024050001比123456789更易读debit_balance用TINYINT存0/1比ENUM(DEBIT,CREDIT)更节省空间且Java层用boolean直接映射避免字符串比较subject_name在明细表冗余存储财务查询高频“按科目查所有凭证”若每次JOINaccount_subject在10万凭证量下响应超2s冗余后查询速度提升5倍ass_typeass_value组合索引支持“查某客户所有应收账款凭证”这是往来对账的核心场景所有DATETIME字段加ON UPDATE CURRENT_TIMESTAMP避免Java层手动设updated_time防止因时区或NTP误差导致时间戳错乱。实体类必须与表结构严格对应禁止LombokData因toString()可能暴露敏感字段用传统getter/setter并添加业务校验// com.finance.entity.VoucherDetail.java public class VoucherDetail { private Long id; private Long headerId; private String subjectCode; private String subjectName; private BigDecimal debitAmount BigDecimal.ZERO; // 初始化为0避免null参与运算 private BigDecimal creditAmount BigDecimal.ZERO; private String assValue; private String assType; private String remark; // 金额校验借方贷方必须大于0且不能同时为0 public boolean isValidAmount() { return (debitAmount.compareTo(BigDecimal.ZERO) 0 || creditAmount.compareTo(BigDecimal.ZERO) 0) debitAmount.add(creditAmount).compareTo(BigDecimal.ZERO) 0; } // 辅助核算必填校验若科目启用了辅助核算 public boolean isAssRequired(String subjectCode) { // 实际代码中会查account_subject表获取ass_type return CUSTOMER.equals(getAssType()) StringUtils.isNotBlank(getAssValue()); } }2.3 手写连接池与事务管理为什么Druid在财务系统里是把双刃剑很多教程一上来就教“集成Druid”但在真实财务场景中Druid的stat监控会吃掉15%的CPU且其removeAbandonedOnBorrow机制在凭证批量导入时误杀有效连接导致“连接池耗尽”假象。我们采用极简策略固定大小连接池 ThreadLocal事务绑定 手动commit/rollback。核心类DBConnectionPool仅187行无第三方依赖// com.finance.util.DBConnectionPool.java public class DBConnectionPool { private static final int MAX_CONNECTIONS 20; // 根据2核4G服务器实测20足够应付300TPS private static final long MAX_IDLE_TIME 30 * 60 * 1000L; // 30分钟空闲回收 private static final ListConnection connections new ArrayList(); private static final ListLong lastUsedTime new ArrayList(); static { // 静态块初始化连接 for (int i 0; i MAX_CONNECTIONS; i) { try { Connection conn DriverManager.getConnection( jdbc:mysql://localhost:3306/finance_db?useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue, root, password); conn.setAutoCommit(true); // 默认自动提交事务由业务层控制 connections.add(conn); lastUsedTime.add(System.currentTimeMillis()); } catch (SQLException e) { throw new RuntimeException(Failed to init DB connection pool, e); } } } public static Connection getConnection() throws SQLException { synchronized (connections) { for (int i 0; i connections.size(); i) { Connection conn connections.get(i); if (conn ! null !conn.isClosed()) { lastUsedTime.set(i, System.currentTimeMillis()); return conn; } } // 池满则等待不新建连接防爆库 throw new SQLException(DB connection pool exhausted); } } public static void releaseConnection(Connection conn) { if (conn null) return; try { if (conn.isClosed()) return; // 归还连接前重置auto-commit状态 conn.setAutoCommit(true); } catch (SQLException ignored) {} } // 关闭所有连接应用卸载时调用 public static void closeAll() { for (Connection conn : connections) { try { if (conn ! null !conn.isClosed()) { conn.close(); } } catch (SQLException ignored) {} } } }事务管理不靠AOP而用ThreadLocal绑定当前线程的Connection和isInTransaction状态// com.finance.util.TransactionManager.java public class TransactionManager { private static final ThreadLocalConnection connectionHolder new ThreadLocal(); private static final ThreadLocalBoolean isInTransaction new ThreadLocal(); public static Connection getConnection() throws SQLException { Connection conn connectionHolder.get(); if (conn null) { conn DBConnectionPool.getConnection(); connectionHolder.set(conn); isInTransaction.set(false); } return conn; } public static void beginTransaction() throws SQLException { Connection conn getConnection(); if (!isInTransaction.get()) { conn.setAutoCommit(false); isInTransaction.set(true); } } public static void commitTransaction() throws SQLException { Connection conn connectionHolder.get(); if (conn ! null isInTransaction.get()) { conn.commit(); conn.setAutoCommit(true); isInTransaction.set(false); } } public static void rollbackTransaction() throws SQLException { Connection conn connectionHolder.get(); if (conn ! null isInTransaction.get()) { conn.rollback(); conn.setAutoCommit(true); isInTransaction.set(false); } } public static void closeConnection() { Connection conn connectionHolder.get(); if (conn ! null) { DBConnectionPool.releaseConnection(conn); connectionHolder.remove(); isInTransaction.remove(); } } }为什么这样设计固定大小池20避免动态扩容导致MySQL连接数超标max_connections200时20个应用实例就占满ThreadLocal保证同一线程内多次DAO调用复用同一Connection这是跨DAO事务的基础begin/commit/rollback方法必须成对出现在Servlet的doPost末尾强制调用closeConnection()否则连接泄漏conn.setAutoCommit(true)在归还前重置防止上一个事务的false状态污染下一个请求。3. 凭证录入与借贷平衡校验手写SQL里的会计逻辑不是ORM能翻译的3.1 凭证保存的完整流程从HTTP请求到MySQL事务提交的7个关键节点财务操作不容出错凭证保存必须是原子性事务。整个流程涉及Servlet接收、数据校验、主表插入、明细插入、借贷平衡检查、科目余额更新、事务提交7步任何一步失败都必须回滚。以下是VoucherServlet.doPost()的核心逻辑已删减日志和异常包装// com.finance.servlet.VoucherServlet.java protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { Connection conn null; try { // 1. 获取连接并开启事务 conn TransactionManager.getConnection(); TransactionManager.beginTransaction(); // 2. 解析JSON请求体前端传来的凭证数据 String json IOUtils.toString(request.getInputStream(), StandardCharsets.UTF_8); Voucher voucher new ObjectMapper().readValue(json, Voucher.class); // 3. 业务校验日期不能未来、摘要不能为空、至少有一条明细 if (voucher.getDate().after(new Date()) || StringUtils.isBlank(voucher.getDescription()) || CollectionUtils.isEmpty(voucher.getDetails())) { throw new BusinessException(凭证日期不能为未来摘要不能为空明细至少一行); } // 4. 生成凭证号YYYYMM 4位流水查当前月最大号1 String period new SimpleDateFormat(yyyyMM).format(voucher.getDate()); String voucherNo generateVoucherNo(period); // 调用DAO查max(voucher_no) like 202405% // 5. 插入凭证主表 long headerId VoucherHeaderDAO.insert(voucher, voucherNo, period); // 6. 插入明细表并实时校验借贷平衡 BigDecimal totalDebit BigDecimal.ZERO; BigDecimal totalCredit BigDecimal.ZERO; for (VoucherDetail detail : voucher.getDetails()) { // 校验每行金额有效性 if (!detail.isValidAmount()) { throw new BusinessException(明细第 (i1) 行金额无效); } totalDebit totalDebit.add(detail.getDebitAmount()); totalCredit totalCredit.add(detail.getCreditAmount()); VoucherDetailDAO.insert(headerId, detail); } if (totalDebit.compareTo(totalCredit) ! 0) { throw new BusinessException(借贷不平衡借方 totalDebit 贷方 totalCredit); } // 7. 更新科目余额表简化版实际需递归更新上级科目 updateSubjectBalance(voucher.getDetails()); // 事务提交 TransactionManager.commitTransaction(); response.getWriter().write({\success\:true,\voucherNo\:\ voucherNo \}); } catch (BusinessException e) { // 业务异常回滚但不打印堆栈避免泄露SQL TransactionManager.rollbackTransaction(); response.setStatus(HttpServletResponse.SC_BAD_REQUEST); response.getWriter().write({\error\:\ e.getMessage() \}); } catch (Exception e) { // 系统异常回滚并记录日志 TransactionManager.rollbackTransaction(); log.error(Voucher save failed, e); response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); response.getWriter().write({\error\:\系统错误请联系管理员\}); } finally { // 必须关闭连接释放ThreadLocal TransactionManager.closeConnection(); } }关键细节说明generateVoucherNo()必须用SELECT MAX(voucher_no) FROM voucher_header WHERE voucher_no LIKE ?加FOR UPDATE锁否则高并发时生成重复号借贷平衡校验放在明细插入之后因为要累加所有行的金额而不是在循环中逐行校验那样无法发现总和不平衡updateSubjectBalance()不是简单UPDATE account_subject SET balance balance ? WHERE code ?而是要根据debit_balance字段判断加减方向例如应收账款借方余额增加时balance debitAmount减少时balance - creditAmountresponse.getWriter().write(...)直接输出JSON不走JSP因为财务接口需被其他系统如银行对账模块调用JSP模板会引入HTML噪音。3.2 科目余额实时更新为什么不能用触发器而要用Java层计算有人提议“在voucher_detail上建MySQL触发器自动更新account_subject.balance”这在测试环境可行但在生产环境会引发严重问题触发器执行在事务内但account_subject表被频繁更新导致行锁竞争凭证提交TPS从120降到30触发器无法处理“红字冲销”负数金额需额外逻辑判断审计要求余额变更必须留痕触发器修改无操作人、无时间戳。我们采用Java层计算批量更新策略// com.finance.dao.SubjectBalanceDAO.java public static void updateBalances(ListVoucherDetail details) throws SQLException { Connection conn TransactionManager.getConnection(); String sql INSERT INTO subject_balance (subject_code, period, balance, updated_by, updated_time) VALUES (?, ?, ?, ?, NOW()) ON DUPLICATE KEY UPDATE balance balance VALUES(balance), updated_by VALUES(updated_by), updated_time NOW(); try (PreparedStatement ps conn.prepareStatement(sql)) { for (VoucherDetail detail : details) { String subjectCode detail.getSubjectCode(); BigDecimal amount detail.getDebitAmount().subtract(detail.getCreditAmount()); // 根据科目方向调整符号借方余额科目借方增加余额贷方余额科目贷方增加余额 BigDecimal balanceChange getBalanceChange(subjectCode, amount); ps.setString(1, subjectCode); ps.setString(2, detail.getPeriod()); // 从voucher_header.period获取 ps.setBigDecimal(3, balanceChange); ps.setString(4, getCurrentUser()); // 从Session获取 ps.addBatch(); } ps.executeBatch(); } } private static BigDecimal getBalanceChange(String subjectCode, BigDecimal amount) { // 查询account_subject.debit_balance字段缓存到ConcurrentHashMap避免每次查DB Boolean isDebit SUBJECT_DEBIT_CACHE.get(subjectCode); return isDebit ? amount : amount.negate(); }性能优化点ON DUPLICATE KEY UPDATE替代INSERT ... SELECT ... UNION ALL减少SQL解析开销SUBJECT_DEBIT_CACHE用ConcurrentHashMap缓存科目方向避免每次凭证保存都查account_subject表批量addBatch()executeBatch()比单条执行快8倍。4. 避坑财务系统里最常踩的5个“玄学”问题90%的翻车都发生在这里4.1 现象凭证列表页显示“2024-05-01”导出Excel却变成“2024-04-30”原因MySQL的DATE字段在JDBC驱动中默认按serverTimezone解析若Tomcat服务器时区为GMT0而MySQL配置为Asia/ShanghaiResultSet.getDate(date)会将2024-05-01减去8小时变成2024-04-30 16:00:00再转Date对象时截断为2024-04-30。解决在JDBC URL中强制指定时区并在Java层统一用LocalDate// JDBC URL必须包含 jdbc:mysql://localhost:3306/finance_db?serverTimezoneAsia/Shanghai... // DAO中用LocalDate而非java.util.Date public LocalDate getDate() { try { return resultSet.getObject(date, LocalDate.class); // JDBC 4.2支持 } catch (SQLException e) { throw new RuntimeException(e); } }4.2 现象同一笔凭证不同用户查看时“已审核”状态不一致原因voucher_header.status字段被多个Servlet并发修改但没加SELECT ... FOR UPDATE锁导致“审核”和“反审核”操作覆盖。例如A用户点击审核status1B用户同时点击反审核status0最终DB里是0但A用户界面仍显示1。解决状态变更必须用乐观锁或悲观锁。我们选悲观锁// 审核操作SQL UPDATE voucher_header SET status 1, updated_time NOW() WHERE id ? AND status 0; -- 条件确保只更新未审核状态 // 执行后检查getUpdateCount()若为0则抛“状态已被修改”异常4.3 现象MySQL慢查询日志显示SELECT * FROM voucher_detail WHERE header_id ?执行2s原因voucher_detail.header_id有索引但EXPLAIN发现typeALL因为header_id是BIGINT而查询时传入的是String如123MySQL隐式转换导致索引失效。解决DAO层严格类型校验所有ID参数用long而非String// 错误写法 public ListVoucherDetail findByHeaderId(String headerId) { ... } // 正确写法 public ListVoucherDetail findByHeaderId(long headerId) { ... } // 调用前在Servlet里做Long.parseLong()4.4 现象DecimalFormat格式化金额1234567.89显示为1,234,567.890多出三位小数原因财务要求金额统一保留两位小数但DecimalFormat默认模式#,##0.###会显示最多三位且BigDecimal构造时若用double参数如new BigDecimal(1234567.89)会引入浮点误差。解决金额字段一律用String构造BigDecimalnew BigDecimal(1234567.89)格式化用#,##0.00模式并设置setRoundingMode(RoundingMode.HALF_UP)在Entity的getter中封装public String getFormattedDebitAmount() { return new DecimalFormat(#,##0.00).format(debitAmount); }4.5 现象Tomcat重启后第一次凭证提交报java.sql.SQLException: Connection closed原因DBConnectionPool静态块在类加载时初始化连接但此时ServletContext尚未就绪db.properties读取失败连接对象为null后续getConnection()返回null。解决将连接池初始化移到ServletContextListener中在contextInitialized()里执行public class DBConnectionPoolListener implements ServletContextListener { Override public void contextInitialized(ServletContextEvent sce) { try { DBConnectionPool.init(); // 将静态块逻辑移到此方法 } catch (Exception e) { throw new RuntimeException(DB pool init failed, e); } } }5. 报表导出与性能压测用POI流式写入撑住万行凭证Excel以及如何用JMeter模拟真实财务流量5.1 流式导出凭证Excel避免OOM10万行内存占用从2GB降到64MB财务月结时需导出当月所有凭证若用XSSFWorkbook内存版加载10万行JVM堆内存瞬间飙到2GBGC频繁导致Tomcat假死。解决方案是SXSSFWorkbook流式版但默认配置仍有坑// com.finance.servlet.ExportVoucherServlet.java protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String period request.getParameter(period); // 如202405 ListVoucher vouchers VoucherDAO.findByPeriod(period); // 设置响应头避免浏览器缓存 response.setContentType(application/vnd.openxmlformats-officedocument.spreadsheetml.sheet); response.setHeader(Content-Disposition, attachment; filenamevouchers_ period .xlsx); // SXSSFWorkbookrowAccessWindowSize100表示内存中只保留100行其余刷到磁盘 SXSSFWorkbook workbook new SXSSFWorkbook(100); Sheet sheet workbook.createSheet(凭证列表); // 写入表头不占内存窗口 Row headerRow sheet.createRow(0); String[] headers {凭证号, 日期, 摘要, 借方金额, 贷方金额, 制单人}; for (int i 0; i headers.length; i) { Cell cell headerRow.createCell(i); cell.setCellValue(headers[i]); } // 流式写入数据行每写100行flush一次释放内存 int rowNum 1; for (Voucher voucher : vouchers) { Row row sheet.createRow(rowNum); row.createCell(0).setCellValue(voucher.getVoucherNo()); row.createCell(1).setCellValue(voucher.getDateStr()); // 已格式化为2024-05-01 row.createCell(2).setCellValue(voucher.getDescription()); row.createCell(3).setCellValue(voucher.getTotalDebit().doubleValue()); row.createCell(4).setCellValue(voucher.getTotalCredit().doubleValue()); row.createCell(5).setCellValue(voucher.getCreatedBy()); // 每100行flush避免磁盘临时文件过大 if (rowNum % 100 0) { ((SXSSFSheet) sheet).flushRows(100); } } // 输出到响应流 try (ServletOutputStream out response.getOutputStream()) { workbook.write(out); } finally { workbook.dispose(); // 必须调用删除磁盘临时文件 } }关键参数说明SXSSFWorkbook(100)内存中最多缓存100行超过则写入磁盘临时文件flushRows(100)每写100行主动刷盘防止临时文件单个过大默认单文件上限10MBworkbook.dispose()必须调用否则磁盘临时文件不删除几天后占满/tmp分区。5.2 用JMeter模拟真实财务流量不只是QPS更要测“凭证提交链路”的端到端延迟财务系统瓶颈不在CPU而在MySQL锁和连接池。JMeter脚本必须模拟真实用户行为阶梯式加压从10用户开始每30秒10用户直到200用户观察TPS拐点参数化凭证数据用CSV Data Set Config读取vouchers.csv每行含不同voucher_no、date、detailsJSON数组事务控制器包裹完整链路包含登录获取Session、凭证保存、查询凭证列表三个请求计算“从点击保存到看到成功提示”的总耗时监听器只用Aggregate Report和Response Times Over Time禁用View Results Tree内存杀手。JMeter关键配置截图文字描述线程组Ramp-Up Period 300 seconds5分钟升到200用户Loop Count ForeverHTTP Header Manager添加Content-Type: application/jsonJSON Extractor从登录响应提取JSESSIONID用于后续请求断言响应JSON含success:true且voucherNo不为空后置处理器用JSR223 PostProcessor计算凭证号生成逻辑确保不重复。压测结果解读重点若TPS在150用户时骤降检查MySQLSHOW PROCESSLIST是否有大量Waiting for table metadata lock说明voucher_header表被长事务阻塞若平均响应时间2s但90%线500ms说明存在个别慢查询如未加索引的ass_value模糊查询需针对性优化连接池耗尽表现为JMeter错误率飙升此时DBConnectionPool.getConnection()抛SQLException需调大MAX_CONNECTIONS本文还有配套的精品资源点击获取