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

为什么需要加密存储?

我们之前公司开发了一款社交类 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
  • 真正要拿原文的,需要审批或特殊权限,解密时记录操作日志。

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

总的来说

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

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

我们最终要保证的是:

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


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

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

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

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

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

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

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

相关推荐
都叫我大帅哥41 分钟前
Docker Swarm 部署方案
后端
都叫我大帅哥44 分钟前
在Swarm中部署Nacos并配置外部MySQL
后端
熏鱼的小迷弟Liu2 小时前
【消息队列】RabbitMQ的基本架构?
面试·架构·rabbitmq
NAGNIP8 小时前
一文搞懂机器学习中的特征降维!
算法·面试
想摆烂的不会研究的研究生8 小时前
每日八股——Redis(1)
数据库·经验分享·redis·后端·缓存
NAGNIP8 小时前
一文搞懂机器学习中的特征构造!
算法·面试
码熔burning8 小时前
MySQL 8.0 新特性爆笑盘点:从青铜到王者的骚操作都在这儿了!(万字详解,建议收藏)
数据库·mysql
毕设源码-郭学长8 小时前
【开题答辩全过程】以 基于SpringBoot技术的美妆销售系统为例,包含答辩的问题和答案
java·spring boot·后端
猫头虎8 小时前
2025最新OpenEuler系统安装MySQL的详细教程
linux·服务器·数据库·sql·mysql·macos·openeuler
哈库纳玛塔塔8 小时前
放弃 MyBatis,拥抱新一代 Java 数据访问库
java·开发语言·数据库·mybatis·orm·dbvisitor