> For the complete documentation index, see [llms.txt](https://blog.tsingjyujing.com/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://blog.tsingjyujing.com/other-tech/20260717-dsl.md).

# Jeff是如何被同事发明的DSL残害的

以下都是Jeff的经历，和作者本人不能说是一点关系没有吧只能说是没有一点关系。

大概在几年前，我就读过一篇文章：[不要以 DRY 之名，发明低代码 DSL 去残害你的同事](https://zhuanlan.zhihu.com/p/357411780) [互联网备份链接](https://archive.vn/IdYQ9)

当时的我还是年轻，低估了这篇文章的威力。当然，有能力做一个DSL出来的人也不多，而且我们那个时候的场景也没有那么多变，所以并没有这样的问题。 我当时也用django开发项目，对于django的“最后10%”问题也深有体会，经常为了实现一些非标的功能绞尽脑汁。话虽如此，却也切实体会到了django的便利之处，所以虽然对文章赞同，但是觉得有点言过其实。

直到Jeff（再次免责声明，不是我，哈哈哈）遇到了一些和DSL有关的奇妙经历。

## Jeff遇到了什么问题

Jeff的公司有很多相似的业务逻辑。比如从A系统，B系统取出数据，按照一些业务逻辑整合后再送到C系统中去计算，计算的结果返回去或者送到D系统中做更多处理。以前这些系统大多是Go写的，有一个神人决定整合这一套系统，于是做了一套复杂的YAML配置，通过配置文件就可以决定数据被如何处理，每一个处理背后都有Go语言写的处理代码来对应。于是大多数的业务逻辑都可以用一个YAML表示。如果处理方法不够就新增处理方法。

虽说是配置文件，我觉得其复杂度和DSL也差不多了。

很快，这个文件就复杂到人类很难看懂了。里面定义了各种各样的数据源、合成器等等，但是没人知道它们会如何作用。 这个时候另一个神人发现：“这不就是定义了一个DAG嘛！我去，不早说！”

于是写了一套DAG运行器。简单来说就是定义了许多Operator，有的可以取数据，有的可以送到推理服务器去计算，中间的变换逻辑Operator（或者说胶水）就让DuckDB的SQL来承担。

用户可以定义很多的STAGE，定义它们之间的依赖关系。下游的STAGE可以用上游的STAGE的计算结果来进行进一步处理，最后经过各种处理之后，可以指定一个输出STAGE，这个STAGE的输出数据（一张表）就会作为返回值。

### 一个很有洞见，但是并不好的方案

必须承认，DAG的方案比第一种方案已经好很多了，至少DAG有清晰的结构和执行顺序，比起第一种的“定义了，但是不知道什么时候会被如何执行”要好得多。

而且这种“原来都是DAG”我认为是非常深刻的洞见，基于这样的理解，是能够做出好产品的。可是，可是！这样的DSL仍然折磨着每一个人。

因为使用这个DAG系统本身有代价，这个代价和DSL的代价是一样的。

## 一个DSL做出来了，那么代价呢？

其实写一个DSL和开发一门新的语言没有太多的区别，要考虑DSL的开发，调试，监控（Tracing/Profiling），执行效率反而是最后需要讨论的一环。

就以Jeff遇到的问题为例，他遇到的DSL是YAML（也可以用JSON，反正能用yq/jq互相转换，所以都一样，包括JSONNET，下略）格式内嵌一部分SQL。我们一个个看：

### 开发

大抵现代的文本编辑器都是支持YAML编辑的，这一点问题不大。至于符合不符合自定义的某些规范，K8S也有珠玉方案在前，其实YAML的验证、补全方案大概是没有问题的（虽然本案例中也没有做就是了）。

SQL的补全本来是有现成的方案的，但是如果和YAML混合到一起就比较麻烦了，YAML对内嵌的文本的编辑做得不是很好。

即使我们克服了这一点，想要有一个顺畅的开发流程，还需要解决另一个问题，就是上下文。 每一个不同的STAGE它能访问的数据表也不一样，表结构也各不相同，上下文当然也不一样。 一个好的开发工具，在代码编写中就要能静态解析出当前DAG能够访问哪些上游的数据表及其表结构来做自动补全和报错。

然而这些目前也都没有。就是靠文本编辑器+最近很流行的Generative AI硬来。😅

### 调试

其实其他类型的Stage还好说，最大的问题出在SQL上，SQL的执行中出问题，可能是本身SQL有问题，也可能是上游Stage输入的数据有问题。

首先SQL本身就不适合调试，写过WITH很多个临时view的大型SQL的人应该有所体会，我们往往是需要一个个地去验证里面的小View是不是输出了正确的结果，像建造金字塔一样地把SQL垒起来。而SQL写好以后，就很难调试了，这个时候如果说中间某个view的输出有问题，那就只能拆了然后重新垒。

即使我们忽略SQL的这个问题（因为有DAG可以把大SQL拆开），一个比较顺畅的开发流程也应该是：准备各个上游的Mock的数据（表），然后单元测试去执行SQL，肉眼或者写一些脚本来验证输出是不是正确。这些原方案都没有做。在SQL执行出错的时候，一个完整的报错（StackTrace）应该包含哪个DAG的哪个Stage出错了，出错的原因是什么，上游已经完成的Stage的输出和输入都是什么。但是或许是为了数据安全，现在也没有支持这一点。

### 监控

这一点Jeff用的那个系统做得比较好，其实DAG天然适合被Opentelemetry做Tracing。 默认所有的环节都做Tracing比在Go里面一个个添加要方便一些。

但是Profile没有做，导致线上性能出现问题以后只能看最近的Tracing的记录去感觉+猜。

### 性能

其实性能是最后解决的问题，因为大多数的服务没有那么多的QPS，QPS过低造成的Connection问题其实比QPS过高造成的Latency问题还要普遍一些。实在遇到了问题可以靠堆机器解决。但是这个DAG运行器的性能也确实是问题，Jeff有一次遇到了一个要求3000QPS的服务，DAG又比较复杂，单单是300QPS就可以吃掉30Core的CPU，也就是一个CPU Core才能扛10QPS。

性能问题的根源也很简单，SQL的解析需要CPU，更何况是每一次Request都会解析一次SQL。哪怕是解析后，SQL的执行效率也不如机器代码或者Java字节码，CPU不是设计来干这个的。当然，这一部分我觉得DuckDB是有解决方案的。

或许以后可以有方案把SQL的Plan预编译成类似字节码的东西，然后执行就会快很多，但是这样调试可能就更麻烦了。

总而言之，现在的方案造成了资源的极大浪费。如果一开始就用Go语言写，一开始的时候或许会失去并行执行的速度优势（当然用chan可以实现，只是写起来也并不优雅），但是最终效率应该是更高的，而且只需要几个Core的CPU和几百M的内存就可以承受数千的QPS。如果用Rust效率可能更高（但是开发效率可能下降）。

## 代价

我们可以看到，其实上面每一个课题都是需要花大功夫的。日常的开发、调试方面，我们之所以有那么多的IDE，就是为了方便地开发某种语言，比如Goland来开发Go，Pycharm来开发Python。即使都用VSCode，至少你也要做一个LSP Server来接入，这些都是要花大力气去做的。而开发DSL的人往往有意无意地忽略了这个问题，大多是写完这门语言以后就不管了……于是除了作者会写这门语言，其他的人写起来都非常的痛苦。

## 不用DSL，那我们用什么？

是啊，用什么？（震声）就他妈的用Python，用Go，用Rust，甚至用C啊！给这些语言写库，然后用这些语言当胶水把你写的库以业务逻辑想要的形式粘起来。

这些大范围被使用的语言，本身调试器，工具链（Tracing，Profiling）都是完备的，有什么理由非要自己造轮子呢？

我们挑个性能最差的说好了（就是你了，Python）。

比如DAG，Python中完全可以用async/await语法来完成并发，Python中有丰富的类型（比如字典）也不至于一个JOIN就浪费很多资源，更重要的是，开发的时候可以用pdb之类的工具进行调试，上下文，当前的StackTrace，都一清二楚，为什么非要写SQL呢？

而且哪怕是Python，只要设计得当，性能其实肯定是比SQL要强的，至少人家开始就编译成字节码了，用不着每次都去Parse AST。

## 总结：复杂度不会消失，只会转移

恰如AI无法解决复杂度的问题一样，DSL更是解决不了复杂度的问题。

DSL的适用场景是：

1. 业务逻辑块高度重复且限定，而且以后新增的可能性较少
2. 对灵活度的要求极高，比如要求随时能够新建一个业务逻辑（有大量不懂编程但是可以被简单培训的技术人员）

除此之外的场景，请不要发明DSL去残害你的同事。
