Ryuu 的个人博客

一个计算机初学者

讨论 GPT 这样的 AI 时,我们很容易陷入两个极端。

一种说法是:它只是概率接龙,根本不理解,也没有智能。另一种说法是:它已经表现得像一个人,所以它当然理解,也当然能判断。

我觉得这两种说法都太快了。GPT 确实不是传统意义上的“查表机器”,它能在语言、代码、概念和任务之间建立复杂关系,也能解决许多过去需要人类经验才能处理的问题。但它的理解不是人的理解,它的判断也不是责任主体的判断。

这个判断不只是使用体验。关于大语言模型是否“理解”,学界本来就有持续争论。Mitchell 和 Krakauer 在关于 LLM 理解能力的综述中,把问题放在“不同理解模式”的层面讨论;Bender 和 Koller 则提醒我们区分语言形式和语言意义,不能因为模型能处理形式,就直接推断它拥有人的意义理解。[1] 另一边,GPT-3、GPT-4 相关研究又确实显示,规模化语言模型能在许多任务上表现出强大的迁移和问题求解能力。[2]

所以,真正值得问的不是“GPT 到底有没有理解”,而是:我们说一个东西理解了某件事时,到底在说什么?

什么叫理解

理解不是一个单层概念。[3]

如果把理解定义得很窄,等同于“能不能在文本中建立结构关系并给出有效解释”,那 GPT 显然有某种理解能力。如果把理解定义得更接近人,要求它把语言和现实经验、行动后果、社会责任连接起来,那它又明显不具备人的理解。这也是为什么我更愿意把问题拆开,而不是直接回答“有”或“没有”。

最低限度的理解,是能建立关系。比如看到一个错误日志,知道它可能和某个配置、某个 API 用法、某个版本变化有关;看到一段代码,知道变量、函数、调用链之间大致是什么关系。GPT 在这个层面上很强,它能从大量文本和代码中学到模式,并在新的上下文里组合这些模式。

更进一步的理解,是能放进上下文里解释。不是只知道“这段代码是什么”,而是知道它为什么在这里,受什么约束,和系统里其他部分有什么关系。如果给 GPT 足够上下文,它也能在这个层面表现得不错。它可以解释一段业务逻辑,可以比较几个方案,可以指出某个实现可能不符合当前架构。

但人的理解还不止这些。

人理解一件事,往往还意味着知道它在现实中的位置。这个需求为什么存在,谁会使用它,错了会造成什么后果,哪些事实已经成立,哪些还只是计划,哪些判断需要承担责任。这些东西不只是文本关系,也不只是概念关系,它们和现实经验、组织规则、项目状态、责任分配有关。

GPT 可以在文本里模拟这些关系,也可以根据你给出的规则做出候选判断。但它并不生活在这个项目里,不承担这个系统上线后的后果,也不会因为一次错误分类让团队文档长期混乱。它可以说“这看起来像架构决策”,但它不能替你确认这是否已经成为项目事实。[4]

这就是我说的:GPT 有理解能力,但它的理解不是人的理解。

什么叫判断

判断也要分层看。

有些判断是规则型的。比如这段代码有没有语法错误,某个测试是否通过,一个接口返回值是否符合约定。只要规则足够明确,GPT 可以很好地辅助这些判断,甚至可以帮你生成验证方式。

但“能给出判断”不等于“能稳定知道自己判断是否正确”。TruthfulQA 这类研究说明,模型可能会复述训练语料中的常见错误;关于模型校准的研究也显示,模型在特定格式下能估计自己答案的可信度,但这种能力会随任务和格式变化而漂移。OpenAI 关于幻觉的研究也指出,很多训练和评测方式会奖励猜测,而不是奖励承认不确定。[5]

有些判断是基于事实状态的。比如一段文档应该放到 docs/featuresdocs/architecture 还是 docs/plan。这表面上像是分类问题,实际上是在判断这段内容到底是什么:它是已经实现的功能事实,还是尚未执行的计划?是已经被系统采纳的架构约束,还是一次讨论中的候选方案?

GPT 容易在这里出错。它可能会根据语义相关性做选择:这段话和功能有关,就放到 features;这段话和架构有关,就放到 architecture。但对项目来说,真正重要的不是“相关”,而是“身份正确”。一个计划即使和架构有关,也仍然只是计划;一个候选方案即使描述得很完整,也还不是系统事实。

这类判断需要事实依据,也需要责任主体。GPT 可以提出候选结论,但不能替程序员确认事实状态。[6]

还有一类判断是价值取舍。比如为了可维护性牺牲一点性能是否值得,为了交付速度临时接受一个技术债是否合适,一个设计是否符合团队长期方向。这些判断并不只是事实题,也不只是偏好题,而是事实、约束、目标和责任放在一起之后的选择。

这类判断在 AI 研究里也很微妙。关于 ChatGPT 道德判断的实验显示,它的回答可能和人类判断不一致,也会受到提示方式、选项呈现和模型差异影响。也就是说,AI 可以生成看起来像价值判断的文本,但这不等于它成为了一个能稳定承担判断后果的价值主体。[7]

GPT 可以参与讨论,可以列选项,可以指出代价。但最后做判断的人,仍然必须是人。[8]

什么叫智能

如果把智能理解为解决问题的能力,那么 GPT 显然表现出了某种真实的机器智能。

它能写代码、解释代码、翻译文本、总结资料、生成测试、提出方案、辅助排障。它不是简单地复制训练材料,而是在新上下文里组合已有模式,形成有用输出。把这些能力说成“完全没有智能”,并不符合我们的实际体验。

这也能从研究脉络里看到。GPT-3 论文展示了大模型在少样本设置下的跨任务能力;“Sparks of AGI” 一文则记录了早期 GPT-4 在数学、代码、视觉、医学、法律等多种任务上的能力跃迁。无论是否接受作者关于 AGI 的判断,至少可以说明:把 GPT 只说成“完全没有智能的文字拼接器”,已经很难解释它在许多任务上的表现。[2:1]

但如果把智能理解为人的完整智能,那 GPT 又明显不够。

人类智能包含目标、经验、行动、反馈、责任、价值感和现实处境。人不是只在文本里做下一步预测,而是在世界里行动,并承受行动带来的后果。程序员写错代码会面对故障、用户、团队、维护成本和长期演进;GPT 不会。

这也是许多治理和出版规则强调人的原因。比如 Springer Nature 的 AI 政策明确不把 LLM 列为作者,理由不是它不能生成文字,而是作者身份意味着对作品负责,而这种责任不能有效适用于 LLM。NIST 的 AI 风险管理框架也把 AI 风险放在组织治理、度量、管理和责任结构中,而不是把责任交给模型本身。[9]

所以,更准确的说法是:GPT 有机器智能,但它不是完整的人类智能。它能在很多任务上表现得聪明,却不是一个能替你承担责任的主体。

为什么这对程序员重要

这个区分不只是哲学问题,它会直接影响我们怎么使用 AI。

如果你觉得 GPT 完全不理解,你可能会低估它,把它只当成补全工具,错过它在信息整理、方案推演、代码理解和排障辅助上的价值。

如果你觉得 GPT 像人一样理解,你又可能会高估它,把事实确认、架构判断、业务取舍和完成断言交给它。这样做的风险更大,因为 GPT 的输出常常很流畅,很像知道自己在干什么。

更稳妥的位置是:承认它有能力,但不把责任交给它。[10]

GPT 可以帮我们更快建立问题地图,更快生成草稿,更快提出候选方案,更快组织信息。但程序员仍然要负责确认事实、判断边界、验证结果,并承担系统后果。[11]

AI 时代的程序员,不应该把自己退化成“审稿的人”,也不应该假装 AI 只是一个高级自动补全。更好的关系是:让 AI 参与思考过程,但由人保留判断权;让 AI 加速表达和试探,但由人确认什么可以成为事实。

所以我更愿意这样总结:

GPT 有智能,但不是责任主体。它可以帮助我们理解问题,却不能替我们成为那个必须负责的人。

这也是为什么我更倾向于 把 AI 放进工作流,而不是把判断权交给它。

参考与延伸阅读


  1. Melanie Mitchell and David C. Krakauer, The debate over understanding in AI’s large language models;Emily M. Bender and Alexander Koller, Climbing towards NLU: On Meaning, Form, and Understanding in the Age of Data。这里引用它们来支撑“LLM 是否理解不是单层问题,语言形式和人的意义理解需要区分”。 ↩︎

  2. Tom B. Brown et al., Language Models are Few-Shot Learners;Sébastien Bubeck et al., Sparks of Artificial General Intelligence: Early experiments with GPT-4。这里引用它们来支撑“规模化语言模型确实展现出跨任务能力”,但不把这些能力直接等同于人的完整智能。 ↩︎ ↩︎

  3. Mitchell 和 Krakauer 将 LLM 理解争论放在多种理解模式中讨论;Bender 和 Koller 则从语言形式与意义的区分出发,提醒不要把形式操作直接等同于人的意义理解。这共同支撑“理解需要分层讨论,而不是简单回答有或没有”:https://www.pnas.org/doi/10.1073/pnas.2215907120https://aclanthology.org/2020.acl-main.463/↩︎

  4. Bender 和 Koller 的形式 / 意义区分说明,文本形式能力不能直接推出现实处境中的意义承担;NIST AI RMF 也把 AI 风险管理放在产品、服务、系统和组织的治理结构中,而不是放在模型自身。这支持“模型可以模拟上下文关系,但不能替人确认项目事实”:https://aclanthology.org/2020.acl-main.463/https://www.nist.gov/itl/ai-risk-management-framework↩︎

  5. Stephanie Lin, Jacob Hilton, Owain Evans, TruthfulQA: Measuring How Models Mimic Human Falsehoods;Saurav Kadavath et al., Language Models (Mostly) Know What They Know;OpenAI, Why language models hallucinate。这里引用它们来支撑“会回答、回答真实、知道自己是否可靠,是不同层次的能力”。 ↩︎

  6. TruthfulQA、模型校准和 OpenAI 关于 hallucination 的研究共同说明,流畅回答、事实可靠性和知道自己是否可靠是不同能力;因此,项目事实状态仍需要来源、工具、测试或人工确认:https://aclanthology.org/2022.acl-long.229/https://arxiv.org/abs/2207.05221https://openai.com/index/why-language-models-hallucinate/↩︎

  7. ChatGPT does not replicate human moral judgments。这里引用它来支撑“AI 可以生成道德/价值判断文本,但不等于稳定复制人类判断或成为价值责任主体”。 ↩︎

  8. 关于 ChatGPT 道德判断的实证研究显示,模型价值判断输出会受提示、选项呈现和模型差异影响;Springer Nature 的 AI policy 也把 authorship 与 accountability 绑定,并说明这种责任不能有效适用于 LLM。这支撑“AI 可参与讨论,但最终价值取舍仍要由人承担”:https://www.nature.com/articles/s41598-025-24700-6https://www.nature.com/nature-portfolio/editorial-policies/ai↩︎

  9. Springer Nature, AI policy;NIST, AI Risk Management Framework。这里引用它们来支撑“AI 输出能力和责任主体资格需要区分,责任应落在可治理的人或组织结构上”。 ↩︎

  10. NIST AI RMF 的目标是帮助组织管理 AI 对个人、组织和社会造成的风险,并把风险治理放进设计、开发、使用和评估过程;Springer Nature AI policy 也强调最终文本需要有人类 accountability。这支撑“承认 AI 能力,但把责任放进工作流和组织结构”:https://www.nist.gov/itl/ai-risk-management-frameworkhttps://www.nature.com/nature-portfolio/editorial-policies/ai↩︎

  11. OpenAI 关于 hallucination 的研究提醒,模型输出会受到训练和评测激励影响,不能只凭流畅性确认事实;NIST AI RMF 则把 AI 风险管理落实到组织的治理、度量和管理实践中。对软件工程而言,这支撑程序员仍需负责事实确认、边界判断、验证和系统后果:https://openai.com/index/why-language-models-hallucinate/https://www.nist.gov/itl/ai-risk-management-framework↩︎

