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. 先实现正确的单线程版本
先保证核心流程正确:
- 读取客户、套餐和通话记录。
- 计算费用、折扣和税费。
- 生成账单结果。
- 保存账单并记录处理状态。
电信计费首先要保证结果准确。金额建议使用 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。
对于当前规模和课程项目,更合适的方案是先完成一个支持分批、重试和幂等处理的单机多线程实现,再通过性能测试确认它能否达到目标。集群化只需作为后续的架构扩展点保留。
备注:内容仅供参考。
