PAYATHON 2026

Java 电信计费系统应使用 multithreading 还是 clustering?

支付阿杰

结论

对于每天计算 10 万份账单的 university project,建议先使用单机 Java 程序,通过受控的多线程并行处理。现阶段没有必要搭建由两三台服务器组成的集群。

10 万份账单平均到一天约为:

[ 100000 \div 86400 \approx 1.16\ \text{份/秒} ]

即使要在 1 小时内全部处理完,吞吐量也只有:

[ 100000 \div 3600 \approx 27.8\ \text{份/秒} ]

这个规模通常不需要 clustering。确定架构前,应先弄清单份账单的计算耗时、数据读取速度、数据库写入能力,以及任务允许多长时间完成。

为什么先选 multithreading

多线程和集群并不冲突,只是解决的问题不同:

  • Multithreading:利用一台机器的多个 CPU 核心,提高单机吞吐量。
  • Clustering:让多台机器共同执行任务,用于提高吞吐量、横向扩展和故障容错。

对于课程项目,集群会带来许多与计费逻辑无关的问题,例如:

  • 任务的分配和重新分配
  • 节点宕机后的恢复
  • 重复执行和幂等处理
  • 分布式锁与任务状态同步
  • 网络通信和部署
  • 多节点日志、监控与排错

在实际测试证明单机无法满足时间要求之前,没有必要承担这些额外的复杂度。

推荐的实现步骤

1. 先定义性能目标

仅知道“每天 10 万份”还无法决定架构,还需要明确处理窗口。例如:

  • 允许在 24 小时内完成
  • 必须在夜间 2 小时内完成
  • 必须在 15 分钟内完成

假设单份账单平均耗时为 (t) 秒,账单总数为 (N),使用 (p) 个并行工作线程时,理想处理时间约为:

[ T \approx \frac{N \times t}{p} ]

实际处理时间还包括数据库、文件 I/O、线程调度和事务提交等开销,所以公式只能用于估算,最终仍要通过测试确认。

2. 先实现正确的单线程版本

先保证核心流程正确:

  1. 读取客户、套餐和通话记录。
  2. 计算费用、折扣和税费。
  3. 生成账单结果。
  4. 保存账单并记录处理状态。

电信计费首先要保证结果准确。金额建议使用 BigDecimal,不要使用 float 或 double:

import java.math.BigDecimal;
import java.math.RoundingMode;

public final class BillingCalculator {

    public BigDecimal calculate(BillingInput input) {
        BigDecimal callCharge = input.callMinutes()
                .multiply(input.pricePerMinute());

        BigDecimal subtotal = input.monthlyFee().add(callCharge);

        return subtotal
                .multiply(BigDecimal.ONE.add(input.taxRate()))
                .setScale(2, RoundingMode.HALF_UP);
    }
}

舍入模式、税费计算顺序和中间金额精度都应由业务规则明确,不能默认所有场景采用同一套规则。

3. 使用固定大小的线程池

不要为每份账单单独创建线程。可以使用 ExecutorService 建立固定大小的线程池:

import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

public final class BillingJob {

    private final BillRepository repository;
    private final BillingCalculator calculator;

    public BillingJob(BillRepository repository,
                      BillingCalculator calculator) {
        this.repository = repository;
        this.calculator = calculator;
    }

    public void process(List<BillingInput> inputs) throws InterruptedException {
        int workers = Math.max(
                1,
                Runtime.getRuntime().availableProcessors() - 1
        );

        ExecutorService executor = Executors.newFixedThreadPool(workers);

        try {
            for (BillingInput input : inputs) {
                executor.submit(() -> processOne(input));
            }
        } finally {
            executor.shutdown();

            if (!executor.awaitTermination(1, TimeUnit.HOURS)) {
                executor.shutdownNow();
            }
        }
    }

    private void processOne(BillingInput input) {
        try {
            var amount = calculator.calculate(input);
            repository.save(input.customerId(), amount);
        } catch (Exception exception) {
            repository.markFailed(input.customerId(), exception.getMessage());
        }
    }
}