引子

很多人喜欢问 AI 会不会取代程序员?然而这个问题问得太急了。它默认了一件事:程序员的核心工作就是写代码。

这个默认前提,值得先停下来看看。

编码只是职责之一

有没有 AI,编码都只是程序员职责的一部分。[1]

这就像会计并不是“制表的人”。制表当然是会计工作中很重要的一环,但会计真正负责的是账务是否准确、规则是否被遵守、风险是否被识别,以及经营事实是否被可靠地表达出来。表格只是承载这些判断与结果的工具。

程序员也是如此。代码很重要,但代码不是工作的全部。程序员真正负责的是理解问题、建立模型、拆分边界、识别约束、设计系统行为,并让这个系统在现实环境中可运行、可维护、可验证。

AI 让编码这件事变得更快,也让“写出代码”这件事显得没有过去那么稀缺。但这并不意味着程序员的责任消失了。[2]相反,它会把程序员的价值更清楚地推回到那些更难外包给工具的部分:判断该不该做,决定怎么做,验证做得对不对,以及承担系统长期演进的后果。[3]

我们应该使用 AI 写代码吗

如果一个工具能够帮助我们更快地表达想法、查找资料、生成样例、整理思路、补全代码,那么程序员没有理由拒绝它。拒绝 AI 本身,并不会让一个人变得更专业。就像拒绝 IDE、调试器、搜索引擎,也不会让人更懂软件工程。

但使用 AI 写代码,并不意味着把程序员的责任交给 AI。AI 可以生成代码,但它不会替你理解业务目标;它可以给出方案,但它不会替你承担系统后果;它可以补全实现,但它不会自动知道这个实现是否适合当前上下文。[4]

AI 更像是一个能力放大器。你想得清楚,它可以放大你的效率;你想得模糊,它也会放大你的混乱。它能让正确的方向更快落地,也能让错误的方向更快堆积成代码。

所以问题不在于“要不要用 AI 写代码”,而在于“用 AI 写代码时,程序员是否仍然掌握判断权”。

AI 时代应该如何写代码

AI 时代写代码,最重要的变化不是少写几行代码,而是写代码之前和之后的工作变得更重要了。

过去我们常常把注意力放在“怎么实现”上:接口怎么写,循环怎么写,数据结构怎么组织,异常怎么处理。这些问题当然仍然重要,但 AI 已经能在很多局部实现上提供不错的帮助。真正容易出问题的地方,反而是那些 AI 不一定知道、也不一定能替你判断的部分。

首先,要先把问题说清楚。不要一上来就让 AI 写代码,而是先描述目标、约束、输入输出、边界条件和不能破坏的现有行为。问题描述得越清楚,AI 给出的代码越可能接近你真正需要的东西。程序员在这里做的不是“下命令”,而是在整理自己的判断。[5]

其次,要把 AI 的输出当作草稿,而不是答案。AI 生成的代码可以作为一个起点,但它需要被阅读、质疑和验证。[6]变量名是否表达了业务含义,抽象是否过度,边界条件是否遗漏,错误处理是否符合系统习惯,这些都需要程序员重新接管。

再次,要用测试和运行结果约束 AI。只靠“看起来对”是不够的。越是借助 AI 快速生成代码,越应该补上可执行的验证:单元测试、集成测试、日志、断言,或者最小可复现的运行结果。[7]AI 可以帮你写测试,但测试要覆盖什么,仍然取决于你对问题的理解。

最后,要保持对上下文的敏感。很多代码不是孤立存在的,它会受到项目风格、历史包袱、性能要求、部署环境、团队习惯和业务规则的影响。AI 很容易给出一个局部上漂亮、整体上不合适的方案。程序员需要判断这段代码能不能放进当前系统,而不仅仅是能不能单独运行。[8]

所以,AI 时代写代码,不是把“思考”交给 AI,然后自己负责复制粘贴。更好的方式是:人负责定义问题、拆分边界、判断取舍和验证结果,AI 负责加速搜索、表达、生成和试探。这样使用 AI,代码会写得更快,但责任仍然留在应该承担责任的人那里。

我使用 AI 的经验

很多时候,我们会反复对 AI 说同样的东西:先理解上下文,不要急着改;修 bug 前先复现;写完以后要验证;不要随便宣布完成;重要结论要沉淀到文档里。我把这些重复要求整理成了 skill:https://cnb.cool/ryuu64/skills。

形成标准流程

AI 很擅长生成内容,但它不一定天然知道什么时候该停下来理解上下文,什么时候该先验证假设,什么时候该把结论写回文档。skill 的作用,就是把这些流程显式写出来。[9]

当我还没有想清楚方案时,grill-then-docs 对我很重要。它先用 grill 的方式把一个决策点问透:有哪些选项,各自的代价是什么,推荐哪一个,为什么。等结论稳定以后,再把这些结论写回项目文档。这样 AI 不只是陪我聊了一轮方案,而是把这轮讨论变成项目之后还能复用的上下文。

比如看不懂一段代码时,我不希望 AI 直接解释当前文件,而是先使用 zoom-out 拉高一层,弄清楚它在系统里的位置:上游是谁、下游是谁、核心概念是什么、下一步应该读哪里。这样后面的修改才不会只盯着局部。

修 bug 时,我会更希望它进入 diagnose 的流程:先建立反馈循环,再复现问题,然后提出可证伪的假设,用日志、测试或调试器去验证。很多 bug 真正难的地方不在改动本身,而在确认自己没有修错方向。

如果是在实现一个明确的新行为,我会倾向于用 tdd。先列业务规则、边界条件和验收范围,再写失败测试,再写最小实现。AI 可以很快生成代码,但测试会把它拉回到具体行为上,不让它只写出一段“看起来像那么回事”的实现。

任务做完以后,我还会要求 verification-before-completion。AI 不能只说“已经完成”,而要先运行对应的验证命令,读输出,再根据结果判断能不能做完成断言。这个 skill 对我很重要,因为 AI 很容易在语气上显得很确定,但工程上真正需要的是证据。

如果过程中形成了稳定结论,比如某个模块的约束、某种排障方法、某个架构决策,我会再用 project-docs-syncproject-docs-writing 把它同步回项目文档。否则这次对话结束以后,经验又散掉了,下次还要重新解释。



flowchart TD
    A[遇到需求] --> B{当前最需要澄清什么}
    B -->|方案还不清楚| K[grill-then-docs<br/>先问透决策,再写回文档]
    B -->|不熟悉代码| C[zoom-out<br/>先建立上下文地图]
    B -->|排查 bug| D[diagnose<br/>复现、假设、验证、收敛根因]
    B -->|实现新行为| E[tdd<br/>测试先行,按行为推进]
    K --> F
    C --> F[修改或继续分析]
    D --> F
    E --> F
    F --> G[verification-before-completion<br/>用新鲜证据确认结果]
    G --> H{形成稳定结论?}
    H -->|是| I[project-docs-sync / project-docs-writing<br/>写回项目文档]
    H -->|否| J[结束任务]
    I --> J

这些流程看起来琐碎,但它们决定了 AI 是在帮你推进工程,还是只是在生成一堆看起来合理的文本。

处理 AI 的失控

上下文污染

我使用 AI 的过程中也遇到过不少问题。最常见的一类,是上下文变得太长、太杂以后,AI 开始不理解业务。它会抓住对话里某个局部信息,然后沿着一个不对的方向继续推理,甚至越解释越偏。

这种时候,继续在原来的对话里纠正,不一定是最好的办法。因为问题有时不在某一句提示词,而在整个上下文已经被污染了。我会选择新开一个对话,或者先清理上下文,再把真正重要的背景、约束和当前目标重新说清楚。如果项目里已经有文档,就让 AI 先看文档,而不是继续依赖一段越来越长的聊天记录。

调试时跳过证据

另一类问题出现在 debug 时。AI 很容易想直接根据代码猜原因,然后给出一个修复方案。这个过程看起来很快,但风险很高,因为它可能只是在修一个“看起来像问题”的地方。我遇到过 AI 死活不肯先插桩、不肯根据日志复现,而是反复尝试直接改代码。

后来我用 diagnose 去约束这个过程,并且反复调整它的规则:先建立反馈循环,先复现,再提出假设;需要日志就插有明确目的的日志;每次观察结果以后更新假设,而不是直接跳到修复。这样做会慢一点,但它能把 AI 从“猜一个答案”拉回到“拿证据收敛问题”。[10]

混淆计划和事实

还有一类更隐蔽的问题:AI 看起来像是知道自己在干什么,但实际上它可能只是选择了一个“看起来合理”的位置。

比如我的 grill-then-docs,它的目标是先把方案问清楚,再把稳定结论沉淀下来。可在一开始,AI 经常会把 grill 之后“接下来要做什么”写进 ./docs/features./docs/architecture。从表面看,这很合理:方案讨论和功能、架构都有关。但实际上这是错误的,因为 grill 后续要做的事情只是计划,不是已经成立的功能事实,也不是架构事实。它应该先进入 ./docs/plan,等实现完成、结论稳定以后,才有资格进入 features 或 architecture。

这个错误很有代表性。AI 并不一定知道“计划”和“事实”之间的价值差异。它能根据文本相似性判断某段内容和哪个目录更相关,却未必能判断这段内容在项目知识体系里的身份:它是待办、假设、决策、事实,还是长期约束。对程序员来说,这些边界很重要;对 AI 来说,如果没有明确规则,它可能只会选择概率上最像的地方。

这里说的价值判断,不是“我更喜欢哪种写法”这种偏好判断,而是基于事实状态做定性:这段内容现在到底是什么。它是已经实现的功能事实,还是尚未执行的计划?是已经被系统采纳的架构约束,还是一次讨论中的候选方案?这些判断依赖项目当前状态,也依赖团队对文档的定义。

GPT 可以根据文字相似性把内容放到看起来相关的地方,也可以在规则足够明确时照着规则执行。但如果规则没有把这些事实身份说清楚,它就很容易把“语义相关”误当成“事实身份正确”。./docs/features./docs/architecture./docs/plan 的区别,本质上不是路径选择,而是项目知识治理中的事实判断:什么已经成立,什么还只是计划,什么可以作为长期约束沉淀下来。

所以问题不在于 AI 能不能给出一个看似合理的分类,而在于它有没有足够事实依据做这个分类,以及分类标准是否被明确表达。对程序员来说,“这个内容应该写到哪里”背后其实是在判断它在项目中的状态;对 AI 来说,如果没有明确规则,它可能只是选择概率上最像的目录。

所以我后来在 skill 里明确约束:grill 之后形成的后续计划,应该写到 /docs/plan,不能直接写进 /docs/features/docs/architecture。这不是一个简单的目录偏好,而是在告诉 AI:什么东西还只是计划,什么东西才可以被当作项目事实。

这些问题也让我更确定一件事:AI 不是一直稳定的。上下文、提示方式、任务阶段都会影响它的表现。程序员需要识别什么时候该继续推进,什么时候该停下来重置上下文,什么时候该强制它回到验证流程。

把偏好变成约束

