“哥们,Milvus 会不会 SQL 注入啊?”
第一次被安全部门的同学这么问时,我差点没忍住笑出声:
“开玩笑吧?它连 SQL 引擎都没有,拿头去注入?”
但当我真想找点铁证甩他脸上时,才发现“没有 SQL”这四个字,根本没法堵住悠悠众口。一连串的灵魂拷问接踵而至:
filter查询语句,捣鼓成了什么玩意儿?SELECT * FROM collection WHERE '1'='1'?filter表达式里塞满了or 1=1--这种东西,服务端会不会直接躺平?+号拼字符串的代码?为了搞清楚这些问题,硬生生把 Milvus 从 Java SDK 一路扒到了 C++ 的执行计划。
如果你也正在应付安全评审、进行源码审计,或者单纯就是好奇“向量数据库到底怎么查数据”,那请泡好咖啡,咱们一口气把它撸到底。

一句话总结:
Java SDK 不拼 SQL,只认 Protobuf;服务端不跑 SQL,只跑表达式树。 所以,“SQL 注入”在 Milvus 的世界里,压根就是个伪命题。
search() 的奇妙漂流:从 Java 代码到 C++ 核心下面这段代码,估计大伙儿都写过:
// 1. 连接 Milvus
MilvusServiceClient client = new MilvusServiceClient(
ConnectParam.newBuilder()
.withHost("127.0.0.1")
.withPort(19530)
.build());
// 2. 准备要搜索的向量
List<List<Float>> vectors = Arrays.asList(Arrays.asList(0.1f, 0.2f, 0.3f));
// 3. 执行搜索
SearchResp resp = client.search(
SearchReq.builder()
.collectionName("article_vector")
.data(vectors)
// 注意!这就是用户输入的地方
.filter("status == 'published' && pv > 1000")
.topK(5)
.build());
现在,我们就跟着这句filter,看看它经历了哪几站。下面的时序图清晰地展示了整个调用链路:

这里的 SearchReq.builder() 就是个平平无奇的 POJO (Plain Old Java Object) 构造器,它干的事就是把你的参数塞进对象的私有字段里。
划重点:
+、.concat() 或者 StringBuilder 在这里搞事情。filter 字符串,原封不动地被存了起来,秋毫无犯。Java SDK 真正干活的地方,是调用 MilvusServiceGrpc.search() 方法。在把我们的 SearchReq 对象转换成 gRPC 能认的 SearchRequest 时,代码是这样的:
SearchRequest grpcReq = SearchRequest.newBuilder()
.setCollectionName(req.getCollectionName())
.setExpr(req.getFilter()) // 直接 set,没有多余动作
...
.build();
划重点:
setExpr() 虽然接收的是个普通字符串,但 Protobuf 会把它编码成 二进制的 UTF-8 字节数组。这个过程很纯粹,不会画蛇添足地搞什么转义或替换。为了看清网络上跑的到底是什么,我打开了 Netty 的 Debug 开关。抓到的网络包长这样:
HEADERS
:method: POST
:path: /milvus.proto.milvus.MilvusService/Search
content-type: application/grpc
DATA
<一坨看不懂的二进制数据>
把这坨二进制数据 dump 下来,用 protoc 工具反解一下,真相大白:
1: "article_vector"
2: "status == 'published' && pv > 1000"
3 { 4: 0.1 4: 0.2 4: 0.3 }
5: 5
划重点:
filter 表达式,肉眼可见,一个 SQL 关键字都没有。filter 写成 "' OR 1=1--",在这里它也仅仅是个 平平无奇的字面量字符串,压根没有机会成为 SQL 语句的一部分。Milvus 服务端收到这个 gRPC 请求后,会把它交给 internal/proxy 模块处理。关键代码如下:
Status ProxyClient::Search(const SearchRequest* req, SearchResponse* reply) {
// 1. 从 Protobuf 里把表达式字符串拿出来
const std::string& expr = req->expr();
// 2. 创建一个搜索计划,注意!这里用的是表达式解析器
auto plan = CreateSearchPlan(expr, schema);
if (!plan.ok()) return plan.status();
// 3. 把计划交给底层的 segcore 引擎去执行
auto result = segcore::Search(*plan, vectors, topK);
...
}
划重点:
expr 字符串被送进了一个叫 CreateSearchPlan() 的函数。这函数用的是 antlr4 生成的表达式语法树解析器,跟 SQL 解析器没有半毛钱关系。LogicalExpr(逻辑表达式)、TermExpr(项表达式)这类节点,根本就没有 SQL 里的 SelectStatement(查询语句)节点。底层的 segcore 拿到了这棵表达式树后,会用一套列式向量化的方式来执行它:
划重点:
为了彻底打消“SQL 注入”的疑虑,我们来点狠的,故意构造一个看起来坏到骨子里的filter:
.filter("id > 0 || '1'='1' ; DROP TABLE article_vector; --")
抓包看,发出去的expr字段原文就是这串:
id > 0 || '1'='1' ; DROP TABLE article_vector; --
再看服务端日志,它无情地拒绝了:
[2025-09-29 14:33:02.503] [error] [Plan] 语法错误: 在 ';' 附近有无法识别的输入,期望的是 {'}', '+', '-', '*', '/', '%', '<', '<=', '>', '>=', '==', '!=', '&&', '||'}
最后,Java 客户端收到的响应状态也是PARAMETER_INVALID(参数不合法)。
结论一目了然:
DROP TABLE的惨剧发生。虽然 SQL 注入是没戏了,但这不代表你可以为所欲为。真正的风险在于 “滥用表达式” 导致的拒绝服务(DoS)攻击。
Milvus 2.4 开始支持 regex_match 函数,你可以这么写:
.filter("title like_regex '^(a+)+$'")
如果攻击者构造一个灾难性的正则表达式(ReDoS),就能让服务端的一条线程 CPU 占用率飙到 100%,活活耗死。
怎么办:
expr 做白名单校验,直接禁用 regex_* 函数。filter的长度(比如 ≤ 200 字符)、深度(比如 ≤ 3 层嵌套)。想象一下这个查询:
.filter("id in [1,2,3, ... , 100000]") // 列表里有十万个元素
这会导致解析出来的语法树包含十万个 TermExpr 节点,序列化后的 Protobuf 包能有好几 MB 大,足以让服务端的 Proxy 内存瞬间暴涨。
怎么办:
IN 查询拆分成多个小批次查询。我把 milvus-java-sdk 的源码拖下来,全局搜了一遍:
$git clone [https://github.com/milvus-io/milvus-sdk-java.git$](https://github.com/milvus-io/milvus-sdk-java.git$) rg -i "select|insert|update|delete" --type java
结果:0 命中。 一个 SQL 动词都没有。
不死心,再搜 + 号拼接:
$ rg -A3 -B3 '(\+|StringBuilder|concat)' --type java | grep -E "(filter|expr)"
结果:只在日志打印里找到了拼接,没有任何查询逻辑的拼接。
到这里,我们完全可以理直气壮地在安全报告里写下结论:
“经源码审计,Milvus Java SDK 不存在将用户输入拼接到 SQL 语句的行为。”
很多人容易把 Milvus 和 pgvector(Postgres 的一个向量插件)搞混。这里我列了个表,方便你直接复制粘贴到 PPT 里去忽悠老板。
维度 | Milvus 2.x | pgvector + Postgres |
|---|---|---|
查询语言 | 自定义的布尔表达式 | 标准 SQL |
注入攻击面 | 无(因为它压根不说 SQL) | 有(必须用参数化查询防范) |
网络协议 | gRPC / Protobuf (二进制) | TCP / SQL (文本) |
服务端引擎 | C++ 自研的 SegCore 引擎 | Postgres 的原生 SQL 引擎 |
典型注入 | 直接报语法错误,无法执行 | '; DROP TABLE-- 可能会得逞 |
安全加固 | RBAC 权限控制 + 表达式白名单 | 预编译语句 + 数据库权限最小化 |
19530 (gRPC)、9091 (HTTP) 端口只对内网业务服务器开放,别暴露在公网。root 账号的 token 写在应用的配置文件里到处跑。expr 字段加个长度限制,比如 ≤ 512 字节。in 列表等有风险的用法,做黑名单过滤。filter 表达式。把它当成一段会执行的代码,而不是一个普通的字符串。grpcurl 把 Protobuf 再拆给你看如果你是个追根究底的极客,还可以用 grpcurl 这个神器,绕开 Java SDK,直接跟 Milvus 服务端对话:
grpcurl -plaintext -d '{
"collection_name": "c",
"expr": "id > 0",
"vectors": {"float_vector": {"data": [0.1,0.2,0.3]}},
"topk": 5
}' 127.0.0.1:19530 milvus.proto.milvus.MilvusService/Search
你会发现,返回的结果也是纯纯的 Protobuf。整个交互过程,SQL 连个影子都没有。
把这条命令截个图放进报告里,保证安全同学看完就给你盖章了。
让我们回到最初的那个问题:
“Milvus 会不会 SQL 注入?”
现在,我们可以给出一个工程师级别的、严谨的答案了:
不会。 因为从架构设计上,它就没有 SQL 引擎,天然免疫 SQL 注入;从代码实现上,官方 SDK 只做 Protobuf 对象的封装,不存在拼接 SQL 字符串的风险;从实际测试来看,任何恶意的类 SQL 字符串都会被当成语法错误而拒绝执行。
Milvus 的安全风险,不在于“注入”,而在于“滥用”。真正的威胁是表达式DoS和未授权访问,只要按照本文的加固清单来操作,就能把风险降到最低。
好了,把这篇文章转发给你的安全、运维和开发小伙伴们吧。下次再有人问起“向量数据库 SQL 注入”的问题,直接把链接甩过去,能帮你省下至少两小时的口舌。