9.2 KiB
Context
tb_iot_card.iccid 是 varchar(20) 的全局唯一字段,所有精确查询均基于它。物联网平台上有两类运营商:
- 19 位运营商(如中国电信):ICCID 本身为 19 位,是唯一标识符
- 20 位运营商(如移动部分号段):ICCID 需要完整 20 位才唯一,第 20 位是真实业务数据而非 Luhn 校验码
上游 IoT 平台返回的 ICCID 格式不固定:同一张卡,不同时候可能传回 19 位或 20 位。当前代码使用 WHERE iccid = ? 精确匹配,格式不一致时静默失败(iotCardID == 0,函数返回 nil),导致 is_current 状态更新无效。这是不可观测的数据错乱。
Goals / Non-Goals
Goals:
- 无论上游传入 19 位还是 20 位,均能正确命中对应卡记录
- 查询性能不降级:每次查询仍走单列精确索引,不引入模糊查询或 OR 条件
- Miss 可观测:上游格式与存储不一致时,记录 Warn 日志暴露数据质量问题
- 覆盖
tb_personal_customer_iccid的同类问题
Non-Goals:
- 不修复上游数据质量问题(这是运营侧职责)
- 不实现跨列降级匹配(19 位 Miss 不去查 20 位列,防止错误命中)
- 不修改 ICCID 的展示逻辑和 Response DTO
- 不影响模糊查询(LIKE)和范围查询(
>=/<=),这类查询语义上不依赖唯一性 - 不改造个人客户发起的 ICCID 查询路径(
client_auth/service.go、exchange/service.go、asset/service.go中的OR iccid = ?条件):这三处查询的 ICCID 来自个人客户手动输入(扫码/手动填写),输入格式与数据库存储格式一致,不存在上游平台的 19/20 位不一致问题;其架构违规(Service 层直接写 SQL)留待专项重构处理
Decisions
决策 1:双列存储(iccid_19 / iccid_20)而非前缀 LIKE
选项 A(选定):新增 iccid_19 varchar(19) 和 iccid_20 varchar(20),分别建 Partial Index。查询时按上游传入长度路由。
选项 B(否决):保留原列,所有精确查询改用 iccid LIKE 'prefix%'。
否决原因:
- PostgreSQL 在非 C locale 下 B-tree 索引不支持 LIKE,需要额外
varchar_pattern_ops索引 - LIKE 仍是范围扫描,效率低于等值查找
- 不解决 20 位运营商 19 位前缀不唯一的根本问题(可能错误命中另一张卡)
选项 C(否决):全量标准化为 19 位存储。
否决原因:部分运营商的 19 位前缀不唯一,截断 20 位 ICCID 会造成数据冲突,存量迁移存在数据丢失风险。
决策 2:按长度路由,禁止跨列降级
len(upstream_iccid) == 19 → WHERE iccid_19 = ?
len(upstream_iccid) == 20 → WHERE iccid_20 = ?
其他长度 → 拒绝,记录 Error 日志
Miss 时 → 记录 Warn 日志,不更新数据,不降级查另一列
禁止降级的原因:若 20 位上游 miss 后降级查 iccid_19(前 19 位),可能命中另一个运营商的 19 位卡,造成 is_current 被更新到错误卡上——错误命中比 Miss 危险得多,且不可观测。
决策 3:Partial Index 而非全量索引
-- iccid_19:所有卡都有值,过滤软删除
CREATE INDEX idx_iot_card_iccid_19 ON tb_iot_card (iccid_19) WHERE deleted_at IS NULL;
-- iccid_20:仅 20 位卡有值,额外过滤 NULL
CREATE INDEX idx_iot_card_iccid_20 ON tb_iot_card (iccid_20)
WHERE deleted_at IS NULL AND iccid_20 IS NOT NULL;
Partial Index 体积更小(排除软删除记录和 NULL 记录),查询效率更高。
决策 3.5:iccid_20 模型字段必须使用指针类型 *string
Go 中 string 类型的零值是 ""(空字符串)。GORM 不会将空字符串自动映射为 SQL NULL,写入结果是 '',导致:
- Partial Index
WHERE iccid_20 IS NOT NULL会把所有 19 位卡行纳入索引,体积膨胀,优化失效 - 任何
WHERE iccid_20 = ?查询不受影响('' != 任何合法 ICCID),但 miss 率虚高,触发大量误 Warn 告警
正确声明:
ICCID20 *string `gorm:"column:iccid_20;type:varchar(20);comment:完整20位ICCID(仅20位运营商卡有值)"`
SplitICCID 工具函数签名须对应:
func SplitICCID(iccid string) (iccid19 string, iccid20 *string)
// 19 位:返回 (iccid, nil)
// 20 位:返回 (iccid[:19], &iccid)
// 其他:返回 ("", nil)
决策 4:写入路径在 Task 层赋值,不修改 Store 层
IotCard 记录只有一个创建入口(iot_card_import.go 的 processBatch())。在 IotCard 初始化时计算并赋值 ICCID19 / ICCID20,Store 层的 Create/CreateBatch 无需感知新字段(GORM 自动处理)。
决策 5.5:GetBoundICCIDs 混合长度路由实现方案
当调用方传入 iccids 混合 19 位和 20 位时,禁止用 OR 条件(c.iccid_19 IN ? OR c.iccid_20 IN ?),因为 OR 会导致全表扫描,无法走 Partial Index。
采用两次独立查询 + 应用层合并:
func (s *DeviceSimBindingStore) GetBoundICCIDs(ctx context.Context, iccids []string) (map[string]bool, error) {
var list19, list20 []string
for _, id := range iccids {
if len(id) == 19 { list19 = append(list19, id) }
if len(id) == 20 { list20 = append(list20, id) }
}
result := make(map[string]bool)
// 19 位组查 iccid_19,SELECT c.iccid_19 AS iccid
// 20 位组查 iccid_20,SELECT c.iccid_20 AS iccid
// 合并两组结果到 result
}
同样规则适用于 IotCardStore.GetByICCIDs(分组后分别 Find,合并 []*model.IotCard 并去重)。
决策 6:enterprise_card/service.go 的内联 SQL 移交 Store 层
该文件有两处(第 46 行、第 185 行)WHERE iccid IN ? 内联 SQL 直接绕过了 Store 层,且同时绕过了数据权限过滤(middleware.ApplyShopFilter)。此次改造中将其重构为调用 IotCardStore.GetByICCIDs(),消除架构违规并恢复数据权限过滤。
依赖注入变更:enterprise_card.Service struct 需新增 iotCardStore *postgres.IotCardStore 字段,New() 签名增加该参数,internal/bootstrap/services.go 调用处同步传入 s.IotCard。
Risks / Trade-offs
[风险] 存量数据中可能存在 19/20 位冲突记录 → 迁移前先执行冲突检测 SQL;若有冲突,人工处理后再执行回填。
[风险] 迁移期间(已加列、未更新代码)查询仍走旧 iccid 列 → 迁移分两阶段:先上线代码(新代码查新列),再回填存量数据;或先回填数据再上线代码。推荐先回填数据再发布代码,避免窗口期数据不一致。
[Trade-off] 两次查询 vs 一次查询:正常路径仍是一次精确索引查询。Miss 场景不发起第二次查询,直接记录日志,无性能影响。
[Trade-off] 存储空间:每张卡多存约 39 字节(19 + 20),百万卡约 39MB,可接受。
Migration Plan
第一步(迁移文件):
注意:
CREATE INDEX CONCURRENTLY不能在事务块内执行,而 golang-migrate 默认每个迁移文件在事务中运行。迁移文件中使用CREATE INDEX IF NOT EXISTS(非 CONCURRENTLY),生产环境若需零停机建索引,可在发布窗口期手动执行 CONCURRENTLY 版本后再合并代码。(与本项目000058_add_covering_index_for_deep_pagination.up.sql范式一致。)
-- 1. 加列(允许 NULL,不阻塞写入)
ALTER TABLE tb_iot_card ADD COLUMN IF NOT EXISTS iccid_19 varchar(19);
ALTER TABLE tb_iot_card ADD COLUMN IF NOT EXISTS iccid_20 varchar(20);
-- 2. 冲突检测(执行后人工确认均为 0 行,否则停止迁移人工处理)
-- 2a. 检测 19 位前缀重复(19 位运营商卡的 iccid_19 必须唯一)
SELECT LEFT(iccid, 19), COUNT(*)
FROM tb_iot_card WHERE deleted_at IS NULL
GROUP BY LEFT(iccid, 19) HAVING COUNT(*) > 1;
-- 2b. 检测异常长度 ICCID(长度既非 19 也非 20 的记录,回填后将永远无法通过新查询路径命中)
SELECT id, iccid, LENGTH(iccid) AS len
FROM tb_iot_card WHERE deleted_at IS NULL AND LENGTH(iccid) NOT IN (19, 20);
-- 3. 回填存量数据
UPDATE tb_iot_card SET
iccid_19 = LEFT(iccid, 19),
iccid_20 = CASE WHEN LENGTH(iccid) = 20 THEN iccid ELSE NULL END;
-- 4. 建索引(迁移文件中不用 CONCURRENTLY,避免事务冲突)
-- 生产零停机版本(手动执行):
-- CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_iot_card_iccid_19
-- ON tb_iot_card (iccid_19) WHERE deleted_at IS NULL;
-- CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_iot_card_iccid_20
-- ON tb_iot_card (iccid_20) WHERE deleted_at IS NULL AND iccid_20 IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_iot_card_iccid_19
ON tb_iot_card (iccid_19) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_iot_card_iccid_20
ON tb_iot_card (iccid_20) WHERE deleted_at IS NULL AND iccid_20 IS NOT NULL;
tb_personal_customer_iccid 同理,仅加 iccid_19 一列(该表只需要按19位匹配)。
第二步(代码发布):更新 Model、Store 查询方法、Task 写入逻辑。
回滚方案:新列加 NULL 约束可随时 DROP,代码回滚至旧版本恢复 WHERE iccid = ? 查询即可。旧 iccid 列全程保留,不做变更。
Open Questions
(无)