使用 AI 写代码时,很多问题不是 AI 不会,而是它不知道你在意什么。你在意命名是否贴合领域,注释是否解释意图而不是复述代码,提交信息是否清楚,文档是否跟着代码一起更新,这些偏好如果每次都临时说明,就很容易遗漏。

skill 的价值在于把这些偏好变成稳定约束。它不是让 AI 替我做判断,而是让 AI 在我设定的边界里工作。[11]

让 AI 更像协作者

skill 是一种协作协议。它让 AI 知道在什么场景下该采用什么工作方式,也让我不用每次都从零开始解释自己的工程习惯。

这也对应了前面说的:AI 可以加速表达、生成和试探,但程序员仍然要负责判断、边界和验证。skill 只是把这些责任拆成更清晰的流程,让 AI 更稳定地参与其中。[12]


  1. Brooks 在 No Silver Bullet 中区分软件开发的本质复杂度与偶然复杂度:工具可以降低表达、实现和环境摩擦,但需求概念、系统边界和复杂关系本身不会因此消失。这支撑“编码只是职责之一”的判断:https://worrydream.com/refs/Brooks_1986_-_No_Silver_Bullet.pdf↩︎

  2. Microsoft Research / GitHub 的 Copilot 受控实验显示,Copilot 在一个特定 JavaScript HTTP server 任务中显著缩短完成时间;这个证据支持“AI 能降低局部编码任务摩擦”,但不能外推为程序员所有职责都被同等替代:https://www.microsoft.com/en-us/research/publication/the-impact-of-ai-on-developer-productivity-evidence-from-github-copilot/↩︎

  3. DORA 2024 研究显示,生成式 AI adoption 与个人生产率、flow、满意度提升相关,但也可能伴随 delivery throughput 和 delivery stability 下降;这提示 AI 生成速度需要 review、测试、CI 和交付系统承接,程序员仍要对判断、验证和后果负责:https://dora.dev/research/2024/dora-report/https://dora.dev/ai/gen-ai-report/↩︎

  4. Microsoft Human-AI Interaction Guidelines 强调 AI 系统需要支持用户理解、反馈、纠正、撤销或接管;这说明 AI 输出进入工作流后,人仍需要保留控制权和责任边界:https://www.microsoft.com/en-us/research/publication/guidelines-for-human-ai-interaction/↩︎

  5. OpenAI Codex best practices 强调通过 AGENTS.md、项目说明、验证命令和环境准备提供上下文;Anthropic 的 Building Effective Agents 也强调工具、上下文和清晰反馈对 agent 行为的重要性。这支撑“先说清目标、约束和边界”的做法:https://developers.openai.com/codex/learn/best-practiceshttps://www.anthropic.com/engineering/building-effective-agents↩︎

  6. OpenAI 关于 hallucination 的研究说明,模型流畅输出可能受到训练和评测激励影响,不能直接等同于事实正确;Microsoft Human-AI Interaction Guidelines 也要求 AI 输出应可被检查、反馈和纠正。因此,AI 生成代码更适合作为草稿,而不是未经验证的答案:https://openai.com/index/why-language-models-hallucinate/https://www.microsoft.com/en-us/research/publication/guidelines-for-human-ai-interaction/↩︎

  7. OpenAI 的 harness engineering 强调用环境、意图和反馈循环让 agent 在可验证的工程系统里工作;Codex best practices 也建议提供明确验证命令。这支撑“用测试、日志、断言和运行结果约束 AI 输出”:https://openai.com/index/harness-engineering/https://developers.openai.com/codex/learn/best-practices↩︎

  8. Brooks 的本质复杂度观点提醒,软件困难常在概念结构和现实约束中;Anthropic 的 agent 工程文章也强调从简单工作流和清晰工具边界开始,而不是默认让模型独立处理所有上下文。这支撑“局部漂亮不等于整体适合”:https://worrydream.com/refs/Brooks_1986_-_No_Silver_Bullet.pdfhttps://www.anthropic.com/engineering/building-effective-agents↩︎

  9. Anthropic 区分 workflow 和 agent,并强调先从简单、可组合的模式开始;OpenAI harness engineering 也把工程重点放在环境、意图和反馈循环。这支持把重复工程要求沉淀成 skill / workflow,而不是每次依赖临时提示:https://www.anthropic.com/engineering/building-effective-agentshttps://openai.com/index/harness-engineering/↩︎

  10. OpenAI 关于 hallucination 的说明支持一个事实边界:模型输出的语气和完整度不能证明其判断正确。对 debug 来说,更稳的做法是建立反馈循环、复现问题、用日志或测试验证假设,而不是直接接受模型猜测:https://openai.com/index/why-language-models-hallucinate/↩︎

  11. OpenAI Codex best practices 使用项目说明和任务边界来稳定 agent 行为;Microsoft Human-AI Interaction Guidelines 也强调用户应能给出反馈、纠正和接管 AI 行为。这支持把个人工程偏好和质量标准沉淀成稳定约束:https://developers.openai.com/codex/learn/best-practiceshttps://www.microsoft.com/en-us/research/publication/guidelines-for-human-ai-interaction/↩︎

  12. Microsoft Human-AI Interaction Guidelines 和 Anthropic Building Effective Agents 都把 AI 系统放在人类反馈、工具边界和可接管流程中理解;这支撑“AI 加速表达、生成和试探,但人负责判断、边界和验证”的协作定位:https://www.microsoft.com/en-us/research/publication/guidelines-for-human-ai-interaction/https://www.anthropic.com/engineering/building-effective-agents↩︎

影响奖励结果的所有输入已经确定、且不再变化的那一刻。[1]

这是确定订单奖励内容的唯一标准。

引子

在一次业务设计讨论中,关于奖励的确定时机出现了不同意见。(我的真实经历)

一种观点认为,奖励应当在最终流程中再计算;
另一种观点认为,只要奖励结果已经可以确定,就应该立即计算并固化。

这看起来只是计算时机的差异,
但背后实际讨论的是:奖励结果在系统中什么时候才应当被视为事实。

什么时候确定奖励内容是合理的?

奖励内容本质上是一次规则计算的结果,它通常依赖以下输入:

  • 订单金额(含优惠、折扣)
  • 商品 / 档位 / 套餐
  • 活动规则(倍率、阈值、档位)
  • 玩家状态(VIP、首充、次数等)

当这些输入被完整收集,且后续流程中不会再发生变化时,奖励内容就可以被确定。[2]

换句话说:

只要奖励的计算结果在逻辑上已经不可再变,就可以确认。

常见的可确认时间点

以下场景通常已经满足“输入已经确定、且不再变化”的条件:[3]

  • 支付成功后,订单金额与商品信息已确定
  • 商品或服务的交付参数被锁定
  • 活动规则以快照形式固化到订单中

这些时间点的共同特点是:
奖励计算所依赖的数据已经稳定。

为什么要尽早确定奖励内容?

原因一:规则是可变的

如果奖励内容不在当时确认,后续再计算就会受到规则变动的影响,例如:[4]

  • 活动配置被调整
  • 奖励倍率发生变化
  • 原有档位被下架

这将直接导致一种严重问题:

用户下单时看到的是 A 奖励,
系统最终确认的是 B 奖励。

这是比技术错误更严重的事故——用户认知不一致。

原因二:奖励需要可解释(对账 & 追责)

提前确定奖励内容,可以让系统具备清晰的“历史解释能力”:[5]

为每一笔订单保留奖励结果快照

支持事后审计与对账

支持客服明确解释“这笔奖励是如何算出来的”

奖励内容一旦确认,就应当成为订单事实的一部分,而不是一个需要反复推导的结果。[6]

小结

奖励可以晚发放,但不应该晚确定。[7]

确定奖励的本质,是把规则计算结果从“可推导结果”转为“订单事实”。

系统应保存当时的输入、规则版本和计算结果。后续如果发生退款、取消、补偿等变化,应通过冲正或补偿流程处理,而不是重新解释历史订单。[8]


  1. 这个判断是从支付、幂等和账本系统的共同约束推导出来的:Stripe 建议在知道金额时创建并复用同一 PaymentIntent,并用幂等键避免同一购买重复创建对象;幂等请求会保存同一 key 的首次结果并比较后续参数,说明高价值写入需要稳定业务键和稳定输入边界:https://docs.stripe.com/payments/payment-intentshttps://docs.stripe.com/api/idempotent_requests↩︎

  2. 奖励计划保存输入快照、规则版本和计算结果,本质上是在业务层保留“当时为什么应得这个结果”的证据。Modern Treasury 的 Ledgers 文档把 ledger 视为记录价值流动的可靠事实系统,并强调不可变、可扩展的 double-entry ledger 是复杂或高频价值流的标准做法;这支撑了奖励这类价值结果也应保留可审计事实记录的设计方向:https://docs.moderntreasury.com/ledgers/docs/overview↩︎

  3. Stripe Payment Intents 文档建议每个购物车或客户会话通常对应一个 PaymentIntent,并在 checkout 被中断后复用它;文档还建议用幂等键防止同一购买重复创建 PaymentIntent。这些支付侧实践支撑“支付事实和订单关键输入稳定后即可固化后续计划”的边界:https://docs.stripe.com/payments/payment-intents↩︎

  4. Martin Fowler 的 Optimistic Offline Lock 模式讨论了业务事务跨多个系统事务时,不能只依赖数据库管理器保证一致性,需要在提交前验证变化没有与其他事务冲突。放到奖励规则上,若后续配置继续解释历史订单,就会把“当时的交易事实”暴露给后来的规则变动:https://martinfowler.com/eaaCatalog/optimisticOfflineLock.html↩︎

  5. Modern Treasury 关于 ledger immutability 的工程文章强调,ledger 的关键保证之一是每个历史状态都被记录并可重建,底层依赖不可变的 append-only log;这正对应订单奖励需要事后解释、审计和对账的需求:https://www.moderntreasury.com/journal/how-to-scale-a-ledger-part-v↩︎

  6. Azure Transactional Outbox 模式把业务对象状态和领域事件写入同一事务边界,再由后台流程发布事件,以避免业务状态已提交但消息丢失。它支持把奖励计划先固化为订单事实,再通过异步链路发放或通知下游,而不是把“是否已发放”当成“是否已确定”的条件:https://learn.microsoft.com/en-us/azure/architecture/databases/guide/transactional-out-box-cosmos↩︎

  7. Enterprise Integration Patterns 的 Idempotent Receiver 模式指出,即使发送方只发送一次,接收方也可能收到多次消息,因此接收方要能安全处理重复消息;Stripe 幂等请求也保存同一 key 的首次结果。这些资料支撑“奖励计划确定”和“奖励 effect 发放”分层:发放可重试、可幂等,但重试不应重新计算计划:https://www.enterpriseintegrationpatterns.com/patterns/messaging/IdempotentReceiver.htmlhttps://docs.stripe.com/api/idempotent_requests↩︎

  8. Azure Compensating Transaction pattern 说明,补偿事务不是简单恢复原始状态,而是按业务规则撤销已完成步骤,并且补偿步骤本身也要可重试、可审计;Saga pattern 也把跨服务流程拆成本地事务序列,并在失败时执行补偿事务。这支撑退款、取消、冲正和补偿生成新事实,而不是改写旧奖励计划:https://learn.microsoft.com/en-us/azure/architecture/patterns/compensating-transactionhttps://learn.microsoft.com/en-us/azure/architecture/patterns/saga↩︎

本文讨论的是 check 命名在业界的默认语义约定,而不是个人风格偏好。[1]

在很多项目中,checkXxx 被滥用于 API、Service 甚至业务逻辑中,导致调用语义混乱。

