1. 为什么我要给 Claude Code 配一个只读的 GaussDB 通道
先说结论:我写了一个用 Go 实现的 MCP 服务,把 GaussDB 的查询能力以只读的方式暴露给 Claude Code。它解决的核心问题很具体——我想让 AI 帮我查生产库的数据、分析表结构、定位慢查询,但绝对不能让它在生产库上执行任何写操作。
这个需求不是凭空冒出来的。日常开发里,我们经常遇到这样的场景:线上某个业务表的数据对不上,需要拉几条样本看看;某张表的字段类型和文档不一致,想确认一下真实结构;一个慢查询卡了很久,想看看执行计划到底走了哪个索引。这些事以前要么自己连上去手动查,要么写个临时脚本跑一遍。现在有了 Claude Code 这类工具,理论上可以直接对话式地完成,但问题也随之而来——你不可能把生产库的写权限交给一个 AI。
MCP(Model Context Protocol)是这里的关键。它本质上是一套让 AI 客户端和外部工具/数据源通信的协议,Claude Code 作为客户端,可以连接各种 MCP Server,把 Server 暴露出来的能力当成自己的工具来用。我做的事情就是写一个 MCP Server,它对外只暴露"查询"这一类工具,内部连接 GaussDB,并且在多个层面把写操作堵死。
为什么选 Go?因为 MCP Server 本质上是一个常驻的、需要处理并发请求的小服务,Go 的编译产物是单个二进制、部署简单、并发模型清晰、标准库对数据库和 JSON 的支持足够好。你不需要为了这么一个小服务去搭一套 Python 虚拟环境或者 Node 运行时,扔一个二进制到服务器上就能跑,这对运维友好度是决定性的。
这篇文章我会把整个实现过程拆开讲:MCP 协议里到底要处理什么、GaussDB 的连接有什么坑、只读是怎么做到"多层设防"的、以及我在实测中踩到的那些文档里不会写的问题。如果你也在琢磨怎么让 AI 安全地碰你的数据库,这篇应该能帮你少走不少弯路。
2. 先把 MCP 协议这层搞明白,别急着写数据库代码
2.1 MCP Server 到底要响应哪些东西
很多人一上来就去研究数据库驱动,结果卡在协议层。我的建议是先把 MCP 的通信模型理清楚,因为它决定了你的代码骨架长什么样。
MCP 目前主流的传输方式是 stdio 和 HTTP/SSE 两类。对于本地工具型服务,stdio 是最省事的:Claude Code 启动你的进程,通过标准输入输出交换 JSON-RPC 消息。你不需要开端口、不需要处理鉴权、不需要考虑网络暴露面。我的选择就是 stdio,理由很直接——这个服务只给本机的 Claude Code 用,没有任何理由把它暴露成网络服务。
协议交互的核心是几个方法:
initialize:握手,双方交换能力声明。客户端告诉你它支持什么,你告诉客户端你提供什么。tools/list:返回你暴露的工具清单,每个工具包含名称、描述、输入参数的 JSON Schema。tools/call:客户端调用某个工具,传入参数,你返回结果。
这里有个容易被忽略的点:工具的 description 字段是给模型看的。它不是写给人看的注释,而是模型决定要不要调用、怎么填参数的主要依据。所以描述要写得精确,比如"执行一条只读 SQL 查询并返回结果,仅支持 SELECT 语句,禁止任何写操作",这种约束写进描述里,模型在生成调用时会更谨慎。
2.2 JSON-RPC 消息的边界处理
stdio 模式下最容易翻车的地方是消息分帧。JSON-RPC over stdio 通常用换行分隔每条消息,但如果你读的时候用bufio.Scanner默认的 64KB 上限,遇到大结果集直接就被截断了。我一开始就踩了这个坑——查一张宽表返回几百行,结果 JSON 被切断,客户端报解析错误,排查了半天才发现是 Scanner 的缓冲区限制。
解决办法是显式设置更大的缓冲区:
scanner := bufio.NewScanner(os.Stdin) scanner.Buffer(make([]byte, 1024*1024), 16*1024*1024)第一参数是初始缓冲,第二参数是最大容量。我给了 16MB,足够应付绝大多数查询结果。但更稳妥的做法是在工具层面就限制返回行数,不要让单次响应无限膨胀,这个后面讲只读设计时会再展开。
另一个坑是标准输出的纯净性。stdio 模式下 stdout 是协议通道,你任何一句fmt.Println调试输出都会污染协议流,导致客户端解析失败。所有日志必须走 stderr。我在代码里把日志统一封装成一个写 stderr 的 logger,杜绝了随手打印的习惯。
2.3 工具清单怎么设计才合理
我没有把所有能力塞进一个"万能查询"工具,而是拆成了几个职责清晰的工具:
| 工具名 | 作用 | 关键约束 |
|---|---|---|
query | 执行只读 SQL | 仅 SELECT,强制 LIMIT |
list_tables | 列出 schema 下的表 | 只读元数据 |
describe_table | 查看表结构 | 只读元数据 |
explain_query | 获取执行计划 | 仅 EXPLAIN,不实际执行 |
拆开的好处是每个工具的输入 Schema 都很简单,模型不容易填错参数。而且像explain_query这种,我可以单独控制它只允许EXPLAIN前缀,比在一个大工具里做字符串判断要清晰得多。
提示:工具数量不要贪多。每多一个工具,模型在选择时的决策成本就高一分。我最初还加了
list_schemas、show_indexes之类的,后来发现describe_table返回的信息已经够用,就砍掉了,实际体验反而更顺。
3. GaussDB 连接层:驱动选择与那些文档没写的细节
3.1 驱动选型:为什么用 pgx 而不是 database/sql 原生
GaussDB 在协议层面高度兼容 PostgreSQL,所以 Go 生态里成熟的 PG 驱动基本都能用。我选的是jackc/pgx,而不是标准库database/sql配lib/pq。
原因有三点。第一,pgx 有原生接口(pgx.Conn、pgxpool.Pool),性能比走database/sql抽象层要好,尤其是批量查询和类型映射。第二,pgx 对 PostgreSQL 协议的支持更完整,GaussDB 的一些扩展类型在 pgx 下解析更顺。第三,pgx 的类型系统更智能,查询结果直接映射成 Go 类型时,遇到numeric、timestamp with time zone这类类型不容易出幺蛾子。
连接串的写法大致是这样:
postgres://user:password@host:port/dbname?sslmode=disable&statement_cache_capacity=0这里有个 GaussDB 特有的坑:statement cache 有时候会出问题。pgx 默认会缓存 prepared statement,但某些 GaussDB 版本在特定配置下对 prepared statement 的支持不完整,表现为偶发的 "prepared statement already exists" 或者参数绑定异常。我一开始遇到间歇性查询失败,查了很久才定位到这里。把statement_cache_capacity设为 0 关掉缓存后,问题消失。代价是每次查询都要重新解析 SQL,但对于一个查询频率不高的辅助工具来说,这点开销完全可以接受。
3.2 连接池参数怎么定
即使是只读服务,也建议用连接池而不是单连接。因为 Claude Code 可能连续发起多个查询,单连接会串行化,体验很差。我用的是pgxpool:
config, err := pgxpool.ParseConfig(connString) if err != nil { return nil, err } config.MaxConns = 4 config.MinConns = 1 config.MaxConnLifetime = time.Hour config.MaxConnIdleTime = 10 * time.MinuteMaxConns给 4 就够了。这个服务不是高并发场景,一个开发者本机用,4 个连接绰绰有余。给太多反而会占用数据库的连接数配额,生产库的连接数是宝贵资源,别浪费。
MaxConnLifetime设一小时是为了避免长连接被数据库端或中间网络设备悄悄掐断后,客户端还拿着死连接用。设一个合理的生命周期,让连接定期重建,能规避很多"连接突然失效"的玄学问题。
3.3 超时控制:别让一个慢查询拖死整个服务
这是我认为最容易被忽视但最重要的一环。生产库上一条没走索引的查询可能跑几分钟甚至更久,如果不设超时,你的 MCP 服务就会一直挂着,Claude Code 那边也会一直等,整个交互就卡死了。
我的做法是给每个查询都套一个 context 超时:
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() rows, err := pool.Query(ctx, sqlText)15 秒是我权衡后的值。太短了正常的分析查询跑不完,太长了卡住体验差。超过 15 秒的查询,说明它本身就不适合在这个场景下跑,应该去专门的慢查询分析工具里处理。
同时,GaussDB 侧也可以设statement_timeout,双保险。可以在连接初始化时执行:
SET statement_timeout = '15s';这样即使客户端 context 因为某些原因没生效,数据库端也会主动掐断。两层超时,一层在客户端,一层在服务端,互为兜底。
4. 只读是怎么做到"多层设防"的
这部分是整个项目的核心,也是标题里"放心连生产库"的底气所在。我的原则是:不依赖任何单一防线,每一层都假设其他层可能失效。
4.1 第一层:数据库账号权限
最根本的一层是数据库账号本身就只有只读权限。这是不可绕过的基础。创建一个专用账号,只授予CONNECT和USAGE,以及对目标 schema 下表的SELECT:
CREATE USER mcp_readonly WITH PASSWORD 'xxx'; GRANT CONNECT ON DATABASE yourdb TO mcp_readonly; GRANT USAGE ON SCHEMA public TO mcp_readonly; GRANT SELECT ON ALL TABLES IN SCHEMA public TO mcp_readonly; ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO mcp_readonly;最后那句ALTER DEFAULT PRIVILEGES很关键,它保证以后新建的表也自动带上 SELECT 权限,省得每次加表都要手动授权。
注意:千万不要图省事用超级用户或者有写权限的账号。哪怕你的代码里做了 SQL 校验,账号权限这一层也必须卡死。代码可能有 bug,但数据库的权限系统不会骗你。
4.2 第二层:SQL 语句白名单校验
在服务端,我对传入的 SQL 做严格校验。核心逻辑是:只允许以 SELECT 或 WITH 开头的语句,且不允许出现任何写操作关键字。
func isReadOnly(sqlText string) bool { normalized := strings.TrimSpace(strings.ToLower(sqlText)) // 去掉开头的注释 normalized = stripLeadingComments(normalized) if !strings.HasPrefix(normalized, "select") && !strings.HasPrefix(normalized, "with") { return false } forbidden := []string{ "insert", "update", "delete", "drop", "truncate", "alter", "create", "grant", "revoke", "merge", "call", "execute", "copy", } for _, kw := range forbidden { if containsKeyword(normalized, kw) { return false } } return true }这里有个细节要小心:不能简单地用strings.Contains判断关键字。因为update_time这样的列名里就包含 "update",created_at里包含 "create"。如果粗暴匹配,正常的查询会被误杀。
我的做法是用词边界匹配,把 SQL 按非字母数字字符切分,然后逐个 token 比对。这样update_time会被切成update和time两个 token……等等,下划线算不算分隔符?如果算,那update_time还是会被误判。
更稳妥的方案是:先做词法切分,识别出 SQL 里的关键字位置,只对"语句级关键字"做判断,而不是对任意 token。但完整实现一个 SQL 词法分析器成本太高。我的折中方案是:用正则匹配关键字前后必须是空白或标点的情况,同时把常见的"关键字+下划线"组合排除掉。实测下来,配合数据库账号权限这层兜底,这个精度已经够用了。
4.3 第三层:强制 LIMIT 与结果集截断
即使查询是只读的,一条SELECT * FROM huge_table也能把内存打爆。所以我在执行前会检查 SQL 是否带 LIMIT,如果没有,自动追加一个:
if !hasLimitClause(normalized) { sqlText = sqlText + " LIMIT 1000" }同时在读取结果时也做行数上限控制,读到 1000 行就停止,并在返回结果里明确标注"结果已截断"。这样模型和用户都知道数据不完整,需要更精确的查询条件。
这里有个坑:给带ORDER BY的查询追加 LIMIT 是安全的,但给某些聚合查询追加可能改变语义。不过对于探索性查询来说,LIMIT 的存在利大于弊,而且我会在工具描述里说明这个行为,让模型知道结果可能被截断。
4.4 第四层:事务只读模式
在连接层面,我还会把事务设为只读:
SET TRANSACTION READ ONLY;或者在 pgx 里用BeginTx指定AccessMode: pgx.ReadOnly。这样即使前面几层都被绕过,数据库层面也会拒绝写操作。这是最后一道保险。
四层防线叠加下来,实际防护效果是:账号权限挡住绝大部分,SQL 校验挡住明显的写语句,LIMIT 防止资源耗尽,事务只读兜底。任何一层单独看都不完美,但叠在一起,误操作的可能性被压到了极低。
5. 实测中那些让人抓头的具体问题
5.1 大字段返回导致的 JSON 序列化问题
GaussDB 里如果有bytea、text这类大字段,查询结果序列化成 JSON 时可能出问题。bytea直接序列化会变成一长串 base64,几 MB 的二进制字段能把响应撑爆。
我的处理方式是在结果转换阶段做类型判断:遇到bytea类型,不返回实际内容,而是返回一个占位符加长度信息,比如"<binary data, 2048 bytes>"。这样既保留了"这里有数据"的信息,又不会把响应撑大。
switch field.DataTypeOID { case pgtype.ByteaOID: result = fmt.Sprintf("<binary data, %d bytes>", len(rawBytes)) default: result = string(rawBytes) }5.2 NULL 值的处理
Go 的pgx在扫描 NULL 值时,如果目标类型是string,会报错。必须用pgtype.Text或者*string来接。我一开始用[]string接所有列,遇到 NULL 直接 panic。
后来改成统一用[]any接,然后逐个判断类型再转换。虽然麻烦一点,但通用性最好,不用为每种列类型写不同的扫描逻辑。
5.3 中文与特殊字符的编码
GaussDB 的字符集配置如果和客户端不一致,中文可能变乱码。连接串里可以指定client_encoding:
postgres://...?client_encoding=UTF8另外,返回 JSON 时 Go 默认会把非 ASCII 字符转义成\uXXXX,虽然合法但可读性差。如果希望保留原始中文,需要在编码时关闭 HTML 转义:
encoder := json.NewEncoder(w) encoder.SetEscapeHTML(false)5.4 Claude Code 侧的配置
服务写好了,还要让 Claude Code 知道怎么启动它。配置里指定命令和参数即可,大致是这样:
{ "mcpServers": { "gaussdb-readonly": { "command": "/path/to/your/mcp-server", "args": ["--conn", "postgres://..."] } } }连接串这种敏感信息,我建议通过环境变量传,而不是写在配置文件的 args 里。配置文件可能被同步、被分享,环境变量相对安全一些。
提示:第一次配置完,先在 Claude Code 里让它调用
list_tables这种最简单的工具,确认链路通了,再去试复杂查询。一上来就查复杂 SQL,出问题不好定位是协议层还是数据库层。
6. 关于"只读"这件事,我的一些额外思考
写这个服务的过程中,我对"只读"的理解比一开始深了不少。只读不只是"不让写",它包含好几个维度:不写数据、不写结构、不消耗过多资源、不泄露敏感信息。
资源消耗这点前面提了 LIMIT 和超时,但还有一个容易被忽略的——并发控制。如果模型一次发起十个查询,每个都跑满连接池,数据库那边压力就上来了。我在服务层加了一个简单的信号量,限制同时执行的查询数量,超出的排队等待。这样即使模型行为激进,也不会把生产库打垮。
敏感信息方面,如果表里有手机号、身份证号这类字段,直接返回给 AI 是有风险的。理想情况下应该做脱敏,但脱敏规则因业务而异,很难做成通用的。我的做法是在工具描述里明确提示"返回结果可能包含敏感数据,请谨慎处理",把判断权交给使用者。更严格的方案是配置一个字段黑名单,命中就替换成掩码,这个可以根据自己团队的情况加。
最后说一个心态上的建议:不要因为做了只读就完全放松警惕。只读查询一样可能触发全表扫描、一样可能锁住某些资源(取决于隔离级别)、一样可能因为返回海量数据拖垮客户端。只读降低的是"数据被篡改"的风险,不是"系统被影响"的风险。把超时、限流、结果截断这些做扎实,才是真正让人放心的关键。
我在实际使用中最大的体会是,这套东西的价值不在于技术多复杂,而在于它把"让 AI 碰生产库"这件原本让人心里发毛的事,变成了一件可以放心做的事。四层防线里没有哪一层是黑科技,都是很朴素的工程手段,但组合起来就形成了足够可靠的保障。如果你也在做类似的事,希望这些经验能帮你把坑填平。