From 582d7dfbcb5abd96e3dc62efc5985332d963bb49 Mon Sep 17 00:00:00 2001 From: "weicw1996@qq.com" Date: Tue, 15 Sep 2026 16:14:05 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AD=A6=E7=94=9F=E5=90=8C=E6=AD=A5=E7=A8=B3?= =?UTF-8?q?=E6=80=81=E5=8F=AA=E5=86=99=E5=8F=98=E6=9B=B4=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 实测写入才是大头,不是平台拉取: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 个。 --- sql/20260915_add_student_row_hash.sql | 21 +++ .../gxmu/openplat/StudentFingerprint.java | 77 ++++++++ .../gxwebsoft/gxmu/openplat/SyncSupport.java | 29 +++- .../gxmu/openplat/entity/GxmuStudent.java | 3 + .../openplat/mapper/GxmuStudentMapper.java | 14 +- .../openplat/mapper/xml/GxmuStudentMapper.xml | 39 +++-- .../openplat/sync/StudentSyncHandler.java | 27 ++- .../gxmu/openplat/StudentFingerprintTest.java | 164 ++++++++++++++++++ 8 files changed, 344 insertions(+), 30 deletions(-) create mode 100644 sql/20260915_add_student_row_hash.sql create mode 100644 src/main/java/com/gxwebsoft/gxmu/openplat/StudentFingerprint.java create mode 100644 src/test/java/com/gxwebsoft/gxmu/openplat/StudentFingerprintTest.java 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 studentHashByXh() { + Map map = new HashMap<>(); + List list = studentMapper.selectHashForDiff(OpenplatSyncConstants.TENANT_ID); + for (GxmuStudent item : list) { + String xh = StrUtil.trimToNull(item.getXh()); + if (xh != null) { + map.put(xh, item.getRowHash()); + } + } + return map; + } + /** * 学生差集停用。 * - *

在 Java 侧求差集而不是拼 NOT IN,避免 9 万多个参数。 + *

用同步开头已窄读到的学号集合求差集,避免再查一次库、也不必拼 NOT IN。 * 只处理同步来源的记录(xh 非空),见 docs/adr/0004。 + * + * @param existingXh 库内现有的同步来源学号 + * @param incomingXh 本次从平台拉到的学号 + * @return 停用条数 */ - public int deactivateMissingStudents(Set incomingXh) { - List existing = studentMapper.selectAllXh(OpenplatSyncConstants.TENANT_ID); + public int deactivateMissingStudents(Set existingXh, Set incomingXh) { List missing = new ArrayList<>(); - for (String xh : existing) { + for (String xh : existingXh) { if (SyncScope.shouldDeactivate(xh, incomingXh.contains(xh))) { missing.add(xh); } diff --git a/src/main/java/com/gxwebsoft/gxmu/openplat/entity/GxmuStudent.java b/src/main/java/com/gxwebsoft/gxmu/openplat/entity/GxmuStudent.java index bc1a05b..23b52eb 100644 --- a/src/main/java/com/gxwebsoft/gxmu/openplat/entity/GxmuStudent.java +++ b/src/main/java/com/gxwebsoft/gxmu/openplat/entity/GxmuStudent.java @@ -124,6 +124,9 @@ public class GxmuStudent implements Serializable { @TableLogic private Integer deleted; + @ApiModelProperty(value = "平台权威字段指纹,用于跳过未变更行") + private String rowHash; + @TableField(exist = false) @ApiModelProperty(value = "学院名称") private String collegeName; diff --git a/src/main/java/com/gxwebsoft/gxmu/openplat/mapper/GxmuStudentMapper.java b/src/main/java/com/gxwebsoft/gxmu/openplat/mapper/GxmuStudentMapper.java index 55b19b3..f15a80f 100644 --- a/src/main/java/com/gxwebsoft/gxmu/openplat/mapper/GxmuStudentMapper.java +++ b/src/main/java/com/gxwebsoft/gxmu/openplat/mapper/GxmuStudentMapper.java @@ -20,24 +20,26 @@ public interface GxmuStudentMapper extends BaseMapper { *

只覆盖平台权威字段;user_id / claim_time 由认领流程维护,不在此处覆盖。 * create_time 不在 UPDATE 子句中,重复执行不会刷新创建时间。 * + *

调用方应先用 {@link #selectHashForDiff} 比对指纹,只传入变化行—— + * 9.8 万行全量写实测要 88 秒,而稳态下变化行通常只有几十到几百条。 + * * @param list 学生名册 * @return 影响行数 */ int batchUpsert(@Param("list") List list); /** - * 查询全部同步来源的学号。 + * 窄读全部同步来源的 (学号, 指纹),用于比对出变化行。 * - *

用于在 Java 侧与本次拉取到的学号求差集——上游消失的记录通常为零, - * 这样就不必用 9 万多个参数去拼 NOT IN。 + *

只取两列,9.8 万行实测约 2.4 秒;换成整行读取会慢一个量级。 * * @param tenantId 租户ID - * @return 学号列表 + * @return 只填了 xh 与 rowHash 的列表 */ - List selectAllXh(@Param("tenantId") Integer tenantId); + List selectHashForDiff(@Param("tenantId") Integer tenantId); /** - * 按学号批量置为停用。 + * 按学号批量置为停用,并把 row_hash 置空。 * *

只作用于同步来源的记录(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 @@ + INSERT INTO gxmu_student ( xh, xm, xbm, sex, mzm, nation, zzmmm, politics, sfzjh, sjh, bjm, bh, class_id, college_id, dwh, zyh, zymc, pyccmc, sfzx, xjzt, xsdqzt, csrq, status, sync_time, tenant_id, - create_time, update_time, deleted + create_time, update_time, deleted, row_hash ) VALUES ( @@ -17,7 +22,7 @@ #{item.zyh}, #{item.zymc}, #{item.pyccmc}, #{item.sfzx}, #{item.xjzt}, #{item.xsdqzt}, #{item.csrq}, #{item.status}, #{item.syncTime}, #{item.tenantId}, - #{item.createTime}, #{item.updateTime}, #{item.deleted} + #{item.createTime}, #{item.updateTime}, #{item.deleted}, #{item.rowHash} ) ON DUPLICATE KEY UPDATE @@ -45,17 +50,31 @@ status = VALUES(status), sync_time = VALUES(sync_time), update_time = VALUES(update_time), - deleted = VALUES(deleted) + deleted = VALUES(deleted), + row_hash = VALUES(row_hash) - + SELECT xh, row_hash + FROM gxmu_student + WHERE tenant_id = #{tenantId} + AND deleted = 0 + AND xh IS NOT NULL + AND xh <> '' + UPDATE gxmu_student - SET status = 0, update_time = NOW() + SET status = 0, row_hash = NULL, update_time = NOW() WHERE tenant_id = #{tenantId} AND status <> 0 AND xh IN @@ -63,10 +82,4 @@ - - diff --git a/src/main/java/com/gxwebsoft/gxmu/openplat/sync/StudentSyncHandler.java b/src/main/java/com/gxwebsoft/gxmu/openplat/sync/StudentSyncHandler.java index b2ce1df..5ca5fc6 100644 --- a/src/main/java/com/gxwebsoft/gxmu/openplat/sync/StudentSyncHandler.java +++ b/src/main/java/com/gxwebsoft/gxmu/openplat/sync/StudentSyncHandler.java @@ -7,6 +7,7 @@ import com.gxwebsoft.gxmu.openplat.OpenPlatformClient; import com.gxwebsoft.gxmu.openplat.OpenPlatformException; import com.gxwebsoft.gxmu.openplat.OpenplatCodeService; import com.gxwebsoft.gxmu.openplat.OpenplatSyncConstants; +import com.gxwebsoft.gxmu.openplat.StudentFingerprint; import com.gxwebsoft.gxmu.openplat.SyncResult; import com.gxwebsoft.gxmu.openplat.SyncSupport; import com.gxwebsoft.gxmu.openplat.entity.GxmuStudent; @@ -17,6 +18,8 @@ import org.springframework.stereotype.Component; import javax.annotation.Resource; import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; import java.util.HashMap; import java.util.HashSet; import java.util.Map; @@ -77,9 +80,12 @@ public class StudentSyncHandler { applyPhones(merged, inSchoolPhone, graduatePhone); + // 库内现有指纹:9.8 万行窄读约 2.4 秒,而全量重写要 88 秒 + Map existingHash = support.studentHashByXh(); + LocalDateTime now = SyncSupport.now(); Set incoming = new HashSet<>(merged.size()); - java.util.List list = new java.util.ArrayList<>(merged.size()); + List changed = new ArrayList<>(); for (Map.Entry entry : merged.entrySet()) { GxmuStudent student = entry.getValue(); student.setXh(entry.getKey()); @@ -92,15 +98,22 @@ public class StudentSyncHandler { student.setStatus(0); } incoming.add(entry.getKey()); - list.add(student); + String hash = StudentFingerprint.of(student); + student.setRowHash(hash); + if (!hash.equals(existingHash.get(entry.getKey()))) { + changed.add(student); + } } - support.upsertStudents(list); - result.setSuccessCount(list.size()); + support.upsertStudents(changed); + // successCount 维持「平台并集人数」的语义,便于与名册总数核对; + // 真正写入的行数记在日志里(生产已放开 openplat 包的日志级别) + result.setSuccessCount(merged.size()); - int deactivated = support.deactivateMissingStudents(incoming); + int deactivated = support.deactivateMissingStudents(existingHash.keySet(), incoming); result.setSkipCount(deactivated); - logger.warn("学生同步完成: 写入 {} 条, 停用 {} 条, 失败 {} 条", - list.size(), deactivated, result.getFailCount()); + logger.warn("学生同步完成: 平台 {} 人, 实际写入 {} 行, 未变更跳过 {} 行, 停用 {} 行, 失败 {} 条", + merged.size(), changed.size(), merged.size() - changed.size(), + deactivated, result.getFailCount()); return result; } diff --git a/src/test/java/com/gxwebsoft/gxmu/openplat/StudentFingerprintTest.java b/src/test/java/com/gxwebsoft/gxmu/openplat/StudentFingerprintTest.java new file mode 100644 index 0000000..d0d446e --- /dev/null +++ b/src/test/java/com/gxwebsoft/gxmu/openplat/StudentFingerprintTest.java @@ -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; + +/** + * 学生变更指纹测试。 + * + *

这里的断言直接决定「稳态只写变更行」是否正确: + * 指纹必须覆盖全部平台权威字段(漏了就会漏更新), + * 同时必须排除每次都在变的字段(含进去就永远判为变化,优化失效)。 + * + * @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 mutation) { + GxmuStudent student = sample(); + mutation.accept(student); + return StudentFingerprint.of(student); + } + +}