Jeff是如何被同事发明的DSL残害的
以下都是Jeff的经历,和作者本人不能说是一点关系没有吧只能说是没有一点关系。
大概在几年前,我就读过一篇文章:不要以 DRY 之名,发明低代码 DSL 去残害你的同事 互联网备份链接
当时的我还是年轻,低估了这篇文章的威力。当然,有能力做一个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的适用场景是:
业务逻辑块高度重复且限定,而且以后新增的可能性较少
对灵活度的要求极高,比如要求随时能够新建一个业务逻辑(有大量不懂编程但是可以被简单培训的技术人员)
除此之外的场景,请不要发明DSL去残害你的同事。
最后更新于