技术探索与实践:我是如何在项目中“摸着石头过河”的

一个会部署的人
2025-06-19 21:08
阅读 742

引言:一次需求引发的思考

引言:一次需求引发的思考

去年年底,我参与了一个内部数据中台的重构项目。原本只是个“老系统改造”,但随着调研深入,我们发现原来的架构已经无法支撑日益增长的数据量和复杂度。最棘手的问题之一,是不同业务模块的数据模型差异巨大,而统一查询接口的需求又非常迫切。那段时间,每天都在跟产品经理和技术负责人讨论方案,大家都有点迷茫——到底怎么做才对?

于是,我开始认真思考一个问题:当我们面对一个没有标准答案的技术问题时,该如何去探索、验证,并最终落地?

这篇文章,我想结合这个项目的经历,从选型思路、实现细节到踩坑教训,讲讲我在技术探索上的所思所感。


问题描述:如何设计一套灵活的统一查询接口?

问题描述:如何设计一套灵活的统一查询接口?

我们的系统中,有多个业务模块,比如用户管理、订单中心、运营报表等。每个模块的数据结构和逻辑完全不同:

  • 用户管理里是典型的实体数据(如姓名、手机号)
  • 订单中心涉及状态流转、金额计算
  • 运营报表则需要聚合统计、时间维度分析

原本的做法是为每个模块定制 RESTful 接口,比如 /users/search/orders/filter 等。但随着模块增多,这种做法带来了几个明显问题:

  1. 接口数量爆炸式增长:每个新增模块至少2~3个查询接口,维护困难。
  2. 前端重复工作多:搜索条件 UI 需要针对每个接口做适配。
  3. 后端逻辑相似但不一致:大量 CRUD 的 search 部分存在复用空间,却因为结构不同难以提取。

我们想要的是:提供一套通用的查询能力,让前后端都能更自由地定义筛选条件和展示字段,而不必为每个业务场景单独开发接口。


解决方案:构建基于 DSL 的动态查询引擎

解决方案:构建基于 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 注册中没有设置字段可见性策略,导致所有字段都可以被显式请求。

解决办法:增加字段白名单配置机制,并引入字段级别权限控制。


效果总结:不是万能药,但确实解决了问题

技术原理图-1

上线后两个月的运行数据显示:

指标 上线前 上线后
接口数量 超过 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

最热最新
暂无评论
匿名用户Lv.1
0
影响力
0
文章
0
粉丝