拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

智能电表远程抄表缴费平台Java实战:DL/T645解析与避坑指南

智能电表远程抄表缴费平台Java实战:DL/T645解析与避坑指南

简介:这是一份基于Java构建的智能电表远程抄表缴费管理平台源码,面向物业、房东、写字楼等用电管理场景,也适合物联网及Java后端开发者学习二次开发。平台以物联网技术为核心,借助GPRS、LoRa、NB-IoT等方式实现远程自动抄表,同时集成线上缴费、用户权限管理、异常报警、能耗统计与报表生成等功能,可适配正泰、人民、天正、许继等主流品牌电表,减少人工抄表负担。资源包共86个文件,主体为77个Java源文件,另有8个XML配置文件和1个iml工程文件,整体仅67KB,目录紧凑,核心工作组件‘wwby-worker-ammeter’清晰展现了定时采集、任务调度与数据处理流程,便于开发者快速定位逻辑并做功能扩展。此外,源码预留了API接口,可对接物业管理或楼宇自控系统,对学习设备接入、数据聚合、支付集成等物联网应用开发技巧有较高参考价值。目前已有2178人学习下载,适合需要搭建智能用电管理原型或深入理解Java物联网项目结构的技术人员参考。

1. 智能电表远程抄表缴费管理平台:这套Java源码到底解决什么问题

一栋写字楼的物业经理会发现,最头疼的不是抄表,而是抄完表之后对不上账。智能电表远程抄表缴费管理平台,本质是用一套Java后端把三件原本靠人工的事接起来:定时从电表读走读数、按读数算费并完成缴费入账、对异常用电和欠费告警。它能解决人工抄表错漏、电费回收周期长、数据没法追溯这些问题。适合两类人:一类是正在找 java 课程设计案例源码的学生,答辩时需要一条讲得清、跑得通的技术链路;另一类是给园区、公寓做水电计费的小团队,需要一个能二次开发的 Java 主站。要强调的是,这套源码覆盖的是上位机主站,不是电表里的嵌入式固件,开局前先把这个边界定住。

2. 从电表到平台的数据链路:DL/T645报文怎么用Java解析

2.1 为什么是DL/T645而不是Modbus:选型边界

国内智能电表用得最多的协议是 DL/T645,1997 版和 2007 版并存,新招标表计基本都是 2007 版。很多第一次做这个方向的人会问:电表能不能用 Modbus RTU 来读?能,但要看表计类型。国网、南网招标的居民表和工商业表,默认走 645;只有工业现场用的第三方电表、或者你自己加的采集模块,才更多见 Modbus。做平台前先做一步调研:把现场表计的规约、波特率、表号清单拿到手,这决定了采集服务的底层实现。

这里的关键认知是:645 是字节级帧格式,跟 Java 语言本身没有关系,但 Java 解析时有几个细节特别容易踩坑。帧结构是固定的——起始符 68H、6 字节地址域、1 字节控制码、1 字节数据域长度、数据域、校验和 CS、结束符 16H。读数据的控制码是 0x11,正常应答是 0x91,异常应答是 0xD1。控制码 bit6 为 1 表示从站异常应答,解析时先判断这一位,再决定是取数还是打日志。

选型上还有一个判断:表计是走 RS485 串口还是已经接了集中器。RS485 总线是半双工,一主多从,主站发一帧、电表回一帧,天然一问一答;如果表计已经挂到集中器下面,集中器会做规约转换,主站可能面对的是集中器暴露出来的另一个协议。做平台时不要把「抄表」写死成串口,把链路抽象成一层采集适配器,后面接串口、接 TCP、接集中器 API 都能换。

2.2 最小可用的645解析器:读有功总电能的请求与应答

先写一个最小可用的工具类,能组读请求、能解析应答。这里以读「组合有功总电能」为例,数据标识用 00 00 00 00,具体值要按表计厂家协议文档确认。完整代码框架如下:

