从单体到微服务:能源计费系统的领域拆分、数据一致性与Spring Cloud实战落地

标题

从单体到微服务:能源计费系统的领域拆分、数据一致性与 Spring Cloud 实战落地


摘要

本文系统性地阐述了能源计费系统从单体架构向微服务架构演进的完整路径。首先,基于领域驱动设计(DDD)方法论,将系统拆分为读数域计费规则域账户域账单域对账域五大核心子域,明确了各领域的职责边界。其次,提出了按领域而非按技术层拆分的微服务策略,并给出了具体的服务拓扑设计。

针对拆分后最关键的跨服务数据一致性 问题,文章区分了强一致性与最终一致性场景:账户扣费采用 TCC 分布式事务 (基于 Seata 实现)保证强一致性;读数到账单的流程则采用事件驱动 + 消息队列实现最终一致性,并强调了幂等键与版本号的重要性。

计费规则引擎部分,介绍了通过"规则模板 + 参数实例"的方式替代硬编码,使用表达式引擎(如 AviatorScript)实现灵活可配置的计费逻辑,并借助 Redis 缓存与热更新机制保障性能与一致性。

文章详细描绘了从读数到出账的完整账单生成链路,并通过时序图清晰展示了六个核心服务(ReadingService、MessageQueue、BillingService、TariffService、AccountService、ReconcileService)间的同步与异步协作。

最后,总结了实践中常见的五大踩坑点,如幂等键设计、账户服务误用、规则缓存不一致、跨服务大表 Join 以及事件时区处理,并提供了具体的反面代码示例与正确解决方案。

核心价值:为面临类似复杂业务系统拆分的团队提供了一套可落地的领域拆分方法论、一致性保障方案、规则引擎设计模式以及基于 Spring Cloud 的实战参考,助力构建高内聚、低耦合、易扩展的现代能源计费系统。


正文

一、前言:为什么能源计费系统必须拆分

在能源管理 SaaS 平台的演进过程中,计费子系统往往是最晚被拆解、却也最容易成为瓶颈的模块。它的特殊性在于:既要承接电表、水表、气表等高频采集数据,又要完成复杂的费率计算、阶梯计价、预付费扣减、账单生成与财务对账。随着接入项目数从几十个增长到几千个,单体架构下的计费服务会同时面临三类问题:

  • 写入冲突:抄表数据、充值数据、扣费任务集中在同一个数据库,高峰期行锁竞争严重;
  • 变更耦合:一次电价策略调整可能牵动整个计费流程的回归测试;
  • 扩展受限 :不同场景对计费规则差异极大------公寓预付费电表系统要求先充值后用电、宿舍智能电表管理系统需要支持限功率断电、园区能耗监测平台则涉及多租户分摊计费,单体代码里的 if-else 分支会指数级增长。

合众致达在智慧能源管理平台的演进中,同样经历了从单体到微服务的改造。以合众致达智能电表接入的某深圳工业园区项目为例,早期计费模块与采集模块耦合在一起,每次费率调整都需要整体停机部署。本文以能源计费系统为例,分享领域拆分思路、数据一致性方案以及基于 Spring Cloud 的实战落地细节,希望能为正在做类似拆分的同学提供参考。

二、能源计费系统的业务领域识别

领域驱动设计(DDD)并不是纸上谈兵,它真正的作用在于:在拆服务之前,先把业务边界说清楚。能源计费系统可以拆成以下五个核心子域:

2.1 读数域(Meter Reading Domain)

负责原始表计读数的接收、清洗、补全与归档。数据来源包括 MQTTModbusDL/T 645CJ/T 188 等协议,在智能电表远程抄表系统场景下,数据特征是量大、时序性强、允许少量延迟。该域同时为宿舍恶性负载识别系统提供清洗后的高质量用电曲线数据,支撑后续 AI 负荷分析。

2.2 计费规则域(Tariff Domain)

负责电价、水价、燃气价、阶梯规则、分时规则、季节性调价等配置。这个域的核心是"规则引擎"而非"数据计算"。

2.3 账户域(Account Domain)

负责用户账户、余额、预付费、信用额度、充值、退款等资金相关操作。在公寓智能电表远程充值系统场景中,该域直接面向租客微信充值与自动扣费,对数据一致性的要求最高,必须保证"钱"不出错。

2.4 账单域(Billing Domain)

