皮皮舟印岁月

A JOURNAL OF TRAVEL · CODE · MARKETS
第三十七期 · 第 37 号 二〇二六年十月 · 秋分之后

手机号加密存储与可检索方案设计

2026-10-05 13 阅读

一句话导读:手机号属于个人敏感信息,落库必须加密;但加密之后的密文每次都不一样,WHERE mobile = ? 这条最基础的查询会彻底失效。本文从一个场景出发,讲清"既要加密、又要能按手机号查"这对矛盾,并给出以 HMAC 盲索引为核心的完整设计——表结构、SQL、服务端代码、密钥轮换与安全边界。


场景设定

一个面向普通用户的在线服务平台,用户注册、登录、找回账号、联系客服,全部以手机号作为唯一标识。平台同时背着两条硬性约束。

第一条来自合规:手机号属于个人敏感信息,落库必须加密,且加密密钥要与数据分开保管。数据库被拖走、备份文件泄露,都不应导致用户手机号被直接读取。

第二条来自业务:几乎每一个核心流程都要"按手机号找到这个人"。

  • 注册时判断这个号码是否已经存在
  • 登录时用手机号定位到账号
  • 找回账号、换绑手机号时校验号码归属
  • 客服接到电话,用号码定位到用户
  • 风控要判断某个号码是否在库里

这两条约束单独看都很合理,叠在一起就打架了:常规加密为了抵抗模式分析会引入随机数,同一个手机号每次加密得到的密文都不同,于是任何"按手机号精确匹配"的查询都命中不了任何一行。


加密与检索的矛盾

先把矛盾讲透。

现代加密算法(如 AES-GCM、AES-CBC)在加密时会生成一个随机初始化向量(IV)。同一段明文,两次加密的结果完全不同:

第一次加密  13800138000  ->  A7f3Qm9xLp2Vc8Kd...
第二次加密  13800138000  ->  B1n6Rt4wZq7Yh3Mg...

密文不一致,就无法用 = 去比较。换句话说,加密保护了数据,同时也抹掉了数据用于检索的价值。

那能不能反过来,用"确定性加密"——也就是每次加密都得到相同密文?

可以,WHERE mobile = ? 立刻就能用了。但代价不小:

  • 相同明文必然产生相同密文,攻击者只要看到两条记录密文一致,就知道是同一个人、同一个号码,这种"频率分析"直接泄露了记录之间的关联关系
  • 缺乏随机性,攻击者若能接触到加密接口,可以自己构造号码拿到密文,离线建表比对,等于把加密降级成了一张可查的映射表
  • 密钥一旦泄露,全库密文可被批量解密

结论是:需要一种结构,它既能保持"可比较",又不在索引列上放置可被解密的密文。这就是盲索引(Blind Index)要解决的问题。


方案选型

把几种常见做法摆在一起比较。

方案 能否按手机号精确检索 拖库后能否还原号码 关联分析风险 索引可用性 结论
明文存储 能 直接可读 无 高 不合规,排除
随机化加密,不建索引 不能 不能 低 不可用 业务不可用
确定性加密 能 有密钥即可批量解密 高 高 可用,但风险偏高
随机化加密 + 盲索引 能 不能(指纹不可逆) 中 高 推荐
全表解密后内存比对 能 不能 低 不可用 数据量大时不可行

盲索引的思路是把两件事拆开:密文列只负责"能还原",指纹列只负责"能比较"。密文列用随机化加密保证安全,指纹列用带密钥的摘要保证可检索,两者各司其职、互不越界。


盲索引的原理

一条手机号在库里存两列,写入时同时落库,读取时分工使用。