线程数不能直接设为账单数量:

  • 计算密集型任务可以从接近 CPU 核心数的线程数开始测试。
  • I/O 密集型任务可以适当增加线程数。
  • 如果数据库连接池只有 10 个连接,创建 100 个同时写数据库的线程,通常不会带来相同比例的性能提升,还可能增加等待和超时。

4. 分批读取和提交

不要一次性把 10 万份账单及其全部明细加载到内存。更稳妥的做法是分批处理,例如每次读取一定数量的记录:

int page = 0;
int batchSize = 1000;

while (true) {
    List<BillingInput> batch = repository.loadPending(page, batchSize);

    if (batch.isEmpty()) {
        break;
    }

    billingJob.process(batch);
    page++;
}

分页方式要考虑处理期间的数据变化。如果记录可能被插入、删除或改变状态,普通的 offset pagination 可能造成遗漏或重复。这时更适合根据稳定且唯一的键进行 keyset pagination,例如:

SELECT *
FROM billing_input
WHERE id > ?
ORDER BY id
LIMIT ?

数据库写入也可以使用 JDBC batch,但批次大小要结合数据库、驱动和事务要求进行测试。

5. 建立可重复的性能测试

不要只看总耗时,还要分别测量:

  • 数据读取耗时
  • 单份账单计算耗时
  • 结果写入耗时
  • 不同线程数下的总吞吐量
  • CPU、内存和数据库连接使用情况
  • 错误、重试和重复账单数量

可以依次测试单线程、2 线程、4 线程和 8 线程。当吞吐量不再提高,或者数据库等待明显增加时,就不应继续增加线程。

为未来集群化预留边界

课程项目不必真正部署集群,但可以让任务支持分片。例如,按照 customerId 的范围或哈希值划分账单:

boolean belongsToPartition(
        long customerId,
        int partition,
        int partitionCount
) {
    return Math.floorMod(Long.hashCode(customerId), partitionCount)
            == partition;
}

以后如果需要两台服务器,可以由不同实例处理不同分区:

  • 节点 0 处理 partition = 0
  • 节点 1 处理 partition = 1

在生产环境中,仅靠这种方法还无法保证可靠性。系统还要处理节点故障、任务重试和分区重新分配。更常见的做法是使用数据库任务表或消息队列,让多个 worker 竞争领取任务。

无论采用多线程还是集群,保存账单的操作都应该具备幂等性。可以为“客户 + 账期”建立唯一约束:

ALTER TABLE bill
ADD CONSTRAINT uk_bill_customer_period
UNIQUE (customer_id, billing_period);

这样即使任务发生重试,也能减少同一账期账单被重复生成的风险。应用仍要正确处理唯一约束冲突,不能只依靠异常兜底。

需要特别注意的问题

线程安全

避免让多个线程修改同一个可变对象。计费输入和结果最好设计为不可变对象,无状态的计算器通常可以安全共享。

数据库事务

不要让所有账单共用一个巨型事务,否则一次错误就可能引发大范围回滚。通常可以按单份账单或合理批次划分事务,具体方式取决于业务的一致性要求。

错误恢复

某份账单处理失败时,应记录状态和原因,便于后续重试,不要因此终止整个批次。重试次数必须有限,还要区分临时错误和永久业务错误。

金额和时间

金额使用 BigDecimal。账期边界需要明确时区,并优先使用 java.time API,例如 Instant、LocalDate 和 ZonedDateTime,以免在月末、夏令时和跨时区场景中出错。

先测量,再扩容

只有当单机多线程无法满足明确的处理窗口,或者系统要求某个节点故障后仍能继续工作时,才需要考虑 clustering。

对于当前规模和课程项目,更合适的方案是先完成一个支持分批、重试和幂等处理的单机多线程实现,再通过性能测试确认它能否达到目标。集群化只需作为后续的架构扩展点保留。

备注:内容仅供参考。