技术探索与实践:我是如何在项目中“摸着石头过河”的
引言:一次需求引发的思考

去年年底,我参与了一个内部数据中台的重构项目。原本只是个“老系统改造”,但随着调研深入,我们发现原来的架构已经无法支撑日益增长的数据量和复杂度。最棘手的问题之一,是不同业务模块的数据模型差异巨大,而统一查询接口的需求又非常迫切。那段时间,每天都在跟产品经理和技术负责人讨论方案,大家都有点迷茫——到底怎么做才对?
于是,我开始认真思考一个问题:当我们面对一个没有标准答案的技术问题时,该如何去探索、验证,并最终落地?
这篇文章,我想结合这个项目的经历,从选型思路、实现细节到踩坑教训,讲讲我在技术探索上的所思所感。
问题描述:如何设计一套灵活的统一查询接口?

我们的系统中,有多个业务模块,比如用户管理、订单中心、运营报表等。每个模块的数据结构和逻辑完全不同:
- 用户管理里是典型的实体数据(如姓名、手机号)
- 订单中心涉及状态流转、金额计算
- 运营报表则需要聚合统计、时间维度分析
原本的做法是为每个模块定制 RESTful 接口,比如 /users/search、/orders/filter 等。但随着模块增多,这种做法带来了几个明显问题:
- 接口数量爆炸式增长:每个新增模块至少2~3个查询接口,维护困难。
- 前端重复工作多:搜索条件 UI 需要针对每个接口做适配。
- 后端逻辑相似但不一致:大量 CRUD 的 search 部分存在复用空间,却因为结构不同难以提取。
我们想要的是:提供一套通用的查询能力,让前后端都能更自由地定义筛选条件和展示字段,而不必为每个业务场景单独开发接口。
解决方案:构建基于 DSL 的动态查询引擎

1. 初步构想
经过几次方案讨论和原型尝试,我们决定采用类似 GraphQL 和 Elasticsearch Query DSL 的思路,打造一个轻量级的、可插拔的动态查询接口引擎。目标如下:
- 支持任意结构的数据模型(JSON schema 或数据库表结构)
- 查询参数支持过滤(filter)、排序(sort)、字段选择(field)等功能
- 响应格式标准化
- 支持权限控制、缓存、审计等扩展功能
最终的接口调用形式大概像这样:
POST /data/query?model=order
Content-Type: application/json
{
"filter": {
"status": "paid",
"amount": { "$gt": 500 }
},
"sort": [
{"field": "created_at", "order": "desc"}
],
"fields": ["id", "customer", "amount"]
}
只要传入 model 参数(例如 order),就能根据对应的 schema 动态生成 SQL/ES 查询语句,然后返回结果。
2. 技术选型与权衡
后端框架:Spring Boot + MyBatis Plus
考虑到团队对 Java 技术栈比较熟悉,以及需要支持多数据源(MySQL、Elasticsearch、ClickHouse),选择了 Spring Boot 框架,并基于 MyBatis Plus 扩展了 query builder 的能力。
数据解析与转换:使用 Jackson + 自定义注解处理器
为了实现 filter 条件到具体数据库语句的映射,我们设计了一套中间表示语言(Intermediate Representation),并通过注解处理器来绑定数据库字段名、类型、校验规则等信息。
安全性与权限:基于 RBAC 的上下文过滤器
我们在 filter 中内置了权限边界,例如普通用户只能看到自己的订单,通过在 filter 处理阶段自动插入 user_id = currentUserId() 来实现。
性能优化:缓存机制 + 字段限制
为了避免无脑查询所有字段造成性能瓶颈,我们要求必须通过 fields 显式指定需要的字段。同时在高频访问路径上增加了 Redis 缓存。
3. 实现思路概览
整体结构大致如下:
HTTP 请求 -> Controller -> QueryResolver -> DatabaseAdapter -> 结果返回
其中关键组件包括:
- QueryResolver:负责将 filter 表达式解析为具体的数据源查询条件(SQL WHERE 子句或 ES query DSL)
- SchemaProvider:负责维护数据模型与实际数据源的映射关系,允许运行时加载新模型
- DatabaseAdapter:封装不同数据源的查询行为,支持扩展新的数据源类型
代码实践:核心逻辑与示例