负责根据读数与规则生成账单、出账、销账、分期、发票对接。它需要同时读取读数域与规则域,并产生结果数据。

2.5 对账域(Reconciliation Domain)

负责与支付渠道、财务系统、物业系统的资金核对。对实时性要求较低,但对准确性要求极高。

【建议配图:领域拆分示意图】

图中用五个边界框表示五大子域,箭头表示数据依赖方向。读数域 → 账单域,规则域 → 账单域,账户域 ↔ 账单域(扣费/销账),账单域 → 对账域。

三、微服务拆分策略:按领域还是按功能

拆分的常见误区是"按技术层拆分"------比如把系统拆成 service 层、DAO 层、Controller 层。这种拆分只会增加网络调用,不会降低业务耦合。能源计费系统更适合采用按领域拆分的策略。

3.1 拆分原则

原则 说明
单一职责 一个服务只负责一个领域概念的生命周期
高内聚低耦合 服务内部修改频率一致,跨服务调用尽量只读
数据所有权 每个服务拥有自己的数据库,禁止直接访问其他服务的数据库
可测试性 每个服务可独立部署、独立压测、独立回滚

3.2 拆分后的服务拓扑

复制代码
┌─────────────────┐      ┌─────────────────┐      ┌─────────────────┐
│   API Gateway   │──────│  Billing Service │──────│  Account Service │
│  Spring Cloud   │      │   账单生成/销账   │      │   余额/充值/扣费   │
└────────┬────────┘      └────────┬────────┘      └────────┬────────┘
         │                          │                         │
         │         ┌────────────────┴─────────────────────────┘
         │         │
┌────────▼────────┐ ┌─────────────────┐      ┌─────────────────┐
│  Tariff Service │ │  Reading Service │      │ Reconcile Svc   │
│   费率规则引擎   │ │   读数/归档/查询   │      │   对账/财务对接   │
└─────────────────┘ └─────────────────┘      └─────────────────┘

【建议配图:微服务拓扑与数据库归属】

每个服务下方标注对应数据库:ReadingService 使用 TimescaleDBTariffService 使用 MySQLAccountService 使用 MySQLBillingService 使用 MySQLReconcileService 使用 MySQL

四、关键问题:跨服务数据一致性怎么保证

拆完服务之后,最头疼的问题就是"一个业务动作要改多个服务的数据"。例如:生成账单时,需要从读数域读取表计读数,从规则域读取费率,然后通知账户域完成扣费。如果中间任何一步失败,数据就会出现不一致。

4.1 强一致性场景:账户扣费

账户扣费涉及钱,必须保证强一致性。我们采用 TCC 分布式事务模式:

  • Try:预扣账户余额;
  • Confirm:确认扣费,更新账单状态;
  • Cancel:释放预扣金额,回滚余额。

4.2 最终一致性场景:读数到账单

读数域与账单域之间不需要实时同步,适合采用 事件驱动 + 消息队列 模式。ReadingService 在收到一批表计读数并完成清洗后,发布 MeterReadingConfirmed 事件;BillingService 订阅该事件,异步生成账单。

这里有一个关键设计:事件必须携带 领域事件版本号幂等键,防止消息重复消费或乱序消费导致重复出账。

4.3 代码示例:基于 Seata 的 TCC 扣费实现

下面是一个简化版的 TCC 账户扣费实现。AccountService 提供三个接口,分别对应 Try、Confirm、Cancel。

java 复制代码
/**
 * 代码块1:TCC 账户扣费接口(Java,Spring Cloud + Seata)
 * 用途:展示预付费场景下,跨服务扣费的强一致性实现
 */
@LocalTCC
public interface AccountTccAction {

    @TwoPhaseBusinessAction(name = "deductBalanceTcc",
            useTCCFence = true,
            tryMethod = "tryDeduct",
            confirmMethod = "confirm",
            cancelMethod = "cancel")
    boolean tryDeduct(@BusinessActionContextParameter(paramName = "accountId") String accountId,
                      @BusinessActionContextParameter(paramName = "amount") BigDecimal amount);

    boolean confirm(BusinessActionContext context);

    boolean cancel(BusinessActionContext context);
}

@Service
public class AccountTccActionImpl implements AccountTccAction {

    @Autowired
    private AccountMapper accountMapper;