check 的定位

check 是可以用的,但是它只能在一个非常窄的语义里使用。[2]
在大多数项目里, checkXxx() 是一个前置条件检查,不满足时抛出异常且没有业务副作用。[3]

  1. 返回类型 void不是 boolean
  2. 失败时抛出异常。
  3. 成功时什么都不发生。

例如:

1
2
3
private void checkStatus();
private void checkPermission();
private void checkConfig();

check 的滥用

对 check 的最严重最常见的滥用出现在命名 API 时。

命名 API 时,我们的目标是消除惊讶,而 check 在 API 语境下是语义不稳定的命名。[4] 对外的 API 一旦叫 checkXxxx,使用者就会在多种猜测中不知所措:[5]

  1. 成功时返回的是一个 boolean还是一个结果?
  2. 失败时是一个 false 异常 还是结果?
  3. 我调用这个 API 会不会修改一些状态?

举一个更具体的例子,当一个方法叫 checkOrder() 时:

如果想知道订单是否有效,我们会期待它叫 isValid()

如果想触发校验流程并拿到错误列表,我们会期待它叫 validateOrder()

如果想在订单无效时阻止程序运行,我们会期待它叫 ensureOrderValid()

check 就像一个黑色盲盒,不看实现永远不知道它会静默失败还是原地爆炸。 因此,它只适合作为类内部的辅助方法(Helper methods)。[6]

正确命名 API 的方式是这样的:

1
2
3
validateXxx()    // 有结果
verifyXxx() // 鉴权 / 第三方
ensureXxx() // 业务保障(失败抛异常)

总结

团队中可以将 check 约定为仅用于内部前置校验的方法命名,对外 API 必须使用结果或行为导向的动词。[7]

场景 用 check 吗
返回 boolean
返回 Result / Error
Controller / API
有业务副作用
失败直接抛异常
内部前置校验
private / protected
Domain 规则校验

  1. Guava Preconditions 使用 checkArgumentcheckStatecheckNotNull 表示前置条件检查;JDK Objects.checkIndex 也把 check 用于边界检查并在失败时抛异常。这说明 check 在主流库中常见的核心语义是“检查前置条件或边界”,不是任意业务动作:https://guava.dev/releases/33.4.8-jre/api/docs/com/google/common/base/Preconditions.htmlhttps://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/Objects.html#checkIndex(int,int)↩︎

  2. Guava Preconditions 文档把 checkArgumentcheckStatecheckNotNull 分别对应参数、状态和非空前置条件;这些方法的失败语义是抛异常,支撑了“check 可用但语义很窄”的边界:https://guava.dev/releases/33.4.8-jre/api/docs/com/google/common/base/Preconditions.html↩︎

  3. Preconditions.checkArgument 在表达式为 false 时抛 IllegalArgumentExceptioncheckState 在状态条件不满足时抛 IllegalStateException,JDK Objects.checkIndex 在越界时抛 IndexOutOfBoundsException;这些例子都不是返回业务结果或推进业务流程的公开动作:https://guava.dev/releases/33.4.8-jre/api/docs/com/google/common/base/Preconditions.htmlhttps://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/Objects.html#checkIndex(int,int)↩︎

  4. Swift API Design Guidelines 强调名字应在调用点清晰,因为声明只出现一次而使用会出现很多次;Microsoft .NET 成员命名指南也建议方法名使用动词或动词短语,布尔属性使用肯定式短语并可用 CanIsHas 增强语义:https://www.swift.org/documentation/api-design-guidelines/#naminghttps://learn.microsoft.com/en-us/dotnet/standard/design-guidelines/names-of-type-members↩︎

  5. OpenAPI 3.1 规定 operationId 在 API 内应唯一且可被工具和库用于识别 operation;Google AIP-136 要求自定义方法用明确动词命名。公开 API 名字会进入文档、代码生成和客户端心智,因此 checkXxx 这类语义不稳定动词风险更高:https://spec.openapis.org/oas/v3.1.0.html#operation-objecthttps://google.aip.dev/136↩︎

  6. check 在 Guava / JDK 中常见于前置条件或边界检查工具方法;这支持把 checkXxx 保留在内部 helper 或模块内约定中,而不是泛化为公开业务 API 名称:https://guava.dev/releases/33.4.8-jre/api/docs/com/google/common/base/Preconditions.html↩︎

  7. Google AIP-136 要求自定义方法名使用动词,AIP-190 把命名视为跨资源、字段和方法的一致性契约;.NET 成员命名指南也要求方法使用动词或动词短语。这些指南共同支撑“对外 API 用结果或行为导向动词”的建议:https://google.aip.dev/136https://google.aip.dev/190https://learn.microsoft.com/en-us/dotnet/standard/design-guidelines/names-of-type-members↩︎

很多人会说 redis 执行 lua 是原子性的,但这样说不太准确。[1]

执行隔离性(Isolation Guarantee)

redis 执行命令是单线程的,执行 lua 脚本时会阻塞其他指令和脚本的执行。[2]

这确保了脚本在执行的过程中不会被并发操作干扰(例如同时修改一个数据)。[3]

请看如下无执行隔离的时序图示例:

时序 客户端A 客户端B
t1 客户端A 执行 GET token:42→ 返回存在
t2 客户端B 执行 GET token:42→ 返回存在
t3 客户端A 执行 DEL token:42(成功)
t4 客户端B 执行 DEL token:42(因 token 已被A删除,此操作实际无效,但B仍会继续下单)

可见A和B都成功执行了下单操作,出现重复下单错误。

再看如下有执行隔离的时序图示例:

时序 客户端A 客户端B Lua
t1 发送 EVAL "if redis.call('GET', KEYS[1]) then return redis.call('DEL', KEYS[1]) else return 0 end" 1 token:42
t2 发送 EVAL "if redis.call('GET', KEYS[1]) then return redis.call('DEL', KEYS[1]) else return 0 end" 1 token:42
t3 执行客户端A的脚本: 1. GET token:42(存在) 2. DEL token:42(成功删除)
t4 客户端B 的请求此时才被处理。执行同样的脚本: 1. GET token:42(已不存在) 脚本返回 0,拒绝操作。

可见只有A成功了,B的操作失败了,未出现重复下单错误。

不满足全有或全无原则(all-or-nothing)

all-or-nothing
指事务要么完全发生,要么根本不发生。例如A向B转账100元,B已收到100元,但A并非扣除100元,这是不可接受的。[4]

原子提交/回滚机制(Atomic Commit/Rollback)

在数据库的实现中一般会使用原子提交/回滚实现全有或全无原则。[5]

原子提交

当事务中的所有操作都成功执行,并且验证没有违反约束时,系统会 一次性把事务的所有修改写入数据库(这一批操作完成了)。

提交之后,所有事务产生的更改对其他事务可见。

原子回滚

如果事务中某个环节失败,或者用户/系统主动要求撤销,就必须 撤回事务中已做的所有修改,让数据库回到事务开始之前的状态(这一批已做的操作都不算数)。

一般会使用 undo log 来实现,保证把执行的操作撤销掉。[6]

请保持脚本的轻量

由于脚本执行期间会阻塞整个 Redis 的执行,避免在脚本中编写复杂循环或耗时操作是关键。否则会严重影响 Redis 的响应性能。[7]


  1. Redis 官方文档《Scripting with Lua》说明,Lua 脚本会在服务器端执行并阻塞其他服务器活动,因此其他客户端只能看到脚本执行前或执行后的效果。这里的“原子性”更接近不可插入执行,而不是关系型数据库事务式自动回滚:https://redis.io/docs/latest/develop/programmability/eval-intro/↩︎

  2. Redis《Scripting with Lua》明确说明,脚本运行时会阻塞所有服务器活动;同一文档也提醒脚本应快速执行,避免影响其他客户端:https://redis.io/docs/latest/develop/programmability/eval-intro/↩︎

  3. Redis《Scripting with Lua》说明脚本执行期间不会与其他客户端请求交错,其他客户端只能观察到脚本前后的状态;这支撑了把 GETDEL 放进同一个 Lua 脚本来消除客户端间竞态窗口的解释:https://redis.io/docs/latest/develop/programmability/eval-intro/↩︎

  4. PostgreSQL 官方文档《Transactions》把事务描述为多个步骤组成的 all-or-nothing 操作:如果任一步失败导致事务不能完成,这些步骤不会影响数据库。Redis 官方《Transactions》则明确说明 Redis 不支持事务回滚,EXEC 后的运行期错误不会阻止其他命令继续执行。参见 https://www.postgresql.org/docs/current/tutorial-transactions.htmlhttps://redis.io/docs/latest/develop/using-commands/transactions/↩︎

  5. PostgreSQL《Transactions》说明事务中的步骤要么作为整体提交,要么在失败时不影响数据库;这类语义是数据库事务 all-or-nothing 的核心,而不是 Redis Lua 的自动回滚承诺:https://www.postgresql.org/docs/current/tutorial-transactions.html↩︎

  6. MySQL 官方《InnoDB Undo Logs》说明,undo log record 包含回滚最近变更所需的信息,也会用于一致性读;这支撑了数据库实现中通过 undo 信息撤销已做修改的说法:https://dev.mysql.com/doc/refman/en/innodb-undo-logs.html↩︎

  7. Redis《Scripting with Lua》要求脚本应快速执行,避免长时间阻塞服务器;Redis《Redis functions》也说明函数执行期间 Redis 服务器会被阻塞,长时间运行会阻塞所有客户端。参见 https://redis.io/docs/latest/develop/programmability/eval-intro/https://redis.io/docs/latest/develop/programmability/functions-intro/↩︎

总结来说,多线程安全主要要满足 三大条件:

原子性(Atomicity)

操作不可分割,要么全部成功,要么全部失败。[1]

1
2
3
4
5
6
7
8
9
int x = 0;

// 原子性保证
Interlocked.Increment(ref x); // 原子 +1,不会丢失更新

// 非原子性
// 其实是 load → add → store,可能被打断,导致多个线程覆盖
// 例如线程a正在++,这个过程中线程b也++,本来x会+2结果会变成只+1
x++;

可见性 (Visibility)

一个线程对共享变量的修改,另一个线程能够立即看到。

CPU 会做缓存[1]和指令重排(跟有序性也有很大关系),导致线程 A 改了变量,线程 B 可能“看不到”。[2]

需要内存屏障(memory barrier)[2]、volatile 或锁来保证。[3]

1
2
3
4
5
6
7
8
9
10
11
12
volatile bool flag = false;

void ThreadA()
{
flag = true; // 修改对其他线程立即可见
}

void ThreadB()
{
while (!flag) { } // 否则可能无限循环
}

有序性 (Ordering)

程序执行顺序符合预期。

编译器,JIT和CPU,可能会重排指令,导致代码执行顺序和书写顺序不一致。[4]

内存屏障、锁等可以限制重排。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Singleton {
private static Singleton instance;
private static readonly object lockObj = new object();

public static Singleton Instance {
get {
if (instance == null) {
lock (lockObj) {
if (instance == null) {
instance = new Singleton();
// 可能被重排为:
// 1. 分配内存
// 2. 把 instance 指向内存
// 3. 调用构造函数
// 线程 B 可能看到一个“未初始化完成”的对象
}
}
}
return instance;
}
}
}

这个时候可以给 instance 添加 volatile,或者是用 Lazy<T>(线程安全的初始化对象)[5]

总结

在多线程编程里,原子性(Atomicity)、有序性(Ordering)、可见性(Visibility) 经常是相辅相成的——它们不是独立孤立的,而是互相依赖来保证线程安全。

脚注