public class Dl645Utils { /** * 组一个读数据请求帧 * @param meterNo 12位表号,如 "123456789012" * @param dataIdent 4字节数据标识,默认组合有功总电能 {0x00,0x00,0x00,0x00} */ public static byte[] buildReadRequest(String meterNo, byte[] dataIdent) { byte[] frame = new byte[15]; int pos = 0; frame[pos++] = 0x68; // 帧起始符 byte[] bcd = toBcd(meterNo); // 表号转BCD for (int i = bcd.length - 1; i >= 0; i--) { frame[pos++] = bcd[i]; // 地址域低字节在前 } frame[pos++] = 0x11; // 控制码:读数据 frame[pos++] = 0x04; // 数据域长度:只有数据标识 frame[pos++] = dataIdent[0]; frame[pos++] = dataIdent[1]; frame[pos++] = dataIdent[2]; frame[pos++] = dataIdent[3]; frame[pos++] = calcCs(frame, 1, pos); // 校验和 frame[pos] = 0x16; // 帧结束符 return frame; } /** * 解析应答帧,返回电量值(kWh) * 帧结构:68 地址(6) 控制码 长度 数据标识(4) 数据值 08 16 */ public static double parseReadResponse(byte[] frame) throws IOException { if (frame == null || frame.length < 17) { throw new IOException("帧长不足"); } if (frame[0] != 0x68 || frame[frame.length - 1] != 0x16) { throw new IOException("帧头帧尾不匹配"); } int csPos = frame.length - 2; if (frame[csPos] != calcCs(frame, 1, csPos)) { throw new IOException("校验和错误"); } int control = frame[7] & 0xFF; if ((control & 0x40) != 0) { throw new IOException("电表返回异常应答,控制码=" + String.format("%02X", control)); } int len = frame[8] & 0xFF; // 数据域长度 int dataStart = 9; // 跳过4字节数据标识,从 dataStart+4 开始读 BCD 电能值 double value = bcdToDouble(frame, dataStart + 4, len - 4); return value; } private static byte[] toBcd(String meterNo) { if (meterNo == null || meterNo.length() != 12) { throw new IllegalArgumentException("表号必须是12位数字"); } byte[] out = new byte[6]; for (int i = 0; i < 6; i++) { int hi = meterNo.charAt(i * 2) - '0'; int lo = meterNo.charAt(i * 2 + 1) - '0'; out[i] = (byte) ((hi << 4) | lo); } return out; } private static double bcdToDouble(byte[] data, int offset, int len) { long raw = 0; for (int i = 0; i < len; i++) { int b = data[offset + i] & 0xFF; raw = raw * 100 + (b >> 4) * 10 + (b & 0x0F); } return raw * 0.01; // 默认两位小数,按表计实际配置调整 } private static byte calcCs(byte[] data, int from, int to) { int cs = 0; for (int i = from; i < to; i++) { cs += data[i] & 0xFF; } return (byte) (cs & 0xFF); } }

逻辑说明:buildReadRequest 里最关键的是地址域的反序发送。表号是 12 位十进制数字,BCD 编码后是 6 字节,但 645 规范要求发送时低字节在前,所以循环里从 bcd[5] 往前填。控制码 0x11 表示读数据,数据域长度 0x04 表示后面只跟 4 字节数据标识。

参数说明里有一个容易错的地方:bcdToDouble 里的默认两位小数。不同型号电表对电能值的小数位定义不一样,有的是整数,有的是三位小数。我一般把小数位数做成配置项,接入新表型时先读一帧,人工核对一次读数再定参数,不要写死。

解析应答时先做三道校验:起始符、结束符、校验和。三道都过再判断控制码异常位。这里特别提醒:校验和范围是「从地址域开始到数据域结束」,起始符 68 和结束符 16 都不参与,具体厂家差异放到第 5 章单独讲。

2.3 串口与网络接入:实际上电表怎么连到Java服务

常见的物理链路有两种。第一种是 RS485 总线直接拉到机房,通过串口服务器转 TCP,Java 服务连 TCP 端口;第二种是现场已经有集中器,主站走集中器的 4G 或者以太网口。不管哪种,Java 侧看到的都是一个字节流,区别只在连接方式。RS485 直连时串口参数通常是 2400 波特率、8 数据位、1 停止位、无校验,但部分厂家表计默认偶校验,接入前用表计手册核对。

用 jSerialComm 做串口读写的代码如下:

import com.fazecast.jSerialComm.SerialPort; public class SerialChannel { private SerialPort port; public void open(String comName, int baud, int readTimeoutMs) { port = SerialPort.getCommPort(comName); port.setComPortParameters(baud, 8, SerialPort.ONESTOPBIT, SerialPort.NO_PARITY); port.setComPortTimeouts(SerialPort.TIMEOUT_READ_BLOCKING, readTimeoutMs, readTimeoutMs); if (!port.openPort()) { throw new IllegalStateException("串口打开失败: " + comName); } } public byte[] transceive(byte[] request) { port.writeBytes(request, request.length); byte[] buffer = new byte[64]; int n = port.readBytes(buffer, buffer.length); if (n <= 0) { throw new RuntimeException("读取超时或未收到数据"); } return Arrays.copyOf(buffer, n); } }

逻辑说明:串口是流式接口,没有消息边界,readBytes 返回的数据不保证正好是一整帧。这里先把读取超时设为 2000 毫秒,保证表计不响应时线程能及时退出,不会把任务池打满。粘包和半包的完整处理放到第 5 章。

参数说明:readTimeoutMs 建议 1500 到 3000 之间。645 应答在表计侧通常不到 200 毫秒,留 10 倍余量是为了覆盖集中器转发时间。超时设太短会把慢表误判为故障,设太长会导致整个轮询周期拉长。

3. 批量抄表的任务调度:轮询、并发与补抄

3.1 三个必调参数:轮询周期、单表超时、并发数

抄表平台的核心不是解析协议,而是把成千上万只表在有限时间内抄完。这里有三组参数决定成败:轮询周期、单表超时、并发数。轮询周期按业务需求定,物业月底出账单,日抄一次就够;要做用电曲线分析,就按 15 分钟一个周期。单表超时按链路类型定,串口直连 1 到 2 秒能完成一问一答,经过集中器转发可能需要 3 到 5 秒。并发数要看链路瓶颈——串口半双工本质上是串行的,一个串口上并发没有意义;走 TCP 的集中器才能多路并发。

我实践下来常用的组合:轮询周期 15 分钟、单表超时 5 秒、单通道并发 8。为什么并发取 8?因为大多数集中器下行模块同时能处理的表地址有限,并发太高会出现电表应答质量下降,表现为偶发校验错和超时;太低又会拉长整轮抄表时间。这个值没有绝对标准,现场接入后先跑一小时,看超时率再调。

3.2 定时抄表主循环:ScheduledExecutorService + CompletableFuture

用 ScheduledExecutorService 做定时触发,用 CompletableFuture 做并发控制,是这套源码里最常见的写法。示例如下:

public class MeterReadScheduler { private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2); private final ExecutorService readExecutor = Executors.newFixedThreadPool(8); private final MeterCollector collector; private final MeterReadMapper readMapper; public void start() { scheduler.scheduleAtFixedRate(this::round, 0, 15, TimeUnit.MINUTES); } private void round() { List<Meter> meters = collector.listOnlineMeters(); Semaphore semaphore = new Semaphore(8); List<CompletableFuture<Void>> futures = new ArrayList<>(); for (Meter meter : meters) { CompletableFuture<Void> future = CompletableFuture.runAsync(() -> { semaphore.acquire(); try { double value = collector.readMeter(meter); readMapper.insert(new MeterReadRecord(meter.getId(), value, new Date())); } catch (Exception e) { retryQueue.offer(meter); // 抄表失败进补抄队列 } finally { semaphore.release(); } }, readExecutor).orTimeout(5, TimeUnit.SECONDS); futures.add(future); } CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); } }

逻辑说明:外层 scheduleAtFixedRate 保证每 15 分钟触发一轮,内层每个表任务用 Semaphore 限流到 8 个并发。orTimeout(5, TimeUnit.SECONDS) 是单表超时兜底,超时后 future 会异常完成,但底层任务线程不一定会被中断,这是第 5 章要展开的坑。

参数说明:readExecutor 的线程数要大于等于 Semaphore 许可数,否则会让部分任务一直排队。我一般线程池设 16,信号量设 8,留一倍余量给超时任务和重试任务。

这里还要注意一点:allOf(...).join() 会让调度线程阻塞等待整轮完成。如果某只表迟迟不返回,会拖慢下一轮调度。解决方法是把轮询周期拆成「触发间隔」和「本轮最大等待」两个配置,触发时先检查上一轮是否还在跑,还在跑就跳过本轮,避免任务堆叠。

3.3 抄表记录的数据模型:存原始读数还是存用量

抄表数据落库,最忌讳只存「本次用量」。一旦后续发现倍率配错或者补抄了几条数据,原始读数丢了就再也对不回来。我建议抄表流水表存原始读数,用量通过相邻两条记录计算。建表 SQL 如下:

CREATE TABLE meter_read_record ( id BIGINT AUTO_INCREMENT PRIMARY KEY, meter_id BIGINT NOT NULL, meter_no VARCHAR(20) NOT NULL, read_value DECIMAL(12,4) NOT NULL, read_time DATETIME NOT NULL, biz_date DATE NOT NULL, source TINYINT NOT NULL DEFAULT 1 COMMENT '1=定时抄表 2=补抄 3=手工录入', status TINYINT NOT NULL DEFAULT 0 COMMENT '0=正常 1=异常', UNIQUE KEY uk_meter_bizdate (meter_id, biz_date), KEY idx_read_time (read_time) ) COMMENT '抄表原始读数流水';

字段说明:read_value 是电表原始读数,不带倍率,倍率在电表档案表里维护;biz_date 是业务日期,配合唯一键防止同一只表同一天被重复插入;source 字段用来区分定时抄表、补抄和人工录入,后面做对账时能看出数据来源。

逻辑说明:为什么存原始读数而不是算好的用量?因为倍率可能调错、表计可能被更换,原始数据是审计的底账。用量计算放到月度结算时做,用相邻两条 read_value 做差再乘倍率,如果出现负数则说明表计被换过或者读数翻转,需要在结算任务里单独标记。

这里有个跟 MyBatis 相关的细节:如果用 MyBatis-Plus 的 saveBatch 批量插入,默认并没有走真正的 JDBC 批量执行,每一条还是单条 insert,几千条数据会明显拖慢一轮抄表。我一般用自定义 SQL 的 foreach 写批量插入,或者开启 rewriteBatchedStatements 参数,批量性能能差出一个量级。

3.4 补抄机制:失败表怎么不丢

一轮抄表不可能百分之百成功,现场总有停电、表计故障、通信瞬断。补抄机制的核心是「先记录,再处理」。每只失败表进入补抄队列后,按指数退避重试:第一次 1 分钟后补抄,第二次 5 分钟,第三次 15 分钟,超过三次后标记为故障表,转入人工工单。

补抄队列我用内存队列加数据库标记表双写,重启不丢任务。轻量做法如下:

@Component public class RetryQueue { private final DelayQueue<DelayItem<Meter>> queue = new DelayQueue<>(); public void offer(Meter meter) { queue.offer(new DelayItem<>(meter, 60 * 1000L)); } public void runLoop() { while (true) { DelayItem<Meter> item = queue.poll(); if (item == null) { Thread.sleep(500); continue; } try { double value = collector.readMeter(item.getData()); readMapper.insert(...); } catch (Exception e) { // 重试次数+1,失败超3次转人工工单 } } } }

逻辑说明:DelayQueue 是 JDK 自带的延迟队列,取不到任务时线程阻塞,不会空转。重试次数记录在电表档案上,超过 3 次后不再自动重试,写一条告警记录,让运维人员去现场确认是表计故障还是通信问题。

参数说明:退避间隔我常用 1 分钟、5 分钟、15 分钟三档。为什么不一直快重试?因为现场通信模块在故障恢复初期并不平稳,连续快速重试反而会加剧集中器负担,拉开间隔让链路自己缓过来。

4. 缴费管理怎么落地:台账、订单与余额的Java实现

4.1 先定账务模型:预付费还是后付费

缴费管理最怕边做边改,账务模型必须在一开始定清楚。两种模式差异很大:预付费模式是用户先充值、用电时扣减余额,余额不足自动跳闸;后付费模式是先用电、出账单后再交钱,存在欠费追缴。园区、公寓多采用预付费,写字楼多采用后付费。平台源码里两种模式都要支持,但核心账务表可以共用一套。

我的做法是用户账户表只存余额和冻结金额,所有变动走流水表。这样不管是充值、扣费、退款还是冲正,账户余额都是一个累积结果,出问题能靠流水回溯。这是面试里常被追问的「Java 怎么保证数据一致性」问题——落到这个项目上,答案就是「事务 + 唯一索引 + 流水对账」三件套,而不是某个分布式事务中间件。

4.2 缴费入账的幂等与事务:别把支付回调包进事务

缴费入账有一个非常典型的并发场景:用户同时用两个支付渠道交费,或者支付网关回调重复推送。如果入账方法没有幂等保护,用户会被重复加钱。核心做法是给每笔缴费单一个业务编号,数据库唯一索引兜底,入账方法如下:

@Transactional(rollbackFor = Exception.class) public PayResult recharge(PayOrder order) { // 幂等校验:同一业务编号只允许入账一次 int exists = payOrderMapper.countByBizNo(order.getBizNo()); if (exists > 0) { return PayResult.duplicated(); } payOrderMapper.insert(order); // 原子累加余额,避免 read-modify-write 并发丢更新 int rows = accountMapper.increaseBalance(order.getAccountId(), order.getAmount()); if (rows != 1) { throw new IllegalStateException("账户不存在或余额更新失败"); } accountFlowMapper.insert(FlowRecord.of(order)); return PayResult.ok(order); }

逻辑说明:increaseBalance 的 SQL 是 UPDATE account SET balance = balance + #{amount} WHERE id = #{accountId},这一条语句由数据库行锁保证原子性。比起先 SELECT 再 UPDATE,它不需要应用层加锁,也不会出现两个事务互相覆盖余额的问题。业务编号唯一索引是第二道防线,即使两个请求同时进来,也只有一个能 insert 成功。

特别强调:这个事务里不能调用支付网关、发短信、发 websocket 通知。因为这些远程调用的耗时不可控,事务会一直攥着数据库连接不放。正确做法是事务里只写本地账务,提交成功后再把「入账完成」的消息发到消息队列,由消费者去通知用户。如果支付回调到达时订单已存在,直接查订单状态返回即可,这样天然幂等。

4.3 阶梯电价与月度账单:用量怎么换算成账单

阶梯电价是缴费平台里最容易算错的地方。电表抄回来的是累计读数,两个读数做差得到周期用量,再用区间单价分段计算。阶梯电价配置表结构如下:

CREATE TABLE price_step ( id BIGINT PRIMARY KEY, meter_type VARCHAR(20) NOT NULL, step_no INT NOT NULL, min_usage DECIMAL(12,4) NOT NULL, max_usage DECIMAL(12,4), unit_price DECIMAL(10,4) NOT NULL ) COMMENT '阶梯电价区间表';

计算逻辑:对每个账户,拿到周期用量 usage,按 step 顺序扣减。比如第一阶梯 0 到 200 度单价 0.5,第二阶梯 200 到 400 度单价 0.6,第三阶梯 400 度以上单价 0.8。某用户用了 350 度,费用是 2000.5 + 1500.6。核心代码如下:

public BigDecimal calcFee(List<PriceStep> steps, BigDecimal usage) { BigDecimal remain = usage; BigDecimal total = BigDecimal.ZERO; for (PriceStep step : steps) { if (remain.compareTo(BigDecimal.ZERO) <= 0) break; BigDecimal span = step.getMaxUsage() == null ? remain : step.getMaxUsage().subtract(step.getMinUsage()); BigDecimal used = remain.min(span); total = total.add(used.multiply(step.getUnitPrice())); remain = remain.subtract(used); } return total; }

逻辑说明:BigDecimal 是账务计算的唯一选择,任何 double 累加都会在后面的对账里翻车。区间用 min 和 max 表示,max 为 null 表示上不封顶。月度结算任务先把每个账户的周期用量算出来,再逐账户跑这个分段逻辑,最后生成账单记录。

踩坑提醒:账单和缴费是两个东西。账单是「应该交多少」,缴费是「实际交了多少钱」。很多实现把这两个混在一张表里,导致一笔缴费又想抵扣历史欠费又想冲抵当月账单时,数据变得一塌糊涂。我做平台时把账单表、缴费订单表、账户流水表严格分开,账单只负责算费,缴费只负责入账,两者通过账户余额间接联动。

5. JAVA源码落地避坑:抄表缴费平台的5个血泪教训

5.1 表号字节序反了:BCD地址域的发送顺序

现象:读请求发出去,电表就是不回,或者返回的地址域不是自己那只表,解析器直接当陌生设备丢弃。

原因:DL/T645 的地址域虽然是 6 字节 BCD,但规范要求「低字节在前」发送。表号 123456789012 按顺序 BCD 编码是 12 34 56 78 90 12,发送时却要反序成 12 90 78 56 34 12。第一次写的人十有八九按自然顺序填,结果就是报文对不上。

解决:在 buildReadRequest 里对所有地址域做一次反转,代码见 2.2 节。接新表时先用厂家调试软件抓一帧正常报文,把这帧逐字节跟自己组出来的对比一遍,字节序问题当场就能发现。

5.2 校验和总对不上:不同厂家对CS范围的实现不一致

现象:同一帧报文,用自己封装的解析器校验和报错,但用厂家调试软件读同一只表却一切正常。

原因:645 规范里校验和范围是「从地址域开始到数据域结束」,但部分厂家表计实现时把起始符 68 也加了进去,或者漏掉了数据域长度字节。这是历史遗留的兼容性问题,不是标准不标准的问题,现场就是存在。

解决:CS 计算做一个开关,支持「从地址域开始」和「从起始符开始」两种模式。接入新表型时,先用厂家协议文档里的样例报文手动校验一次,确认 CS 范围再改配置。顺手在日志里把接收到的原始帧打出来,排查校验错时能直接看到是哪个字节对不上。

5.3 串口粘包与半包:读回来的帧断成两截

现象:readBytes 一次读回来的数据,有时比一帧长,有时比一帧短。解析器按整帧处理时,要么报帧长不足,要么把两帧数据当成一帧导致校验和错误。

原因:串口是流式协议,没有消息边界。一次 read 可能只读到一帧的前半段,也可能把下一帧的开头一起带回来。这在 RS485 半双工场景下特别常见,因为表计应答速度快,连续读两只表时相邻帧会挤在一起。

解决:不做「一次 read 就是一帧」的假设,改为帧积累器。先找到 68 起始符,读固定帧头,从帧头取数据域长度 L,再凑够 L+2 个字节(数据域加校验和加结束符),凑不满就继续等。拿到完整帧后再交给解析器。

public class FrameAccumulator { private final ByteArrayOutputStream buf = new ByteArrayOutputStream(); public List<byte[]> push(byte[] chunk) { buf.write(chunk, 0, chunk.length); List<byte[]> frames = new ArrayList<>(); byte[] data = buf.toByteArray(); int pos = 0; while (pos < data.length) { if (data[pos] != 0x68) { pos++; continue; } // 找起始符 if (pos + 10 > data.length) break; // 帧头没凑齐 int len = data[pos + 8] & 0xFF; // 数据域长度 int total = 1 + 6 + 1 + 1 + len + 1 + 1; // 整帧长度 if (pos + total > data.length) break; // 整帧没到齐 frames.add(Arrays.copyOfRange(data, pos, pos + total)); pos += total; // 跳到下一帧 } buf.reset(); buf.write(data, pos, data.length - pos); // 剩余数据留到下次 return frames; } }

逻辑说明:核心是每次 push 只处理完整帧,剩余半截留在缓冲区。这个类放在串口读取层和协议解析层中间,能解决 90% 的粘包半包问题。参数上要注意 total 计算里的 68 起始符本身占了 1 字节,地址域 6 字节,控制码 1 字节,长度 1 字节,数据域 len 字节,CS 1 字节,16 结束符 1 字节。

5.4 并发抄表把线程池打满:超时配置的级联效应

现象:一轮抄表本来应该 5 分钟跑完,实际跑了 40 分钟,而且越跑越慢,最后几轮任务全部卡死。

原因:这是一个典型的级联故障。单表读取的网络层没设读超时,表计不响应时线程一直挂在 read 上;CompletableFuture.orTimeout 虽然让调用方超时返回了,但底层线程池里的任务并没有被中断,线程被占满,后续任务全部排队。

解决:网络层和任务层两层都要兜底。网络层 sockread 必须设置读超时,我说过一般 2000 到 3000 毫秒;任务层 orTimeout 设置 5 秒,比网络层超时略长,让底层线程先被释放。线程池任务里用 try-finally 保证信号量释放,这样即使线程卡住,也不会阻塞整个调度循环。上线前做一次故障演练:拔掉一半表计的通信线,看整轮抄表时间是否还受控。

5.5 事务里调远程接口:数据库连接池被一场缴费拖死

现象:缴费高峰期应用偶尔卡顿,数据库连接池耗尽,日志里全是获取连接超时。

原因:入账方法加了 @Transactional,里面调了支付网关接口。支付网关平均响应 300 毫秒,但偶发 10 秒超时,这个期间事务一直握着数据库连接。几十个缴费请求同时进来,连接池瞬间被打满,连普通的抄表入库都进不去了。

解决:事务里只做本地库操作,远程调用一律移到事务外面。入账事务提交成功后,通过 Spring 的事件机制或者消息队列触发后续通知。代码审查时定一条死规矩:所有 Service 方法里,只要加了 @Transactional,就不准出现 HTTP 调用、MQ 发送、短信发送,这是 Java 开发规范里最常见也最容易被突破的一条。

6. 不连真表也能验证:本地模拟电表跑通全链路

6.1 最小模拟从站:一个字节数组造出完整应答帧

没有真电表时,用一段 ServerSocket 代码模拟一只表计。监听一个端口,收到读请求后返回写死的应答帧,全链路从「采集服务、入库、账单计算到缴费入账」都能验证。核心代码如下:

public class MeterSimulator { public static void main(String[] args) throws Exception { byte[] response = buildResponse(); try (ServerSocket server = new ServerSocket(5020)) { while (true) { try (Socket socket = server.accept()) { byte[] req = socket.getInputStream().readAllBytes(); if (req.length > 0 && req[0] == 0x68) { socket.getOutputStream().write(response); } } } } } private static byte[] buildResponse() { byte[] frame = new byte[19]; frame[0] = 0x68; // 地址域:反序的BCD表号 123456789012 byte[] addr = {0x12, (byte) 0x90, 0x78, 0x56, 0x34, 0x12}; System.arraycopy(addr, 0, frame, 1, 6); frame[7] = (byte) 0x91; // 正常应答 frame[8] = 0x08; // 数据域长度:4字节标识 + 4字节电能 frame[9] = 0x00; // 数据标识 DI0 frame[10] = 0x00; frame[11] = 0x00; frame[12] = 0x00; frame[13] = 0x00; // 电能 BCD 高位 frame[14] = 0x13; frame[15] = 0x05; // 读数 1305,两位小数 = 13.05 kWh frame[16] = 0x16; // CS 占位 frame[17] = frame[17]; // 这里换成调用 CS 计算逻辑 frame[18] = 0x16; return frame; } }

逻辑说明:模拟器只做一件事——收到 68 开头的帧就回一帧固定数据。这样 Java 主站侧的调度、入库、算费流程全部能跑起来。响应的数据标识要和请求里的保持一致,否则解析器会取出错值。

6.2 从模拟到真表:验证平台要盯的三个指标

本地模拟跑通后,接真表时我习惯盯三个指标。第一个是一轮抄表成功率,初次接入应达到 99% 以上,低于这个值先查通信参数;第二个是日冻结数据完整率,跑满 24 小时后比对电表屏幕读数,确认小数位和倍率配对了;第三个是缴费入账延迟,本地转账模拟从下单到余额变更不超过 2 秒,不含支付网关时间。三个指标全过,平台才算具备交付条件。

6.3 进阶技巧:冻结曲线与报表导出的量纲统一

量纲统一是进阶阶段最常见的坑。抄表原始值是 kWh,报表要显示万 kWh,同比环比又要百分比,一个数值在三套体系里转来转去很容易出 bug。我的习惯是:数据库一律存原始值,只在最外层展示层做单位换算。导出 Excel 报表时同样用转换后的数据,避免在底层沉淀「已换算」的字段。负责做这个功能的人总会问 Apache POI 能不能生成图表,能,但真正的坑从来不在图表,而在单位没统一导致曲线差几个数量级。这行做的越久越觉得,把数据和事务的边界管好,比引入花哨的框架可靠得多。希望这份拆解帮你在智能电表这条赛道上少踩几个坑。

本文还有配套的精品资源,点击获取

返回列表