如何实现MongoDB加密字段的直接查询搜索功能
mongoose-field-encryption 默认采用AES-256-CBC概率加密逻辑,同一段明文每次生成的密文都不相同,所以直接写查询条件匹配密文必然搜不到结果。针对邮箱、姓名、手机号码、地址这类需要搜索的加密字段,可以根据自身业务场景从下面三个成熟方案里选,都是生产环境验证过的实现。
方案1:开启确定性加密(适合精确匹配、前缀匹配场景)
这个插件原生支持确定性加密模式,开启后相同明文生成的密文固定,插件会自动把查询条件里传入的明文加密后再匹配库中密文,不需要写额外转换逻辑,最适合邮箱、手机号、姓名这类需要精确查找、前缀匹配的字段。
直接修改Schema配置即可:
const userSchema = new mongoose.Schema({ email: { type: String, // 给需要精确搜索的字段开启确定性加密 encrypt: { deterministic: true } }, phone: { type: String, encrypt: { deterministic: true } }, name: { type: String, encrypt: { deterministic: true } }, // 地址如果只需要精确匹配也可以开,要做模糊搜索就走后面的方案 address: { type: String, encrypt: true } }); // 插件初始化注意要固定salt生成规则,不能用默认的随机salt userSchema.plugin(mongooseFieldEncryption, { secret: process.env.MONGO_ENC_SECRET, saltGenerator: (secret) => secret.slice(0, 16), // 确定性加密必须返回固定salt值 encryptedFieldType: String });
查询的时候正常写条件就行,插件会自动处理加密转换逻辑:
// 精确查询对应邮箱的用户 const targetUser = await User.findOne({ email: 'user@example.com' }); // 前缀匹配手机号,比如查询138开头的所有用户 const prefixMatchUsers = await User.find({ phone: /^138/ });
踩坑提醒:确定性加密的安全性比默认的概率加密低,相同明文对应密文一致,拖库后存在被频率分析反推明文的风险,只给有强搜索需求的字段开启,身份证、银行卡这类极高敏感字段不要用这个模式。
方案2:分词哈希索引(适合地址、姓名这类任意位置模糊搜索场景)
地址这类字段经常需要做包含匹配,比如搜“朝阳”要命中“北京市朝阳区”的记录,确定性加密做不到中间/后缀模糊匹配,这种场景可以额外存一份不可逆的分词哈希索引,查询时先把搜索词转成哈希再匹配索引字段,不会泄露明文信息。
实现步骤:
- 给每个需要模糊搜索的加密字段加一个数组类型的索引字段,用来存分词后的哈希值
- 写pre-save钩子,存数据时把明文按n-gram规则分词,每个分词用HMAC生成哈希存到索引数组
- 查询时把用户输入的关键词按同样规则生成哈希,匹配索引数组即可返回结果
参考实现代码:
const crypto = require('crypto'); const SEARCH_HMAC_KEY = process.env.SEARCH_INDEX_KEY; // 注意和字段加密密钥分开存储 // 生成分词,默认按2-3字切分,粒度可以根据自己的搜索精度调整 function genNGrams(text, gramLen = 2) { const gramSet = new Set(); const cleanText = text.trim().toLowerCase(); for (let i = 0; i <= cleanText.length - gramLen; i++) { gramSet.add(cleanText.slice(i, i + gramLen)); } return Array.from(gramSet); } // 分词转HMAC哈希,避免被彩虹表反推明文 function hashGram(gram) { return crypto.createHmac('sha256', SEARCH_HMAC_KEY).update(gram).digest('hex'); } const userSchema = new mongoose.Schema({ name: { type: String, encrypt: true }, address: { type: String, encrypt: true }, // 搜索索引字段不需要加密,存的是不可逆哈希值 name_search_idx: [{ type: String, index: true }], address_search_idx: [{ type: String, index: true }] }); // 数据保存时自动生成对应的搜索索引 userSchema.pre('save', function(next) { if (this.isModified('name')) { this.name_search_idx = genNGrams(this.name).map(hashGram); } if (this.isModified('address')) { this.address_search_idx = genNGrams(this.address, 3).map(hashGram); } next(); }); // 封装搜索静态方法,业务调用更方便 userSchema.statics.fuzzySearchAddress = function(keyword) { const targetHash = hashGram(keyword.trim().toLowerCase()); return this.find({ address_search_idx: targetHash }); };
踩坑提醒:分词粒度越小搜索召回率越高,但对应的存储成本和误匹配概率也会上升,根据业务实际效果调整即可;不要用普通sha256做哈希,一定要用带密钥的HMAC,防止被彩虹表撞出明文。
方案3:内存过滤(适合小数据量内部系统)
如果你的表总数据量在10万条以内,又是内部使用的管理系统,没必要搞上面的复杂逻辑,直接查全量数据(插件会自动解密所有加密字段),在内存里做过滤就行:
// 查询地址里带"朝阳"的所有用户 const allUsers = await User.find({}); const matchedUsers = allUsers.filter(user => user.address.includes('朝阳'));
踩坑提醒:数据量超过10万别用这个方案,全表扫描+全量数据拉到内存很容易把服务打挂,只适合小体量内部工具使用。
通用注意事项
- 别为了搜索直接关闭字段加密,等于白引入这个加密插件
- 确定性加密的salt规则一旦确定不要随意修改,不然老数据的密文匹配不上,会直接搜不到
- 搜索索引用的密钥和字段加密的密钥要分开存储,降低密钥泄露后的风险
内容的提问来源于stack exchange,提问作者Harshal Faldu