    @Override
    public boolean tryDeduct(String accountId, BigDecimal amount) {
        // 检查余额,并冻结对应金额
        int rows = accountMapper.freezeBalance(accountId, amount);
        if (rows == 0) {
            throw new BizException("余额不足或账户不存在");
        }
        return true;
    }

    @Override
    public boolean confirm(BusinessActionContext context) {
        String accountId = context.getActionContext("accountId").toString();
        BigDecimal amount = new BigDecimal(context.getActionContext("amount").toString());
        // 将冻结金额转为实际扣减
        accountMapper.confirmDeduction(accountId, amount);
        return true;
    }

    @Override
    public boolean cancel(BusinessActionContext context) {
        String accountId = context.getActionContext("accountId").toString();
        BigDecimal amount = new BigDecimal(context.getActionContext("amount").toString());
        // 释放冻结金额
        accountMapper.releaseFrozen(accountId, amount);
        return true;
    }
}

对应的数据库操作需要设计 balancefrozen_balance 两个字段,确保在 Try 阶段只是"锁定"而非真正扣减。

五、计费规则引擎:如何用规则表替代硬编码

能源计费系统最难维护的部分往往不是代码,而是规则。不同城市、不同园区、不同业主的合同条款差异巨大。如果把这些规则写成 if-else,系统会迅速腐化。

5.1 规则模型设计

我们采用"规则模板 + 参数实例"的方式。一个规则模板定义了计算表达式的结构,参数实例则为具体客户赋值。例如:

复制代码
规则模板:夏季阶梯电价
- 第一档:0-260 kWh,单价 P1
- 第二档:261-600 kWh,单价 P2
- 第三档:600 kWh 以上,单价 P3
- 生效时间:每年 6-9 月

参数实例:深圳某工业园区 2026 夏季(合众致达智慧能源管理平台项目)
- P1 = 0.68, P2 = 0.78, P3 = 0.98
- 生效月份 = 6,7,8,9

5.2 规则执行引擎

规则引擎使用 AviatorScriptQLExpress 表达式引擎,将计费规则解析为可执行脚本。服务启动时从 TariffService 加载规则到本地缓存,账单计算时按 tenantId + ruleCode + effectiveDate 选取命中规则。

python 复制代码
"""
代码块2:阶梯电价计算逻辑(Python,基于规则参数解析)
用途:演示如何从规则模板与实例参数生成最终账单金额
"""

def calculate_tiered_bill(reading_kwh, rule_params):
    """
    reading_kwh: 本期用电量(kWh)
    rule_params: {
        'tier1_limit': 260, 'tier1_price': 0.68,
        'tier2_limit': 600, 'tier2_price': 0.78,
        'tier3_price': 0.98
    }
    """
    total = 0.0
    remaining = reading_kwh

    # 第一档
    tier1 = min(remaining, rule_params['tier1_limit'])
    total += tier1 * rule_params['tier1_price']
    remaining -= tier1

    if remaining > 0:
        # 第二档
        tier2_limit = rule_params['tier2_limit'] - rule_params['tier1_limit']
        tier2 = min(remaining, tier2_limit)
        total += tier2 * rule_params['tier2_price']
        remaining -= tier2

    if remaining > 0:
        # 第三档
        total += remaining * rule_params['tier3_price']

    return round(total, 2)

# 示例:某公寓预付费电表系统用户 7 月用电 450 kWh
params = {
    'tier1_limit': 260, 'tier1_price': 0.68,
    'tier2_limit': 600, 'tier2_price': 0.78,
    'tier3_price': 0.98
}
print(calculate_tiered_bill(450, params))  # 输出:214.4

5.3 规则缓存与热更新

规则数据变更频率低,但读取频率高,适合放入 Redis 缓存。同时需要支持热更新:当运营人员在后台修改电价后,通过 Spring Cloud Bus 或自定义消息通道通知所有计费节点刷新本地缓存,避免重启服务。

六、账单生成流程:从读数到出账的完整链路

一次典型的账单生成流程如下:

  1. ReadingService 完成日冻结后,发布 DailyReadingFrozenEvent
  2. BillingService 消费事件,按 tenantIdmeterId 聚合读数;
  3. 调用 TariffService 获取当前生效的费率规则;
  4. 执行规则计算,生成待确认账单;
  5. 对预付费账户触发 TCC 扣费;
  6. 扣费成功后,账单状态变更为"已出账",并推送 BillIssuedEvent
  7. ReconcileService 异步订阅事件,记录对账流水。

