声明式与命令式:一个被讲烂的概念,以及它真正重要的地方

声明式和命令式的区别,每篇云原生入门文章都会讲。但大多数解释停留在”你描述目标,系统决定过程”这个层面,然后举个 SQL 的例子,结束。

这个解释并没有错,只是它遮蔽了更重要的工程含义。


本质差异不在语法,在谁持有状态

命令式的核心假设:调用方知道当前状态,知道如何从 A 到 B

1
2
3
kubectl scale deployment my-app --replicas=5
kubectl set image deployment/my-app app=my-image:v2
kubectl rollout restart deployment/my-app

这三条命令依赖你对系统当前状态的了解。你知道现在是 3 个副本,才能 scale 到 5;你知道当前镜像版本,才能决定要不要更新。如果你对现状判断有误,命令就会打到错误的起点上。

声明式的核心假设:调用方只描述期望终态,系统自己负责状态迁移

1
2
3
4
5
6
7
spec:
replicas: 5
template:
spec:
containers:
- name: app
image: my-image:v2

这个 YAML 不关心现在有几个副本、现在跑的是什么镜像。Kubernetes 的 controller loop 会持续比较 desired state 和 actual state,驱动系统收敛到目标。你提交的是终态,不是操作序列。

这个差异的工程含义是:声明式天然具备幂等性。同一份 manifest apply 十次,结果和 apply 一次一样。命令式脚本如果重复执行,可能会出现副作用——创建重复资源、状态覆盖、计数器累加。


声明式为什么更适合分布式系统

分布式系统里有一个不可回避的事实:网络是不可靠的,操作的执行结果是不确定的

命令式操作在这种环境下很脆弱。你发出一条指令,收不到响应——你不知道这条指令有没有被执行。于是你面临一个经典困境:重试可能导致重复执行,不重试可能什么都没发生。

声明式操作规避了这个问题。你描述的是终态,系统会持续对账。即使某次 reconcile 中途失败,下一次 reconcile 会接着把系统推向目标状态。最终一致性不需要操作者保证,由控制器保证。

这正是 Kubernetes Operator 模式的核心逻辑,也是 Terraform 比 Ansible Playbook 在基础设施管理场景中更受青睐的原因。Terraform plan/apply 每次都从当前 state 和目标 state 的 diff 出发,不关心中间经历了多少次失败的 apply。


声明式的边界在哪里

声明式并非万能。它有一个根本性的前提:终态必须可以被清晰定义

当”目标”本身就是过程性的,声明式就会失效或变形。

几个典型场景:

数据迁移。你无法用声明式描述”把旧表的数据按照某个转换规则导入新表”,因为这个目标包含了一次性的过程逻辑,不是一个稳定的终态。Flyway、Liquibase 这类工具本质上是在命令式的迁移脚本上套了一层版本管理。

有序操作。某些资源的创建有严格的依赖顺序——先建 VPC,再建 Subnet,再建 EC2。声明式工具(Terraform、Helm)需要在内部维护依赖图,把隐含的顺序重新引入执行过程。这时候,声明式的外壳下跑的其实是命令式的拓扑排序。

副作用操作。发一封邮件、触发一次支付、执行一次外部 API 调用——这类操作本质上是命令式的,不可幂等,不应该被包装成声明式。强行声明式会引入”at-least-once 语义”的风险。

SQL 里的 LATERAL 关键字是一个典型的症状:当声明式语言试图表达更复杂的过程逻辑时,它不得不引入类似命令式的”执行顺序”语义。语言的边界在这里显现。


对 API 和系统设计的启示

一个系统的接口设计选择声明式还是命令式,决定了调用方的认知负担。

声明式接口的调用方不需要了解系统内部状态,也不需要设计补偿逻辑——系统自己会对账。代价是:系统实现复杂度更高,需要实现 reconcile 逻辑,需要处理并发 apply 的冲突。

命令式接口的调用方拥有更精确的控制,但也承担了更多责任:需要了解当前状态,需要处理幂等,需要设计重试策略。

在 AI Agent 基础设施里,这个选择同样存在。Agent 的工具调用通常是命令式的(执行一个动作,拿到结果),但 Agent 的运行环境配置、资源调度往往适合声明式(描述 Agent 需要什么能力、资源边界,由 Harness 层去调度)。两层边界清晰,不要混用。


结论

声明式和命令式不是对立的,而是适用于不同场景的两种工程工具。

用声明式管理稳态:基础设施、服务配置、期望拓扑。控制器/reconciler 负责收敛。

用命令式处理过程:数据迁移、一次性操作、有副作用的任务。调用方负责幂等和重试。

判断用哪种的快速问法:这件事有没有一个稳定的”终态”可以描述? 有就用声明式,没有就老实用命令式,不要强行抽象。


声明式与命令式:一个被讲烂的概念,以及它真正重要的地方
https://www.krli.org/2024/10/20/声明式与命令式:编程世界的两种思路/
作者
李科燃
发布于
2024年10月20日
许可协议