[1] 现代的 cpu 大多有多级缓存,比如 L1,L2和L3,RAM,这么做是为了减少访存的时间。

缓存层级 特点
L1 Cache CPU 核心私有,最小、最快
L2 Cache CPU 核心私有,稍大,速度略慢
L3 Cache 共享缓存,多核心共享,速度比 L1/L2 慢,但比内存快
主存 (RAM) 所有核心共享,访问最慢

一般在cpu核心读操作时先从最近的缓存找,如果能命中缓存就直接读,不会再访问其他缓存。写操作时先写到缓存再延迟写回主存(根据cpu决定,通常也会写入 L2/L3,写主存很慢,所以这个操作是延迟的)。

[2] 内存屏障是编译器和CPU提供的用来限制指令的重排序和控制缓存可见性的指令/机制。

编译器为了性能可能会做指令重排,寄存器缓存。

为了避免这种优化导致问题,编译器会提供屏障语义:

比如编译器会在volatile变量前后插入内存屏障,保证可见和有序。

比如 lock,编译器会在周围插入 full fence(禁止编译器和cpu的重排序,同时保证读写可见性)。

CPU同样可能会为了性能去乱序执行,写缓冲区延迟写回等。

为了避免这种优化导致问题,cpu有专门的指令:

x86 有 mfencelfencesfence

ARM 有 dmbdsb

这些指令就是内存屏障。


  1. Microsoft Learn 的 Interlocked.Increment API 文档说明,该方法会递增指定变量并以原子操作存储结果,适合支撑单变量 read-modify-write 的原子性边界:https://learn.microsoft.com/en-us/dotnet/api/system.threading.interlocked.increment↩︎

  2. Microsoft Learn 的 C# volatile 参考把 volatile 读写描述为 acquire / release 语义,并提醒它不保证所有线程观察到完全相同的全局顺序,因此这里的“看不到”更精确地说是缺少同步规则时没有跨线程可见性保证:https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/volatile↩︎

  3. Microsoft Learn 的 Thread.MemoryBarrier 文档说明内存屏障会限制屏障前后的内存访问重排;C# lock 文档说明 lock 会进入互斥临界区并转换为 Monitor.Enter / Monitor.Exit 结构;volatile 文档则说明字段级 acquire / release 语义。参见 https://learn.microsoft.com/en-us/dotnet/api/system.threading.thread.memorybarrierhttps://learn.microsoft.com/en-us/dotnet/csharp/language-reference/statements/lockhttps://learn.microsoft.com/en-us/dotnet/csharp/language-reference/keywords/volatile↩︎

  4. Thread.MemoryBarrier 官方文档明确说,屏障之前的内存访问不能被重排到屏障之后,屏障之后的内存访问也不能被重排到屏障之前;这正是有序性讨论中的底层边界:https://learn.microsoft.com/en-us/dotnet/api/system.threading.thread.memorybarrier↩︎

  5. Microsoft Learn 的 Lazy initialization 指南说明,Lazy<T> 默认创建线程安全对象,常用于延迟初始化和单例式对象发布:https://learn.microsoft.com/en-us/dotnet/framework/performance/lazy-initialization↩︎

背景

缓存 + 数据库 的经典架构里,缓存通常作为加速层来缓解数据库压力。[1]
但在更新数据时,如果操作顺序处理不当,就可能导致缓存与数据库之间出现短暂的不一致。[2]

常见的更新顺序有两种:

  1. 先写数据库,再写缓存

    • 优点:简单直观,能保证数据库最终一致性。
    • 缺点:在删除缓存和数据库更新之间的时间差,可能有请求先查到老数据,一致性弱。
  2. 先写缓存,再写数据库

    • 优点:能一定程度上减少脏读。
    • 缺点:如果数据库更新失败,就会丢掉缓存,反而更糟。

因此我们大多时候会选择先写数据库再写缓存。

删除与更新

在写缓存的时候我们可以选择删除或者更新,大多数时候我们会选择更新,因为[1]:

删除缓存 (Cache-Aside) 更新缓存
复杂程度 简单 复杂
幂等性 天然幂等(无论怎么删,最终结果都是缓存被删除了) 非幂等操作
并发写安全 安全 (删除顺序不影响最终状态) 不安全 (并发更新可能导致缓存数据错乱或覆盖,即使是redis也只保证单个命令是安全的)
效率 高 (直接删除,不关心数据) 低 (可能频繁更新一个后续无人读取的值,消耗CPU和带宽,特别是当你在维护一个复杂的缓存时)
数据最终一致性 更易保证 (删除不依赖数据) 更难保证 (依赖数据,若顺序错误可能脏甚至是错误)

缓存失效策略(Cache-Aside / Lazy-Loading)

写数据库,删除缓存,下次请求重建缓存,简单可靠。[3]

双删

双删也是经常提到的办法,属于一种增强版的写数据库后删除缓存,能够减少不一致窗口期。

在更新数据库之后,执行两次缓存删除操作[4]

事务A 事务B
更新数据库
删除缓存(第一次)
访问数据
缓存未命中,读取数据库
返回数据库中的数据
延时一段时间后删除缓存(第二次)

第二次删除缓存通常会延迟,目的是解决以下问题:

事务A 事务B
写数据库 val = 37
写数据库 val = 42
写缓存 val = 42
写缓存 val = 37
  1. 延迟时间的选择
    延迟需要大于业务中可能的并发请求处理时间。
  2. 幂等性
    删除缓存操作需要是幂等的,即多删几次不会有副作用。

缺陷

弱一致性

  • 在第一次删除和第二次删除之间,如果有读请求访问数据,还是可能会从数据库加载旧数据回填缓存 → 读到脏数据
  • 因此双删保证的只是 最终一致性无法保证请求级别的强一致性

令人纠结的时间

  • 延迟太短:第二次删除可能还没覆盖到并发读回填的缓存,最终一致性可能都无法保证(长时间或永久脏缓存)。
  • 延迟太长:第二次删除的间隔内,缓存可能被大量请求读写回填,脏数据存在的时间过长(脏数据多)。

惊群效应(Thundering Herds)[5]

  • 频繁的缓存失效可能会导致惊群,会有多个请求导致请求访问数据库

实际上,延迟双删更多是工程妥协:在读多写少、对短时间不一致可容忍的业务场景下适用(个人感觉有点骚操作,要么就别这么写,要写就干脆写更稳妥的租约或版本号方案)。

租约(Lease)

这是由 Meta 分享的方案。[6]

请求 缓存 token 租约
请求 A 访问缓存,等待缓存更新 失效,触发更新 创建 token 与失效的缓存的 key 绑定 租期开始
请求 B 访问缓存,等待缓存更新 失效 在租期中,获取和 A 共享的 token 租期中
请求 C 访问缓存,等待缓存更新 失效 在租期中,获取和 A 共享的 token 租期中
请求 A,B,C 获取最新缓存 更新完毕 token 失效 租期结束

这里的 A 作为更新者触发了缓存的更新,其他的请求在租期内时会等待更新完毕。A,B,C 会拿着 token 等待缓存更新完毕。

等待

请求 A,B,C 等待时一般会使用带抖动(避免惊群效应)的指数退避算法进行主动重试。[7]

为什么不用其他的方法?

  1. 立即重试:这样会加重了访问压力。
  2. 固定间隔重试:可能导致周期性的压力和惊群。
  3. 线性退避:分散负载减少冲突的效果不如指数退避。
  4. 随机重试:缺乏不断增长的延迟,无法应对如微服务一段时间不可用或是数据库错误这种时间较长的场景。

版本号(Version)

这是由 Uber 分享过的方案[8]

数据库每行数据都有一个时间戳作为版本号。数据变更时时间戳会被更新。数据写入缓存时会将要写入的版本号和缓存中已有的版本号进行比较,更新的版本号才会被写入缓存。

原子化操作 redis

使用 redis eval 执行 lua 脚本,脚本可以原子[2]化的执行操作:[9]

  1. 读取缓存已有的版本号
  2. 比较已有的版本号和想要写入的版本号
  3. 当要写入的数据的版本号大于已有版本号时,写入数据和版本号至内存
1
2
3
4
5
6
7
8
9
10
11
local key = KEYS[1]
local value = ARGV[1]
local current_version = ARGV[2]
local version_key = 'version:'..key
local version_value = redis.call('get', version_key)
if version_value == false or version_value < current_version then
redis.call('mset', version_key, current_version, key, value)
return {value, true}
else
return {false, false}
end

变更数据捕获(Change Data Capture,CDC)

变更数据捕获是一种用于捕获和记录数据库中数据变化
的机制。当数据库数据发生变化(增删改)时,不通过业务代码或是主动查询/触发,而是直接在数据库或日志层面去自动捕获这种变化,并将内容记录下来给其他系统使用。[10]

例如 Uber 基于 每个集群的 mysql binlog 实现了 CDC [3]。

总结

维度 删除(单删) 延迟双删 版本号机制(Version-based / 乐观锁) 租约机制(Lease-based / 悲观锁)
核心思想 更新 DB 后直接删缓存,依赖后续请求重建 更新 DB 后先删缓存 → 延迟一段时间再删一次,降低并发不一致风险 乐观假设冲突少,操作前不阻止并发 悲观独占,操作前获取租约确保独占性
并发处理 并发写不管,可能产生脏数据 并发写允许,延迟的第二次删除尽量覆盖不一致场景 并发写允许,提交时检查版本号 → 冲突失败需重试 租约持有者独占资源,其他客户端需等待或抢占
理论实现复杂度 :删缓存即可 低-中:删缓存 + 延迟调度机制即可 :只需在数据表加一个 version 字段,更新时比对版本号即可 :要设计租约过期、释放、续租等逻辑
工程实现复杂度 :最常见做法,但一致性弱 低-中:需要可靠的延迟任务机制(消息队列 / 定时任务),处理好失败重试 :分布式缓存场景下,需要 CDC 捕获数据库变更,还要用 Redis + Lua 脚本保证缓存原子更新,整体链路复杂 :可以依赖 Redis Redlock、ZooKeeper、Etcd 等现成分布式锁库,流程直接(获取租约 → 执行业务 → 释放租约)
数据一致性 弱一致性:高并发下可能缓存和 DB 不一致 较强一致性:大多数情况下能避免不一致,但仍可能有极端并发问题 最终一致性,由冲突重试保证 独占期间强一致性
适用场景 对一致性要求不高的业务,如商品详情 对一致性要求高但容忍极少数异常的业务 高读低写、冲突少、操作快 写冲突高、关键资源操作、长事务或分布式锁
故障处理 缓存丢失影响性能,不影响数据正确性 延迟任务失败可能导致不一致,需要补偿 冲突重试 租约过期 → 可被抢占,避免死锁
优点 实现简单,性能好 简单扩展单删思路,提高一致性 轻量、无需租约管理 写操作冲突少,操作安全,可控并发
缺点 高并发下不一致概率大 延迟时间不好把握,仍可能有小概率不一致 冲突重试成本高 租约管理复杂,过期设置敏感(例如租期太短可能导致操作频繁中断),依赖外部锁系统

另请参阅

Scaling Memcache at Facebook

meta-server-design

How Uber Serves Over 40 Million Reads Per Second from Online Storage Using an Integrated Cache

脚注

[1] 删除是 KISS原则
YAGNI原则
的体现。简单并不是简陋,好的设计是对需求的精准把握,我们作为设计师要从需求考虑,找出需求中的关键因素。

[2] redis 执行 lua 并非完全的原子性,详情请见 redis lua