【建议配图:账单生成时序图】
ReconcileService对账服务 AccountService账户服务 TariffService费率服务 BillingService账单服务 MessageQueue消息队列 ReadingService读数服务 ReconcileService对账服务 AccountService账户服务 TariffService费率服务 BillingService账单服务 MessageQueue消息队列 ReadingService读数服务 #mermaid-svg-3q8L3nXlwtzjy61v{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-3q8L3nXlwtzjy61v .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-3q8L3nXlwtzjy61v .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-3q8L3nXlwtzjy61v .error-icon{fill:#552222;}#mermaid-svg-3q8L3nXlwtzjy61v .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-3q8L3nXlwtzjy61v .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-3q8L3nXlwtzjy61v .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-3q8L3nXlwtzjy61v .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-3q8L3nXlwtzjy61v .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-3q8L3nXlwtzjy61v .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-3q8L3nXlwtzjy61v .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-3q8L3nXlwtzjy61v .marker{fill:#333333;stroke:#333333;}#mermaid-svg-3q8L3nXlwtzjy61v .marker.cross{stroke:#333333;}#mermaid-svg-3q8L3nXlwtzjy61v svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-3q8L3nXlwtzjy61v p{margin:0;}#mermaid-svg-3q8L3nXlwtzjy61v .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-3q8L3nXlwtzjy61v text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-3q8L3nXlwtzjy61v .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-3q8L3nXlwtzjy61v .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-3q8L3nXlwtzjy61v .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-3q8L3nXlwtzjy61v .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-3q8L3nXlwtzjy61v #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-3q8L3nXlwtzjy61v .sequenceNumber{fill:white;}#mermaid-svg-3q8L3nXlwtzjy61v #sequencenumber{fill:#333;}#mermaid-svg-3q8L3nXlwtzjy61v #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-3q8L3nXlwtzjy61v .messageText{fill:#333;stroke:none;}#mermaid-svg-3q8L3nXlwtzjy61v .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-3q8L3nXlwtzjy61v .labelText,#mermaid-svg-3q8L3nXlwtzjy61v .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-3q8L3nXlwtzjy61v .loopText,#mermaid-svg-3q8L3nXlwtzjy61v .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-3q8L3nXlwtzjy61v .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-3q8L3nXlwtzjy61v .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-3q8L3nXlwtzjy61v .noteText,#mermaid-svg-3q8L3nXlwtzjy61v .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-3q8L3nXlwtzjy61v .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-3q8L3nXlwtzjy61v .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-3q8L3nXlwtzjy61v .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-3q8L3nXlwtzjy61v .actorPopupMenu{position:absolute;}#mermaid-svg-3q8L3nXlwtzjy61v .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-3q8L3nXlwtzjy61v .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-3q8L3nXlwtzjy61v .actor-man circle,#mermaid-svg-3q8L3nXlwtzjy61v line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-3q8L3nXlwtzjy61v :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 1. 完成日冻结 事件携带 tenantId, meterId,batchId, 版本号, 幂等键 2. 按 tenantId 与 meterId 聚合读数 3. 执行规则计算,生成待确认账单 4. 账单状态变更为"已出账" 事件携带账单ID、金额、状态、时间戳 5. 记录对账流水 发布 DailyReadingFrozenEvent(异步,实线)消费事件(异步,实线)同步调用获取费率规则(同步,虚线)返回规则数据TCC Try: 预扣账户余额(同步,虚线)预扣成功TCC Confirm: 确认扣费(同步,虚线)扣费成功推送 BillIssuedEvent(异步,实线)异步订阅事件(异步,实线)

时序图包含六个泳道:读数服务、消息队列、账单服务、费率服务、账户服务、对账服务。箭头标注同步调用(虚线)与异步事件(实线)。

时序图包含六个泳道:读数服务、消息队列、账单服务、费率服务、账户服务、对账服务。箭头标注同步调用(虚线)与异步事件(实线)。

