LOGO OA教程 ERP教程 模切知识交流 PMS教程 CRM教程 开发文档 其他文档  
 
网站管理员

[点晴永久免费OA]敏感字段信息数据库加密方案实践:我们选的不是最完美,但是真的够用了

admin
2025年9月28日 14:38 本文热度 43

为什么需要加密存储?

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

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

App 需要对用户个人信息进行加密传输与加密存储,
尤其是「密码、手机号、身份证号等敏感字段」「明文存储会直接驳回备案申请」,严重的还会被叫停业务或处罚。

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

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

一是觉得——「没必要啊」。数据库又不是对外开放的,正常情况下谁能碰到这些数据?除非我们服务器被攻破了,否则怎么可能泄漏?

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

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

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

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

也正是从这次教训之后,我们开始系统性地去思考一件事:

「敏感数据,到底该怎么加密存储,才能既安全又好用?」


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

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

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

比如,有的说可以搞一个“明文映射表”,用户手机号存明文,然后另起一张加密表做关联。
但是我觉得这种设计其实就是:「脱裤子放屁,自己骗自己。」

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

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

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

所以我们的思路是:

不搞那种“高大上”的通用加密框架,
也不想为了安全牺牲掉所有可用性,

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

说白了就是:哪些字段该加密就加密,哪些该查的我们用结构化字段保留一小部分明文,
既保证了合规和安全,也没把业务搞瘫。

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

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

CREATE TABLE`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'更新时间',

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

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

字段名
作用
示例值
user_id
用户 ID(主键)
10001
phone_cipher
加密后的手机号密文
看不懂的一串密文
phone_iv
加密用的随机向量
随机字节
phone_tag
AES-GCM 的认证 tag
随机字节
phone_idx
HMAC 生成的等值查询索引值(不可逆)
二进制摘要
phone_prefix
手机号前三位
138
phone_last4
手机号后四位
5678
key_version
当前密钥版本
1
created_at
创建时间
2025-08-01 10:00:00
updated_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_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位区域编码(可选)

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

我们不会在业务里去“模糊查身份证”,但为了支持按出生年月、性别这些维度做筛选,我们在应用层「解析身份证后把这些派生字段存入库中」,这样就可以支持场景需求,又不需要对加密字段本身做任何操作。

下面是我们几个典型的查询示例:

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

SELECT * FROM user_identity
WHERE pid_idx = :hashedPid;

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

筛选出生在 1990 年的用户

SELECT * FROM user_identity
WHERE pid_birth BETWEEN '1990-01-01' AND '1990-12-31';

查询性别为女性的用户

SELECT * FROM user_identity
WHERE pid_gender = 'F';

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

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 算法生成一个不可逆的索引值,用于精准查找。

SELECT * FROM user_email
WHERE email_idx = :hashedEmail;

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

email_domain

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

  • 查所有 @qq.com 的用户;
  • 或者导出 @163.com 的账号;
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
  • 真正要拿原文的,需要审批或特殊权限,解密时记录操作日志。

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

总的来说

这一套加密方案本身没问题,关键是我们上线之后怎么落地、怎么控制使用。
技术上设计得再完善,如果权限乱、日志乱、调用随意,最后都还是风险点。

我们最终要保证的是:

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


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

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

但我们没有直接照搬任何一种方案。原因很简单:

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

我们不是在做一个“为了合规而合规”的系统,也不是为了安全做而做。
我们的目标是既能保护好用户的敏感数据,也不能把系统复杂度拉满,影响业务、拖慢开发节奏。

所以最后这套方案看起来可能没那么“高级”,也不通用,但它「足够简单、够安全、能落地」,而且我们确实在上线之后运行得还不错。

如果我们项目里也刚好面临类似的问题,我觉得不一定要一上来就追求通用框架或者最全场景支持,不如从业务出发,做一套「适合我们自己系统的加密方案」,先跑起来,再逐步优化,也完全来得及。


阅读原文:https://mp.weixin.qq.com/s/2LNJkaFnClIrTaKL6UW8Sg


该文章在 2025/10/10 15:01:45 编辑过
关键字查询
相关文章
正在查询...
点晴ERP是一款针对中小制造业的专业生产管理软件系统,系统成熟度和易用性得到了国内大量中小企业的青睐。
点晴PMS码头管理系统主要针对港口码头集装箱与散货日常运作、调度、堆场、车队、财务费用、相关报表等业务管理,结合码头的业务特点,围绕调度、堆场作业而开发的。集技术的先进性、管理的有效性于一体,是物流码头及其他港口类企业的高效ERP管理信息系统。
点晴WMS仓储管理系统提供了货物产品管理,销售管理,采购管理,仓储管理,仓库管理,保质期管理,货位管理,库位管理,生产管理,WMS管理系统,标签打印,条形码,二维码管理,批号管理软件。
点晴免费OA是一款软件和通用服务都免费,不限功能、不限时间、不限用户的免费OA协同办公管理系统。
Copyright 2010-2025 ClickSun All Rights Reserved