diff --git a/sql/20260915_add_student_row_hash.sql b/sql/20260915_add_student_row_hash.sql new file mode 100644 index 0000000..ff8b75b --- /dev/null +++ b/sql/20260915_add_student_row_hash.sql @@ -0,0 +1,21 @@ +-- ============================================================================ +-- 学生名册变更指纹:稳态只写变更行 +-- +-- 背景:gxmu_student 已有 9.8 万行时,一次全量 upsert 实测 88 秒(800 行/批 716ms × 123 批), +-- 而窄读全部 (xh, row_hash) 只要 2.4 秒。多数行每天并不变化,没必要每天重写一遍。 +-- 做法:把「平台权威字段」拼成一个指纹存在 row_hash 里,同步时先窄读现有指纹, +-- 只 upsert 指纹变化的行。 +-- +-- 附带好处:未变更的行不再被刷新 update_time,该字段恢复其「最后真正变更时间」的含义。 +-- +-- 可重复执行 +-- ============================================================================ + +SET @exist := (SELECT COUNT(*) FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'gxmu_student' + AND COLUMN_NAME = 'row_hash'); +SET @stmt := IF(@exist = 0, + 'ALTER TABLE `gxmu_student` ADD COLUMN `row_hash` VARCHAR(32) NULL COMMENT ''平台权威字段指纹,用于跳过未变更行'' AFTER `deleted`', + 'SELECT 1'); +PREPARE s FROM @stmt; EXECUTE s; DEALLOCATE PREPARE s; diff --git a/src/main/java/com/gxwebsoft/gxmu/openplat/StudentFingerprint.java b/src/main/java/com/gxwebsoft/gxmu/openplat/StudentFingerprint.java new file mode 100644 index 0000000..5e731e7 --- /dev/null +++ b/src/main/java/com/gxwebsoft/gxmu/openplat/StudentFingerprint.java @@ -0,0 +1,77 @@ +package com.gxwebsoft.gxmu.openplat; + +import cn.hutool.crypto.SecureUtil; +import com.gxwebsoft.gxmu.openplat.entity.GxmuStudent; + +/** + * 学生名册的变更指纹。 + * + *
为什么需要它:gxmu_student 有 9.8 万行时,一次全量 upsert 实测 88 秒 + * (800 行/批 716ms × 123 批),而窄读全部 (xh, row_hash) 只要 2.4 秒。 + * 多数行每天并不变化,把指纹存下来先比对、只写变化行,稳态就能把 88 秒压到 3 秒级。 + * + *
覆盖范围必须与 mapper 的 {@code ON DUPLICATE KEY UPDATE} 子句严格对应: + * 只有「指纹变了」才能推出「upsert 会写入不同的值」。因此这里包含全部平台权威字段, + * 但不含 {@code syncTime} / {@code updateTime}(它们每次都变,含进去就永远判为变化), + * 也不含 {@code userId} / {@code claimTime}(归认领流程维护)。 + * + *
附带好处:未变更的行不再被刷新 {@code update_time}, + * 该字段因此恢复「最后一次真正变更时间」的含义。 + * + *
另有一个有用的副作用:{@code nation} / {@code politics} 是经码表解析出来的名称。 + * 码表修正后(例如 RS_JZGXX 授权后回填),即使平台的原始码没变、指纹也会变, + * 从而自动触发全表重算——正是 docs/adr/0006 承诺的行为。 + * + * @author Codex + * @since 2026-09-15 + */ +public final class StudentFingerprint { + + /** + * 空值与分隔符的哨兵,保证 null 与空串可区分、且不会因拼接歧义产生碰撞。 + */ + private static final String NULL_TOKEN = "\u0000"; + + private static final char SEPARATOR = '\u0001'; + + private StudentFingerprint() { + } + + /** + * 计算指纹(MD5 十六进制)。字段为空时用哨兵占位。 + */ + public static String of(GxmuStudent student) { + if (student == null) { + return null; + } + StringBuilder sb = new StringBuilder(160); + append(sb, student.getXm()); + append(sb, student.getXbm()); + append(sb, student.getSex()); + append(sb, student.getMzm()); + append(sb, student.getNation()); + append(sb, student.getZzmmm()); + append(sb, student.getPolitics()); + append(sb, student.getSfzjh()); + append(sb, student.getSjh()); + append(sb, student.getBjm()); + append(sb, student.getBh()); + append(sb, student.getClassId()); + append(sb, student.getCollegeId()); + append(sb, student.getDwh()); + append(sb, student.getZyh()); + append(sb, student.getZymc()); + append(sb, student.getPyccmc()); + append(sb, student.getSfzx()); + append(sb, student.getXjzt()); + append(sb, student.getXsdqzt()); + append(sb, student.getCsrq()); + append(sb, student.getStatus()); + return SecureUtil.md5(sb.toString()); + } + + private static void append(StringBuilder sb, Object value) { + sb.append(value == null ? NULL_TOKEN : String.valueOf(value)).append(SEPARATOR); + } + +} diff --git a/src/main/java/com/gxwebsoft/gxmu/openplat/SyncSupport.java b/src/main/java/com/gxwebsoft/gxmu/openplat/SyncSupport.java index 67f3e09..a2356d1 100644 --- a/src/main/java/com/gxwebsoft/gxmu/openplat/SyncSupport.java +++ b/src/main/java/com/gxwebsoft/gxmu/openplat/SyncSupport.java @@ -174,16 +174,37 @@ public class SyncSupport { } } + /** + * 窄读现有 学号 -> 指纹。 + * + *
只取两列,9.8 万行实测约 2.4 秒;一次全量 upsert 却要 88 秒。
+ * 拿它比对出变化行,稳态下写入量能降两三个数量级。
+ */
+ public Map 在 Java 侧求差集而不是拼 NOT IN,避免 9 万多个参数。
+ * 用同步开头已窄读到的学号集合求差集,避免再查一次库、也不必拼 NOT IN。
* 只处理同步来源的记录(xh 非空),见 docs/adr/0004。
+ *
+ * @param existingXh 库内现有的同步来源学号
+ * @param incomingXh 本次从平台拉到的学号
+ * @return 停用条数
*/
- public int deactivateMissingStudents(Set 只覆盖平台权威字段;user_id / claim_time 由认领流程维护,不在此处覆盖。
* create_time 不在 UPDATE 子句中,重复执行不会刷新创建时间。
*
+ * 调用方应先用 {@link #selectHashForDiff} 比对指纹,只传入变化行——
+ * 9.8 万行全量写实测要 88 秒,而稳态下变化行通常只有几十到几百条。
+ *
* @param list 学生名册
* @return 影响行数
*/
int batchUpsert(@Param("list") List 用于在 Java 侧与本次拉取到的学号求差集——上游消失的记录通常为零,
- * 这样就不必用 9 万多个参数去拼 NOT IN。
+ * 只取两列,9.8 万行实测约 2.4 秒;换成整行读取会慢一个量级。
*
* @param tenantId 租户ID
- * @return 学号列表
+ * @return 只填了 xh 与 rowHash 的列表
*/
- List 只作用于同步来源的记录(xh 非空),人工创建的记录不在其中,见 docs/adr/0004。
*
diff --git a/src/main/java/com/gxwebsoft/gxmu/openplat/mapper/xml/GxmuStudentMapper.xml b/src/main/java/com/gxwebsoft/gxmu/openplat/mapper/xml/GxmuStudentMapper.xml
index eed8b95..95c1e8c 100644
--- a/src/main/java/com/gxwebsoft/gxmu/openplat/mapper/xml/GxmuStudentMapper.xml
+++ b/src/main/java/com/gxwebsoft/gxmu/openplat/mapper/xml/GxmuStudentMapper.xml
@@ -2,12 +2,17 @@
这里的断言直接决定「稳态只写变更行」是否正确:
+ * 指纹必须覆盖全部平台权威字段(漏了就会漏更新),
+ * 同时必须排除每次都在变的字段(含进去就永远判为变化,优化失效)。
+ *
+ * @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