[3] Flux 会 tail MySQL binlog,监视数据库的数据变更实现 CDC(使用 binlog 是一个较好的选择,这样不会让CDC被未提交的脏事务影响)。

Flux 是 Uber 的一个中间件,它不止会把数据发送给CDC还会发送给 replication, materialized views, data lake
ingestion,还会为验证集群中节点之间的数据一致性提供支持。


  1. Microsoft Azure Architecture Center 的《Cache-Aside pattern》把 cache-aside 描述为应用先查缓存、miss 后从数据源加载并写回缓存的模式;AWS 的缓存最佳实践也把缓存定位为降低延迟、减轻后端负载的性能层。参见 https://learn.microsoft.com/en-us/azure/architecture/patterns/cache-asidehttps://aws.amazon.com/caching/best-practices/↩︎

  2. Azure《Cache-Aside pattern》说明,当应用更新信息时,应更新数据源并让缓存项失效;这也意味着数据库提交、缓存失效和下一次回填之间存在需要治理的不一致窗口:https://learn.microsoft.com/en-us/azure/architecture/patterns/cache-aside↩︎

  3. Azure《Cache-Aside pattern》明确把“更新数据源后让缓存项失效”作为写路径建议;Amazon ElastiCache 文档也把 lazy loading / cache-aside 描述为 miss 时再加载数据的策略:https://learn.microsoft.com/en-us/azure/architecture/patterns/cache-asidehttps://docs.aws.amazon.com/AmazonElastiCache/latest/dg/Strategies.html↩︎

  4. 延迟双删可以理解为 cache-aside 写路径上的工程补偿:第一次删除配合 DB 更新,第二次删除试图覆盖并发读回填旧值的窗口。这个结论是基于 Azure cache-aside 写路径与缓存旧值回填风险的工程推论,不是官方文档中的强一致保证:https://learn.microsoft.com/en-us/azure/architecture/patterns/cache-aside↩︎

  5. Facebook / Meta 的 NSDI 2013 论文《Scaling Memcache at Facebook》把 thundering herds 作为缓存 miss 后大量客户端同时请求后端数据源的问题,并介绍了 lease 机制来降低 stale sets 与峰值数据库压力:https://www.usenix.org/system/files/conference/nsdi13/nsdi13-final170_update.pdf↩︎

  6. 《Scaling Memcache at Facebook》中的 lease 机制用于控制 miss 后谁有资格回填缓存。论文报告在一个实验场景中,lease 将峰值数据库查询从 17K/s 降到 1.3K/s:https://www.usenix.org/system/files/conference/nsdi13/nsdi13-final170_update.pdf↩︎

  7. AWS Builders Library《Timeouts, retries, and backoff with jitter》说明,重试会放大压力,指数退避配合 jitter 可以减少客户端同步重试导致的峰值冲击:https://aws.amazon.com/builders-library/timeouts-retries-and-backoff-with-jitter/↩︎

  8. Uber Engineering《How Uber Serves Over 40 Million Reads Per Second from Online Storage Using an Integrated Cache》描述了基于 MySQL binlog 的变更流、行时间戳版本,以及在缓存写入时用版本比较避免旧行覆盖新行的方案:https://www.uber.com/us/en/blog/how-uber-serves-over-40-million-reads-per-second-using-an-integrated-cache/↩︎

  9. Redis 官方《Scripting with Lua》说明 Lua 脚本在服务端执行,脚本运行期间不会被其他客户端命令插入,因此适合把读取版本、比较版本和写入缓存合并成一个 Redis 内部条件写:https://redis.io/docs/latest/develop/programmability/eval-intro/↩︎

  10. Debezium 文档把 CDC 描述为捕获数据库行级变化并生成事件流的机制,并说明 log-based CDC 相对轮询可以低延迟捕获变化、减少对业务表的侵入:https://debezium.io/documentation/reference/stable/features.html↩︎

引子

Java 原生不支持 .NET 样式的 multicast delegate,但我们可以通过手动维护 List<Listener>
来实现类似功能。随着业务复杂化,这种做法会导致代码臃肿,维护成本上升。

这样的需求是很常见的,我看了下项目有类似需求的地方,问题还是挺大的。

项目的现状

项目里的实现有两种

直接获取其他业务的引用

A 直接 调用了 B,B 又 调用了 C,D。还有 A 里写了本该是 B 处理的逻辑的问题,有的时候想找 B 的功能甚至得去 A
里找。有的地方改起来特别头疼,动一个地方好几个地方要一起改。

分析
直接的耦合

直接持有其他对象的引用并调用耦合度过强了,逻辑会像网一样越织越复杂。
考虑到最坏的情况,有 N 个相互依赖的类:

直接引用:

O(N*(N-1)) 的复杂度,且依赖是直接的引用,非常强的耦合。

观察者模式(Observer Pattern):

O(N*M) 的复杂度,N 为观察者,M为被观察者(subject)。依赖建立在被观察者和观察者之间,松耦合。[1]

  1. M 通常会远小于 N
  2. M 一般只是个方法的集合,而不是业务类,耦合度低,维护成本也很低

因此在实践中复杂度通常是 O(N)。

发布订阅模式(Publish-Subscribe Pattern):
O(M+N) 的复杂度,M 为发布者,N为订阅者。依赖建立在发布者和消息中介,订阅者和消息中介间,非常松耦合。

比起观察者模式,发布者订阅者只需要关心主题(subject),只维护一个消息中介,而不是维护多个被观察者。

对比表:

模式 依赖关系数量 复杂度 直观感受
全互相依赖 N*(N-1) O(N^2) 地狱耦合 O(N^2)
观察者模式 (Observer) N*M O(N*M) 中等耦合 近似 O(N)
发布订阅 (Pub/Sub) M+N O(N) 极低耦合 近似 O(1)
派生的耦合

当使用直接引用进行强耦合的同时,往往完全没必要的弱耦合也会出现,比如 A 直接引用 B,然后又在 A 里写了本该在 B
里处理的逻辑,这种现象在不同语境下有多种叫法:

叫法 语境 含义
职责泄漏 (Responsibility Leakage) 架构 A 越界实现了 B 的逻辑,职责不清晰
逻辑泄漏 (Logic Leakage) 一般工程实践 内部实现细节暴露到调用方
侵入式依赖 (Intrusive Dependency) 软件设计 依赖对象的内部细节侵入调用方
领域逻辑泄漏 (Domain Logic Leakage) DDD 领域逻辑没有收敛在正确的领域模型或聚合根
贫血模型 (Anemic Domain Model) DDD 反模式 领域对象只有数据,没有行为,逻辑散落在应用层或服务层
横向逻辑扩散 (Logic Scattering) 面向切面编程/架构 同一类逻辑被分散到多个调用点
总结

直接引用带来的强耦合,不仅仅体现在 依赖数量 的复杂度上,更隐蔽的危害在于它会 诱导错误的逻辑分布
这会导致开发的成本从开始到维护都很高,如果一开始就做好能省很多时间(当然后续能补救也是好的,至少后续不会再浪费时间了)。具体地,业务上的关系如果没有很强,那最好还是一开始就使用观察者模式或者发布订阅模式。

维护 List

有几个 class 维护了多个 List,每个 List 都有增删和调用的 api,class 里光是这些 api 就有十来个方法。
不仅如此,有的这些调用的 api 里还混了这个class自身的其他操作。

分析

可能是这个项目有很多人维护过,更新一些实现倒是有用 List 实现了多播,使用了观察者模式处理业务。
但之前提到的派生的耦合的问题还在,而且违反了 DRY 原则,我在维护这个项目的时候经常是要到处找这些 List 的 api,
看看有没有什么额外操作,又或者是有没有地方忘记加 api 了,很浪费时间。

结论

现在项目里直接引用和维护 List 的方式都已经出现维护的时候很费时间的问题了,后面任务加了那么多再这样下去问题就更大了。
再这么浪费时间下去可不行,得像 .NET 的 multicast delegate 一样,提供一个统一多播实现。

设计与方案

设计目标

该库需要尽快完成,以满足项目需求,并确保高效开发。

成熟可靠,因为这是要用在项目的核心功能上,关系到百万千万个用户的体验,不能出错。

易用,我们的项目里有很多同事,水平参差不齐,得让大家都能用。我实际也问过同事,有的同事甚至都没听说过观察者模式。

方案

综合考虑,直接模仿 .NET 的 multicast delegate 是最合适的方案。又快又成熟可靠,还易用。[2]
虽然 .NET 里的 delegate 一等公民而且还有方便的语法糖,我们在 java 里不能做到完全一样,但也能做到很接近。

详细设计

由于 java 已经有 function interface(函数式接口)去作为 lambda 表达式的目标了,我们是无法做到像 .NET
一样委托和多播委托无感的。但用函数式接口去做委托,再用已经写好了的委托去写多播委托是可以的。[3]

委托实现

委托直接用函数式接口就行,主要是规范 api 和为多播委托做铺垫,同时不要忘记做好和 java 的适配。

在实现前我们可以先做好 java EventListener[1] 的适配同时用接口标记 delegate[4]

1
2
3
4
5
6
package org.ryuu.functional;

import java.util.EventListener;

interface Delegate extends EventListener {
}

保持和 .NET 一样,调用时的方法名称为 ... invoke(...) 减少理解成本[2]。用@FunctionalInterface[3] 注解修饰实际的
delegate[3:1]

1
2
3
4
5
6
package org.ryuu.functional;

@FunctionalInterface
public interface Action extends Delegate {
void invoke();
}

现在委托就做好了。这样的另一个好处是,我们不需要在需要传 lambda 表达式的时候满世界找一个适配的函数式接口了,因为我们会像
.NET 一样预定义好:

1
2
3
4
5
void invoke(T arg);
void invoke(T1 arg1, T2 arg2);
TResult invoke();
TResult invoke(T arg);
...

多播委托

我们需要先清楚 .NET MulticastDelegate 的实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
protected sealed override Delegate CombineImpl(Delegate? follow)
{
return this;
throw new ArgumentException(SR.Arg_DlgtTypeMis);
return NewMulticastDelegate(resultList, resultCount);
return NewMulticastDelegate(resultList, resultCount, true);
}

protected sealed override Delegate? RemoveImpl(Delegate value)
{
// Special case - no values left
return null;
return this;
// they are both not real Multicast
return null;
return NewMulticastDelegate(list, invocationCount - 1, true);
// Special case - only one value left, either at the beginning or the end
return (Delegate)invocationList[i != 0 ? 0 : invocationCount - 1];
return NewMulticastDelegate(list, invocationCount - vInvocationCount, true);
return this;
}
不可变性

任何“修改”都通过构造新对象来完成。虽然 MulticastDelegate 内部维护了一个 _invocationList,但每次 CombineImpl
RemoveImpl 实际上都会创建一个新的委托对象,而不会修改已有对象。这种方式使得委托本质上是不可变的(immutable)(虽然里面的 _
invocationList 在实现细节上并非只读)。[5]

在 Java 中,我们可以利用深拷贝和 Collections.unmodifiableList 来实现不可变性。[6]

多播委托

在 .NET 中,委托(delegate)统一由 Delegate / MulticastDelegate 管理。用户代码层面并没有“单播 / 多播”的区分
,这个细节完全隐藏在实现内部:

  • 单播 (single delegate)invocationList 为空,只存一个方法引用。
  • 多播 (multicast delegate)invocationList 是数组,存多个方法引用。

多播委托的合并与移除逻辑会自动处理这两种情况。因此,.NET 用户只会看到“委托可以 += / -= 方法”,而不会关心它当前是单播还是多播。[7]