七、常见踩坑

  1. 幂等键设计太短 :账单生成依赖读数事件,如果幂等键只用 meterId + date,重跑历史数据时容易重复。建议加入 batchIdeventId

  2. 把账户服务当成余额查询服务:账户域不仅提供余额,还负责充值、冻结、扣费、退款等状态机。其他服务直接读账户余额字段会导致并发扣费超扣,必须走服务接口。

    反面示例(直接查询数据库):

    java 复制代码
    // ❌ 错误做法:BillingService 直接查询 AccountService 的数据库
    @Service
    public class BillingService {
        @Autowired
        private JdbcTemplate jdbcTemplate;
        
        public void deductBalance(String accountId, BigDecimal amount) {
            // 直接查询账户余额(绕过服务接口)
            BigDecimal balance = jdbcTemplate.queryForObject(
                "SELECT balance FROM account WHERE account_id = ?", 
                BigDecimal.class, accountId);
            
            if (balance.compareTo(amount) < 0) {
                throw new InsufficientBalanceException("余额不足");
            }
            
            // 直接扣减余额(没有事务保护)
            jdbcTemplate.update(
                "UPDATE account SET balance = balance - ? WHERE account_id = ?",
                amount, accountId);
        }
    }

    正确做法(通过服务接口):

    java 复制代码
    // ✅ 正确做法:通过 AccountService 接口操作账户
    @Service
    public class BillingService {
        @Autowired
        private AccountClient accountClient; // Feign/RestTemplate 客户端
        
        public void deductBalance(String accountId, BigDecimal amount) {
            // 调用账户服务的扣费接口(支持事务和并发控制)
            DeductRequest request = new DeductRequest(accountId, amount, "BILL_DEDUCT");
            DeductResponse response = accountClient.deduct(request);
            
            if (!response.isSuccess()) {
                throw new BusinessException("扣费失败: " + response.getMessage());
            }
        }
    }
    
    // AccountService 内部的扣费逻辑(带乐观锁)
    @Service
    public class AccountServiceImpl implements AccountService {
        @Transactional
        public DeductResponse deduct(DeductRequest request) {
            Account account = accountRepository.findById(request.getAccountId())
                .orElseThrow(() -> new AccountNotFoundException());
            
            // 使用版本号防止并发超扣
            if (account.getBalance().compareTo(request.getAmount()) < 0) {
                return DeductResponse.fail("余额不足");
            }
            
            account.setBalance(account.getBalance().subtract(request.getAmount()));
            account.setVersion(account.getVersion() + 1); // 乐观锁版本更新
            accountRepository.save(account);
            
            // 记录扣费流水
            deductionRecordRepository.save(new DeductionRecord(
                request.getAccountId(), 
                request.getAmount(),
                request.getBizType()
            ));
            
            return DeductResponse.success();
        }
    }
  3. 规则缓存不一致 :分布式环境下,如果规则更新只刷新了某一台节点,其他节点仍在使用旧规则,会导致同一账单不同计算结果。建议规则数据带 version 字段,并在每次计算时做版本校验。

  4. 跨服务大表 Join :拆分后仍然有人写 SELECT ... FROM reading_table JOIN bill_table,这是架构倒退。必须彻底禁止跨库 Join,需要关联的数据通过服务接口或事件同步获得。

    反面示例(跨库 Join):

    java 复制代码
    // ❌ 错误做法:在 BillingService 中直接 Join 两个服务的表
    @Repository
    public class BillingRepository {
        @Autowired
        private JdbcTemplate jdbcTemplate;
        
        public List<BillingDetail> getBillingWithReadings(String tenantId) {
            // 跨服务 Join:reading_table 属于 ReadingService,billing_table 属于 BillingService
            String sql = """
                SELECT r.meter_id, r.reading_value, r.reading_time,
                       b.bill_id, b.amount, b.status
                FROM reading_db.reading_table r
                JOIN billing_db.billing_table b ON r.meter_id = b.meter_id
                WHERE r.tenant_id = ? AND b.tenant_id = ?
                ORDER BY r.reading_time DESC
                """;
                
            return jdbcTemplate.query(sql, (rs, rowNum) -> {
                BillingDetail detail = new BillingDetail();
                detail.setMeterId(rs.getString("meter_id"));
                detail.setReadingValue(rs.getBigDecimal("reading_value"));
                detail.setReadingTime(rs.getTimestamp("reading_time").toLocalDateTime());
                detail.setBillId(rs.getString("bill_id"));
                detail.setAmount(rs.getBigDecimal("amount"));
                detail.setStatus(rs.getString("status"));
                return detail;
            }, tenantId, tenantId);
        }
    }

    正确做法(服务接口聚合):

    java 复制代码
    // ✅ 正确做法:通过服务接口获取关联数据
    @Service
    public class BillingService {
        @Autowired
        private ReadingClient readingClient;
        @Autowired
        private BillingRepository billingRepository;
        
        public List<BillingDetail> getBillingWithReadings(String tenantId) {
            // 1. 从本服务查询账单数据
            List<Bill> bills = billingRepository.findByTenantId(tenantId);
            
            // 2. 批量调用读数服务获取读数数据
            List<String> meterIds = bills.stream()
                .map(Bill::getMeterId)
                .distinct()
                .collect(Collectors.toList());
                
            Map<String, List<Reading>> readingsMap = readingClient
                .batchGetReadings(meterIds, tenantId, LocalDate.now().minusMonths(1));
            
            // 3. 在应用层聚合数据
            return bills.stream()
                .map(bill -> {
                    BillingDetail detail = new BillingDetail();
                    detail.setBillId(bill.getId());
                    detail.setAmount(bill.getAmount());
                    detail.setStatus(bill.getStatus());
                    
                    // 关联读数数据
                    List<Reading> readings = readingsMap.get(bill.getMeterId());
                    if (readings != null && !readings.isEmpty()) {
                        detail.setLatestReading(readings.get(0));
                    }
                    
                    return detail;
                })
                .collect(Collectors.toList());
        }
    }
    
    // 或者使用事件驱动架构(最终一致性)
    @Service
    public class ReadingService {
        @EventListener
        @Async
        public void handleReadingConfirmed(MeterReadingConfirmedEvent event) {
            // 读数确认后,异步同步到账单服务的冗余表
            billingSyncService.syncReadingToBillingDb(
                event.getMeterId(),
                event.getReadingValue(),
                event.getReadingTime()
            );
        }
    }
  5. 事件序列化忽略时区 :能源计量数据通常带本地时间,OffsetDateTimeLocalDateTime 更适合事件字段,避免夏令时或租户时区切换导致账单周期错误。## 八、写在最后