flowchart TD
    A["用户输入手机号<br/>+86 138-0013-8000"] --> B["归一化<br/>13800138000"]
    B --> C{"写入还是查询"}
    C -->|写入| D["随机化加密<br/>AES-GCM + 随机 IV"]
    C -->|查询| E["计算指纹<br/>HMAC-SHA256 pepper, 号码"]
    D --> F[("mobile_cipher<br/>可逆 · 每次不同")]
    E --> G[("mobile_fingerprint<br/>不可逆 · 每次相同")]
    G --> H["按指纹走唯一索引<br/>精确定位到 1 行"]
    H --> I["取出该行密文<br/>解密后展示或校验"]
    F --> I

指纹为什么用 HMAC,而不是直接做哈希

手机号的取值空间非常小。11 位数字,去掉非法号段后有效组合在十亿量级,这个规模对现代算力来说完全可以用穷举或彩虹表在可接受时间内还原。所以裸 SHA-256(手机号) 得到的摘要,本质上只是一层可被暴力破解的编码,起不到保护作用。

HMAC 引入一个只有应用知道的密钥(业界称 pepper,可理解为"全局固定密钥"):

fingerprint = HMAC-SHA256(pepper, 归一化后的手机号)

攻击者即使拿到整张表,只要没有 pepper,就无法离线穷举比对。pepper 与数据库分开保管,就形成了一道独立于加密密钥的第二道门槛。

为什么不能用盐

"加盐"这个词容易混淆。常规口令哈希中的盐是每行一个随机值,作用是让相同口令在不同行得到不同哈希——这恰恰是盲索引最不能要的性质:同一手机号在不同行指纹不同,检索能力立刻消失。

盲索引需要的是一种相反的形态:全库共用同一个固定密钥,保证同一手机号永远得到同一个指纹。所以这里用的是 pepper,不是 per-row salt。

定长与编码

SHA-256 输出 32 字节,十六进制编码后是 64 个字符,定长且不含特殊字符,非常适合建唯一索引。字段类型用 CHAR(64) 或 BYTEA(32) 均可,本文取前者,便于人工核对。

检索路径

一次"按手机号查人"的完整过程:

  1. 应用层把用户输入的号码归一化
  2. 用 pepper 计算指纹
  3. 拿指纹去数据库走索引查找,命中零行或一行
  4. 命中后取出该行的密文,解密得到明文,用于展示或后续校验

注意第 3 步的查找成本与普通等值查询完全一样,指纹列上有唯一索引,是 B-tree 索引查找;解密只发生在命中的那一行上,不会产生全表开销。


数据结构设计

假设账号表命名为 user_account,与手机号相关的字段有三个。

字段 类型 约束 说明
id bigserial 主键 账号主键
mobile_cipher varchar(255) 非空 手机号密文,随机化加密,可逆
mobile_fingerprint char(64) 非空 手机号指纹,HMAC-SHA256,定长十六进制
fingerprint_version smallint 非空,默认 1 指纹密钥版本,用于密钥轮换
nickname varchar(50) 昵称
account_status varchar(20) 非空,默认 ACTIVE 账号状态
created_at timestamptz 非空 创建时间
updated_at timestamptz 非空 更新时间
deleted_at timestamptz 软删标记

索引设计有三点考虑。

唯一索引要带上"未删除"条件。用户注销后,号码应能被重新注册,所以唯一约束只作用于未软删的行:

CREATE UNIQUE INDEX uk_user_account_mobile_fp
    ON user_account (mobile_fingerprint)
    WHERE deleted_at IS NULL;

密文列不建索引。它每次加密结果都不同,建索引既没有检索价值,也白占空间。

指纹版本单独建索引,用于密钥轮换时分批重算存量数据:

CREATE INDEX idx_user_account_fp_version
    ON user_account (fingerprint_version);

需要提醒的是,部分唯一索引是 PostgreSQL 的能力。如果用的是 MySQL 8,它不支持带 WHERE 条件的部分索引,常见替代做法是把 deleted_at 并入唯一键(例如用 0/1 或时间戳占位),或者把注销账号迁移到归档表。


SQL 实现

建表