在 Java 中,因为 lambda 表达式必然作用在单个函数的接口上,所以单播对用户是显式的,实现多播时可以与 .NET 一样过内部封装隐藏掉单播多播的区别。

语法糖

在 c# 中可以直接用 += -= 修改委托

High-Level c#

1
2
3
4
5
6
7
8
[Fact]
public void Test1()
{
Action a = () => { };
Action b = () => { };
a += b;
a -= b;
}

Low-Level c#

1
2
3
4
5
6
7
[Fact]
public void Test1()
{
Action a1 = UnitTest2.<>c.<>9__0_0 ?? (UnitTest2.<>c.<>9__0_0 = new Action((object) UnitTest2.<>c.<>9, __methodptr(<Test1>b__0_0)));
Action b = UnitTest2.<>c.<>9__0_1 ?? (UnitTest2.<>c.<>9__0_1 = new Action((object) UnitTest2.<>c.<>9, __methodptr(<Test1>b__0_1)));
Action a2 = (Action) Delegate.Remove(Delegate.Combine((Delegate) a1, (Delegate) b), (Delegate) b);
}

c# 在编译的时候把 a += ba -= b 变成了 Delegate.Remove Delegate::Combine 而且把新产生的对象赋回了 a。[7:1]

在 java 里我们是不可能通过编写代码实现这样的语法糖的,在 java 里用方法去实现就行。

栈式操作(Stack Semantics)

  1. 语义一致
    在委托(delegate)的操作语义上,+= 始终将新委托附加到 尾部(类似 push),而 -= 则撤销 最近一次添加的委托(类似
    pop)。这种设计确保了委托的修改符合 栈(LIFO)语义,简单直观。[8]
  2. 实现高效
    从尾部开始查找和移除,可以直接截断尾部,操作成本低;若从头部开始匹配,则需要移动后续所有元素,效率更差。

Java 中同样可以实现这种语义。做法是:

  • 添加:始终往尾部追加。
  • 删除:从尾部开始执行 子列表匹配(Sublist Matching),只移除最近一次匹配到的委托。

这样既能保证语义一致,又能在实现上保持高效。

event

c# 中 event,是 基于 delegate
的一种特殊成员

High-Level c#

1
private event Action a = () => { };

Low-Level c#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
[CompilerGenerated]
[DebuggerBrowsable(DebuggerBrowsableState.Never)]
private Action a;

private event Action a
{
[CompilerGenerated] add
{
// 先读取当前事件委托链,存入 action1,这里是为了在循环中对比 CAS 是否成功
// 虽然这里的 action1 和 this.a 引用类型的,但 delegate 是不可变的
// this.a 改变时会引用一个新的实例,而 action1 还是会引用 this.a 改变前的旧实例
// 如果是可变的:
// this.a 改变时引用的实例没有改变,action1 引用的还是 this.a 的实例,此时无法进行 != 判断
Action action1 = this.a;
// 保存当前尝试的旧值
Action action2;
do
{
// 存放 尝试用 CAS 更新的“旧值”。
action2 = action1;
// public static T CompareExchange<T>(ref T location, T value, T comparand);
// location:你要修改的变量(这里是 this.a)。
// value:如果 location == comparand 条件满足,要写入的新值(这里是 Delegate.Combine(action2, value))。
// comparand:预期旧值(这里是 action2)。
// return:location 在此操作之前的值(无论是否成功)
// 这保证了无论怎样循环 action1 都是最新的 this.a 的值
action1 = Interlocked.CompareExchange<Action>(ref this.a, (Action) Delegate.Combine((Delegate) action2, (Delegate) value), action2);
}
// action1 不等于 action2(说明 CAS 失败,this.a 被其他线程修改过)。
// 循环重新读取 this.a 并尝试合并。
while (action1 != action2);
}
[CompilerGenerated] remove
{
Action action1 = this.a;
Action action2;
do
{
action2 = action1;
action1 = Interlocked.CompareExchange<Action>(ref this.a, (Action) Delegate.Remove((Delegate) action2, (Delegate) value), action2);
}
while (action1 != action2);
}
}

可见编译器为 event 生成了 add 和 remove 访问器,使用了 CAS[4] 做自旋锁,来保证对多播的修改是线程安全的。[9]

在 Java 中,如果想实现类似的体验,可以通过 APT(Annotation Processing Tool) 来生成访问器代码,使使用体验更接近
.NET。然而,APT 会带来一些问题:

  1. 增加了项目复杂度。
  2. IDE 对生成代码的支持可能不完善。
  3. 用户自定义代码时,APT 生成的代码可能出现冲突或不易维护。

考虑到这个项目是一个通用库,而不是语法糖增强工具,我们不会采用 APT 的方式。

因此,我们选择通过 接口 实现类似 .NET 的 event

  • 提供修改委托(添加/移除)的方法。
  • 不暴露 invoke 方法给外部用户。

这样既能保证多播操作的安全与一致性,也简化了使用和维护。

多播委托实现

以多播委托移除为例,同时展示栈式操作不可变性的具体实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private void removeMulticastDelegate(MulticastDelegate<T> multicastDelegate) {
int sourceCount = count();
int targetCount = multicastDelegate.count();

// 栈式操作
for (int i = sourceCount - targetCount; i >= 0; i--) { // 从尾部开始
if (subListEquals(multicastDelegate.delegates, i, targetCount)) { // 子表匹配
List<T> newList = new ArrayList<>(delegates);
newList.subList(i, i + targetCount).clear();
delegates = Collections.unmodifiableList(newList); // 内部实现 delegates 的不可变
return;
}
}
}

内部的添加方法为例,展示多播委托的内部隐藏单播多播区别实现:

1
2
3
4
5
6
7
8
9
10
11
private void addInternal(T delegate) {
if (delegate == null) {
return;
}

if (delegate instanceof MulticastDelegate) {
addMulticastDelegate((MulticastDelegate<T>) delegate);
} else /* if (delegate instanceof Delegate) */ {
addDelegate(delegate);
}
}

添加为例,展示 event 多线程安全实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
private final boolean isEvent;
private final Object delegatesWriteLock = new Object();

public MulticastDelegate(boolean isEvent) {
this.isEvent = isEvent;
}

@Override
public final void add(T delegate) {
if (isEvent) {
addSync(delegate);
} else {
addAsync(delegate);
}
}

private void addSync(T delegate) {
synchronized (delegatesWriteLock) {
addInternal(delegate);
}
}

private void addAsync(T delegate) {
addInternal(delegate);
}

这里我们用的是 synchronized 保证多线程安全而不是基于 CAS
写一个自旋锁。我们需要考虑锁实现的性能,自旋锁是一种忙等(busy waiting
),如果竞争很激烈[5]会非常浪费 CPU。这种情况可以让线程阻塞,CPU 还可以去执行其他线程,这样效率更高。[10]

代码段示例,展示 event 的调用全只留给声明事件的类内部实现:

1
2
3
4
public interface Event<T extends Delegate> {
void add(T delegate);
void remove(T delegate);
}
1
2
public abstract class MulticastDelegate<T extends Delegate> implements Iterable<T>, Delegate, Event<T> {
}
1
2
3
4
5
6
7
8
9
10
11
private static class ClassWithActionsEvent {
private final Actions actions = Actions.event();

public Event<Action> getActions() {
return actions;
}

private void privateMethod() {
actions.invoke();
}
}

微基准测试

.NET 的 delegate 的性能是不错的,我们可以使用 JMH(Java Microbenchmark Harness)[6]进行基准测试来查看是否符合性能模型。[11]

Benchmark Param (baseActions) Sub-Benchmark Mode Cnt Score Error Units
MultithreadInvokeBenchmark.invoke 1 total thrpt 64 270.771 ±5.094 ops/us
gc.alloc.rate thrpt 64 8080.918 ±190.085 MB/sec
gc.norm thrpt 64 32.001 ±0.001 B/op
gc.count thrpt 64 6187.000 counts
gc.time thrpt 64 4410.000 ms
MultithreadMixedOpsBenchmark.mix 1 total thrpt 64 36.785 ±3.842 ops/us
add thrpt 64 0.507 ±0.054 ops/us
invoke thrpt 64 35.550 ±3.920 ops/us
remove thrpt 64 0.729 ±0.042 ops/us
gc.alloc.rate thrpt 64 2004.565 ±83.029 MB/sec
gc.norm thrpt 64 61.101 ±5.008 B/op
gc.count thrpt 64 5122.000 counts
gc.time thrpt 64 3669.000 ms
SingleThreadInvokeBenchmark.invoke 1 total avgt 64 7.601 ±0.429 ns/op
gc.alloc.rate avgt 64 3989.645 ±227.297 MB/sec
gc.norm avgt 64 32.002 ±0.002 B/op
gc.count avgt 64 4292.000 counts
gc.time avgt 64 4203.000 ms
32 total avgt 64 27.727 ±0.925 ns/op
gc.alloc.rate avgt 64 1081.411 ±34.195 MB/sec
gc.norm avgt 64 32.007 ±0.008 B/op
gc.count avgt 64 4471.000 counts
gc.time avgt 64 3918.000 ms

单线程 invoke

我们先从单线程的 invoke 性能分析开始,测试机器的 CPU 频率为 4.5GHz。执行速度测试结果如下:

单个委托执行消耗约 32 个 CPU 时钟周期,具体分布为:

  • invokeinterface 调用 + JIT 内联 lambda:约 3–10 cycles
  • 循环控制、列表访问、JVM 辅助调整及 CPU 流水线开销:约 20 cycles

而执行 32 个委托的多播调用时,总开销仅约 126 个时钟周期:

  • 平均每个委托仅需 3–4 cycles,远低于单个委托的开销。
  • 这是因为多委托调用形成连续指令流,充分利用了 CPU 流水线,大幅减少了方法调用开销。
  • 另外,委托对象在内存中连续布局,提高了缓存命中率,有效摊平了列表访问和循环控制开销。

性能模型分析

JVM 处理单个委托调用的开销主要来自方法调用和循环管理

多线程 invoke

MultithreadInvokeBenchmark.invoke 中,我们测试了多线程环境下的 invoke 性能,基准测试结果显示:

  • 16个线程的总吞吐量约为 270.77 ops/us,虽然高于单线程(3.694ns < 7.6 ns),但性能瓶颈明显(GC)。
  • GC 分配率高达 8080 MB/sec,表明多线程并发执行 invoke 时创建的快照式迭代器严重增加了垃圾回收负担。
  • 每操作平均内存开销约 32 B,与单线程一致,这主要来自快照式迭代器的创建成本。
  • GC 时间与次数数据显示垃圾回收被频繁触发,特别是在短周期高频调用场景下,内存分配压力显著增大。

性能模型分析

这是快照式迭代器的性能特征:读取速度快、线程安全,但在高频多线程调用下 GC 压力明显。[12]

多线程混合 add/remove/invoke

当我们将16线程 invoke 改为12线程 invoke、2线程 add、2线程 remove时,invoke 性能从270.771降至35.550,显著变慢。这是由于 invoke
的CPU缓存失效,线程间需要频繁同步内存数据所导致。而 add 和 remove 操作比 invoke 更慢,主要因为它们需要保证线程安全。

性能模型分析

写时复制表(Copy-On-Write List)的性能模型:读取操作高效,不受线程竞争影响,可并行执行多个读操作;写操作则需要复制并修改表,时间和空间成本较高,频繁写入会造成性能瓶颈。[12:1]

总结

至此微基准测试与分析完毕,基准测试结果表明,该实现符合预期的性能模型。[13]

另请参阅

详细的实现请查看 项目源码仓库

