学生同步稳态只写变更行

实测写入才是大头,不是平台拉取:9.8 万行的表上 800 行/批 upsert 要 716ms,
123 批共 88 秒;而窄读全部 (xh, row_hash) 只要 2.1 秒。

做法:
- gxmu_student 增列 row_hash,存放「平台权威字段」的 MD5 指纹
  (迁移 sql/20260915_add_student_row_hash.sql,可重复执行)
- 同步时先窄读现有指纹,只 upsert 指纹变化的行
- 窄读同时服务于停用差集,省掉原先单独的一次 selectAllXh
- deactivateByXh 顺带把 row_hash 置空,保证停用行以后重新出现时一定被重新写入

指纹规则单独成类 StudentFingerprint:它必须与 mapper 的
ON DUPLICATE KEY UPDATE 子句严格对应——往 UPDATE 里加字段却忘了加进指纹,
那一列就会静默地不再更新,这类 bug 不报错、只产生过期数据。
因此附有逐字段的测试,并由 MapperFieldWhitelistTest 守住 UPDATE 子句本身。

附带收益:未变更的行不再刷新 update_time,该字段恢复「最后变更时间」的含义;
码表修正后 nation/politics 变化会触发自动重算,正是 ADR-0006 承诺的行为。

预期学生同步 170 秒 -> 约 85 秒,其中写库 88 秒 -> 约 2 秒。
现有 98187 行 row_hash 为空,部署后第一次仍会全量写一次。
离线单测 50 个。
This commit is contained in:
2026-09-15 16:14:05 +08:00
parent cd54039f28
commit 582d7dfbcb
8 changed files with 344 additions and 30 deletions
@@ -0,0 +1,164 @@
package com.gxwebsoft.gxmu.openplat;
import com.gxwebsoft.gxmu.openplat.entity.GxmuStudent;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
/**
* 学生变更指纹测试。
*
* <p>这里的断言直接决定「稳态只写变更行」是否正确:
* 指纹必须覆盖全部平台权威字段(漏了就会漏更新),
* 同时必须排除每次都在变的字段(含进去就永远判为变化,优化失效)。
*
* @author Codex
* @since 2026-09-15
*/
class StudentFingerprintTest {
private static GxmuStudent sample() {
GxmuStudent student = new GxmuStudent();
student.setXh("201510051");
student.setXm("张三");
student.setXbm("1");
student.setSex("男");
student.setMzm("01");
student.setNation("汉族");
student.setZzmmm("3");
student.setPolitics("共青团员");
student.setSfzjh("450101200201011234");
student.setSjh("13800000000");
student.setBjm("GZ2203");
student.setBh("14420114");
student.setClassId(100);
student.setCollegeId(60);
student.setDwh("010318");
student.setZyh("100201");
student.setZymc("临床医学");
student.setPyccmc("普通本科");
student.setSfzx("1");
student.setXjzt("01");
student.setXsdqzt("01");
student.setCsrq("2002-01-01");
student.setStatus(1);
return student;
}
@Test
void sameDataProducesSameFingerprint() {
assertEquals(StudentFingerprint.of(sample()), StudentFingerprint.of(sample()));
}
@Test
void nullStudentProducesNull() {
assertNull(StudentFingerprint.of(null));
}
/**
* 最重要的一条:每次同步都在变的字段不得进入指纹,
* 否则所有行永远判为「已变更」,优化彻底失效。
*/
@Test
void volatileFieldsDoNotAffectFingerprint() {
String base = StudentFingerprint.of(sample());
GxmuStudent other = sample();
other.setSyncTime(LocalDateTime.now());
other.setUpdateTime(LocalDateTime.now());
other.setCreateTime(LocalDateTime.now().minusYears(1));
other.setUserId(831);
other.setClaimTime(LocalDateTime.now());
other.setRowHash("前一次算出来的指纹");
assertEquals(base, StudentFingerprint.of(other),
"syncTime/updateTime/userId/claimTime/rowHash 变化不应触发重写");
}
/**
* 平台权威字段逐个变化都必须被识别出来。
*/
@Test
void everyAuthoritativeFieldAffectsFingerprint() {
String base = StudentFingerprint.of(sample());
assertNotEquals(base, hashOf(s -> s.setXm("李四")), "姓名");
assertNotEquals(base, hashOf(s -> s.setXbm("2")), "性别码");
assertNotEquals(base, hashOf(s -> s.setSex("女")), "性别名称");
assertNotEquals(base, hashOf(s -> s.setMzm("08")), "民族码");
assertNotEquals(base, hashOf(s -> s.setNation("壮族")), "民族名称");
assertNotEquals(base, hashOf(s -> s.setZzmmm("13")), "政治面貌码");
assertNotEquals(base, hashOf(s -> s.setPolitics("群众")), "政治面貌名称");
assertNotEquals(base, hashOf(s -> s.setSfzjh("450101200201019999")), "身份证");
assertNotEquals(base, hashOf(s -> s.setSjh("13900000000")), "手机号");
assertNotEquals(base, hashOf(s -> s.setBjm("GZ2204")), "班级码");
assertNotEquals(base, hashOf(s -> s.setBh("14420115")), "班号");
assertNotEquals(base, hashOf(s -> s.setClassId(101)), "班级ID");
assertNotEquals(base, hashOf(s -> s.setCollegeId(61)), "学院ID");
assertNotEquals(base, hashOf(s -> s.setDwh("010319")), "单位号");
assertNotEquals(base, hashOf(s -> s.setZyh("100202")), "专业号");
assertNotEquals(base, hashOf(s -> s.setZymc("麻醉学")), "专业名称");
assertNotEquals(base, hashOf(s -> s.setPyccmc("研究生")), "培养层次");
assertNotEquals(base, hashOf(s -> s.setSfzx("0")), "是否在校");
assertNotEquals(base, hashOf(s -> s.setXjzt("02")), "学籍状态");
assertNotEquals(base, hashOf(s -> s.setXsdqzt("07")), "学生当前状态");
assertNotEquals(base, hashOf(s -> s.setCsrq("2003-02-02")), "出生日期");
assertNotEquals(base, hashOf(s -> s.setStatus(0)), "状态");
}
/**
* 码表修正后名称会变而原始码不变,此时必须判为变化——
* 这正是 docs/adr/0006 承诺的「码表修正后可全表重算」。
*/
@Test
void codeTableCorrectionTriggersRewrite() {
GxmuStudent before = sample();
before.setNation(null);
GxmuStudent after = sample();
after.setNation("汉族");
assertNotEquals(StudentFingerprint.of(before), StudentFingerprint.of(after),
"码表回填后民族名称为空变为有值,应触发重算");
}
/**
* null 与空串必须可区分,否则「补上空值」这类变更会被漏掉。
*/
@Test
void nullAndEmptyStringAreDistinct() {
GxmuStudent withNull = sample();
withNull.setSjh(null);
GxmuStudent withEmpty = sample();
withEmpty.setSjh("");
assertNotEquals(StudentFingerprint.of(withNull), StudentFingerprint.of(withEmpty));
}
/**
* 字段拼接不得产生歧义:("ab","c") 与 ("a","bc") 不能算出同一指纹。
*/
@Test
void concatenationIsUnambiguous() {
GxmuStudent first = sample();
first.setXm("ab");
first.setZymc("c");
GxmuStudent second = sample();
second.setXm("a");
second.setZymc("bc");
assertNotEquals(StudentFingerprint.of(first), StudentFingerprint.of(second));
}
private static String hashOf(java.util.function.Consumer<GxmuStudent> mutation) {
GxmuStudent student = sample();
mutation.accept(student);
return StudentFingerprint.of(student);
}
}