# sensitive-query-demo **Repository Path**: send2ocean/sensitive-query-demo ## Basic Information - **Project Name**: sensitive-query-demo - **Description**: 敏感字段(手机号/身份证)加密模糊查询 Demo 基于 **Spring Boot 3.2 + MyBatis-Plus + PostgreSQL(pgcrypto) / MySQL** 实现手机号/身份证号 AES 加密存储后的模糊查询,对比三种常见方案的实现复杂度、查询性能、灵活性。 - **Primary Language**: Unknown - **License**: GPL-3.0 - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-07-13 - **Last Updated**: 2026-07-17 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # 敏感字段(手机号/身份证)加密模糊查询 Demo 基于 **Spring Boot 3.2 + MyBatis-Plus + PostgreSQL(pgcrypto) / MySQL** 实现手机号/身份证号 AES 加密存储后的模糊查询,对比三种常见方案的实现复杂度、查询性能、灵活性。 **支持 PostgreSQL / MySQL 一键切换**(通过 `app.db-type` 配置,见二.2.2)。 **测试数据量:10 万条**,三种方案同表同数据对比。 **项目改进记录**(P0-P2 级别): - ✅ NgramIndexService 交集逻辑修复(二次校验改为 `contains(keyword)` 精确匹配) - ✅ DB密码/AES密钥/HashSalt 环境变量化(支持 `PG_PASSWORD`/`MYSQL_PASSWORD`/`AES_KEY`/`NGRAM_SALT`) - ✅ 单元测试覆盖(`NgramIndexServiceTest`) - ✅ 方案1 全表扫描加 limit(默认1000,防止 OOM) - ✅ N+1 查询改批量 `IN` 查询(`selectBatchIds`) - ✅ Controller 输入校验(手机号/身份证正则校验) - ✅ 三个 Entity 抽取 `BaseUser` 基类 - ✅ `CryptoConfig` 重构为 `@Component + @ConfigurationProperties` - ✅ `DataInitializer` 分批事务(每批1000条独立提交) --- ## 一、三种方案对比 | 方案 | 实现方式 | 查询性能(10万行) | 灵活性 | 适用场景 | |------|----------|-------------------|--------|----------| | **方案1:全表解密后 LIKE** | 字段用 AES 加密存 BYTEA/VARBINARY;查询时 SQL 端数据库内置解密函数解密后再 `LIKE '%xxx%'` | 前缀查询 ~330ms,中间片段 ~110ms(全表扫描) | **极高**:任意位置、任意长度关键词 | 数据量 < 10 万行、低频后台查询;实现最简单 | | **方案2:固定片段冗余列 + B树索引** | 写入时把常用查询维度(手机号前3/后4、身份证前6/出生年份/后4)**单独加密**成冗余二进制列并建索引;查询时直接对加密列等值匹配 | **3~40ms**(全部走索引) | 中:只能查**预先拆好的固定片段**,不支持任意位置模糊 | 90% 中后台业务场景;最推荐 | | **方案3:N-gram 滑动窗口 + 加盐哈希倒排索引** | 写入时把手机号/身份证按 3 位窗口滑动切片,每个片段 `sha256(salt+chunk)` 后写入倒排索引表;查询时关键词同样切片哈希,JOIN 出候选集再回表校验 | 短关键词 ~170~390ms,结果集大时 ~870ms(走哈希索引 + 内存交集) | 高:支持任意位置 **≥3 位** 的模糊匹配 | 百万级以上数据量、需要灵活搜索的场景 | ### 关键设计点 1. **加密算法**:统一使用 AES-256-ECB/PKCS5(32 字节 key),Java 端 hutool `SecureUtil.aes`,PG 端 `pgcrypto.decrypt(..., 'aes-ecb/pad:pkcs')`,MySQL 端 `AES_DECRYPT`(JDBC URL 通过 `sessionVariables=block_encryption_mode='aes-256-ecb'` 对齐);密钥 32 位,生产请通过配置中心/环境变量注入,**不要硬编码在 yml**。 2. **方案1 的 SQL** 在数据库端做解密(方言 SQL 通过 MyBatis `databaseId` 自动切换): ```sql -- PostgreSQL SELECT * FROM t_user_plain_decrypt WHERE convert_from(decrypt(phone_cipher, #{key}::bytea, 'aes-ecb/pad:pkcs'), 'UTF8') LIKE CONCAT('%', #{keyword}, '%') OR convert_from(decrypt(idcard_cipher, #{key}::bytea, 'aes-ecb/pad:pkcs'), 'UTF8') LIKE CONCAT('%', #{keyword}, '%'); -- MySQL SELECT * FROM t_user_plain_decrypt WHERE CONVERT(AES_DECRYPT(phone_cipher, #{key}) USING utf8mb4) LIKE CONCAT('%', #{keyword}, '%') OR CONVERT(AES_DECRYPT(idcard_cipher, #{key}) USING utf8mb4) LIKE CONCAT('%', #{keyword}, '%'); ``` 3. **方案2 的冗余列**:加密后存二进制列(PG `BYTEA`,MySQL `VARBINARY(128)`),B树索引对二进制等值匹配效率极高;只加常用维度,不要贪多。 4. **方案3 的 N-gram 窗口大小**选 3;手机号 11 位 → 9 个 chunk,身份证 18 位 → 16 个 chunk,10 万行约产生 250 万行索引;为防止哈希碰撞,JOIN 出候选后会在内存里做一次 `contains` 二次校验。 5. **明文字段 `*_plain`**:Demo 里同时保留明文方便测试和结果对比,**生产环境应删掉或只留密文**。 --- ## 二、环境准备 ### 2.1 依赖 - JDK 21 - Maven 3.6+ - 数据库(二选一): - **PostgreSQL 12+**(需要启用 `pgcrypto` 扩展,`schema-pg.sql` 启动时自动 `CREATE EXTENSION IF NOT EXISTS pgcrypto`,需超管权限) - **MySQL 5.7+ / 8.0+**(InnoDB + utf8mb4,`AES_DECRYPT` 内置,无需额外扩展) - 网络可达 maven 中央仓库(首次构建需要下载 Spring Boot / MyBatis-Plus / hutool 依赖) ### 2.2 配置(数据库切换) `src/main/resources/application.yml`(主配置,通用项): ```yaml server: port: 8088 # 数据库类型切换:pg(PostgreSQL) | mysql,默认 pg app: db-type: pg spring: profiles: active: ${app.db-type} # 自动激活 application-{db-type}.yml mybatis-plus: configuration: map-underscore-to-camel-case: true mapper-locations: classpath:mapper/*.xml # 加密配置(生产环境建议通过环境变量注入,见下方) crypto: key: 0123456789abcdef0123456789abcdef # 32字节 AES-256 密钥,生产请换 hash-salt: demo-salt-2026 # N-gram 哈希盐值 ``` **环境变量支持**(优先级:环境变量 > yml 默认值): | 变量名 | 用途 | 默认值 | |--------|------|--------| | `PG_PASSWORD` | PostgreSQL 密码 | `45dfjk32d089Id113dfmndd` | | `MYSQL_PASSWORD` | MySQL 密码 | `test1234` | | `AES_KEY` | AES-256 加密密钥 | `0123456789abcdef0123456789abcdef` | | `NGRAM_SALT` | N-gram 哈希盐值 | `demo-salt-2026` | 使用方式:`export AES_KEY=your-secret-key && ./startup.sh pg` 数据库连接等差异配置抽到 profile 文件里: - `application-pg.yml`:PG 驱动、URL、`schema-pg.sql`、分隔符 `;;` - `application-mysql.yml`:MySQL 驱动、URL(含 `block_encryption_mode=aes-256-ecb`)、`schema-mysql.sql`、分隔符 `;` **切换方法**:改 `application.yml` 里的 `app.db-type: mysql`,或启动时 `--app.db-type=mysql`,无需改代码。 启动时 schema DDL 会自动执行(`CREATE TABLE IF NOT EXISTS`),重复启动不会清数据。 --- ## 三、数据初始化 ### 3.1 建库 PostgreSQL: ```bash # 用 postgres 账号建库 sudo -u postgres psql -c "CREATE DATABASE sensitive_demo ENCODING 'UTF8';" # 或用 TCP 连接 PGPASSWORD=<密码> psql -h 127.0.0.1 -U postgres -d postgres \ -c "CREATE DATABASE sensitive_demo ENCODING 'UTF8';" ``` MySQL: ```bash mysql -h 127.0.0.1 -u root -p -e "CREATE DATABASE sensitive_demo DEFAULT CHARACTER SET utf8mb4;" ``` 默认账号密码:PG `postgres/45dfjk32d089Id113dfmndd`,MySQL `root/root`,请在对应的 `application-{db-type}.yml` 里改成自己的。 ### 3.2 启动方式 方式一:用项目根目录的脚本(推荐,自动识别并按 pg/mysql 打包启动,详见三.2.3) ```bash # 默认使用 PG ./startup.sh # 指定 MySQL ./startup.sh mysql ``` 方式二:开发态 mvn 直接跑 ```bash cd /home/xhy/Gitee/sensitive-query-demo # PG(默认) mvn spring-boot:run # MySQL mvn spring-boot:run -Dspring-boot.run.arguments="--app.db-type=mysql" ``` 方式三:打包后跑 jar ```bash mvn clean package -DskipTests # PG java -jar target/sensitive-query-demo-1.0.0.jar # MySQL java -jar target/sensitive-query-demo-1.0.0.jar --app.db-type=mysql ``` ### 3.3 自动造数 应用启动时 `DataInitializer`(实现 `CommandLineRunner`)会自动判断: - 如果 `t_user_plain_decrypt` 为空 → 批量生成 **10 万条**测试数据,每批 1000 条; - 如果已有数据 → 跳过,不重复插入。 造数逻辑: - **手机号**:从 44 个真实号段前缀(130~139/150~159/180~189/170~199 等)里随机挑一个,后 8 位随机数字; - **身份证**:前 6 位从北京/哈尔滨/上海/广州/南京/杭州共 30 个真实地区码随机挑,出生年份 1960~2005 随机,月/日各 2 位简化随机(日固定 1~28 避免大小月),后 4 位随机数字; - **姓名**:`测试用户{i}`,方便定位。 三表同一份数据,AES 加密后分别以不同策略写入: | 表 | 写入内容 | |----|---------| | `t_user_plain_decrypt` | 明文 + AES 密文(`phone_cipher`/`idcard_cipher` 二进制列) | | `t_user_fragment_index` | 明文 + AES 密文 + 5 个片段冗余列(`phone_prefix3_cipher` / `phone_suffix4_cipher` / `idcard_prefix6_cipher` / `idcard_birth_year_cipher` / `idcard_suffix4_cipher`) | | `t_user_ngram` + `t_ngram_index_phone` + `t_ngram_index_idcard` | 主表明文+密文;倒排表存每个 3 位片段的 sha256(salt+chunk) 和 (user_id, pos) | 本机实测 10 万条数据初始化耗时约 30~60 秒(取决于磁盘和 CPU),进度在日志里按每 1000 条打印: ``` [INFO] c.e.s.config.DataInitializer: 已插入 1000 / 100000 条数据,耗时 0 s ... [INFO] c.e.s.config.DataInitializer: 测试数据插入完成,共100000条,总耗时 38 s ``` ### 3.4 手动加数据(可选) ```bash curl -X POST -H "Content-Type: application/json" \ -d '{"name":"徐老师测试","phone":"13999999999","idcard":"230103197501018888"}' \ http://127.0.0.1:8088/api/user/add ``` 该接口会同时把同一条数据写入三方案的三张表,方便横向对比。 ### 3.5 初始化校验 PostgreSQL: ```bash PGPASSWORD=<密码> psql -h 127.0.0.1 -U postgres -d sensitive_demo -c " SELECT 't_user_plain_decrypt' tbl, COUNT(*) FROM t_user_plain_decrypt UNION ALL SELECT 't_user_fragment_index', COUNT(*) FROM t_user_fragment_index UNION ALL SELECT 't_user_ngram', COUNT(*) FROM t_user_ngram UNION ALL SELECT 't_ngram_index_phone', COUNT(*) FROM t_ngram_index_phone UNION ALL SELECT 't_ngram_index_idcard',COUNT(*) FROM t_ngram_index_idcard;" ``` MySQL: ```bash mysql -h 127.0.0.1 -u root -p sensitive_demo -e " SELECT 't_user_plain_decrypt' tbl, COUNT(*) FROM t_user_plain_decrypt UNION ALL SELECT 't_user_fragment_index', COUNT(*) FROM t_user_fragment_index UNION ALL SELECT 't_user_ngram', COUNT(*) FROM t_user_ngram UNION ALL SELECT 't_ngram_index_phone', COUNT(*) FROM t_ngram_index_phone UNION ALL SELECT 't_ngram_index_idcard', COUNT(*) FROM t_ngram_index_idcard;" ``` 预期结果(10万行测试): ``` tbl | count ----------------------+-------- t_user_plain_decrypt | 100000 t_user_fragment_index| 100000 t_user_ngram | 100000 t_ngram_index_phone | 900000 -- 10万 × 9 个手机号片段 t_ngram_index_idcard | 1600000 -- 10万 × 16 个身份证片段 ``` --- ## 四、接口列表 服务启动后默认 `http://127.0.0.1:8088`,返回格式统一: ```json {"code": 0, "msg": "success", "data": [...]} ``` ### 通用接口 | 方法 | 路径 | 说明 | |------|------|------| | POST | `/api/user/add` | 新增用户,同时写入三方案表;body: `{"name":"...","phone":"11位","idcard":"18位"}` | ### 方案1(全表解密 LIKE) | 方法 | 路径 | 参数 | 说明 | |------|------|------|------| | GET | `/api/search/plain` | `keyword`(必填), `limit`(可选,默认1000) | 任意关键词,全表解密后 LIKE 匹配 phone/idcard;limit 防止全表扫描返回过多数据 | 示例:`/api/search/plain?keyword=138`、`/api/search/plain?keyword=1990`、`/api/search/plain?keyword=138&limit=10` ### 方案2(固定片段索引) | 方法 | 路径 | 参数 | 说明 | |------|------|------|------| | GET | `/api/search/fragment/phone-prefix` | `prefix`(3位数字) | 手机号前 3 位(号段)查询 | | GET | `/api/search/fragment/phone-suffix` | `suffix`(4位数字) | 手机号后 4 位查询 | | GET | `/api/search/fragment/idcard-prefix` | `prefix`(6位数字) | 身份证前 6 位(地区码)查询 | | GET | `/api/search/fragment/idcard-year` | `year`(4位,19xx/20xx) | 身份证出生年份查询 | | GET | `/api/search/fragment/idcard-suffix` | `suffix`(4位数字) | 身份证后 4 位查询 | 示例:`/api/search/fragment/phone-prefix?prefix=138`、`/api/search/fragment/idcard-year?year=1990` ### 方案3(N-gram 哈希索引) | 方法 | 路径 | 参数 | 说明 | |------|------|------|------| | GET | `/api/search/ngram` | `keyword`(≥3位数字) | 任意位置包含 keyword 的手机号或身份证;支持手机号/身份证同时匹配 | 示例:`/api/search/ngram?keyword=138`(前缀)、`/api/search/ngram?keyword=600`(中间)、`/api/search/ngram?keyword=230`(身份证哈尔滨地区) ### 输入校验规则 | 接口 | 参数 | 校验规则 | 错误提示 | |------|------|---------|---------| | `/api/user/add` | name | 非空 | 姓名不能为空 | | `/api/user/add` | phone | 11位手机号格式(`1[3-9]\d{9}`) | 手机号格式不正确 | | `/api/user/add` | idcard | 18位身份证格式(含X) | 身份证号格式不正确 | | `/api/search/plain` | keyword | 非空 | 关键词不能为空 | | `/api/search/fragment/phone-prefix` | prefix | 3位数字 | 手机号前缀必须为3位数字 | | `/api/search/fragment/phone-suffix` | suffix | 4位数字 | 手机号后缀必须为4位数字 | | `/api/search/fragment/idcard-prefix` | prefix | 6位数字 | 身份证前缀必须为6位数字 | | `/api/search/fragment/idcard-year` | year | 4位,19xx/20xx | 出生年份格式不正确 | | `/api/search/fragment/idcard-suffix` | suffix | 4位数字 | 身份证后缀必须为4位数字 | | `/api/search/ngram` | keyword | ≥3位数字 | 关键词必须为3位及以上数字 | --- ## 五、测试方法与结果 ### 5.1 测试环境 - CPU:本机 WSL/物理机(i5/Ryzen 级别即可) - PG:本机 PG 18,`shared_buffers` 默认配置 - 数据量:10 万行(三表数据一致),服务预热后(连接池已建立,数据已进 cache) ### 5.2 测试命令(一键可复现) ```bash # 启动服务(pg) ./startup.sh # 或启动 MySQL 版 ./startup.sh mysql sleep 15 # 方案1 curl -s -o /dev/null -w "方案1 前缀 '138' : HTTP %{http_code}, %{time_total}s\n" \ "http://127.0.0.1:8088/api/search/plain?keyword=138" curl -s -o /dev/null -w "方案1 中间 '6001' : HTTP %{http_code}, %{time_total}s\n" \ "http://127.0.0.1:8088/api/search/plain?keyword=6001" # 方案2 curl -s -o /dev/null -w "方案2 手机前3 '138' : HTTP %{http_code}, %{time_total}s\n" \ "http://127.0.0.1:8088/api/search/fragment/phone-prefix?prefix=138" curl -s -o /dev/null -w "方案2 手机后4 '8000' : HTTP %{http_code}, %{time_total}s\n" \ "http://127.0.0.1:8088/api/search/fragment/phone-suffix?suffix=8000" curl -s -o /dev/null -w "方案2 身份证前6 '230103': HTTP %{http_code}, %{time_total}s\n" \ "http://127.0.0.1:8088/api/search/fragment/idcard-prefix?prefix=230103" curl -s -o /dev/null -w "方案2 出生年 '1990' : HTTP %{http_code}, %{time_total}s\n" \ "http://127.0.0.1:8088/api/search/fragment/idcard-year?year=1990" curl -s -o /dev/null -w "方案2 身份证后4 '1234' : HTTP %{http_code}, %{time_total}s\n" \ "http://127.0.0.1:8088/api/search/fragment/idcard-suffix?suffix=1234" # 方案3 curl -s -o /dev/null -w "方案3 N-gram '138' : HTTP %{http_code}, %{time_total}s\n" \ "http://127.0.0.1:8088/api/search/ngram?keyword=138" curl -s -o /dev/null -w "方案3 N-gram '600' : HTTP %{http_code}, %{time_total}s\n" \ "http://127.0.0.1:8088/api/search/ngram?keyword=600" curl -s -o /dev/null -w "方案3 N-gram '230' : HTTP %{http_code}, %{time_total}s\n" \ "http://127.0.0.1:8088/api/search/ngram?keyword=230" # 写入 curl -s -o /dev/null -w "新增一条写入三表 : HTTP %{http_code}, %{time_total}s\n" \ -X POST -H "Content-Type: application/json" \ -d '{"name":"徐老师测试","phone":"13999999999","idcard":"230103197501018888"}' \ http://127.0.0.1:8088/api/user/add ``` ### 5.3 实测结果(10 万行,PG 预热后) | 用例 | 关键词 | 命中条数 | 耗时 | 备注 | |------|--------|---------|------|------| | **方案1** 全表解密 LIKE 前缀 | `138` | 3,611 | **0.34 s** | 全表扫描解密,慢但最灵活 | | **方案1** 全表解密 LIKE 中间 | `6001` | 212 | 0.11 s | 结果少时稍快 | | **方案2** 手机号前3 | `138` | 2,293 | **0.040 s** | 走 B树索引 | | **方案2** 手机号后4 | `8000` | 10 | **0.003 s** | 结果集小,毫秒级 | | **方案2** 身份证前6(哈尔滨南岗) | `230103` | 3,052 | 0.035 s | 走索引 | | **方案2** 出生年份 | `1990` | 2,178 | 0.026 s | 走索引 | | **方案2** 身份证后4 | `1234` | 7 | 0.004 s | 结果集小,毫秒级 | | **方案3** N-gram 前缀 | `138` | 3,611 | 0.39 s | 哈希 JOIN + 回表校验 | | **方案3** N-gram 中间 | `600` | 2,620 | 0.17 s | 灵活度高 | | **方案3** N-gram 大结果集 | `230` | 25,182 | 0.87 s | 结果集大时交集 + 回表变重 | | **写入** 单条新增 | — | 1 | 0.03 s | 同时写主表 + N-gram 倒排表 | > 注:方案1 的结果条数(3611)比方案2 按 `138` 片段索引(2293)多,是因为方案1 是 phone **或** idcard 含 `138`;方案2 只查 phone 前缀。方案3 的 `138` 结果(3611)和方案1 一致,验证了召回率正确。 ### 5.4 正确性验证 写入一条特定数据后,三种方案都能搜回来: ```bash curl -X POST -H "Content-Type: application/json" \ -d '{"name":"徐老师测试","phone":"13999999999","idcard":"230103197501018888"}' \ http://127.0.0.1:8088/api/user/add # 方案1:手机号里搜 '1399999' curl -s "http://127.0.0.1:8088/api/search/plain?keyword=1399999" | jq '.data[] | select(.name=="徐老师测试")' # 方案3:N-gram 搜 '9999' curl -s "http://127.0.0.1:8088/api/search/ngram?keyword=9999" | jq '.data[] | select(.name=="徐老师测试")' ``` ### 5.5 性能结论 - **方案2(固定片段索引)**:性能最好(几毫秒到几十毫秒),应该是默认首选;代价是要提前想清楚要支持哪些查询维度。 - **方案1(全表解密 LIKE)**:10 万量级还能在 0.1~0.4s 返回,数据量上去(>50万)后会明显拖慢 DB,只适合小表/低频运营后台查询,**不要给高频前台接口用**。 - **方案3(N-gram 哈希索引)**:在百万级以上优势明显;代价是倒排表会膨胀到主表的 20~30 倍存储。适合"确实需要任意位置模糊搜手机号/身份证"的场景(如公安/风控/客服检索)。 - **MySQL vs PG**:方案2/3 因为 SQL 都是标准等值/JOIN,两种库性能相当;方案1 MySQL 的 `AES_DECRYPT` 性能与 PG `pgcrypto.decrypt` 在 10 万行下处于同一量级。 --- ## 六、表结构(速查) PostgreSQL 版: ```sql t_user_plain_decrypt( id BIGSERIAL PK, name VARCHAR(50), phone_plain VARCHAR(20), phone_cipher BYTEA, idcard_plain VARCHAR(20), idcard_cipher BYTEA, create_time TIMESTAMP ); -- 方案2 多 5 个 *_cipher BYTEA 冗余列;每个 _cipher 列都建 B-tree 索引 -- 方案3 t_ngram_index_phone/idcard(chunk_hash CHAR(64), user_id BIGINT, pos SMALLINT, PK(chunk_hash,user_id,pos)) ``` MySQL 版(差异点:`BIGSERIAL→BIGINT AUTO_INCREMENT`,`BYTEA→VARBINARY(128)`,Engine=InnoDB,无 `CREATE EXTENSION`): ```sql t_user_plain_decrypt( id BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY, name VARCHAR(50), phone_plain VARCHAR(20), phone_cipher VARBINARY(128), idcard_plain VARCHAR(20), idcard_cipher VARBINARY(128), create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ``` 完整 DDL 见: - PG:`src/main/resources/db/schema-pg.sql` - MySQL:`src/main/resources/db/schema-mysql.sql` - 原 `schema.sql` 已不再被引用,保留作为历史。 --- ## 七、工程目录 ``` sensitive-query-demo/ ├── pom.xml ├── README.md ├── startup.sh # 一键编译+启动脚本(pg|mysql) └── src/main/ ├── java/com/example/sensitive/ │ ├── SensitiveQueryApplication.java │ ├── config/ │ │ ├── CryptoConfig.java # AES bean + 读取 crypto.key/hash-salt │ │ ├── DatabaseIdConfig.java # MyBatis databaseIdProvider(按 PG/MySQL 切 SQL 方言) │ │ └── DataInitializer.java # 启动时自动造 10 万条测试数据 │ ├── controller/TestController.java │ ├── dto/AddUserRequest.java │ ├── entity/ # 三个方案各一张主表实体(密文字段用 byte[]) │ │ ├── UserPlainDecrypt.java │ │ ├── UserFragmentIndex.java │ │ └── UserNgram.java │ ├── mapper/ │ │ ├── UserPlainDecryptMapper.java # 方案1 fuzzySearch(SQL 下沉到 XML 按 databaseId 分支) │ │ ├── UserPlainDecryptMapper.xml │ │ ├── UserFragmentIndexMapper.java # 5个片段等值@Select(标准SQL,两库通用) │ │ ├── UserNgramMapper.java # N-gram 倒排 INSERT/SELECT(标准SQL) │ │ ├── PlainDecryptBatchMapper.java/xml │ │ ├── FragmentBatchMapper.java/xml │ │ └── NgramBatchMapper.java/xml # upsert/getStartId 按 databaseId 分支 │ └── service/ │ ├── PlainDecryptService.java │ ├── FragmentIndexService.java │ └── NgramIndexService.java └── resources/ ├── application.yml # 主配置(含 app.db-type 切换) ├── application-pg.yml # PG profile(驱动/URL/schema) ├── application-mysql.yml # MySQL profile(驱动/URL/schema) ├── db/ │ ├── schema-pg.sql # PG 建表(含 pgcrypto/BYTEA/BIGSERIAL) │ ├── schema-mysql.sql # MySQL 建表(VARBINARY/AUTO_INCREMENT/InnoDB) │ └── data.sql └── mapper/ # Mapper XML(含 databaseId 方言分支) ``` --- ## 八、多数据库切换实现原理(速览) 1. **Spring Profile**:主 yml 用 `spring.profiles.active: ${app.db-type}`,把 `app.db-type` 的值(`pg`/`mysql`)直接作为 profile 名,自动加载 `application-{db-type}.yml`,从而切换 JDBC 连接和 schema 脚本路径。 2. **MyBatis `VendorDatabaseIdProvider`**(`DatabaseIdConfig`):启动时通过 `DatabaseMetaData#getDatabaseProductName()` 识别数据库产品,把 `PostgreSQL→pg`、`MySQL→mysql` 注入到 MyBatis 的 `databaseId`;Mapper XML 里同一 `id` 的 SQL 可写两个版本,用 `databaseId="pg"`/`databaseId="mysql"` 区分,运行时自动选用匹配方言的那条。 3. **密钥以 `byte[]` 传 JDBC**:避免 MySQL `AES_DECRYPT` 对字符串 key 的隐式派生,保证 Java hutool 与 MySQL `AES_DECRYPT` 加解密结果一致;JDBC URL 上通过 `sessionVariables=block_encryption_mode='aes-256-ecb'` 对齐 PG `pgcrypto` 在 32 字节 key 下实际使用的 AES-256-ECB 算法。 4. **自增 ID 通用化**:批量插入 N-gram 倒排索引时原实现依赖 PG 序列 `last_value` 推算 user_id,现改成标准 `SELECT COALESCE(MAX(id),0)`,PG/MySQL 均兼容。 --- ## 九、上生产前要改的东西 1. **删掉 `*_plain` 明文字段**——Demo 留着只是方便看结果,生产不该落明文。 2. **密钥别硬编码**:`crypto.key` 从环境变量/配置中心/KMS 注入,不要提交到 git。 3. **加密模式可升级**:ECB 简单但有同款明文→同款密文的风险;要求更高可换 AES-GCM 或国密 SM4(需数据库端同步支持,方案2/3 因为解密只在应用侧或等值匹配中进行,可完全用应用侧加密,数据库仅存字节/哈希值,无此约束)。 4. **N-gram 的窗口大小和 salt**:上线后不要改,否则索引要全量重建;salt 泄露后可通过枚举 1000 个 3 位数字组合反推(10^3 = 1000 很小),可考虑把 chunk 长度加到 4 或者对 chunk 做 HMAC。 5. **方案1 不要给高 QPS 接口**:一次解密全表很吃 CPU,QPS 高了会把 DB 打满。 6. **方案3 大结果集**:当关键词太常见会返回几十万候选,要在 service 层加 limit 或对关键词最小长度做限制(Demo 里已强制 ≥3 位)。 7. **MySQL 用户注意**:JDBC URL 默认的 `allowPublicKeyRetrieval=true&useSSL=false` 仅适合本地开发,生产请关闭并配置证书、强密码;`root/root` 请改为业务账号最小权限。