能源计费系统的微服务拆分不是"为了拆而拆",而是业务复杂度与团队协作规模达到一定程度后的自然演进。拆分的核心收益在于:每个团队可以独立迭代自己的领域,规则、账户、读数、账单可以各自选择最适合的存储与计算方案。

在合众致达智慧能源管理平台的落地过程中,我们也不断调整领域边界。例如早期的"读数域"与"账单域"边界一度模糊,后来发现读数数据的归档策略与账单周期并不一致,才彻底分离。这一经验在多个项目(从公寓智能电表远程充值系统到园区能耗监测平台)中得到验证。这些经验表明,微服务架构没有一次性正确的方案,只有在持续演进中逐步清晰边界。

每周一/三/五更新,关注专栏获取更多技术分享。


代码块清单

  • 代码块1(约45行,Java):TCC 账户扣费接口实现,用于预付费场景下跨服务强一致性扣费
  • 代码块 2(约 35 行,Python):阶梯电价计算逻辑,用于规则模板与参数实例化后的金额计算

标签

微服务架构, Spring Cloud, 能源计费系统, DDD 领域驱动设计, TCC 分布式事务, Seata, 分布式系统, 智慧能源 SaaS

--

相关推荐
LiaoWL12318 小时前
【SpringCloud合集-04】Sentinel 流量控制与熔断降级 学习笔记
学习·spring cloud·sentinel
Misnearch20 小时前
nacos配置管理改造
java·spring cloud·nacos
worilb1 天前
Spring Cloud 学习与实践(15):使用 TraceId 与 MDC 串联微服务日志
学习·spring cloud·微服务
西山创罪梦不觉1 天前
能源IEMS
能源·需求分析
2601_959982211 天前
从单体架构到微服务,再到云原生:现代软件架构的发展趋势
微服务·云原生·架构
Wang's Blog1 天前
Go-Zero框架上手前奏2: 微服务核心要素 —— 拆分、通信与无状态设计
开发语言·微服务·golang
yydbjx2 天前
2026 博容能源应急电源产品线解析:液电 2.0 技术与选型指南
能源
长不胖的路人甲2 天前
SpringCloud 服务雪崩、熔断、降级
后端·spring·spring cloud
珠海西格电力2 天前
数据采集与治理:零碳园区管理系统的 “生命线”
大数据·人工智能·算法·架构·能源