尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
MyBatis数据加密落地:TypeHandler与盲索引实现加密模糊查询
做数据加密这个需求我最怕的不是加密算法选型也不是密钥怎么管而是“加密之后业务还能不能正常跑”。字段存进去了、查询查不出来了或者查出来了但界面上显示一坨乱码这种半吊子上线比不做还糟。我最近在一个电商项目里完整落地了一套MyBatis体系下的数据加密方案覆盖了加密落库、加密回显、加密模糊查询三个关键场景中间踩了不少坑。这篇就把我认为相对最优的落地路径完整写出来包括表结构怎么设计、TypeHandler怎么写、模糊查询到底怎么做才能既安全又能用。先说清楚这套方案的核心思路加密动作收口在MyBatis的TypeHandler里业务代码几乎无感模糊查询不在密文上直接做而是额外维护一个“检索指纹列”用确定性加密的派生索引去匹配关键词。这个组合能覆盖绝大多数业务场景而且不会像“解密全表到内存里再模糊匹配”那样把性能和安全性一起丢光。1. 先拆解需求这三个词背后藏着什么问题1.1 加密本身不难难在“加密后还得能用”给数据库字段做加密直接用AES算法加密后存密文很多刚接触的开发者会觉得“这有什么难的”。确实光加密这一步不难。真正让项目卡壳的是后续的连锁反应第一加密后字段从明文的11位手机号变成一大串密文长度变了、字符集变了原来的索引直接失效。如果不做处理等于每次查询都是全表扫描。第二原来业务代码里user.getPhone()拿的是明文加密后拿到的是一堆不可读字符前端页面、日志、导出报表全部炸掉这就是“加密回显”问题。第三最头疼的模糊查询——需求方经常会说“我们要在后台按手机号关键字搜用户比如输入138把匹配的列表列出来”。数据量小的时候你把表全查出来解密再过滤还能忍数据量上来之后这种方案就是灾难。而且如果加密方案只是简单地在Service层做字符串替换慢慢就会发现漏网之鱼到处都是某个报表SQL没用Service方法、某个定时任务直接拼SQL、某个查询接口走了MyBatis但返回的字段没有解密。这就是为什么需要在框架层统一收口。1.2 回显解密框架层做还是业务层做回显解密如果放在业务层做最直接的问题是“容易漏”。一个项目几百上千个查询点你不可能保证每个开发都记得调用解密方法。而且解密逻辑散落在Service层会让代码非常臃肿每个返回给前端的对象都要手动遍历一遍字段出错的概率极高。放在MyBatis框架层做就完全不一样。数据从数据库读出来、映射成Java对象的这个过程是天然的统一收口点。我们只需要让对应字段走一个自定义TypeHandler查询时自动解密写入时自动加密。业务代码看到的永远是明文数据库里存的是密文操作和感知完全透明。唯一要注意的是这个“透明”是有代价的它要求所有对加密字段的读写都必须经过MyBatis如果你项目里有直接用JDBC、JdbcTemplate绕过的场景需要先统一收口到MyBatis体系里来。1.3 模糊查询为什么是“最优解”的命门模糊查询是整套方案里最考验设计的地方。因为标准加密算法都是“同样的输入得到同样密文”或者“同样的输入得到不同密文”无论哪种都无法在密文上直接做LIKE %abc%这样的操作。你不可能用密文的一部分去匹配另一段密文。可以把密文想象成一个黑箱子这个黑箱子里面的结构对SQL完全不可见那数据库层面的模糊匹配自然无从谈起。于是有人提出“把数据解密到内存再过滤”。如果表只有几千行这个方案没问题但如果表有几百万行每次查询都把全表数据解密一遍CPU和内存都会被打满接口响应时间直接飙到几十秒。还有人提出“不要模糊查询了只支持精确查询”这又会把需求方惹毛因为后台搜索是逃不掉的刚需。所以模糊查询的真正解法不是在没有索引的密文上硬做而是为“检索”单独建一条路一条不泄露原始数据、却能支持匹配判断的“指纹索引”。这个思路就是整个方案的核心。2. 方案选型为什么选择TypeHandler 盲索引列2.1 TypeHandler是MyBatis留给你的安全屋MyBatis的TypeHandler本质上是一个类型转换器它承担了两件事插入时把Java对象转换成JDBC类型参数查询时把JDBC结果转换成Java对象。我们要做的加密和解密恰好能被这两个钩子完美覆盖。我选择TypeHandler而不是AOP原因有几点AOP切入点不好定。拦截Service方法的话只能覆盖业务方法调用链路一旦方法内部有连表查询、多数据源查询、SQL直接映射拦截点就不完整了。TypeHandler是在SQL执行层面生效的只要这条SQL走了MyBatis且字段配置了对应的typeHandler加密和解密就必然执行。另外TypeHandler有很清晰的粒度控制。你可以全局注册让它只管某一种类型也可以在某一个字段上单独指定。项目里并不是所有字符串都需要加密比如用户名、备注这种字段如果也走同一个通用加密Handler反而会带来大量不必要的性能损耗。把加密限定在手机号、身份证号、银行卡号这类敏感字段上是最可靠的做法。我见到不少团队用“全局拦截所有String字段”的方式做加密上线后问题一堆比如分页插件里OrderBy字段被加解密搞乱了、JSON序列化字段被重复加密了。所以我的建议很明确敏感字段用TypeHandler精确配置宁可在XML或者注解里多写几行也别为了省事去全局拦截所有String。2.2 可搜索加密的实用思路密文列之外的索引列模糊查询的通用技术思路是在数据表里增加一个“检索索引列”英文圈子里叫Blind Index。这个索引列不会保存明文也不会保存可以被直接还原成明文的密文它保存的是“对明文特征做确定性变换后的指纹”。查询的时候我们对用户输入的关键字做同样的变换生成对应的指纹或者指纹匹配模式再去索引列上做匹配。举个例子。用户输入“138”系统会把“138”经过某种固定算法处理成一段固定字符串比如“3f9a7b2c”然后在数据库里WHERE search_col LIKE %3f9a7b2c%。乍一看很完美但这里有个安全边界问题如果索引列只做了简单哈希恶意者可以通过字典攻击猜测明文。比如他知道手机号是11位数字就生成所有11位数字的哈希做比对很容易还原出原始数据。所以实用的做法是索引列的变换必须结合足够强的密钥用带密钥的HMAC或者带密钥的哈希来生成指纹同时要控制索引列暴露的信息量尽量不要让对方能通过暴力枚举把整个字段还原。这不是绝对的密码学安全但已经能抵抗绝大多数数据库泄露场景下的黑产洗库攻击。2.3 三种检索模式的取舍我在实际项目中把检索模式分为三类按业务场景选择不会无脑套用同一种方案检索模式适用场景实现方式特点精确匹配按完整手机号、身份证号查单条数据直接对明文做HMAC存索引列查询时同样生成HMAC做等值匹配索引可用性能最好安全性最高前后缀匹配后台按手机号前几位、卡号后四位模糊搜索引列存储“前缀指纹片段”或“后缀指纹片段”组合能走索引或前缀扫描业务上最常见任意包含匹配输入的关键字可能出现在字段任意位置对原文做字符级位置绑定指纹或n-gram指纹组合支持%keyword%但需要扫索引列且索引列信息量会多一些真正做之前先跟需求方确认清楚模糊查询到底是想搜“开头是”还是“中间含有”。大量后台搜索其实都是前缀查询手机号搜前几位订单号搜完整号这种场景用“前缀指纹等值匹配”不仅能满足需求性能和安全性都能保住。只有极少数场景需要支持中间片段匹配这种我再上字符级位置绑定的索引方案。我后面落地时就是两条路都做了默认字段用前缀指纹个别需要任意匹配的字段用字符位置指纹。3. 核心代码落地加密落库、解密回显、模糊查询3.1 表结构设计多一个字段多一份安心既然要在数据库层面做加密和检索表结构不能照旧。我以一个用户表为例手机号作为加密字段表设计如下CREATE TABLE t_user ( id BIGINT NOT NULL AUTO_INCREMENT, name VARCHAR(64) DEFAULT NULL, phone_encrypt VARCHAR(255) DEFAULT NULL COMMENT 手机号密文, phone_index VARCHAR(255) DEFAULT NULL COMMENT 手机号检索指纹, is_encrypted TINYINT DEFAULT 0 COMMENT 历史数据兼容标记, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;说明几个关键点phone_encrypt存密文长度要按“加密后字符串可能膨胀”的余量来估。手机号加密后如果是Base64编码大概会到几十个字符直接给255足够。phone_index存检索指纹指纹长度取决于算法如果是HMAC-SHA256转十六进制固定64个字符就够255绰绰有余。is_encrypted是用来做渐进式数据迁移的线上的老数据不可能一次性刷完需要分批处理这个标记非常有用。注意这个表里原来的phone字段被我直接换掉了。不要做成“同时保留明文phone和密文phone_encrypt”的过渡表那样虽然兼容性最好但一旦明文列留在库里加密就形同虚设任何有库权限的人都能直接读走明文。真要留过渡明文列必须在迁移完成后立刻物理删除。3.2 加密TypeHandler完整实现先创建一个加密服务接口屏蔽具体加密算法细节public interface CryptoService { // 加密返回Base64编码的密文 String encrypt(String plainText); // 解密传入Base64编码的密文返回明文 String decrypt(String cipherText); }然后写一个AES-GCM实现。选GCM而不是CBC是因为GCM同时提供加密和完整性校验密文被篡改时能及时发现不至于解密出垃圾数据。每次加密使用随机IVIV和密文一起拼在结果里解密时再拆分Component public class AesGcmCryptoService implements CryptoService { private static final String TRANSFORMATION AES/GCM/PKCS5Padding; private static final int IV_LENGTH 12; private static final int TAG_LENGTH 128; private final SecretKey key; public AesGcmCryptoService(Value(${crypto.master-key}) String base64Key) { byte[] keyBytes Base64.getDecoder().decode(base64Key); this.key new SecretKeySpec(keyBytes, AES); } Override public String encrypt(String plainText) { if (plainText null || plainText.isEmpty()) { return plainText; } try { byte[] ivBytes new byte[IV_LENGTH]; SecureRandom random new SecureRandom(); random.nextBytes(ivBytes); Cipher cipher Cipher.getInstance(TRANSFORMATION); cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_LENGTH, ivBytes)); byte[] encrypted cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); byte[] combined new byte[IV_LENGTH encrypted.length]; System.arraycopy(ivBytes, 0, combined, 0, IV_LENGTH); System.arraycopy(encrypted, 0, combined, IV_LENGTH, encrypted.length); return Base64.getEncoder().encodeToString(combined); } catch (Exception e) { throw new IllegalStateException(encrypt failed, e); } } Override public String decrypt(String cipherText) { if (cipherText null || cipherText.isEmpty()) { return cipherText; } try { byte[] combined Base64.getDecoder().decode(cipherText); byte[] ivBytes Arrays.copyOfRange(combined, 0, IV_LENGTH); byte[] encrypted Arrays.copyOfRange(combined, IV_LENGTH, combined.length); Cipher cipher Cipher.getInstance(TRANSFORMATION); cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(TAG_LENGTH, ivBytes)); byte[] decrypted cipher.doFinal(encrypted); return new String(decrypted, StandardCharsets.UTF_8); } catch (Exception e) { throw new IllegalStateException(decrypt failed, e); } } }然后是核心的TypeHandler。我在密文前面加了一个固定前缀ENC:这样当程序读到一段明文时通过前缀判断可以快速识别它是不是密文这在历史数据迁移阶段非常重要Log4j2 public class EncryptTypeHandler extends BaseTypeHandlerString { private static final String ENC_PREFIX ENC:; private final CryptoService cryptoService; public EncryptTypeHandler() { this.cryptoService SpringContextHolder.getBean(CryptoService.class); } public EncryptTypeHandler(CryptoService cryptoService) { this.cryptoService cryptoService; } Override public void setNonNullParameter(PreparedStatement ps, int i, String parameter, JdbcType jdbcType) throws SQLException { if (parameter null || parameter.isEmpty()) { ps.setString(i, parameter); return; } String encrypted ENC_PREFIX cryptoService.encrypt(parameter); ps.setString(i, encrypted); } Override public String getNullableResult(ResultSet rs, String columnName) throws SQLException { return decryptIfNecessary(rs.getString(columnName)); } Override public String getNullableResult(ResultSet rs, int columnIndex) throws SQLException { return decryptIfNecessary(rs.getString(columnIndex)); } Override public String getNullableResult(CallableStatement cs, int columnIndex) throws SQLException { return decryptIfNecessary(cs.getString(columnIndex)); } private String decryptIfNecessary(String value) { if (value null || value.isEmpty()) { return value; } // 没有ENC前缀的认为是历史明文数据直接返回 if (!value.startsWith(ENC_PREFIX)) { return value; } try { return cryptoService.decrypt(value.substring(ENC_PREFIX.length())); } catch (Exception e) { log.error(decrypt column failed, value prefix {}, value.substring(0, Math.min(10, value.length())), e); return null; } } }SpringContextHolder是一个静态持有的Spring容器工具类这样TypeHandler通过反射被MyBatis实例化时也能拿到CryptoService的Bean。如果项目里有完整的Spring Boot管理也可以把TypeHandler注册成Bean再交给MyBatis两种方式都可行只要保证CryptoService不为空就行。3.3 模糊检索列生成与查询实现模糊检索索引列我建议按两套方案设计分别应对两种常见场景。第一套方案前缀/后缀指纹。适用于“按手机号前几位搜”“按卡号后四位搜”。做法是对明文截取片段后做HMAC查询时用等值匹配。假设要支持手机号前7位的前缀搜索public class SearcheableIndexGenerator { private static final String HMAC_ALGORITHM HmacSHA256; // 手机号归一化去空格、统一小写 public static String normalize(String raw) { if (raw null) { return ; } return raw.trim().toLowerCase(); } // 前缀检索指纹 public static String prefixIndex(String raw, int prefixLen) { String normalized normalize(raw); if (normalized.length() prefixLen) { return hmac(normalized); } return hmac(normalized.substring(0, prefixLen)); } private static String hmac(String data) { try { Mac mac Mac.getInstance(HMAC_ALGORITHM); // 项目里从密钥管理系统获取派生子密钥不要和主密钥混用 SecretKeySpec keySpec new SecretKeySpec(SearchIndexKeyLoader.getIndexKey(), HMAC_ALGORITHM); mac.init(keySpec); byte[] rawBytes mac.doFinal(data.getBytes(StandardCharsets.UTF_8)); return HexUtil.encodeHexStr(rawBytes); } catch (Exception e) { throw new IllegalStateException(hmac failed, e); } } }查询SQL里直接用等值匹配这样可以走普通索引SELECT * FROM t_user WHERE phone_index #{prefixIndex}第二套方案字符位置绑定指纹支持更通用的%keyword%匹配。这个方案的核心是让“明文中每个字符出现在哪个位置”参与指纹计算使得构建出来的索引序列既能匹配子串又不会让攻击者轻松通过统计频率还原明文。具体做法是对明文的每个字符用“字符#位置”拼接后做HMAC然后把整串字符的指纹用分隔符拼起来。查询的时候对关键字同样按位置生成指纹再拼成LIKE模式public class CharPositionIndexGenerator { private static final String SEP #; private static final String TOKEN_SEP :; // 生成整列索引值 public static String generateIndex(String raw) { String normalized raw.trim().toLowerCase(); ListString tokens new ArrayList(); for (int i 0; i normalized.length(); i) { tokens.add(tokens(normalized, i)); } return String.join(TOKEN_SEP, tokens); } // 生成查询LIKE匹配模式 public static String generateQueryPattern(String keyword) { String normalized keyword.trim().toLowerCase(); if (normalized.isEmpty()) { return null; } ListString tokens new ArrayList(); for (int i 0; i normalized.length(); i) { tokens.add(tokens(normalized, i)); } return % String.join(TOKEN_SEP, tokens) %; } private static String tokens(String text, int position) { String source String.valueOf(text.charAt(position)) position; return hmac(source); } }写入数据时phone_index存generateIndex(13812345678)。后台搜索时用户输入“138”就用generateQueryPattern(138)生成%token1:token2:token3%去匹配。由于位置参与了指纹计算相同字符在不同位置会得到不同指纹这能避免“某个字符在多个字段中重复出现”导致的大范围误匹配。这个方案的SQL长这样select idsearchByPhoneKeyword resultMapuserResultMap SELECT * FROM t_user where if testkeyword ! null and keyword ! AND phone_index LIKE #{pattern} /if /where /select要特别强调这里的LIKE是对“索引列”做匹配不是对密文列做匹配。密文列的内容完全没有参与SQL匹配。索引列的指纹信息只暴露了“字符的相对位置序列”在密钥不泄露的前提下无法直接还原明文。3.4 注册与使用业务代码的改动面其实很小TypeHandler的使用方式有两种。第一种是在MyBatis的XML中针对字段单独指定这样影响面最小resultMap iduserResultMap typeUser id columnid propertyid/ result columnphone_encrypt propertyphone typeHandlercom.example.handler.EncryptTypeHandler/ result columnphone_index propertyphoneIndex typeHandlercom.example.handler.CharPositionIndexTypeHandler/ /resultMap insert idinsertUser INSERT INTO t_user (name, phone_encrypt, phone_index) VALUES (#{name}, #{phone, typeHandlercom.example.handler.EncryptTypeHandler}, #{phoneIndex}) /insert第二种是用MyBatis-Plus的注解方式如果你的项目采用的是MyBatis-Plus可以在实体类字段上标注Data public class User { private Long id; private String name; TableField(typeHandler EncryptTypeHandler.class) private String phone; TableField(typeHandler CharPositionIndexTypeHandler.class) private String phoneIndex; }这里有个经验不要在application.yml里全局注册type-handlers-package让所有String字段都走EncryptTypeHandler。那样虽然写起来省事但它会接管所有字符串列的加解密连name、remark这类字段也会被加密查询时一旦某些字段没有配置对应的ResultMap就会出现“读出来是密文”甚至“解不出来”的情况。所以就算多写几行配置也要采用字段级指定。4. 完整案例某项目手机号加密改造实录4.1 项目背景与改造范围我做的这个电商项目用户表有近400万条数据手机号和身份证号是强制要求加密的敏感字段。项目原本有两种查询需求一是登录时按完整手机号精确查询二是运营后台按手机号关键字搜用户。上线前改造范围包括用户表、订单表、客服工单表三张表总计五个敏感字段。改造前我先把所有涉及敏感字段的SQL都拉出来过了一遍。这一步特别重要因为很多历史SQL是直接在XML里写死字段的你想在实体类上改TypeHandler但SQL里如果写了phone列名而表结构已经改成phone_encrypt那就要同步改SQL。我是分三步走的先把代码分支中所有引用旧字段名的Mapper全部找出来再决定是改字段名还是保留一个视图做兼容最后统一提交改表脚本。实际做下来改动量主要集中在Mapper XML和实体类Service层中业务逻辑反而基本没动因为TypeHandler把加解密动作透明化了。4.2 数据迁移与历史数据兼容400万条数据的迁移不可能停机一次性刷完我用的是渐进式清洗方案。上线前先执行一次初始化脚本把老数据的明文手机号批量加密填入phone_encrypt和phone_index同时把is_encrypted置为1。但数据量太大第一次脚本执行到一半被线上写入打断于是改为应用启动后后台任务扫描is_encrypted 0的数据批量读取、加密、更新每次处理1万条。这里有一个关键容错设计在数据完全迁移干净之前EncryptTypeHandler的decryptIfNecessary方法要能识别出历史明文并原样返回。我的做法就是前面代码里的ENC:前缀判断——读到没带前缀的值就当成明文直接返回。这个设计让我不用停机也不怕迁移过程中有漏网的数据。但务必记住迁移完成后要把旧明文列物理删除并且把is_encrypted0的数据全部清洗干净不然时间久了就会变成“库里既有明文又有密文”的混乱状态。4.3 密钥管理建议加密方案里密钥管理比加密算法本身重要得多。我不建议把密钥硬编码在配置文件里也不建议所有字段共用同一个密钥。我的做法是主密钥放在配置中心通过环境变量注入到服务对每个敏感字段用主密钥派生出一个子密钥比如约定phone_key HKDF(masterKey, field:phone)phone_index_key HKDF(masterKey, index:phone)这样即使索引指纹的密钥意外泄露攻击者也拿不到密文密钥反之亦然。轮换密钥时加密列需要全量重解密检索列需要重新生成指纹所以上线前就要想好轮换流程别等到需要轮换的时候才手忙脚乱。4.4 性能观察与安全收益性能测试时我重点看了两个场景。完整手机号等值查询走phone_index等值匹配走了普通索引400万行数据下响应在个位数毫秒级别和改造前基本没有差异。后台关键字搜索走LIKE %token%时因为无法利用BTree索引只能扫索引列但索引列比整行数据小很多且扫描的是定长字符串400万行实测在200到400毫秒之间对后台管理页面来说完全可接受。如果数据量再往上走我建议加一层ES或者限制搜索条件必须至少输入前几位。安全收益要客观地说密文列使用AES-GCM加密数据库泄露后无法直接还原明文索引列使用带密钥的HMAC指纹没有密钥的前提下很难反推原文也能防住绝大多数灰黑产通过Hash碰撞猜原文的手段。但指纹列毕竟暴露了字符间的相似度安全性不能和密文列等同对待所以线上库权限管控依然不能放松。5. 踩坑记录与排查速查表5.1 回显出来还是密文先查这三处我改造过程中最常遇到的问题就是“明明加了TypeHandler查出来还是密文”。排查顺序我整理成三条第一看ResultMap有没有指定typeHandler。MyBatis中如果使用了自定义ResultMap必须确保对应的result标签上挂了typeHandler如果你依赖的是自动映射TypeHandler可能不会生效。第二看实体类字段类型和数据库字段类型是否匹配TypeHandler是绑定在Java类型和JdbcType之间的一旦类型不匹配MyBatis可能跳过它。第三看是否被二级缓存干扰了。如果开启了MyBatis二级缓存并且缓存里已经存了解密后的对象第二次查询拿到的还是密文——问题是缓存的是Java对象怎么会是密文最常见的原因是缓存的是第一次查询后TypeHandler还没生效的中间状态或者序列化和反序列化过程中字段被重复处理。建议敏感字段不要用默认的二级缓存或者直接关闭第二层缓存只保留一级缓存。5.2 模糊查询查不到数据的四个怀疑点模糊查询搜不到表面上看起来很诡异其实原因通常集中在四个地方一是归一化不一致。写入索引列时做了trim().toLowerCase()但查询时忘了做同样的处理用户输入“138 ”和库里存的“138”生成完全不同的指纹自然查不到。二是位置偏移不一致。字符位置索引依赖“字符在整串字符串中的位置”如果写入时从第0位开始计算查询时也从第0位开始能匹配前缀一旦业务方想搜“中间某一段”这个方案就会失效因为位置对不上。此时要么限制用户必须从前缀开始搜要么改用n-gram方案。三是索引列没有更新。数据插入时只更新了phone_encrypt忘了同步维护phone_index导致搜索时匹配不到最新数据。四是LIKE模式拼接错误。查询词生成的多个token之间要用分隔符隔开如果少拼了分隔符就可能匹配到完全不相关的记录。5.3 一个必须想清楚的安全边界问题字符位置指纹方案比简单的全字段哈希安全但有一个边界要心里有数它支持子串匹配的同时也意味着相同字符组合会暴露一定的模式信息。举一个极端例子如果库里只存了“138”和“1381234”两条记录搜索“138”时两条都会被匹配到这在业务上是合理的但攻击者如果拿到库且能对指纹做碰撞分析还是可能推断出一部分位置信息。因此如果业务上允许我建议对搜索条件做强约束后台搜索时要求至少输入4位以上关键字避免单字甚至双字搜索暴露过多指纹模式。另一个做法是把索引列单独放到另一个数据库实例或者至少做列权限控制不要让所有开发账号都能直接查询该列。这些措施不是为了对抗有管理员权限的人而是为了防住SQL注入、备份泄露、低权限账号拖库这类大概率风险事件。5.4 常见问题速查表问题现象可能原因检查与处理插入时业务字段报错Data too long for column加密后密文超过字段长度扩大phone_encrypt字段长度AES-GCM Base64后建议直接给255查询返回null密文解密失败或字段前缀不是ENC:查日志中的decrypt异常检查是否是历史明文数据精确查询查不到记录查询条件没有走TypeHandler加密确认Mapper XML中参数是否也指定了同一个typeHandlerLIKE匹配结果远多于预期指纹拼接缺少位置字段或归一化规则不一致检查索引列生成和查询pattern生成是否使用同一套代码迁移完成后老数据无法读取解密方法不支持无前缀明文确认decryptIfNecessary逻辑在迁移完成前保留明文直通搜索性能在数据量增大后明显下降LIKE%keyword%导致索引列全扫描限制最低搜索位数考虑改造为前缀指纹等值匹配或引入外部搜索组件最后再分享两个小经验这套方案上了一年多整体运行稳定。如果让我重新做一遍我会在一开始就把“索引列归属”想得更清楚业务方要求模糊搜索的字段绝不直接对密文列做SQL计算而是设计独立的检索列业务方只要求精确查询的字段甚至可以不生成模糊索引列只做精确指纹库里的索引列越少安全面就越小。另外加解密逻辑一定要留下可观测性。“解密失败”不应该只打一行error日志最好把失败时的密文前几位、字段名、涉及的表名一起记录下来但绝不能把完整密文打印到日志里否则日志本身就成了泄密渠道。我后来在解密异常日志里只保留前8位和末尾4位既方便排查又不至于让日志成为另一个数据库。加密改造本质上是“把风险从数据库分散到了代码和密钥管理”上只有每一环都收好口这套方案才真正称得上最优解。
RELATED

相关推荐

Java系统里做Agent+RAG落地实录:让AI从会回答到会干活

Java系统里做Agent+RAG落地实录:让AI从会回答到会干活

制造业的Java系统里做AgentRAG落地,最难的不是把大模型的API接通,也不是把向量库搭起来,而是让AI真正从“会回答”跨到“会干活”。我们在一家装备制造企业的维修业务系统上跑通了一个典型场景:老师傅的经验沉淀在PDF规范、维修记…

📅 2026/10/21 14:33:20
VC++打印ZPL条码:USB直连GT800完整实现与避坑指南

VC++打印ZPL条码:USB直连GT800完整实现与避坑指南

简介:面向需要在 Windows 环境中通过 USB 接口控制斑马 GT800 打印机、并使用 ZPL 语言打印条形码的 VC 开发者,这份资源重在演示底层打印控制思路,适用于物流、仓储、零售等需要定制标签输出的场景。压缩包共 24 个文件,大小 5.7…

📅 2026/10/21 14:33:20
n8n工作流模板化:从一次性脚本到可复用积木体系

n8n工作流模板化:从一次性脚本到可复用积木体系

1. 先想清楚:我们要的“一劳永逸”到底是什么先说结论:在 n8n 里说的“工作流模板”,本质上不是指 n8n 官方那个 Templates 库里的现成流程(那个是别人做好的方案,你拿过来改),而是指你自己沉淀…

📅 2026/10/21 14:33:20
MORE NEWS

更多资讯

📰

Kubernetes探针实战:startup/liveness/readiness配置与调优

1. 从一次诡异 502 说起:探针到底在保护什么有次凌晨一点,某服务的 Pod 状态明明显示 Running,但用户投诉首页一直在报 502。我登上去看,容器进程活着,日志也没有 panic,CPU、内存都正常。诡异的是&#xf…

📰

无人机航拍视角下的目标检测适配:从数据采集到YOLO模型优化落地

无人机航拍视角下的视角和地面视角差异很大。我刚拿到第一批数据的时候,第一反应是“这跟我想的完全不一样”。无人机在50到80米高度俯拍,视野宽、目标多、但单个目标占的图像面积很小,而且商贩摊位往往有遮阳伞、货架、商品堆叠等复杂结构&a…

📰

技能体系怎么搭?从知识到能力,用技能地图终结无效学习

写代码写了几年,我越来越觉得“skills”这个词被严重低估了。简历上填技能、面试时聊技能、学习计划里列技能,可你真要问一句“你这一身技能到底是怎么组织起来的”,大多数人答不上来。问题不在于你会的少,而在于你从来没有把自己…

📰

PXE无人值守安装Ubuntu 20.04:dnsmasq+Apache+autoinstall实战

机房新到一批机器要统一装 Ubuntu 20.04 server,十几台机器挨个插U盘装到晚上九点还是小事,真正烦的是装出来的系统细节对不上:有人分区选了LVM,有人忘了配SSH,有人装完才发现网卡名不一样。后来我在一台 Ubuntu 20.04…

📰

时域卷积网络TCN:从原理到实战的时间序列建模指南

1. 为什么我开始研究时域卷积网络1.1 序列建模中反复遇到的两类痛点前阵子做一个工业时序回归任务,数据是一台设备的秒级采样,一个小时就是3600个点,累积到几天长度接近十万步,还带着明显的周期性和偶发的阶跃漂移。我一开始图省事…

📰

交通信号灯识别数据集全解析:YOLO训练与82.88%识别率实测

开车的朋友应该都有这种经历:早晚高峰堵在路口,眼睛盯着远处的信号灯,一边要防着旁车加塞,一边还得分辨这个圆灯到底能不能右转、左转箭头是不是绿了。这种对注意力的高度占用,恰好是自动驾驶和辅助驾驶系统最想替人类…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