CREATE TABLE user_account (
    id                  BIGSERIAL    PRIMARY KEY,
    mobile_cipher       VARCHAR(255) NOT NULL,
    mobile_fingerprint  CHAR(64)     NOT NULL,
    fingerprint_version SMALLINT     NOT NULL DEFAULT 1,
    nickname            VARCHAR(50),
    account_status      VARCHAR(20)  NOT NULL DEFAULT 'ACTIVE',
    created_at          TIMESTAMPTZ  NOT NULL DEFAULT now(),
    updated_at          TIMESTAMPTZ  NOT NULL DEFAULT now(),
    deleted_at          TIMESTAMPTZ
);

COMMENT ON COLUMN user_account.mobile_cipher      IS '手机号密文,随机化加密,可逆';
COMMENT ON COLUMN user_account.mobile_fingerprint IS '手机号指纹,HMAC-SHA256,不可逆,用于精确检索';

按手机号查询

指纹由应用层计算,SQL 只接收结果参数,数据库全程不接触明文:

-- :fp 由应用层计算得到
SELECT id, mobile_cipher, fingerprint_version, account_status
FROM user_account
WHERE mobile_fingerprint = :fp
  AND deleted_at IS NULL;

注册写入

把"是否已注册"的判断交给唯一索引,避免先查后插之间的并发竞态:

INSERT INTO user_account (mobile_cipher, mobile_fingerprint, fingerprint_version, nickname)
VALUES (:cipher, :fp, :fpVersion, :nickname)
ON CONFLICT (mobile_fingerprint) WHERE deleted_at IS NULL
DO NOTHING;

影响行数为 0,就说明该号码已存在。

换绑手机号

密文与指纹必须在同一条 UPDATE 里同时更新,否则会出现两者不一致的中间态,导致"能查到但解不出"或"能解出但查不到":

UPDATE user_account
SET mobile_cipher       = :newCipher,
    mobile_fingerprint  = :newFp,
    fingerprint_version = :fpVersion,
    updated_at          = now()
WHERE id = :id
  AND deleted_at IS NULL;

批量导入去重

从外部名单导入用户时,先对整份名单做归一化并算出指纹,再与库中指纹做集合比对,全程不需要解密任何存量数据:

SELECT mobile_fingerprint
FROM user_account
WHERE mobile_fingerprint = ANY(:fpList)
  AND deleted_at IS NULL;

返回的那批指纹,就是名单中已经存在的号码。


服务端实现

组件划分

把手机号相关的三件事收拢到一个组件里,避免加密、解密、指纹散落在各处的业务代码中:

  • 归一化:把各种写法收敛成统一格式
  • 加解密:随机化加密与还原
  • 指纹:计算盲索引值

配置与密钥

密钥一律通过环境变量注入,不写进代码仓库、不落进数据库:

phone:
  codec:
    aes-key: ${PHONE_AES_KEY}          # Base64,32 字节
    fingerprint-key: ${PHONE_FP_KEY}   # Base64,32 字节 pepper
    fingerprint-version: 1
@ConfigurationProperties(prefix = "phone.codec")
public class PhoneCodecProperties {

    /** AES-256 密钥,Base64 编码,32 字节 */
    private String aesKey;

    /** 指纹 pepper,Base64 编码,建议 32 字节随机数 */
    private String fingerprintKey;

    /** 当前指纹版本号 */
    private int fingerprintVersion = 1;

    // getters / setters 略
}

归一化、加解密与指纹

@Component
public class PhoneCodec {

    private static final int GCM_IV_LENGTH = 12;
    private static final int GCM_TAG_BITS = 128;

    private final SecureRandom random = new SecureRandom();
    private final SecretKey aesKey;
    private final byte[] fingerprintKey;
    private final int fingerprintVersion;

    public PhoneCodec(PhoneCodecProperties props) {
        this.aesKey = new SecretKeySpec(
                Base64.getDecoder().decode(props.getAesKey()), "AES");
        this.fingerprintKey = Base64.getDecoder().decode(props.getFingerprintKey());
        this.fingerprintVersion = props.getFingerprintVersion();
    }

