目录
- 前言
- 时间字段的乌龙
- [JSON 序列化的坑](#JSON 序列化的坑)
- [PostgreSQL 的方言陷阱](#PostgreSQL 的方言陷阱)
- 数据结构的重构:从单表到分表
- [架构调整:从 Pipeline 到 Executor](#架构调整:从 Pipeline 到 Executor)
- 总结
前言
在开发合同审查引擎时,尽管前期已确定整体架构设计,但在实际编码与调试过程中,仍遭遇诸多问题。这些问题成因各异,有的源于粗心导致的低级失误,有的是因对技术理解不够深入,还有的是架构调整引发的连锁反应。将这些经历记录下来,既是对自身开发过程的总结,也期望能为其他开发者提供借鉴。
时间字段的乌龙
问题描述
在任务状态管理的早期版本中,出现了一个奇特现象:任务重新启动后,先前记录的完成时间依旧存在,致使统计的平均执行时长严重失准。这就好比你记录跑步完成一圈的时间,第二次跑的时候,上一次的完成时间还在干扰记录,导致你统计平均每圈用时出错。
问题根源
此问题的本质较为直观,即在状态转换时,未对时间字段进行正确处理。当任务从 COMPLETED 状态重置为 PENDING 并再次启动时,completedAt 字段未被清空。同样,在任务启动时,也未重置该字段。
修复前代码:
java
// 该方法启动任务,但忘记清空completedAt字段
public void start() {
validateStatusTransition(TaskStatus.RUNNING);
this.status = TaskStatus.RUNNING;
this.startTime = LocalDateTime.now();
// 忘记清空 completedAt
}
修复后代码:
java
// 修复后,在启动任务时清空completedAt字段
public void start() {
validateStatusTransition(TaskStatus.RUNNING);
this.status = TaskStatus.RUNNING;
this.startTime = LocalDateTime.now();
this.completedAt = null; // 新增
}
同样的逻辑错误也出现在 complete() 和 reset() 方法中。解决方法很简单,即在合适的位置添加对 completedAt 的赋值或清空操作。
经验教训
这类问题具有一定隐蔽性,日常运行时可能难以察觉,但一旦涉及统计分析,便会立刻暴露。此后,养成了一个习惯:但凡涉及时间字段的状态转换,都要仔细检查每个分支对时间字段的处理。所幸相关代码聚合性较好,仅在一个类中完成修复,否则若分散在各个 service 中,修复工作恐怕要耗费更多时间。
JSON 序列化的坑
问题描述
在处理审查结果的证据类型(EvidenceType)时,遭遇反序列化失败问题。前端传递过来的 JSON 数据在转换为 Java 对象时,始终报错,提示"Unknown EvidenceType"。这就像是有人给你一种语言的文字信息,但你却找不到对应的翻译方法,无法理解它。
问题根源
最初定义枚举时,仅考虑了数据库存储,未顾及 JSON 序列化场景。
修复前代码:
java
// 该枚举定义仅考虑数据库存储,未考虑JSON序列化
public enum EvidenceType {
CONTRACT_TEXT("合同原文"),
LEGAL_RULE("法律规则"),
INDUSTRY_STANDARD("行业标准");
private final String type;
EvidenceType(String type) {
this.type = type;
}
}
如此定义在 Java 内部使用并无问题,但 Jackson 在反序列化时,找不到对应的工厂方法,故而报错。
解决方案
通过添加 Jackson 的注解,并实现自定义反序列化逻辑来解决此问题。
修复后代码:
java
// 序列化时使用该方法
@JsonValue
public String getType() {
return type;
}
// 反序列化时使用该工厂方法
@JsonCreator
public static EvidenceType fromString(String value) {
if (value == null) {
return null;
}
return Stream.of(values())
.filter(e -> e.type.equalsIgnoreCase(value))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("Unknown EvidenceType: " + value));
}
经验教训
枚举类型的序列化/反序列化是常见的陷阱。若枚举需用于网络传输或持久化,务必提前规划好序列化策略。Jackson 的注解虽提供了灵活控制手段,但需显式声明。
PostgreSQL 的方言陷阱
问题描述
在实现任务统计查询时,一个看似简单的 SQL 查询却始终报错。该查询逻辑仅是计算平均执行时长,然而却无法正常执行。
问题根源
最初采用的 SQL 写法为:
sql
WHERE task_status = ANY(:statusList)
此语法在 PostgreSQL 中本身是合法的,但结合 JPA 使用时,可能会出现兼容性问题。这就好比你用一种地方特色的烹饪方法做菜,在当地厨房没问题,但换了个通用的厨房环境,就可能出问题。
解决方案
改用更为标准的 IN 语法:
sql
WHERE task_status IN (:statusList)
经验教训
在使用特定数据库的方言特性时,必须考虑 ORM 框架的兼容性。若无法确定,使用更标准、通用的 SQL 语法会更为稳妥。此次出现问题,归根结底还是对 JPA 的选用及使用不够熟悉。
数据结构的重构:从单表到分表
背景
在最初的数据库设计中,审查结果(ReviewResult)包含大量 JSON 字段,如风险项列表、关键点列表等。随着业务逻辑日益复杂,这种设计逐渐暴露出一系列问题:
- 查询困难:无法直接针对某个风险项进行查询或过滤,就像在一个大仓库里找特定的一件物品,却没有清晰的分类标识。
- 数据冗余:同样的规则结果可能在不同任务中重复存储,造成存储空间浪费。
- 扩展性差:添加新的关联数据时,需要修改整体结构,牵一发而动全身。
重构过程
将原本存储在 JSON 字段中的数据拆分为独立的子表:
主表:
sql
-- 主表保持精简
review_results (
id, task_id, overall_risk_level, summary,
key_points, -- 保留为 JSONB
evidences, -- 保留为 JSONB
...
)
新增子表:
sql
review_rule_results (
id, review_result_id, risk_name, rule_type,
risk_level, risk_score, summary, findings,
recommendation,...
)
clause_results (
id, review_result_id, risk_type, content,
risk_level, clause_text, location,...
)
同时,在代码层面也进行了相应调整:
java
// 在领域模型中建立关联
@OneToMany(mappedBy = "reviewResult", cascade = CascadeType.ALL)
private List<ReviewRuleResultEntity> ruleResults;
@OneToMany(mappedBy = "reviewResult", cascade = CascadeType.ALL)
private List<ClauseResultEntity> clauseResults;
收获
此次重构带来了显著好处:
- 查询便利:能够直接针对规则结果进行查询和统计,提高数据检索效率。
- 结构清晰:数据结构更加清晰,维护起来更加便捷。
- 符合规范:符合数据库设计的规范化原则,提升数据管理的科学性。
但同时也需留意:
- 懒加载问题:JPA 的懒加载机制需要妥善考虑,避免出现 N + 1 查询问题,即多次重复查询数据库,影响性能。
- 双向引用:关联数据的保存需要建立双向引用,确保数据的完整性和一致性。
java
// 保存前建立双向关联
if (reviewResult.getRuleResults() != null) {
reviewResult.getRuleResults().forEach(ruleResult ->
ruleResult.setReviewResult(reviewResult)
);
}
架构调整:从 Pipeline 到 Executor
背景
项目初期,设计了基于 Pipeline 模式的审查流程,每个阶段实现 PipelineStage 接口。此设计看似优雅,但在实际应用中暴露出一些弊端:
- 耦合度高:阶段之间的耦合度较高,一个阶段的变动可能影响其他阶段,就像多米诺骨牌,一张倒了可能引发连锁反应。
- 逻辑复杂:错误处理和重试逻辑复杂,增加了开发和维护成本。
- 效率低下:批量处理的效率不够高,难以满足业务发展需求。
转型过程
最终决定摒弃 Pipeline 模式,改用基于执行器(Executor)的批处理模式:
java
// 新的架构
ContractReviewScheduler(调度器)
↓
ContractReviewAggregatorProcessor(聚合处理器)
↓
按阶段分组
↓
各阶段执行器(ClauseExtractionExecutor、ModelReviewExecutor、ReportGenerationExecutor)
每个执行器承担以下职责:
- 查询处于该阶段的所有任务。
- 批量调用外部服务或执行本地逻辑。
- 更新任务状态并推进到下一阶段。
收获与反思
此次架构调整让我对"过度设计"有了更深刻的认识。Pipeline 模式理论上虽更优雅,但在实际业务场景中,简单的批处理模式或许更为适宜。在选择架构模式时,需综合考量以下因素:
- 业务需求:实际的业务量和并发需求,确保架构能够支撑业务发展。
- 维护成本:团队的维护成本,选择易于理解和维护的架构。
- 调试监控:调试和监控的便利性,便于及时发现和解决问题。
总结
回顾这些开发过程中出现的 bug 和问题,大部分是可以避免的:
- 充分测试:许多问题在实际运行时才被发现,若具备完善的单元测试和集成测试,便能更早暴露问题。尽管此项目为个人 DEMO 版本,未进行集成测试,但从业务角度看,集成测试对于尽早发现问题至关重要。
- 代码审查:诸如统计计数之类的笔误,若存在代码审查流程,很容易被察觉。
- 理解工具:深入理解 Jackson、JPA 等工具,可规避诸多潜在问题。
- 简化设计:避免过度设计,选择最契合业务场景的方案。
另一方面,这些问题的解决过程也促使系统更加健壮:
- 数据结构优化:数据结构更为合理,提升数据管理效率。
- 状态逻辑清晰:状态逻辑更加清晰,便于理解和维护。
- 错误处理完善:错误处理更加完善,增强系统稳定性。
- 代码可维护性提升:代码可维护性更好,降低后续开发成本。
开发是一个不断发现问题、解决问题的过程。关键在于从每次问题中汲取经验,避免重复犯错,从而推动项目不断优化与完善。