数据库加密方案实践:我们选的不是最完美,但是真的够用了。

为什么需要加密存储?

我们之前公司开发了一款社交类 App,准备上线各大应用市场的时候,突然发现上架被拒了。原因是:平台要求提供"公安备案号",否则 App 无法审核通过,甚至有下架风险。

没办法,我们只好去注册地的网警办跑备案。结果拿到网警办的审核要求后,发现其中有一条写得特别明确:

App 需要对用户个人信息进行加密传输与加密存储,

尤其是密码、手机号、身份证号等敏感字段明文存储会直接驳回备案申请,严重的还会被叫停业务或处罚。

当时我们一看就傻了。密码确实加密了,用的是常规的哈希算法,但像手机号、身份证、邮箱这些,全都明文躺在数据库里。

其实我们不是不知道加密这事有多重要,而是当初在设计的时候,根本就没往那方面想过

一是觉得------没必要啊。数据库又不是对外开放的,而且数据库安全做的相对来说很到位了。正常情况下谁能碰到这些数据?除非服务器被攻破了,否则怎么可能泄漏?

二是我们页面上本来也做了脱敏。手机号、身份证这些信息,在前端或后端接口返回的时候都会做遮盖,后台操作人员看不到全量数据。就算有人越权进后台,也拿不到完整信息,我们当时觉得这就够了。

三是,加密太麻烦了。像用户登录、注册、实名认证这些流程,本来就已经够复杂了,偶尔还得救个急、处理失败回调什么的。再加一层加密,前端传参、后端校验、数据库查询,全得改一轮,系统复杂度直接上天。而且加密之后,什么模糊查询、手机号筛选、关键词匹配基本全废,干脆就没往那块考虑。

结果这些"我们以为不会出问题"的地方,最后全变成了卡我们脖子的点。

我们又花了一周时间重新设计字段、做加密、兼容查询逻辑,才终于顺利通过了联网备案。


手机号加密怎么做?我们是这样设计的

在整改的时候,我们首先把用户信息里的手机号字段单独拆了出来,放到一张新的表里做专门的加密设计。

当时也确实花了不少时间研究各种方案。网上有很多资料,有些看起来挺先进的,但实际落地的时候问题一堆。

比如,有的说可以搞一个"明文映射表",用户手机号存明文,然后另起一张加密表做关联。

但是我觉得这种设计其实就是:脱裤子放屁,自己骗自己。

还有的方案是说把手机号拆成三段,中间部分加密、首尾保留,甚至还有人搞什么正则分词加密索引的。我们看完之后的感受是:太复杂了,维护成本太高,不适合我们业务节奏

我们最后回过头来,还是从我们自己系统的使用场景出发来设计,因为我们对手机号的使用,其实就这几种:

  • 客服系统要通过手机号查用户;
  • 后台风控系统会用手机号做定位;
  • 审计、运营导数也基本都是以手机号为主键查;
  • 还有不少业务逻辑里会用到手机号尾号做筛选,比如查"尾号是 5678 的用户"。

所以我们的思路是:

不搞那种"高大上"的通用加密框架,

也不想为了安全牺牲掉所有可用性,

我们做了一个更实际、更折中的方案:受控拆分字段 + 明文索引 + 模糊脱敏策略。

说白了就是:哪些字段该加密就加密,哪些该查的我们用结构化字段保留一小部分明文,

既保证了合规和安全,也没把业务搞瘫。

确定了要做"加密 + 可查询"之后,我们就开始重新设计手机号的存储结构。

我们把手机号从原来的 user_info 表里单独拆了出来,放到一张叫 user_phone 的表中。这张表专门用于存储用户手机号的加密信息,并保留了一部分可查询的字段,设计结构如下:

sql 复制代码
CREATE TABLE `user_phone` (
  `user_id`       BIGINT UNSIGNED     NOT NULL COMMENT '用户ID,主键,一对一关联用户表',
  `phone_cipher`  VARBINARY(128)      NOT NULL COMMENT '加密后的手机号密文',
  `phone_iv`      VARBINARY(12)       NOT NULL COMMENT 'AES-GCM加密使用的随机IV',
  `phone_tag`     VARBINARY(16)       NOT NULL COMMENT 'AES-GCM认证Tag',
  `phone_idx`     BINARY(32)          NOT NULL COMMENT '手机号HMAC索引值(不可逆,用于等值查询)',
  `phone_prefix`  CHAR(3)             NOT NULL COMMENT '手机号前3位,用于受控前缀查询',
  `phone_last4`   CHAR(4)             NOT NULL COMMENT '手机号后4位,用于受控尾号查询',
  `key_version`   SMALLINT UNSIGNED   NOT NULL DEFAULT 1 COMMENT '加密时使用的密钥版本号',
  `created_at`    DATETIME            NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `updated_at`    DATETIME            NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',

  PRIMARY KEY (`user_id`),
  UNIQUE KEY `uk_phone_idx` (`phone_idx`),
  KEY `idx_phone_prefix_last4` (`phone_prefix`, `phone_last4`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户手机号表,加密存储 + 可控查询支持';

里面的字段其实不复杂,核心就这几个:

字段名 作用 示例值
user_id 用户 ID(主键) 10001
phone_cipher 加密后的手机号密文 看不懂的一串密文
phone_iv 加密用的随机向量 随机字节
phone_tag GCM 的认证 tag 随机字节
phone_idx HMAC 索引值(不可逆) 二进制摘要
phone_prefix 手机号前三位 138
phone_last4 手机号后四位 5678
key_version 密钥版本号 1

为什么要设计这么多字段?它们都是干啥的?

我们最开始也纠结:只存密文不行吗?干嘛还要搞什么索引值、前缀、后缀?

后来发现,不行。想要安全,就要加密;想要查,就要加索引;想要模糊,就得拆字段------这就是结合场景考虑。

我这边简单说下我们当时为什么要这样设计这些字段。

phone_cipher / phone_iv / phone_tag

这三个是一组,加密手机号用的。我们用了 AES-GCM 模式做加密,需要带上随机的 IV 和 tag,用来保证密文的完整性。加密之后得到的结果就是 phone_cipher,数据库里只存这一组,原始手机号不会落库。

这个字段组主要用于后续需要展示或导出的场景 ,比如我们这边要导出用户手机号给运营或者客服留存时,就需要解密手机号再导出。

平时的业务逻辑,比如登录、查找、风控,都不会直接用到这个字段。

phone_idx

这个字段是用手机号原文计算出来的 HMAC 值,用于等值查询。不可逆,也就是说别人拿到这个字段是没法还原手机号的,但如果我们知道原手机号,在应用层也能算出一样的值,就能查出来是谁。

我们所有基于手机号的查找场景(比如查"手机号等于 13888888888 的用户")都是通过这个字段完成的,而不是查密文。

phone_prefix / phone_last4

这两个是我们保留下来的明文段,用于支持一些常见的模糊查找。

比如客服说:"能不能查一下尾号是 5678 的用户?"或者运营要查"138 开头的手机号用户",再比如我们要导出所有 138 开头的用户手机号给市场部门做短信通知,这类场景我们就通过这两个字段来查。

我们明确只保留前缀和后缀两个可查段,不支持任意位置模糊,比如 %3888% 这种查法,在数据库和代码层都限制了,防止被滥用。

这样做的目的是:在能满足常见业务的前提下,尽量减少信息泄露的风险。

key_version

密钥是一定要支持轮换的,这个字段就是用来记录每条数据使用的是哪一版密钥。以后密钥升级时,不需要一次性重加密所有数据,而是根据版本去解密、或后台分批升级。


姓名字段的加密设计

手机号搞定之后,接下来就是姓名字段的处理。相比手机号,姓名的使用频率没那么高,基本都是用于后台展示,或者做一些精确匹配的筛选,比如查"名字叫张三"的用户。

我们这边对姓名的使用场景也比较明确:

  • 后台用户详情页需要展示完整姓名;
  • 运营偶尔会根据姓名做等值筛选,比如查"叫王磊的用户有多少";
  • 但我们没有任何模糊匹配的需求,比如 %张% 或"姓李的所有用户",这种我们业务层面压根不支持。

基于这些情况,我们的加密策略就比较简单了:

字段名 作用
name_cipher 加密后的姓名密文
name_iv 加密用的随机向量
name_tag AES-GCM 认证 tag
name_idx HMAC 生成的等值查询索引值

字段设计跟手机号差不多,也是一组密文字段 + 一个索引字段。

  • name_cipher / iv / tag 是用于解密展示用的,比如后台详情页看到完整姓名;
  • name_idx 是通过 HMAC 算法生成的,不可逆,用于等值查询,比如查"名字等于张三"的用户。

我们没有保留什么前缀、后缀字段,也没有拆成字或词来支持模糊查询,这一块我们定得很死:姓名只支持等值查,不能模糊匹配

主要原因也是出于安全考虑------中文姓名空间太小了,如果支持模糊,风险太大,查个"李"就能把一堆用户暴露出来。所以我们业务上直接禁止这类需求,也就没必要为它设计复杂的字段结构。


身份证字段的加密设计

身份证号这块,我们在处理时是比较谨慎的。一方面它是非常敏感的个人信息,属于法律明确定义的"个人敏感信息";另一方面,它本身就包含了大量可识别的数据,比如出生日期、性别、地区编码这些,都能被人反推出来。

我们这边业务对身份证的使用需求,主要集中在以下几个场景:

  • 实名认证流程中做格式校验和合法性判断;
  • 后台偶尔需要根据身份证号等值查询某个用户;
  • 风控系统会根据出生日期、性别等字段做一些筛查,比如排除未成年用户,或者识别虚假身份;
  • 运营需要导出符合某些特征(比如出生时间段、性别、地区)的用户。

根据这些场景,我们做了如下设计:

字段名 作用
pid_cipher 加密后的身份证密文
pid_iv 加密用的随机向量
pid_tag GCM 加密认证 tag
pid_idx HMAC 等值查询索引(不可逆)
pid_birth 出生日期(从身份证中解析)
pid_gender 性别(M / F,从身份证中解析)
pid_area_code 身份证前6位区域编码(从身份证中解析)

字段思路基本和手机号一致:密文存储 + 索引查找 + 结构化派生字段。

我们不会在业务里去"模糊查身份证号",但为了支持按出生年月、性别、地区等维度做筛选,我们在应用层解析身份证号后把这些派生字段写入数据库。这样就可以满足业务需求,又不需要对加密字段本身做任何操作。

下面是我们几个常用的查询示例:

精确查某个身份证号(等值查询)

sql 复制代码
SELECT * FROM user_identity
WHERE pid_idx = :hashedPid;

这个查询是通过应用层先算出身份证号的 HMAC 值,然后用 pid_idx 去查的,原文不会暴露,也不需要解密。

筛选出生在 1990 年的用户

sql 复制代码
SELECT * FROM user_identity
WHERE pid_birth BETWEEN '1990-01-01' AND '1990-12-31';

查询性别为女性的用户

sql 复制代码
SELECT * FROM user_identity
WHERE pid_gender = 'F';

查询地区编码为 310101 的用户(例如上海黄浦)

sql 复制代码
SELECT * FROM user_identity
WHERE pid_area_code = '310101';

这些字段都是在实名认证通过时就一次性提取并存入数据库的,系统运行过程中不需要每次都重新解析身份证号。

我们也没有保留身份证号的前缀、后缀等字段,更不支持任何形式的 LIKE '%xxx%' 查询。身份证字段只支持等值查 + 结构字段筛选,这一点在我们系统里是严格限制的。


邮箱字段的加密设计

邮箱这个字段,在我们系统里的使用频率相对没那么高,主要出现在这些地方:

  • 用户注册、绑定邮箱;
  • 忘记密码时用来找回账号;
  • 后台偶尔需要根据邮箱精准查用户,比如客服根据用户提供的邮箱地址定位账号;
  • 运营偶尔会筛选某些邮箱域名的用户,比如导出全部 @qq.com@gmail.com 的用户。

和前面的手机号、身份证一样,我们也对邮箱做了加密处理,但整体设计更轻量一些:

字段名 作用
email_cipher 加密后的邮箱密文
email_iv 加密用的随机向量
email_tag GCM 加密认证 tag
email_idx HMAC 等值查询索引(不可逆)
email_domain 邮箱域名(明文)

思路还是一样的:加密存原文,保留索引,适当结构拆分

email_cipher / iv / tag

和其他字段一样,email_cipher 是密文,用 AES-GCM 加密后存的,ivtag 是为了完整性校验。解密只在导出邮箱或后台查看原文的时候用到,平时业务不会直接用这个字段。

email_idx

邮箱虽然不像手机号那样高频查询,但也有等值查的场景,比如用户忘记绑定的手机号、用邮箱登录的时候,就会用到这个字段。我们这里同样是通过 HMAC 算法生成一个不可逆的索引值,用于精准查找。

sql 复制代码
SELECT * FROM user_email
WHERE email_idx = :hashedEmail;

应用层先把用户输入的邮箱标准化(比如统一小写、去空格),再计算 HMAC,和数据库里的 email_idx 去匹配。

email_domain

这个字段是我们主动拆出来的,用来支持一些按邮箱域名的筛选,比如:

  • 查所有 @qq.com 的用户;
  • 或者导出 @163.com 的账号;
sql 复制代码
SELECT * FROM user_email
WHERE email_domain = 'qq.com';

这个字段是通过应用层在用户绑定邮箱时就拆出来的,直接存明文。我们系统默认所有邮箱都在存储前做了统一处理,比如去空格、小写化,这样可以保证查询时不会出错。

说明一下我们没做什么

我们没有做"邮箱前缀模糊查",比如查 abc*@qq.com 这种。这种需求要么是伪需求,要么就是会被滥用的风险点,我们一律不支持。邮箱字段只支持等值查 + 域名筛选,不开放模糊搜索。


后台展示这么多字段,不会查起来很麻烦吗?

在我们把用户敏感信息都做了加密拆分之后,一个最直接的问题就摆在面前了:

后台列表要展示用户信息,手机号、邮箱、身份证、姓名这些都加密了,字段又不在主表里,是不是每次都要联合三四张表?这样查性能压力大不说,写代码也麻烦。

我们一开始也试过用视图、用聚合查询,甚至做过服务端拼接,但最终都觉得太重。

后来我们选择了一个最简单、最实用的做法

在主表(user_info)里,直接存一份脱敏后的字段,专门用于展示,不参与查询,也不存明文。

我们是怎么做的?

我们在用户主表里增加了下面几个字段:

字段名 含义 示例值
phone_masked 脱敏后的手机号 138****5678
email_masked 脱敏后的邮箱 j***@qq.com
pid_masked 脱敏后的身份证号 3101**********1234
name_masked 脱敏后的姓名 张**李*

这些字段的生成方式非常简单,都是在用户注册或实名认证成功后,系统在后端加密字段的同时,同步生成一份脱敏展示内容,写入用户主表。

举个例子:

  • 手机号加密写入 user_phone 表 → 同时生成 phone_masked = 138****5678
  • 姓名加密写入 user_identity 表 → 同时生成 name_masked = 张**
  • 邮箱加密写入 user_email 表 → 同时生成 email_masked = j***@qq.com

这些脱敏字段用在哪里?

我们限定这些字段只用于展示场景,比如:

  • 后台用户列表页;
  • 客服工单系统里的用户信息面板;
  • 用户中心"我的资料"页(非编辑态);
  • 日志记录、审计结果中需要展示用户信息但不能暴露原文的地方。

这些字段不会参与任何查询、筛选或逻辑判断,只作为纯展示字段存在,避免每次都去解密原文、拼接脱敏字符串。


为什么要这么做?

一方面是出于性能考虑。加密字段都拆出去了,如果展示时还要联合三四个表、还要解密、还要拼接脱敏格式,展示一个列表可能就要跑几十次逻辑,根本扛不住。

另一方面也是代码维护成本的问题。每个功能点都写一套"解密 + 脱敏"的逻辑,容易出错不说,还到处复制粘贴,不好统一。

我们统一用这个做法之后:

  • 展示层只拿 *_masked 字段,逻辑清晰;
  • 加密字段照样存在,查找、导出、风控都能用;
  • 敏感原文不在主表里落地,合规层面也没问题。

脱敏字段的写入时机?

统一都是在用户信息首次写入或者更新敏感字段时自动写入。

比如:

  • 用户注册成功 → 生成手机号、邮箱的加密字段 → 同时写入脱敏字段;
  • 用户实名认证成功 → 生成身份证号和姓名的加密字段 → 同时写入 pid_maskedname_masked
  • 后续如果用户更换手机号/邮箱,也会重新生成对应的 *_masked 字段。

我们在内部封装了一套写入逻辑,所有调用加密写入的地方,都会自动带上脱敏字段生成。


这套加密方案落地后,我们遇到的一些现实问题

上面说的这一整套加密设计,基本能覆盖我们项目中大多数合规要求和常见业务场景了。

但我们在实际项目里上线后,还是遇到了一些问题,有些是系统层面的,有些是团队习惯、协作流程带来的。这里简单说说,踩过的坑能少一个是一个。

1. 密钥怎么管理是个麻烦事

刚开始大家说得好好的"支持密钥轮换",但真要搞,才发现没几个人愿意接这个锅。

我们一开始是直接把 key 写在配置文件里的,然后放到环境变量。这个方式其实风险挺大的,密钥如果泄露,数据库里的敏感信息几乎就是裸奔。

后来我们简单封装了一下,把加密解密逻辑统一放到一个工具类里,预留了切换密钥的能力。再后来才慢慢开始接入 KMS。

建议: 如果团队能力允许,尽早把 key 管理这块独立出来,别写死在代码里。

2. 有人想查中间模糊,办不到

我们只支持手机号前缀、后缀,身份证出生年月,邮箱域名。

但上线之后,总有同事会说:"能不能查手机号里包含 88 的用户?"或者"查姓李的用户?"这类查询业务上其实说得通,但技术上我们故意不支持。

但是我们明确限制搜索能力是出于安全考虑,不支持就是不支持,当时就统一回一句:"不能查,这是设计上就不允许的。"

建议: 上线之前就把查询能力边界定清楚,不支持模糊就别让前端传 %xxx% 这种条件。模糊查越灵活,风险越大。

3. 解密逻辑容易被滥用

一开始我们把解密逻辑封装成公共函数,想着大家统一调用。但上线之后,慢慢发现有些地方开始滥用了:

比如日志里输出了解密后的手机号、运营脚本里直接全量解密再导出,甚至还有一些测试工具绕过权限直接解密。

这玩意一旦用得太随意,加密就等于白做了。

建议: 所有解密操作都要有明确入口,最好加审计。不是谁想解就解。我们后来是把"解密行为"统一放在一个 service 里,并做了日志记录。

4. 历史数据加密是个大坑

上线之前的老数据全是明文的,怎么办?总不能一刀切直接换表吧。

我们是做了一个"回填脚本",分批把旧数据慢慢加密进来,跑了两周才完全处理完。中间还遇到过字符编码、无效数据的问题,有些字段根本没值。

建议: 上线前要考虑数据迁移路径。尽量用"新增字段 + 回填 + 双写"方式做平滑迁移,别想着一次到位。

5. BI、分析、导出系统全挂了

敏感字段一加密,BI 查询直接废了。原来可以查手机号前 3 位注册分布,现在什么也查不了。

后来我们给 BI 系统专门开了一张"脱敏副本表",只保留能分析的那几段,比如手机号段、邮箱域名、出生年月等。

建议: 做加密前,先跟 BI 团队聊一聊,把他们日常用到的字段结构先梳理出来。该保留结构的保留,但不能留下泄露口子。

6. 后台导出要啥都给,这也很危险

我们系统里有后台导出功能。以前是全字段导出,连身份证号、手机号全都明文给到 Excel。

上线加密后,我们调整了导出规则:

  • 默认导出只给脱敏字段,比如 138****5678
  • 真正要拿原文的,需要审批或特殊权限,解密时记录操作日志。

建议: 导出、审计这类场景最容易出问题,别放松,要么不给,要么严格审批。

总的来说

这一套加密方案本身没问题,关键是我们上线之后怎么落地、怎么控制使用。

技术上设计得再完善,如果权限乱、日志乱、调用随意,最后都还是风险点。

我们最终要保证的是:

加密只是底线,不滥用才是保障。


我们选的不是最完美,但是真的够用了

说实话,我们在做这个方案之前,也看了很多网上关于数据库加密的文章,不少都讲得很完整,也很专业,确实值得学习。

但我们没有直接照搬实用性很高的一些方案。原因很简单:

加密方案可以有很多种,但最终落地的方案必须跟我们的实际业务场景对得上。

我们不是在做一个"为了合规而合规"的系统,也不是为了安全做而做。

我们的目标是既能保护好用户的敏感数据,也不能把系统复杂度拉满,影响业务、拖慢开发节奏。

所以最后这套方案看起来可能没那么"全面",有很多限制与不足,也不通用,但它足够简单、够安全、能落地,而且我们确实在上线之后运行得还不错。

相关推荐
该用户已不存在11 分钟前
OpenJDK、Temurin、GraalVM...到底该装哪个?
java·后端
丘大梨18 分钟前
QT 基础聊天应用项目文档
运维·数据库·系统架构
怀刃31 分钟前
内存监控对应解决方案
后端
HMBBLOVEPDX36 分钟前
MySQL的多版本并发控制(MVCC):
数据库·mysql·mvcc
码事漫谈1 小时前
VS Code Copilot 内联聊天与提示词技巧指南
后端
.用户昵称已存在.1 小时前
MongoDB 从入门到精通:安装配置与基础操作指令详解
数据库·mongodb
Moonbit1 小时前
MoonBit Perals Vol.06: MoonBit 与 LLVM 共舞 (上):编译前端实现
后端·算法·编程语言
Moonbit1 小时前
MoonBit Perals Vol.06: MoonBit 与 LLVM 共舞(下):llvm IR 代码生成
后端·程序员·代码规范
Moonbit1 小时前
MoonBit Pearls Vol.05: 函数式里的依赖注入:Reader Monad
后端·rust·编程语言
小奋斗1 小时前
深入浅出:ES5/ES6+数组扁平化详解
javascript·面试