    /**
     * 归一化:去掉空格与连字符,剥掉国际区号,最终保留 11 位数字。
     * 加密与指纹都必须基于归一化后的结果,否则同一号码会算出不同指纹。
     */
    public String normalize(String raw) {
        if (raw == null) {
            throw new IllegalArgumentException("手机号不能为空");
        }
        String s = raw.replaceAll("[\\s\\-]", "");
        if (s.startsWith("+86")) {
            s = s.substring(3);
        } else if (s.startsWith("86") && s.length() == 13) {
            s = s.substring(2);
        }
        if (!s.matches("1[3-9]\\d{9}")) {
            throw new IllegalArgumentException("手机号格式不正确");
        }
        return s;
    }

    /** 加密:AES-GCM,随机 IV,输出 Base64(IV || 密文 || 认证标签) */
    public String encrypt(String normalizedMobile) {
        try {
            byte[] iv = new byte[GCM_IV_LENGTH];
            random.nextBytes(iv);
            Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
            cipher.init(Cipher.ENCRYPT_MODE, aesKey, new GCMParameterSpec(GCM_TAG_BITS, iv));
            byte[] cipherText = cipher.doFinal(
                    normalizedMobile.getBytes(StandardCharsets.UTF_8));

            byte[] out = new byte[iv.length + cipherText.length];
            System.arraycopy(iv, 0, out, 0, iv.length);
            System.arraycopy(cipherText, 0, out, iv.length, cipherText.length);
            return Base64.getEncoder().encodeToString(out);
        } catch (GeneralSecurityException e) {
            throw new IllegalStateException("手机号加密失败", e);
        }
    }

    public String decrypt(String cipherText) {
        try {
            byte[] all = Base64.getDecoder().decode(cipherText);
            byte[] iv = Arrays.copyOfRange(all, 0, GCM_IV_LENGTH);
            byte[] body = Arrays.copyOfRange(all, GCM_IV_LENGTH, all.length);

            Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
            cipher.init(Cipher.DECRYPT_MODE, aesKey, new GCMParameterSpec(GCM_TAG_BITS, iv));
            return new String(cipher.doFinal(body), StandardCharsets.UTF_8);
        } catch (GeneralSecurityException e) {
            throw new IllegalStateException("手机号解密失败", e);
        }
    }

    /** 指纹:HMAC-SHA256(pepper, 归一化手机号),输出 64 位十六进制小写 */
    public String fingerprint(String normalizedMobile) {
        try {
            Mac mac = Mac.getInstance("HmacSHA256");
            mac.init(new SecretKeySpec(fingerprintKey, "HmacSHA256"));
            byte[] digest = mac.doFinal(
                    normalizedMobile.getBytes(StandardCharsets.UTF_8));
            return HexFormat.of().formatHex(digest);
        } catch (GeneralSecurityException e) {
            throw new IllegalStateException("手机号指纹计算失败", e);
        }
    }

    public int currentFingerprintVersion() {
        return fingerprintVersion;
    }

    /** 展示用掩码,先解密再调用 */
    public static String mask(String plainMobile) {
        return plainMobile.substring(0, 3) + "****" + plainMobile.substring(7);
    }
}

数据访问层

@Mapper
public interface UserAccountMapper {

    UserAccount findByFingerprint(@Param("fp") String fp);

    int insertIfAbsent(@Param("cipher") String cipher,
                       @Param("fp") String fp,
                       @Param("fpVersion") int fpVersion,
                       @Param("nickname") String nickname);

    int updateMobile(@Param("id") long id,
                     @Param("cipher") String cipher,
                     @Param("fp") String fp,
                     @Param("fpVersion") int fpVersion);
}
<select id="findByFingerprint" resultType="com.example.account.UserAccount">
    SELECT id, mobile_cipher, fingerprint_version, account_status
    FROM user_account
    WHERE mobile_fingerprint = #{fp}
      AND deleted_at IS NULL
