数据库加密方案实践:我们选的不是最完美,但是真的够用了。
为什么需要加密存储?我们之前公司开发了一款社交类 App,准备上线各大应用市场的时候,突然发现上架被拒了。原因是:平台要求提供“公安备案号”,否则 App 无法审核通过,甚至有下架风险。
没办法,我们只好去注册地的网警办跑备案。结果拿到网警办的审核要求后,发现其中有一条写得特别明确:
?App 需要对用户个人信息进行加密传输与加密存储,
尤其是「密码、手机号、身份证号等敏感字段」,「明文存储会直接驳回备案申请」,严重的还会被叫停业务或处罚。
?当时我们一看就傻了。密码确实加密了,用的是常规的哈希算法,但像手机号、身份证、邮箱这些,全都明文躺在数据库里。
其实我们不是不知道加密这事有多重要,而是当初在设计的时候,根本就「没往那方面想过」。
一是觉得——「没必要啊」。数据库又不是对外开放的,正常情况下谁能碰到这些数据?除非我们服务器被攻破了,否则怎么可能泄漏?
二是我们页面上「本来也做了脱敏」。手机号、身份证这些信息,在前端或后端接口返回的时候都会做遮盖,后台操作人员看不到全量数据。就算有人越权进后台,也拿不到完整信息,我们当时觉得这就够了。
三是,「加密太麻烦了」。像用户登录、注册、实名认证这些流程,本来就已经够复杂了,偶尔还得救个急、处理失败回调什么的。再加一层加密,前端传参、后端校验、数据库查询,全得改一轮,系统复杂度直接上天。而且加密之后,什么模糊查询、手机号筛选、关键词匹配基本全废,干脆就没往那块考虑。
结果这些“我们以为不会出问题”的地方,最后全变成了卡我们脖子的点。
我们又花了一周时间重新设计字段、做加密、兼容查询逻辑,才终于顺利通过了联网备案。
也正是从这次教训之后,我们开始系统性地去思考一件事:
「敏感数据,到底该怎么加密存储,才能既安全又好用?」
手机号加密怎么做?我们是这样设计的在整改的时候,我们首先把用户信息里的手机号字段「单独拆了出来」,放到一张新的表里做专门的加密设计。
当时也确实花了不少时间研究各种方案。网上有很多资料,有些看起来挺先进的,但实际落地的时候问题一堆。
比如,有的说可以搞一个“明文映射表”,用户手机号存明文,然后另起一张加密表做关联。
但是我觉得这种设计其实就是:「脱裤子放屁,自己骗自己。」
还有的方案是说把手机号拆成三段,中间部分加密、首尾保留,甚至还有人搞什么正则分词加密索引的。我们看完之后的感受是:「太复杂了,维护成本太高,不适合我们业务节奏」。
我们最后回过头来,还是「从我们自己系统的使用场景出发来设计」,因为我们对手机号的使用,其实就这几种:
客服系统要通过手机号查用户;后台风控系统会用手机号做定位;审计、运营导数也基本都是以手机号为主键查;还有不少业务逻辑里会用到手机号尾号做筛选,比如查“尾号是 5678 的用户”。所以我们的思路是:
?不搞那种“高大上”的通用加密框架,
也不想为了安全牺牲掉所有可用性,
?「我们做了一个更实际、更折中的方案:受控拆分字段 + 明文索引 + 模糊脱敏策略。」
说白了就是:哪些字段该加密就加密,哪些该查的我们用结构化字段保留一小部分明文,
既保证了合规和安全,也没把业务搞瘫。
确定了要做“加密 + 可查询”之后,我们就开始重新设计手机号的存储结构。
我们把手机号从原来的user_info表里单独拆了出来,放到一张叫user_phone的表中。这张表专门用于存储用户手机号的加密信息,并保留了一部分可查询的字段,设计结构如下:
CREATETABLE`user_phone`(
`user_id` BIGINTUNSIGNED NOTNULLCOMMENT'用户ID,主键,一对一关联用户表',
`phone_cipher` VARBINARY(128) NOTNULLCOMMENT'加密后的手机号密文',
`phone_iv` VARBINARY(12) NOTNULLCOMMENT'AES-GCM加密使用的随机IV',
`phone_tag` VARBINARY(16) NOTNULLCOMMENT'AES-GCM认证Tag',
`phone_idx` BINARY(32) NOTNULLCOMMENT'手机号HMAC索引值(不可逆,用于等值查询)',
`phone_prefix`CHAR(3) NOTNULLCOMMENT'手机号前3位,用于受控前缀查询',
`phone_last4` CHAR(4) NOTNULLCOMMENT'手机号后4位,用于受控尾号查询',
`key_version` SMALLINTUNSIGNED NOTNULLDEFAULT1COMMENT'加密时使用的密钥版本号',
`created_at` DATETIME NOTNULLDEFAULTCURRENT_TIMESTAMPCOMMENT'创建时间',
`updated_at` DATETIME NOTNULLDEFAULTCURRENT_TIMESTAMPONUPDATECURRENT_TIMESTAMPCOMMENT'更新时间',
PRIMARYKEY(`user_id`),
UNIQUEKEY`uk_phone_idx`(`phone_idx`),
KEY`idx_phone_prefix_last4`(`phone_prefix`,`phone_last4`)
)ENGINE=InnoDBDEFAULTCHARSET=utf8mb4COMMENT='用户手机号表,加密存储 + 可控查询支持';
里面的字段其实不复杂,核心就这几个:
字段名作用示例值user_id用户 ID(主键)10001phone_cipher加密后的手机号密文看不懂的一串密文phone_iv加密用的随机向量随机字节phone_tagAES-GCM 的认证 tag随机字节phone_idxHMAC 生成的等值查询索引值(不可逆)二进制摘要phone_prefix手机号前三位138phone_last4手机号后四位5678key_version当前密钥版本1created_at创建时间2025-08-01 10:00:00updated_at更新时间2025-08-01 10:00:00「为什么要设计这么多字段?它们都是干啥的?」
我们最开始也纠结:只存密文不行吗?干嘛还要搞什么索引值、前缀、后缀?
后来发现,不行。想要安全,就要加密;想要查,就要加索引;想要模糊,就得拆字段——这就是结合场景考虑。
我这边简单说下我们当时为什么要这样设计这些字段。
「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_tagAES-GCM 认证 tagname_idxHMAC 生成的等值查询索引值字段设计跟手机号差不多,也是一组密文字段 + 一个索引字段。
name_cipher/iv/tag是用于解密展示用的,比如后台详情页看到完整姓名;name_idx是通过 HMAC 算法生成的,不可逆,用于等值查询,比如查“名字等于张三”的用户。我们没有保留什么前缀、后缀字段,也没有拆成字或词来支持模糊查询,这一块我们定得很死:「姓名只支持等值查,不能模糊匹配」。
主要原因也是出于安全考虑——中文姓名空间太小了,如果支持模糊,风险太大,查个“李”就能把一堆用户暴露出来。所以我们业务上直接禁止这类需求,也就没必要为它设计复杂的字段结构。
身份证字段的加密设计身份证号这块,我们在处理时是比较谨慎的。一方面它是非常敏感的个人信息,属于法律明确定义的“个人敏感信息”;另一方面,它本身就包含了大量可识别的数据,比如出生日期、性别、地区编码这些,都能被人反推出来。
我们这边业务对身份证的使用需求,主要集中在以下几个场景:
实名认证流程中做格式校验和合法性判断;后台偶尔需要根据身份证等值查询某个用户;风控系统会根据出生日期、性别等字段做一些筛查,比如排除未成年用户,或者识别虚假身份;运营需要导出符合某些特征(比如出生时间段、性别)的用户。根据这些场景,我们做了如下设计:
字段名作用pid_cipher加密后的身份证密文pid_iv加密用的随机向量pid_tagGCM 加密认证 tagpid_idxHMAC 等值查询索引(不可逆)pid_birth出生日期(从身份证中解析出来)pid_gender性别(M / F,从身份证中解析)pid_area_code身份证前6位区域编码(可选)字段思路基本和手机号一致:密文存储 + 索引查找 + 结构化派生字段。
我们不会在业务里去“模糊查身份证”,但为了支持按出生年月、性别这些维度做筛选,我们在应用层「解析身份证后把这些派生字段存入库中」,这样就可以支持场景需求,又不需要对加密字段本身做任何操作。
下面是我们几个典型的查询示例:
精确查某个身份证号(等值查询)SELECT*FROMuser_identity
WHEREpid_idx = :hashedPid;
这个查询是通过应用层先算出身份证号的 HMAC 值,然后用pid_idx去查的,原文不会暴露,也不需要解密。
筛选出生在 1990 年的用户SELECT*FROMuser_identity
WHEREpid_birthBETWEEN'1990-01-01'AND'1990-12-31';
查询性别为女性的用户SELECT*FROMuser_identity
WHEREpid_gender ='F';
查询地区编码为 310101 的用户(例如上海黄浦)SELECT*FROMuser_identity
WHEREpid_area_code ='310101';
这些字段都是在实名认证通过时就一次性提取并存入数据库的,系统运行过程中不需要每次都重新解析身份证号。
我们也没有保留身份证号的前缀、后缀等字段,更不支持任何形式的LIKE '%xxx%'查询。身份证字段只支持「等值查 + 结构字段筛选」,这一点在我们系统里是严格限制的。
邮箱字段的加密设计邮箱这个字段,在我们系统里的使用频率相对没那么高,主要出现在这些地方:
用户注册、绑定邮箱;忘记密码时用来找回账号;后台偶尔需要根据邮箱精准查用户,比如客服根据用户提供的邮箱地址定位账号;运营偶尔会筛选某些邮箱域名的用户,比如导出全部@qq.com或@gmail.com的用户。和前面的手机号、身份证一样,我们也对邮箱做了加密处理,但整体设计更轻量一些:
字段名作用email_cipher加密后的邮箱密文email_iv加密用的随机向量email_tagGCM 加密认证 tagemail_idxHMAC 等值查询索引(不可逆)email_domain邮箱域名(明文)思路还是一样的:「加密存原文,保留索引,适当结构拆分」。
email_cipher / iv / tag和其他字段一样,email_cipher是密文,用 AES-GCM 加密后存的,iv和tag是为了完整性校验。解密只在导出邮箱或后台查看原文的时候用到,平时业务不会直接用这个字段。
email_idx邮箱虽然不像手机号那样高频查询,但也有等值查的场景,比如用户忘记绑定的手机号、用邮箱登录的时候,就会用到这个字段。我们这里同样是通过 HMAC 算法生成一个不可逆的索引值,用于精准查找。
SELECT*FROMuser_email
WHEREemail_idx = :hashedEmail;
应用层先把用户输入的邮箱标准化(比如统一小写、去空格),再计算 HMAC,和数据库里的email_idx去匹配。
email_domain这个字段是我们主动拆出来的,用来支持一些按邮箱域名的筛选,比如:
查所有@qq.com的用户;或者导出@163.com的账号;SELECT*FROMuser_email
WHEREemail_domain ='qq.com';
这个字段是通过应用层在用户绑定邮箱时就拆出来的,直接存明文。我们系统默认所有邮箱都在存储前做了统一处理,比如去空格、小写化,这样可以保证查询时不会出错。
说明一下我们没做什么我们没有做“邮箱前缀模糊查”,比如查abc*@qq.com这种。这种需求要么是伪需求,要么就是会被滥用的风险点,我们一律不支持。邮箱字段只支持等值查 + 域名筛选,不开放模糊搜索。
后台展示这么多字段,不会查起来很麻烦吗?在我们把用户敏感信息都做了加密拆分之后,一个最直接的问题就摆在面前了:
后台列表要展示用户信息,手机号、邮箱、身份证、姓名这些都加密了,字段又不在主表里,是不是每次都要联合三四张表?这样查性能压力大不说,写代码也麻烦。
我们一开始也试过用视图、用聚合查询,甚至做过服务端拼接,但最终都觉得太重。
后来我们选择了一个「最简单、最实用的做法」:
?在主表(user_info)里,直接存一份脱敏后的字段,专门用于展示,不参与查询,也不存明文。
?我们是怎么做的?我们在用户主表里增加了下面几个字段:
字段名含义示例值phone_masked脱敏后的手机号138****5678email_masked脱敏后的邮箱j***@qq.compid_masked脱敏后的身份证号3101**********1234name_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;真正要拿原文的,需要审批或特殊权限,解密时记录操作日志。「建议:」导出、审计这类场景最容易出问题,别放松,要么不给,要么严格审批。
总的来说这一套加密方案本身没问题,关键是我们上线之后怎么落地、怎么控制使用。
技术上设计得再完善,如果权限乱、日志乱、调用随意,最后都还是风险点。
我们最终要保证的是:
?加密只是底线,不滥用才是保障。
?我们选的不是最完美,但是真的够用了说实话,我们在做这个方案之前,也看了很多网上关于数据库加密的文章,不少都讲得很完整,也很专业,确实值得学习。
但我们没有直接照搬任何一种方案。原因很简单:
?加密方案可以有很多种,但最终落地的方案必须跟我们的「实际业务场景」对得上。
?我们不是在做一个“为了合规而合规”的系统,也不是为了安全做而做。
我们的目标是既能保护好用户的敏感数据,也不能把系统复杂度拉满,影响业务、拖慢开发节奏。
所以最后这套方案看起来可能没那么“高级”,也不通用,但它「足够简单、够安全、能落地」,而且我们确实在上线之后运行得还不错。
如果我们项目里也刚好面临类似的问题,我觉得不一定要一上来就追求通用框架或者最全场景支持,不如从业务出发,做一套「适合我们自己系统的加密方案」,先跑起来,再逐步优化,也完全来得及。
AI编程资讯AI Coding专区指南:
https://aicoding.juejin.cn/aicoding
点击"阅读原文"了解详情~
阅读原文
网站开发网络凭借多年的网站建设经验,坚持以“帮助中小企业实现网络营销化”为宗旨,累计为4000多家客户提供品质建站服务,得到了客户的一致好评。如果您有网站建设、网站改版、域名注册、主机空间、手机网站建设、网站备案等方面的需求...
请立即点击咨询我们或拨打咨询热线:13245491521 13245491521 ,我们会详细为你一一解答你心中的疑难。 项目经理在线