1. 接口定义(DTO)
public class QueryRequest {
private Map<String, Object> filter;
private List<SortField> sort;
private List<String> fields;
// getters/setters
}
public class SortField {
private String field;
private String order; // asc or desc
}
2. 查询解析(伪代码)
public class QueryResolver {
public WhereClause buildWhere(QueryRequest request, Schema schema) {
WhereClause clause = new WhereClause();
for (Map.Entry<String, Object> entry : request.getFilter().entrySet()) {
String fieldName = entry.getKey();
Object value = entry.getValue();
if (value instanceof Map && ((Map<?, ?>) value).containsKey("$gt")) {
clause.and(fieldName, ">", value.get("$gt"));
} else if (value instanceof Map && ((Map<?, ?>) value).containsKey("$in")) {
clause.in(fieldName, (List<?>) value.get("$in"));
} else {
clause.eq(fieldName, value);
}
}
return clause;
}
}
这里只是简单实现了几个常用的查询操作符,实际我们会将其抽象为表达式树(Expression Tree),并支持嵌套对象、数组类型、子查询等复杂场景。
3. 数据模型注册方式(简化版)
models:
user:
source: mysql
table: users
fields:
id: int
name: string
mobile: string
created_at: datetime
踩坑经验:那些你以为不会错的地方
在实际落地过程中,有不少地方是我一开始没预料到的。
1. 类型安全 vs 性能:过度泛化带来的后果
最初我们试图把各种数据源都抽象成统一的“数据模型”类,结果导致大量运行时类型检查和转换,严重影响了性能。后来改为根据不同数据源分别处理,性能提升了近 40%。
教训:不要追求“统一抽象”,在可控范围内接受异构是合理的。
2. 查询复杂度失控:递归嵌套表达式带来灾难
曾经支持了 $and、$or 等嵌套表达式语法,结果在一次线上查询中,某个复杂的 filter 直接导致慢 SQL 出现。最终我们做了两件事:
- 限制最大嵌套层级
- 引入静态分析工具,在请求进入 DB 之前进行预判
教训:灵活是好事,但得加“护栏”。
3. 忘记考虑数据敏感性:暴露不该暴露的字段
有一次上线后被安全部门告警,说有人通过字段选择获取到了加密字段(如用户身份证号)。原来是因为我们在 schema 注册中没有设置字段可见性策略,导致所有字段都可以被显式请求。
解决办法:增加字段白名单配置机制,并引入字段级别权限控制。
效果总结:不是万能药,但确实解决了问题

上线后两个月的运行数据显示:
| 指标 | 上线前 | 上线后 |
|---|---|---|
| 接口数量 | 超过 60 个 | 统一为 1 个 /query |
| 新增模块平均开发周期 | 5天 | 1天 |
| 接口响应时间 P99 | 2.1s | 0.7s |
虽然它并不适用于所有场景,比如大数据量导出、高并发写入等,但在查询灵活性和易用性方面取得了显著提升。
更重要的是,这套机制让我们可以快速应对产品层面的调整和迭代,比如临时加个搜索条件、换一种排序方式,不再需要后端重新上线。
经验分享:给你的技术探索之路提些建议
如果你也遇到类似的探索类技术问题,以下是一些我在实践中总结出来的建议,希望能帮到你:
1. 从问题出发,别沉迷“炫技”
很多时候我们容易陷入所谓的“新技术热潮”,比如看到别人用 Rust 写了个高性能服务,我们也跃跃欲试。但别忘了,技术的本质是为了解决问题。选型之前,先问自己:“这个方案能不能真正解决当前的问题?”
2. 尽量“少造轮子”,多“组合已有方案”
在我最初的版本中,我打算从头实现一个表达式解析器,最后发现其实很多开源库(如 Apache Calcite、javalang、EL 表达式)已经做得很好了。适当封装而不是完全重造,往往效率更高。
3. 先跑通再优化,拒绝完美主义
早期我们纠结于要不要用 AST 树、是否需要做词法分析、要不要提前编译等等。后来发现,在 MVP(最小可行性方案)阶段,简单的 map 匹配就可以满足需求。先把东西跑起来再说,性能问题可以后期逐步优化。
4. 文档+测试,一样不能少
这类系统一旦投入使用,就会有很多下游依赖它。一定要及时写出清晰的 API 示例文档和测试用例,否则后续修改成本会越来越高。我们甚至专门写了 playground 页面供前端同学调试。
5. 拒绝“闭门造车”,多沟通少自嗨
在整个方案演进过程中,我一直保持与产品、测试、前端同事的密切沟通。有时候他们的反馈会直接影响技术方案的方向。比如前端希望某个查询条件可以用下拉框选择,我们就相应地增强了 filter schema 的元信息支持。
结语:技术探索就是不断试错的过程
回顾整个过程,说实话,一开始我自己也怀疑这个想法会不会太理想化。但正是因为在小范围试点成功,我们才有信心把它推广出去。技术探索从来不是一蹴而就的,而是不断地试错、修正、迭代。
如果你也在做一些“没人做过的事”,或者正面临一堆不确定的技术选型,不妨停下来问问自己这几个问题:
- 我想解决的核心问题是什么?
- 已有的工具能不能帮助我?
- 我能不能先做一个最小可用的版本验证想法?
- 是否有办法降低决策风险?
这些问题可能不会马上给你答案,但我相信它们能帮你走得更稳一些。
最后送给大家一句话,也是我现在经常提醒自己的一句话:
“真正的创新,往往藏在朴素的实现之中。”

评论 0