</select>

<insert id="insertIfAbsent">
    INSERT INTO user_account (mobile_cipher, mobile_fingerprint, fingerprint_version, nickname)
    VALUES (#{cipher}, #{fp}, #{fpVersion}, #{nickname})
    ON CONFLICT (mobile_fingerprint) WHERE deleted_at IS NULL DO NOTHING
</insert>

<update id="updateMobile">
    UPDATE user_account
    SET mobile_cipher       = #{cipher},
        mobile_fingerprint  = #{fp},
        fingerprint_version = #{fpVersion},
        updated_at          = now()
    WHERE id = #{id}
      AND deleted_at IS NULL
</update>

注册与查询流程

@Service
public class AccountService {

    private final PhoneCodec codec;
    private final UserAccountMapper mapper;

    public AccountService(PhoneCodec codec, UserAccountMapper mapper) {
        this.codec = codec;
        this.mapper = mapper;
    }

    public void register(String rawMobile, String nickname) {
        String mobile = codec.normalize(rawMobile);
        int rows = mapper.insertIfAbsent(
                codec.encrypt(mobile),
                codec.fingerprint(mobile),
                codec.currentFingerprintVersion(),
                nickname);
        if (rows == 0) {
            throw new BizException("该手机号已注册");
        }
    }

    public UserProfile findByMobile(String rawMobile) {
        String mobile = codec.normalize(rawMobile);
        UserAccount account = mapper.findByFingerprint(codec.fingerprint(mobile));
        if (account == null) {
            throw new BizException("账号不存在");
        }
        String plain = codec.decrypt(account.getMobileCipher());
        return new UserProfile(account.getId(), PhoneCodec.mask(plain));
    }

    public void changeMobile(long accountId, String rawNewMobile) {
        String mobile = codec.normalize(rawNewMobile);
        mapper.updateMobile(
                accountId,
                codec.encrypt(mobile),
                codec.fingerprint(mobile),
                codec.currentFingerprintVersion());
    }
}

业务代码里始终只有三个动作:normalize、encrypt/decrypt、fingerprint。任何一处直接拿明文去比对密文,都会立刻失效——这也是这套设计需要被明确写进团队约定的一点。


密钥管理与轮换

两把密钥,职责分开

系统里有两把与手机号相关的密钥:加密密钥负责"可逆",pepper 负责"可比较"。它们必须分开保管、分开轮换。只泄露其中一把,另一条防线仍然成立:

  • 只有 AES 密钥泄露:密文可解,但攻击者仍需遍历全表解密才能定位某个号码
  • 只有 pepper 泄露:指纹可被穷举比对,但密文仍不可解

指纹轮换

pepper 一旦更换,全库已有指纹全部失效,WHERE mobile_fingerprint = ? 会查不到任何历史数据。处理方式是引入 fingerprint_version:

  1. 配置里同时保留新旧两版 pepper,版本号递增
  2. 新写入的数据一律使用新版本
  3. 查询时若新版本未命中,再用旧版本算一次指纹兜底
  4. 后台任务分批重算存量数据,每批更新 fingerprint_version,跑完即可下线旧版本

分批重算必须能断点续跑,因为它要解密每一行的密文再重算指纹,是全表级操作,中途失败不能从头再来。

归一化的一致性

归一化是整套设计的前置条件。+86 138-0013-8000、138 0013 8000、13800138000 必须收敛成同一个 11 位字符串。只要归一化规则在写入和查询两侧有任何差异,同一号码就会算出两个指纹,表现为"明明注册过,却查不到"。归一化逻辑必须收口到唯一一个组件里,不允许各业务模块各写一份。


落地细节与常见坑

软删除与唯一索引的配合

唯一索引带上 WHERE deleted_at IS NULL,注销后的号码才能被重新注册。如果唯一索引不带这个条件,用户注销后号码就被永久占用,客服和用户都会收到"该号码已注册"的错误提示,而实际数据已经不可见。

换绑时的原子性

密文列与指纹列必须同一条语句更新。分成两条 UPDATE,中间一旦失败,就会出现密文与指纹指向不同号码的脏数据,且这类脏数据很难被业务发现——因为查询走指纹、展示走密文,两个入口会给出互相矛盾的答案。

批量导入

导入外部名单时,先在内存里对整份名单做归一化与指纹计算,再一次性与库中指纹集合比对。这一步不需要解密任何存量数据,导入十万条记录也只产生一次批量查询。

数据迁移

从明文库迁移到密文库时,需要一次性回填 mobile_cipher 与 mobile_fingerprint。迁移期间建议双写:新写入同时落明文(临时列)与密文,迁移任务按主键分批处理存量数据,全部回填完成并校验后,再删除临时明文列。校验方式可以抽样解密比对,也可以直接比对总行数与指纹非空行数。

性能

指纹列定长、带唯一索引,等值查询走 B-tree 索引。整个链路上唯一的重操作是解密,而它只作用于命中的单行。相比之下,"全表解密后内存比对"的方案在百万级数据上就已经不可接受了。


安全边界

任何方案都有它守得住和守不住的地方,把边界说清楚比笼统宣称"安全"更有用。

能防住的

  • 数据库文件、备份文件、从库被拖走:密文无密钥不可解,指纹不可逆,手机号无法被直接读取
  • 无 pepper 的内部人员:即使能连库执行任意 SQL,也无法反查某个号码是否在库中
  • 批量脱库后直接出售明文数据:拿到的是一堆无意义的密文与摘要

防不住的

  • 应用层被攻破且 AES 密钥与 pepper 同时泄露:密文可被批量解密,指纹可被离线穷举
  • 等值测试与频率关联:相同手机号的指纹必然相同,攻击者若已掌握某个号码,可以判断它是否在库中;也能看出哪些记录属于同一个号码
  • 模糊查询:按号段或前缀做模糊搜索,无法用完整指纹实现(见下一节)

与确定性加密的取舍

盲索引并没有消除"指纹相同即号码相同"这一关联特性,它消除的是"密文可被解密"这一更严重的风险。把不可逆的摘要放在索引列上,而不是把可解的密文放在索引列上,是这套设计相对确定性加密的核心改进。


能力边界与延伸方案

盲索引擅长等值查询,也就是"精确匹配一个手机号"。超出这个范围的需求需要额外设计。

按前缀或号段模糊搜索(例如"查所有 1380 开头的号码")无法用完整指纹完成。一种延伸做法是分段指纹:把号码切成若干段,为每个前缀段单独建指纹列并建索引,查询时按已知前缀命中对应列。代价是指数列膨胀,且每多一个前缀段,就多泄露一层信息——前缀越短,可被关联的范围越大,需要按实际业务谨慎取舍。

若合规要求更严,连"等值测试"都要避免,可以考虑可搜索加密(Searchable Encryption)或基于可信执行环境(TEE)的密钥托管方案。这类方案能把指纹也隐藏起来,但实现复杂度、性能开销和运维成本都会显著上升,通常只在监管明确要求时才值得引入。

对绝大多数以手机号为唯一标识的账号体系而言,"随机化加密 + HMAC 盲索引"是安全性与工程成本之间性价比最高的一档。


小结

这套设计只有一个核心动作:把"能还原"和"能比较"拆到两列上。密文列用随机化加密,负责还原;指纹列用带 pepper 的 HMAC,负责比较。数据库里既没有明文,也没有可被解密的索引值,而业务侧最常用的那句 WHERE mobile = ? 得以用等值索引查询的方式继续工作。

真正需要在工程上守住的是三件事:归一化规则收口到一处、密文与指纹同写同更、pepper 与数据分开保管。前两条决定功能是否正确,第三条决定安全边界是否成立。

分享此文

评论 (0)

还没有评论,来抢沙发。

发表评论