为什么需要加密存储?
我们之前公司开发了一款社交类 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 加密后存的,iv
和 tag
是为了完整性校验。解密只在导出邮箱或后台查看原文的时候用到,平时业务不会直接用这个字段。
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_masked
和name_masked
; - 后续如果用户更换手机号/邮箱,也会重新生成对应的
*_masked
字段。
我们在内部封装了一套写入逻辑,所有调用加密写入的地方,都会自动带上脱敏字段生成。
这套加密方案落地后,我们遇到的一些现实问题
上面说的这一整套加密设计,基本能覆盖我们项目中大多数合规要求和常见业务场景了。
但我们在实际项目里上线后,还是遇到了一些问题,有些是系统层面的,有些是团队习惯、协作流程带来的。这里简单说说,踩过的坑能少一个是一个。
1. 密钥怎么管理是个麻烦事
刚开始大家说得好好的"支持密钥轮换",但真要搞,才发现没几个人愿意接这个锅。
我们一开始是直接把 key 写在配置文件里的,然后放到环境变量。这个方式其实风险挺大的,密钥如果泄露,数据库里的敏感信息几乎就是裸奔。
后来我们简单封装了一下,把加密解密逻辑统一放到一个工具类里,预留了切换密钥的能力。再后来才慢慢开始接入 KMS。
建议: 如果团队能力允许,尽早把 key 管理这块独立出来,别写死在代码里。
2. 有人想查中间模糊,办不到
我们只支持手机号前缀、后缀,身份证出生年月,邮箱域名。
但上线之后,总有同事会说:"能不能查手机号里包含 88 的用户?"或者"查姓李的用户?"这类查询业务上其实说得通,但技术上我们故意不支持。
但是我们明确限制搜索能力是出于安全考虑,不支持就是不支持,当时就统一回一句:"不能查,这是设计上就不允许的。"
建议: 上线之前就把查询能力边界定清楚,不支持模糊就别让前端传 %xxx%
这种条件。模糊查越灵活,风险越大。
3. 解密逻辑容易被滥用
一开始我们把解密逻辑封装成公共函数,想着大家统一调用。但上线之后,慢慢发现有些地方开始滥用了:
比如日志里输出了解密后的手机号、运营脚本里直接全量解密再导出,甚至还有一些测试工具绕过权限直接解密。
这玩意一旦用得太随意,加密就等于白做了。
建议: 所有解密操作都要有明确入口,最好加审计。不是谁想解就解。我们后来是把"解密行为"统一放在一个 service 里,并做了日志记录。
4. 历史数据加密是个大坑
上线之前的老数据全是明文的,怎么办?总不能一刀切直接换表吧。
我们是做了一个"回填脚本",分批把旧数据慢慢加密进来,跑了两周才完全处理完。中间还遇到过字符编码、无效数据的问题,有些字段根本没值。
建议: 上线前要考虑数据迁移路径。尽量用"新增字段 + 回填 + 双写"方式做平滑迁移,别想着一次到位。
5. BI、分析、导出系统全挂了
敏感字段一加密,BI 查询直接废了。原来可以查手机号前 3 位注册分布,现在什么也查不了。
后来我们给 BI 系统专门开了一张"脱敏副本表",只保留能分析的那几段,比如手机号段、邮箱域名、出生年月等。
建议: 做加密前,先跟 BI 团队聊一聊,把他们日常用到的字段结构先梳理出来。该保留结构的保留,但不能留下泄露口子。
6. 后台导出要啥都给,这也很危险
我们系统里有后台导出功能。以前是全字段导出,连身份证号、手机号全都明文给到 Excel。
上线加密后,我们调整了导出规则:
- 默认导出只给脱敏字段,比如
138****5678
; - 真正要拿原文的,需要审批或特殊权限,解密时记录操作日志。
建议: 导出、审计这类场景最容易出问题,别放松,要么不给,要么严格审批。
总的来说
这一套加密方案本身没问题,关键是我们上线之后怎么落地、怎么控制使用。
技术上设计得再完善,如果权限乱、日志乱、调用随意,最后都还是风险点。
我们最终要保证的是:
加密只是底线,不滥用才是保障。
我们选的不是最完美,但是真的够用了
说实话,我们在做这个方案之前,也看了很多网上关于数据库加密的文章,不少都讲得很完整,也很专业,确实值得学习。
但我们没有直接照搬实用性很高的一些方案。原因很简单:
加密方案可以有很多种,但最终落地的方案必须跟我们的实际业务场景对得上。
我们不是在做一个"为了合规而合规"的系统,也不是为了安全做而做。
我们的目标是既能保护好用户的敏感数据,也不能把系统复杂度拉满,影响业务、拖慢开发节奏。
所以最后这套方案看起来可能没那么"全面",有很多限制与不足,也不通用,但它足够简单、够安全、能落地,而且我们确实在上线之后运行得还不错。