ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

后台系统搜索与回收站模块实战:从模糊查询到逻辑删除的完整方案

后台系统搜索与回收站模块实战:从模糊查询到逻辑删除的完整方案 如果你正在做一个前后端分离的管理系统功能做到七八成的时候大概率会被产品和测试追着提两个需求列表页加个搜索框删除操作再加个回收站。搜索模块和回收站模块单拎出来都不算难但它们藏在背后的数据过滤、逻辑删除、接口契约、索引优化问题足够让一个后端加好几天班。第七章我做的就是这两件事写这篇文章是想把完整实现路径、踩坑过程、和前端联调时要提前约定的细节一次性说清楚给正在做同类后台项目的后端开发一个可以直接抄作业的参考。我做这个模块时用的技术栈是 Spring Boot 3 MyBatis-Plus MySQL这也是目前后台管理系统里最常见的组合。文章里会涉及部分 Java 代码但核心的设计思路和坑是通用的哪怕你用的是 Python FastAPI、RuoYi 框架或者 Node.js 后端读下来同样能避开大部分问题。1. 先理清需求本质搜索和回收站不是两个“接口”那么简单1.1 产品经理说的“搜索”到底要求什么大多数产品经理提搜索需求时话术通常是这样的“列表页加个搜索框输入关键词就能查出来。”“按名称、手机号、邮箱搜一下。”“再加个状态筛选用户停用启用都要能筛。”如果你真按字面意思去做前端传一个关键词后端一条LIKE %keyword%拼出来列表能出数据就算了那这个搜索模块上线之后一定会被骂。因为“能查出来”和“好找”是两回事。我一般会把搜索需求拆成这么几个维度需求条目后端要做的关键字搜索确定搜索字段范围多个字段间用 OR 组合组合筛选下拉框选项状态、类型、时间范围与关键字做 AND排序按时间倒序/正序、按相关度排序且排序字段要做白名单分页返回当前页数据和总条数保证 count 查询性能空状态搜索无结果时返回空列表而不是报错在这个环节里后端不是被动接需求而是要主动问清楚关键字是匹配单个字段还是多个字段搜索框是否需要支持输入手机号的一部分时间筛选范围最大是多久这些边界问题一旦前期不定清楚后期改接口成本非常高。1.2 回收站背后的逻辑删除与物理删除删除操作是所有业务系统都绕不开的。但很多新人对“删除”的理解还停在一句DELETE FROM user WHERE id ?上。真实项目里直接物理删除数据是非常危险的行为尤其是用户、商品、发布内容这类核心数据。误删之后想恢复如果没有备份基本等于事故。所以产品经理提出“加个回收站”时本质上是在要求所有删除操作先变成“假删除”数据保留下来过一段时间或者被用户主动恢复时再回到正常列表。这就是我们常说的逻辑删除软删除核心思路是在表上加一个标记字段比如deleted正常数据是 0已删除数据是 1所有查询默认只查deleted 0的数据。但不是所有数据都适合逻辑删除。我建议在需求阶段就做一张判断表数据类型建议方式原因用户账号、商品、文章内容逻辑删除涉及核心业务误删恢复成本高角色、权限配置逻辑删除可能被引用物理删除会导致关联关系断裂支付流水、订单快照不删除或者物理删除涉及财务合规审计不能从业务列表消失也不能改变记录真实性临时表数据、导入失败日志物理删除没有恢复价值留着占存储还影响查询在线下和产品对需求时我通常会把这个表格直接甩出来基本能避免后期“为什么这个删了回收站里没有”的扯皮。1.3 产品经理只说了“加个回收站”你要自己反推出的边界问题这个场景我遇过太多次。产品只给了一句话删除后的数据进回收站用户可以恢复也可以彻底删除。听起来很简单但你需要在一开始就确定几个额外问题回收站要不要分类型用户、部门、商品分开展示还是一个列表混着来彻底删除后关联的子表数据怎么办比如用户关联的收货地址、订单、角色绑定。回收站数据保留多久40 天还是永久超期要不要自动清理谁能打开回收站谁能彻底删除要不要独立的操作权限恢复数据时如果用户名被新用户占用了怎么办恢复是否要检查唯一性冲突删除操作要不要记录操作日志删除人、删除时间是否需要追溯这些问题前期不问开发到一半必然返工。第七章的时间很大一部分就花在了这些看似琐碎、实际决定接口复杂度的边界确认上。2. 搜索模块的实现路径从LIKE查询到全文检索2.1 第一版用 MyBatis-Plus 拼条件先把功能跑起来搜索模块不用一上来就上 Elasticsearch绝大多数业务系统用数据库查询就能解决。我们的第一个版本就是基于 MyBatis-Plus 的 LambdaQueryWrapper 写条件拼接。以用户列表搜索为例后端接口长这样RestController RequestMapping(/api/v1/users) public class UserController { private final UserService userService; public UserController(UserService userService) { this.userService userService; } PostMapping(/search) public ResultPageResultUserVO search(RequestBody Valid UserSearchRequest request) { return Result.success(userService.searchUsers(request)); } }搜索请求参数我习惯单独建一个 DTO而不是直接把前端参数散着接收方便校验也方便后续加字段Data public class UserSearchRequest { private String keyword; private Integer status; private Long startTime; private Long endTime; NotNull(message 页码不能为空) Min(value 1, message 页码不能小于1) private Integer pageNo 1; NotNull(message 每页条数不能为空) Max(value 100, message 每页条数不能超过100) private Integer pageSize 10; private String sortField; private String sortOrder; }Service 层实现Service public class UserServiceImpl implements UserService { private final UserMapper userMapper; public UserServiceImpl(UserMapper userMapper) { this.userMapper userMapper; } Override public PageResultUserVO searchUsers(UserSearchRequest request) { LambdaQueryWrapperUser wrapper new LambdaQueryWrapper(); // 关键字进行多字段模糊匹配 if (StringUtils.hasText(request.getKeyword())) { wrapper.and(w - w .like(User::getUsername, request.getKeyword()) .or().like(User::getMobile, request.getKeyword()) .or().like(User::getEmail, request.getKeyword())); } // 状态筛选 if (request.getStatus() ! null) { wrapper.eq(User::getStatus, request.getStatus()); } // 创建时间范围 if (request.getStartTime() ! null request.getEndTime() ! null) { wrapper.between(User::getCreateTime, request.getStartTime(), request.getEndTime()); } // 排序默认按创建时间降序 wrapper.orderByDesc(User::getCreateTime); PageUser page userMapper.selectPage( new Page(request.getPageNo(), request.getPageSize()), wrapper ); return PageResult.of(page); } }这里有几个容易忽略的细节多字段匹配时用and(w - w.like(...).or().like(...))包一层否则后面再加其他筛选条件时OR 会把整个 where 条件优先级打乱。MyBatis-Plus 的分页查询需要配置分页插件直接selectPage不生效的话多半是漏了MybatisPlusInterceptor。Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); PaginationInnerInterceptor pagination new PaginationInnerInterceptor(DbType.MYSQL); pagination.setMaxLimit(100L); interceptor.addInnerInterceptor(pagination); return interceptor; } }2.2 查询参数的约束与分页包装如果你写过一段时间后端就会发现搜索接口的查询参数是最容易失控的地方。今天加一个字段明天加一个筛选一个方法里堆进去五六个参数维护起来非常痛苦。所以我强烈建议所有搜索接口都用 DTO 接参并且把分页参数也直接放进 DTO。上面代码里分页结果我用了一个PageResultT统一包装。这个类很有必要推荐每个项目都沉淀一个Data public class PageResultT { private ListT records; private Long total; private Long pageNo; private Long pageSize; private Long pages; public static T PageResultT of(PageT page) { PageResultT result new PageResult(); result.setRecords(page.getRecords()); result.setTotal(page.getTotal()); result.setPageNo(page.getCurrent()); result.setPageSize(page.getSize()); result.setPages(page.getPages()); return result; } }前端只需要拿到固定的结构就能在页面上做分页组件的渲染不会出现“你返回的是 totalPage我前端要的是 pages”这种低级联调冲突。另一个特别容易被忽视的地方是排序字段注入。很多同学直接写wrapper.orderByAsc(request.getSortField());如果sortField是前端传过来的字符串那用户可以在请求里传一段任意内容轻则导致 SQL 语法错误重则经过特殊构造产生注入风险。处理方案非常简单排序列做白名单。private static final SetString SORT_WHITELIST Set.of(createTime, updateTime, id); private String safeSortField(String sortField) { if (sortField null || !SORT_WHITELIST.contains(sortField)) { return createTime; } return sortField; }这样即使用户往请求里塞了一个sortField1,extractvalue(...)后端也只会在白名单里选默认值不会拼进 SQL。2.3 索引优化为什么左右模糊查询一慢就该看执行计划功能跑通之后紧接着就是性能问题。搜索接口最容易出现的慢查询根源几乎都是LIKE %keyword%。MySQL 里LIKE的索引使用规则是这样的写法是否走索引说明LIKE abc%走索引前缀匹配可以利用普通索引或联合索引LIKE %abc不走索引后缀匹配无法利用索引LIKE %abc%基本不走索引包含匹配全表扫描所以在设计搜索时你要有意识地和产品确认搜索框的匹配逻辑是不是必须包含关键字还是只需要以关键字开头如果只是前缀匹配查询性能会好很多。但现实情况往往是产品就是要包含匹配“输入手机号中间几位也能查”。这时候我的处理思路是数据量不大几十万以内接受全表扫描但做好分页和缓存别让用户连续翻页刷爆数据库。做了不可避免的模糊搜索考虑建立覆盖索引。比如用户表经常查username、mobile、status、create_time这几个字段就建一个联合索引让查询尽量走索引覆盖减少回表。用EXPLAIN排查这是基本功。EXPLAIN SELECT * FROM user WHERE mobile LIKE %138% AND status 1 ORDER BY create_time DESC;看type字段如果是ALL就是全表扫描如果是range或ref说明命中索引如果是index说明用了全索引扫描也要留意。我实测过一个用户表50 万条数据不带任何条件直接LIKE %keyword%查询耗时在 900ms 左右加了status组合索引和create_time排序优化后降到了 300ms 以内虽然没到毫秒级但后台列表这种低频查询已经完全能接受。2.4 升级之路数据库全文索引与 Elasticsearch 的取舍如果数据量继续涨比如到千万级或者搜索需求升级成“输入一段话找出相关文章”数据库内LIKE方案就会不够用。两个常见升级方向我对比一下MySQL 全文索引适合中量数据、字段较少的简单全文检索。建完索引后可以用MATCH...AGAINST查询支持分词。但它的分词是英文友好的对中文支持一般需要通过 ngram 分词插件做配置查询语法也有限制。Elasticsearch适合真正的全文检索、大数据量、复杂查询、高亮展示、同义词匹配。但引入 ES 意味着要处理数据同步、索引重建、集群维护复杂度会上升一个量级。我的建议是分步走用户列表、商品列表这种结构化字段搜索数据库内解决。日志搜索、内容平台的文章/帖子搜索一开始就规划 ES不要中途硬切。如果暂时不具备 ES 条件又需要中文分词效果可以先把 MySQL 全文索引用起来顶一版但心里要有数这只是权宜之计。3. 回收站模块用逻辑删除字段撑起可恢复的删除3.1 逻辑删除的落地配置与 DB 改造逻辑删除的实现其实很标准但细节不少。MyBatis-Plus 提供了现成的逻辑删除能力配置两步就够第一步在application.yml里声明逻辑删除字段的取值含义mybatis-plus: global-config: db-config: logic-delete-field: deleted logic-delete-value: 1 logic-not-delete-value: 0第二步在实体类对应的字段上加TableLogic注解TableName(sys_user) Data public class User { TableId(type IdType.AUTO) private Long id; private String username; private String mobile; TableLogic private Integer deleted; private LocalDateTime createTime; private LocalDateTime deleteTime; }配置完成之后MyBatis-Plus 会自动在你写的查询和更新 SQL 后面追加deleted 0条件。也就是说你调用userMapper.selectById(1)实际执行的 SQL 是SELECT * FROM sys_user WHERE id 1 AND deleted 0所以当用户在前端点“删除”时后端代码不需要写 UPDATE直接userMapper.deleteById(id);MyBatis-Plus 会把它转换成UPDATE sys_user SET deleted 1, delete_time NOW() WHERE id ? AND deleted 0这一步实现了“假删除”数据还在表里只是被标记为已删除。3.2 回收站列表怎么绕过自动过滤查“已删除”数据配置了逻辑删除后普通查询都被自动加上了deleted 0那回收站列表要查deleted 1的数据怎么办这里很容易踩坑很多第一次做逻辑删除的同学会在这里卡住。第一个方案是用 MyBatis-Plus 的InterceptorIgnore注解让当前 SQL 跳过逻辑删除拦截Mapper public interface UserMapper extends BaseMapperUser { InterceptorIgnore(tenantLine true) Select(SELECT * FROM sys_user WHERE deleted 1 ORDER BY delete_time DESC) ListUser selectRecycledUsers(); }注意InterceptorIgnore的tenantLine参数主要是针对多租户插件的逻辑删除的忽略是否生效在不同版本里表现不完全一致。如果你发现这个注解没生效直接走第二个方案。第二个方案最稳妥就是在 Mapper XML 里手写 SQL绕过 MyBatis-Plus 的方法调用逻辑。逻辑删除拦截器默认只会改装 BaseMapper 提供的方法以及通过 MyBatis-Plus 的 Wrapper 生成的 SQL自定义 XML 里的 SQL 由你自己完全掌控。select idselectRecycledUsers resultTypecom.example.entity.User SELECT * FROM sys_user WHERE deleted 1 ORDER BY delete_time DESC /selectListUser selectRecycledUsers();我更推荐第二种。原因很直接自定义 SQL 的语义一目了然不会依赖框架某个版本的隐藏行为排查问题也方便。3.3 恢复数据与彻底删除的操作细节回收站页面通常有两个操作恢复、彻底删除。恢复操作对应 SQLUPDATE sys_user SET deleted 0, delete_time NULL WHERE id ?;在 MyBatis-Plus 里直接写恢复方法Update(UPDATE sys_user SET deleted 0, delete_time NULL WHERE id #{id} AND deleted 1) int restoreById(Long id);恢复动作一定要带上AND deleted 1作为条件。原因很简单如果一条数据已经被彻底删除restoreById不应该把它“恢复”出来如果这条数据已经被恢复过了重复操作也不应该产生副作用。带上条件之后返回值0就代表没有可恢复的数据。彻底删除才是真正的物理删除。这里要特别警惕关联数据。比如删除一个用户时如果只把sys_user表里那条记录 delete 掉那用户关联的收货地址、订单记录、角色绑定关系可能还残留在各自的表里成为脏数据。所以在设计删除接口时要根据业务链路把子表数据一起处理。事务是必须的Transactional(rollbackFor Exception.class) public void deleteUserPermanently(Long userId) { // 1. 删除用户角色绑定 userRoleMapper.deleteByUserId(userId); // 2. 删除用户收货地址 userAddressMapper.deleteByUserId(userId); // 3. 最后删除用户主记录 userMapper.deleteById(userId); }如果子表数据也要进回收站那彻底删除时子表的逻辑删除标记也要变成物理删除行为要依据具体业务确认。3.4 回收站保留期与删除审计逻辑删除最大的隐患是表数据越积越多所以回收站不能只进不出。我建议在产品需求阶段就约定保留期比如 30 天。超期数据由后端定时任务自动物理删除实现方式很朴素每天凌晨跑一次Component public class RecycleBinCleanTask { private final UserMapper userMapper; public RecycleBinCleanTask(UserMapper userMapper) { this.userMapper userMapper; } Scheduled(cron 0 0 3 * * ?) public void cleanExpiredUsers() { LocalDateTime deadline LocalDateTime.now().minusDays(30); userMapper.deletePermanentlyBefore(deadline); } }删除审计也是容易被遗忘的一环。用户点了删除是谁删的、什么时候删的、当时在哪个页面操作真出问题时后端完全说不清楚。我的做法是在sys_user表加两个字段delete_by删除人和delete_time删除时间删除操作和恢复操作都显式更新这两个字段。如果业务更复杂可以单独建一张删除日志表字段参考这样设计字段含义id主键biz_type业务类型用户、商品、文章biz_id业务数据主键deleted_by删除人deleted_time删除时间deleted_reason删除原因extra_data删除前的数据快照 JSON4. 前后端分离项目里这两个模块的联调规范4.1 搜索接口用 GET 还是 POST、参数怎么传很多前端同学习惯把搜索参数直接拼在 GET 请求的 URL 后面比如GET /api/v1/users?keyword张三status1pageNo1pageSize10这在搜索条件简单时没问题但一旦搜索条件多了——时间范围、排序、多关键字、复杂筛选——URL 会变得很长而且容易超出服务器对 URL 长度的限制。我的做法是搜索条件相对简单的用 GET复杂搜索用 POST请求体传 JSON。上面的用户搜索就是典型的复杂搜索所以我用的是POST /api/v1/users/search{ keyword: 张三, status: 1, startTime: 1700000000000, endTime: 1705000000000, pageNo: 1, pageSize: 10, sortField: createTime, sortOrder: desc }用 POST 还有一个好处前端可以很自然地在请求体里扩展字段后端 DTO 也能平滑演进不会动不动就破坏接口路径。4.2 统一响应体与业务错误码前后端分离项目最忌讳前后端各写各的响应格式。后端今天返回{code: 200, data: ...}明天返回{status: 0, result: ...}前端就得跟着改逻辑。我的习惯是统一用ResultT包装固定结构Data public class ResultT { private int code; private String message; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.setCode(200); result.setMessage(ok); result.setData(data); return result; } public static T ResultT error(int code, String message) { ResultT result new Result(); result.setCode(code); result.setMessage(message); return result; } }业务错误码要和前端约定好不能只靠 HTTP 状态码判断。比如回收站模块我会定义一组错误码code含义前端行为200成功正常处理4001恢复失败数据不存在提示“数据不存在或已恢复”4002恢复失败用户名已被占用提示“该用户名已被使用无法恢复”4003彻底删除失败有关联数据提示“请先处理关联数据”401未登录跳转登录页403无权限提示“没有操作权限”只要 code 和 message 的映射关系保持稳定前端就可以根据 code 做全局统一拦截而不是每次都写死提示文案。4.3 前端交互背后防抖、loading、空状态与二次确认搜索框的交互细节和接口本身一样重要。我在联调时反复被问过“为什么我搜一下请求发了好几次”这是因为搜索框有一种典型逻辑问题用户每输入一个字符就触发一次搜索请求。前端加防抖并不能由后端解决但后端可以配合做好两件事接口要足够轻量即使前端没有完美的防抖请求也能快速返回。响应结构稳定空数据时返回[]和total0而不是报错。回收站的交互上删除和恢复都需要前端弹二次确认框。后端接口要设计成幂等的同一批 ID 提交两次恢复请求不会产生脏数据。上面提到的restoreById带AND deleted 1条件返回值为 0 时就自然实现了幂等。4.4 跨域配置开发环境下后端怎么配合前端前后端分离项目联调时跨域问题几乎是必现的。前端跑在 localhost:5173后端跑在 localhost:8080浏览器直接请求必然跨域。开发环境下后端临时放开 CORS 是常见做法Configuration public class CorsConfig { Bean public CorsFilter corsFilter() { UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); CorsConfiguration config new CorsConfiguration(); config.addAllowedOriginPattern(*); config.addAllowedHeader(*); config.addAllowedMethod(*); config.setAllowCredentials(true); source.registerCorsConfiguration(/**, config); return new CorsFilter(source); } }但这里有个关键坑addAllowedOriginPattern(*)配合setAllowCredentials(true)如果浏览器策略严格请求带 cookie 时会被拦截。生产环境我一般建议走网关或 Nginx 反向代理让前后端同源不要完全依赖 CORS 全开。CORS 全开不仅影响安全性还会让一些浏览器预检请求增多影响性能。5. 这轮实战我记录下来的坑与排查过程5.1 唯一索引与逻辑删除字段的冲突这是我在回收站模块踩过最大的坑必须放在第一个说。业务场景是用户名单字段唯一username建有唯一索引。之前用户删掉后再注册一个同名用户时报错Duplicate entry zhangsan for key uk_username。第一次我天真地把唯一索引改成了联合索引(username, deleted)想着这样(zhangsan, 0)和(zhangsan, 1)就不会冲突。但很快发现问题第一个zhangsan被删了deleted 1过了几天这个用户在回收站被彻底清除接着新用户注册zhangsandeleted 0然后新用户又被删了deleted 1。此时表里已经有一条(zhangsan, 1)又来了一个(zhangsan, 1)直接冲突。最终我用的方案是逻辑删除标记字段不只存 0/1删除时把deleted字段写成主键 id 或者时间戳毫秒值。UPDATE sys_user SET deleted id, delete_time NOW() WHERE id ? AND deleted 0恢复时再重置UPDATE sys_user SET deleted 0, delete_time NULL WHERE id ? AND deleted id这样联合索引(username, deleted)永远都不会冲突因为每条已删除数据的deleted值都不相同。如果你不想用 id也可以用一个delete_token字段存 UUID原理一致。这个方案我在用户、商品等多个表上都验证过稳定可靠。不过要提醒一点使用这个方案时MyBatis-Plus 全局配置的logic-delete-value: 1就不能帮你做“真删除标记”了删除动作需要手写 SQL否则插件会统一填 1冲突问题依然存在。所以配置逻辑删除有自己的边界必要时你会从“依赖插件”退回到“手写 SQL”的路线这很正常理解机制比照抄配置更重要。5.2 逻辑删除字段与乐观锁版本号更新顺序导致恢复失败另一个实务常见问题是业务表喜欢加乐观锁版本号version来控制并发更新。当逻辑删除和乐观锁混在一起时恢复操作的执行顺序很讲究。有一次恢复用户数据时一直不成功排查后发现的 SQL 长这样UPDATE sys_user SET deleted 0, delete_time NULL WHERE id 1 AND deleted 1这条 SQL 本身没问题。问题出现在我使用了 MyBatis-Plus 的updateById插件会自动追加version ?条件而表的 version 在删除操作时已经自增过一次。恢复时的 version 已经和内存里的不一致导致更新影响行数为 0。解决方式有两种恢复操作不用updateById改成手写 SQL手动更新必要字段不碰 version 或显式重置 version。如果用updateById恢复前必须重新查一次最新 version并传给更新语句。我最终选择了手写 SQL因为回收站恢复属于后台低频操作并发冲突概率很低业务上也不需要通过 version 做太多控制关注点应该是“能不能稳定恢复”。5.3 搜索关键字里的%和_没做转义这个坑很小但能让搜索功能看起来很“业余”。用户在前台搜索框输入了一个%比如他搜的是“完成率50%”后端直接拼到LIKE %完成率50%%里MySQL 会把%当通配符处理查询结果就会异常。_也一样它匹配任意单个字符。我后来在工具类里统一做了转义public static String escapeLikeKeyword(String keyword) { if (keyword null) { return null; } return keyword .replace(\\, \\\\) .replace(%, \\%) .replace(_, \\_); }搜索前对关键字做一次转义再用wrapper.like(...)。这个问题虽然不影响系统稳定性但会让搜索体验差很多而且前端也不容易直观发现是哪里错了。5.4 回收站数据量大时deleted 字段该不该建索引回收站列表查询条件是deleted 1 ORDER BY delete_time DESC。正常业务数据绝大多数都是未删除状态deleted 1在索引中的区分度并不高单独给deleted建索引效果通常不好。但回收站查询一般还有一个特点按删除时间倒序翻页。所以真正有用的索引通常是(deleted, delete_time)组合索引查询时用deleted过滤已删除数据用delete_time做排序。如果删除的数据占比很小MySQL 优化器可能直接选全表扫描然后 filesort这时候建组合索引的收益也不大需要根据实际数据量用EXPLAIN验证。我的经验是回收站数据量在百万级以下时普通索引和组合索引有没有其实区别不大关键是分页别太深。上百万之后配合保留期定时清理比建一堆索引更有效。如果你正在做管理后台里的搜索和回收站模块我的建议是先把需求边界和产品对齐再按“先功能后性能”的顺序落地。逻辑删除本身不复杂复杂的是它和唯一索引、乐观锁、关联数据、定时清理串在一起后产生的各种边界情况。这篇文章里记录的坑和排查思路都是我实际踩过之后沉淀下来的希望对你有用。
返回列表