dotnet runtime 仓库 委托的实现在 System.Private.CoreLib 程序集中

脚注

[1] 详情请参考文档 https://docs.oracle.com/javase/8/docs/api/java/util/EventListener.html

[2] 在自然语言中,invoke是比call更正式的一个单词,例如,“invoke a law” (启用一条法律)或“invoke a blessing”
(祈求祝福)。一般用来表示某种东西被激活或应用。在编程中invoke一般是对事件,委托和回调的操作,而call是对函数和方法的直接调用。

[3] @FunctionalInterface会将接口标记为函数式接口,这种类型的接口是lambda表达式和方法引用的目标。如果不小心添加了多个方法,编辑器会显示错误,如果需要一个函数式接口,请坚持使用
@FunctionalInterface。同样的情况还有 Effective-Java-40坚持使用Override注解

[4] 从线程安全性上来说,CAS 同时拥有原子性(硬件CPU指令级别的原子性),可见性(CAS操作自带内存屏障,操作结果立刻对其他线程可见)和有序性(CAS前后的代码会限制重排序),所以是线程安全的。

从设计上来说 CAS 有 ABA 问题和忙等待(busy waiting)问题,但 delegate 是不可变对象,所以根本不会产生 ABA 的情况,而 +=,-=
操作频率远低于 invoke,一般来说不会忙等待或较少见,而如果选择加重锁,会额外引入用户态和内核态之间的上下文开销(唤醒线程又得有一次上下文切换开销)。

[5] delegates 的业务是典型的读多写少,竞争激烈的情况应该不会很多,但无论如何我们还是要为使用者考虑,尽量覆盖更多情况。

[6] 微基准测试是用来理解底层代价的,不能当作现时的使用场景,不能用于推测 QPS。

基准测试的数值本身并不重要,重要的是我们可以从这些数值推导的性能模型(比如在我们的 delegate 里是快照不可变列表和*
*接口调用/方法引用**的性能模型)。

如果你对此很好奇请阅读 nanotrusting-nanotime 博客内容。

如果你对代码的性能很好奇可以观看此视频 code::dive conference 2015 - Andrei Alexandrescu - Writing Fast Code II


  1. Gamma 等人的 Design Patterns 将 Observer 描述为 subject 与多个 observer 之间的一对多依赖,用于在 subject 状态变化时通知依赖对象;这里的复杂度表达是对依赖形态和维护成本的工程化估算,不是严格数学证明。 ↩︎

  2. Microsoft Learn C# language specification - Delegates 定义 delegate 类型、invocation list 和多播调用顺序;它能支撑“借鉴 .NET multicast delegate 语义”的设计方向,但不意味着 Java 能复制 C# 的语言级语法。 ↩︎

  3. JLS §15.27 Lambda Expressions 说明 lambda 表达式依赖目标类型;Oracle Java SE FunctionalInterface 说明函数式接口是 lambda 表达式和方法引用的目标类型,@FunctionalInterface 会让编译器检查接口是否满足函数式接口要求。 ↩︎ ↩︎

  4. Oracle Java SE EventListener 是所有事件监听器接口应扩展的标记接口;把自定义 delegate 接口挂到 EventListener 更像语义标记,而不是继承行为。 ↩︎

  5. Microsoft Learn Delegate.CombineDelegate.Remove 支撑组合 / 移除返回委托结果的公开语义;具体 _invocationList 属于 .NET runtime 实现细节。 ↩︎

  6. Oracle Java SE Collections.unmodifiableList 返回指定列表的不可修改视图;若底层列表仍可变,应结合拷贝或私有封装理解不可变边界。 ↩︎

  7. Microsoft Learn delegate combinationdelegate removal 说明 + / += 会组合 delegate,- / -= 会移除 delegate;Delegate.Combine / Delegate.Remove 是对应的运行时 API 语义。 ↩︎ ↩︎

  8. Microsoft Learn Delegate.Remove 说明移除的是目标 invocation list 的最后一次出现;因此文中“栈式”更准确地说是“追加到尾部、从右往左移除最近匹配项”的语义。 ↩︎

  9. C# language specification Events 支撑 event 的 add / remove 访问器和外部访问边界;Microsoft Learn Interlocked.CompareExchange 支撑 CAS 更新模式。 ↩︎

  10. JLS §14.19 The synchronized Statement 定义 Java synchronized 通过 monitor 进入和退出临界区;它支撑这里用阻塞互斥而不是自旋 CAS 实现写侧同步的设计边界。 ↩︎

  11. OpenJDK JMH 是 JVM 微基准 harness;用它验证性能模型比手写 System.nanoTime() 循环更可靠。 ↩︎

  12. Oracle Java SE CopyOnWriteArrayList 说明写操作会复制底层数组,迭代器使用创建时快照,适合遍历远多于修改的场景;这支撑文中的读多写少和 GC/写成本边界。 ↩︎ ↩︎

  13. OpenJDK JMH 与 Aleksey Shipilev Nanotrusting the Nanotime 都指向同一个边界:微基准可用于理解底层成本模型,但不能直接推出线上业务 QPS 或用户体验结论。 ↩︎

引子

起因是有群友在尝试解决 double 不够存数据的问题,看到了 decimal,但对 decimal 的理解还是不对。

这段话说的很含糊,可能是从 ai 的文本中截取的一段。而问为什么要用 decimal 时,这也是一般人的回答。

清晰的解释

浮点数在计算机中以二进制形式存储,而很多十进制小数(例如 0.1)在二进制下表示为无限循环小数。由于浮点数的存储空间有限,这些无限二进制小数必须被截断,从而引入精度误差。[1]相比之下,decimal 类型使用十进制存储,可以精确表示十进制小数,因此不会产生类似的精度问题,适合对精度要求高的场景(如财务计算)。[2]

进制与截断

decimal 如其名一样是十进制的,而一般的浮点(float/double)是基于二进制(binary floating-point)遵循 IEEE 754 标准的。[3]

最常见的情况是 0.1 + 0.2 = 0.3 的例子,请参考以下代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public static void main(String[] args) {
checkFiniteInBinary("0.1");
checkFiniteInBinary("0.2");
checkFiniteInBinary("0.3");
checkFiniteInBinary("0.5");

float floatResult = .1f + .2f;
System.out.printf("floatResult = %.64f\n", floatResult);

double doubleResult = .1 + .2;
System.out.printf("doubleResult = %.64f\n", doubleResult);

BigDecimal decimalResult = new BigDecimal("0.1").add(new BigDecimal("0.2"));
System.out.printf("decimalResult = %.64f\n", decimalResult);

doubleResult = .2 + .3;
System.out.printf("doubleResult = %.64f\n", doubleResult);
}

输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
19:53:38: Executing ':org.ryuu.Main.main()'…

Starting Gradle Daemon...
Gradle Daemon started in 1 s 634 ms
> Task :compileJava UP-TO-DATE
> Task :processResources NO-SOURCE
> Task :classes UP-TO-DATE

> Task :org.ryuu.Main.main()
十进制数0.1在二进制数下不能有限表示。
0.0001100110011001100110011001100110011001100110011001100110011001... (仅显示64位)

十进制数0.2在二进制数下不能有限表示。
0.0011001100110011001100110011001100110011001100110011001100110011... (仅显示64位)

十进制数0.3在二进制数下不能有限表示。
0.0100110011001100110011001100110011001100110011001100110011001100... (仅显示64位)

十进制数0.5在二进制数下能有限表示。
0.1

floatResult = 0.3000000119209289600000000000000000000000000000000000000000000000
doubleResult = 0.3000000000000000400000000000000000000000000000000000000000000000
decimalResult = 0.3000000000000000000000000000000000000000000000000000000000000000
doubleResult = 0.5000000000000000000000000000000000000000000000000000000000000000

BUILD SUCCESSFUL in 6s
2 actionable tasks: 1 executed, 1 up-to-date
19:53:46: Execution finished ':org.ryuu.Main.main()'.

0.1,0.2这样的数在二进制里类似十进制的1/3(0.3333333…),是无限循环的。浮点数会将数据截断,因此会丢失精度。[4]

1
2
0.1(decimal) = 0.0001100110011001100110011001100110011001100110011001100110011001... (binary)
0.2(decimal) = 0.0011001100110011001100110011001100110011001100110011001100110011... (binary)

另请参阅

github 有完整的示例工程


  1. David Goldberg 的经典论文《What Every Computer Scientist Should Know About Floating-Point Arithmetic》说明,有限位表示只能近似覆盖无限多实数,因此多数实数运算结果无法在固定 bit 数中精确表示;其中也专门用 0.1 解释了十进制有限小数在二进制中的不可精确表示。 ↩︎

  2. Microsoft C# 官方文档《Floating-point numeric types》把 decimal 定位为适合金融、货币、利率等由小数点右侧位数决定精度的场景,并说明 0.1 可由 decimal 精确表示但不能由 doublefloat 精确表示。Java 的 BigDecimal 官方 API 也将其定义为由任意精度整数 unscaled value 和 32-bit scale 组成的十进制数值类型,见 Oracle Java SE 17《BigDecimal》。 ↩︎

  3. IEEE Standards Association 的《IEEE 754-2019 - IEEE Standard for Floating-Point Arithmetic》是浮点算术标准入口,说明该标准规定了计算机编程环境中的二进制和十进制浮点交换格式、算术格式和方法。 ↩︎

  4. Goldberg 论文同样指出,0.1 虽然有有限十进制表示,但在二进制中是无限循环表示,因此在以 2 为基数的有限精度浮点中不能被精确表示。 ↩︎

如果你想在 Project 视图中定位到当前正在编辑的文件[1]

自定义快捷键

  1. 打开 Settings/PreferencesCtrl + Alt + S⌘ Command + ,)。[2]
  2. 选择 Keymap[3]
  3. 搜索 “Select File in Project View”[4]
  4. 给这个动作分配一个你喜欢的快捷键。(我自己用的 alt + shift 1[5]

  1. JetBrains Guide 的 Select Opened File tip 说明,可以用 Project tool window 中的 Select Opened File 图标定位当前文件,也可以用 Select In action;IntelliJ IDEA Project tool window 文档还说明 Always Select Opened File 会自动选中当前编辑器文件:https://www.jetbrains.com/guide/java/tips/select-opened-file/https://www.jetbrains.com/help/idea/project-tool-window.html↩︎

  2. JetBrains 的 Configure keyboard shortcuts 文档把添加快捷键的入口放在 Settings dialog 的 Keymap page,并以 Ctrl+Alt+S 作为打开 Settings 的快捷键示例:https://www.jetbrains.com/help/idea/configuring-keyboard-and-mouse-shortcuts.html↩︎

  3. JetBrains Keymap 文档说明 Keymap 页面用于搜索 shortcuts 和 actions、创建/编辑 keymap,并修改 custom keymap 中 action 关联的快捷键:https://www.jetbrains.com/help/idea/settings-keymap.html↩︎

  4. JetBrains Configure keyboard shortcuts 文档说明,可以在 Keymap 页面通过搜索字段按 action name 查找需要的 action;如果知道快捷键,也可以用 Find Action by Shortcut 反查:https://www.jetbrains.com/help/idea/configuring-keyboard-and-mouse-shortcuts.html↩︎

  5. JetBrains Configure keyboard shortcuts 文档说明,为 action 添加快捷键时,右键 action 选择 Add Keyboard Shortcut,再输入组合键;如果存在冲突,IDE 会显示 warning,系统或第三方快捷键冲突也应重新分配或禁用:https://www.jetbrains.com/help/idea/configuring-keyboard-and-mouse-shortcuts.html↩︎

0%