一方面,是Vibe效率的飞升:三天完成一个Demo,一下午Vibe一个Poc项目。
另一方面,是企业效率没有想象中的提升,似乎,没有AI辅助流程,对企业没有任何影响。

为什么?

首先,这是极具代表性的两个开发场景:
其一,单人开发,担任产品经理、开发、测试、运维角色,一人承担所有的责任。(即使GPT写错了付款接口你也只能责怪自己)。
其二,多人协作开发,分工明确,各自承担不同的角色与责任。

基于此,针对AI辅助开发,笔者分析整理了三个原因,这三个问题层层叠加,从模型能力到系统隔离,再到协作方式,构成了企业级 AI 提效的现实瓶颈。

一、模型、算力

模型的推理能力、支持上下文的长度,对工具的效果影响是天差地别的。企业出于对资产安全的考虑,往往会选择私有化搭建模型,这可能会导致如下两个问题:

  • 内部模型参数量较小:推理能力明显低于公有云大模型,尤其在多语言代码生成和复杂语义补全上差距明显。
  • 上下文长度受限: 受限于上下文窗口,AI 工具只能看到当前编辑文件或部分上下文,导致生成结果割裂。

    先进的理论、经验都是基于AK 47的,确定一定要基于AK 36继续优化么?

二、信息系统孤岛

在传统的逻辑下,将信息分隔到多个系统,进行精细化管理,通过人来串起整个流程,这无疑是非常优秀的做法。笔者也曾经将团队中的所有数据库相关实体定义集中到一个代码库管理。

而AI辅助开发工具通常运行在代码仓,单人开发模式下,我可以把设计文档提交到代码仓,典型测试集都提交到代码仓,编写代码需要的Everything,只要适合转换为文本模式的,我都可以提交到代码仓。

在AI辅助开发工具没有集成对应信息系统的情况下,需要开发人员手动提炼复制上下文到AI辅助开发工具,这和我再提炼提炼,以Chat的方式对话,有什么区别?

其实这一点在代码仓管理上也存在,Repo的分隔也存在这个问题,前端、多个语言的客户端、API的Yaml文件,进行精细化的管理。但CC、Cursor这样的工具无法看到全貌。

下一个我做的项目,我倾向于使用Mono Repo,充分利用AI工具提交,并且如果是AI相关项目的话,我倾向于使用Python语言。

但是我相信,如果多个系统都能与AI工具对接打通,给AI提供了更加结构化、质量更高的数据,理论上上限要高于所有东西以markdown格式放在代码仓库不同文件夹的做法。

三、工作流程

AI没有改变软件工程,只是把其中的很多工作加速到不可思议的地步。

AI只能加速单人闭环的工作,对人和人的交互没有帮助。

AI 当前的强项,是提升“单人闭环任务”的速度。但当任务涉及跨角色交互时(如开发与测试、测试与运维、产品与开发),AI 的作用显著减弱。

左图是人与人的交互,严丝合缝。如右边两张图所示,AI交付件还不能做到严丝合缝地对接,为了能顺利走完流程,蓝色、橙色,必须得有一方做出额外的工作才行。(当然,为了避免同事说你不靠谱,我还是推荐蓝色方做出额外工作。)

AI时代,应该尽量把一个工作的各个方面交给一个人,这样来减少人与人的交互,充分提高效率。

这和交给Agent足够的上下文的道理是相通的。

来看一个典型案例:微服务开发与测试的协同。一个典型微服务的开发、测试、发布上线流程如下:

产品与开发、开发和运维,这样的问题也存在,以编码举例子,主要是因为时间相对长,矛盾更加明显。


测试负责产品质量的出口,测试在自己的领域范围内,通过自己的理解,对整体的测试进行分层落地(基本接口测试与集成测试环节)。

图中共有四项活动:代码、单元测试、基本接口测试、集成测试。这个模式下,单元测试与基本接口测试存在能力上的重叠。对流程进行了如下优化(测试将测试设计左移,基本接口测试放在开发阶段,弱化单元测试。

但是现在,下面这种方式和上面这种方式,谁用AI辅助的效率更高?

从传统领域的角度来看,下面的方式,测试更早介入,且减少了重复工作。但是从AI辅助的角度来看,那个效率更高?

从AI辅助的角度,橙色的环节充斥着大量的交互,诸如代码中书写的测试要匹配测试的设计(通过方法名与用例Id匹配),开发要确认测试设计表达的含义等。当团队想要使用AI提效时,这个矛盾就被放大。尤其是当 模型能力不足信息孤岛 并存时,协作复杂度被进一步放大。

当下,在笔者所在的团队,上下两种流程都存在。上下两种方式那种更好?笔者下意识地觉得当未来AI的生产力突飞猛进的时候,上面的方式会更好(这一判断源于相信人和AI协作的效率会远远大于人和人协作的效率),但是在当下,笔者想不清楚,也给不出答案。当前笔者针对上下两种场景,寻找集成了对应信息源、最合适的工具进行提效。

总得来说,企业要避免陷入 “工具因为流程导致效果不及预期”、”流程不会为没有效果的工具变更,让步”、”开发人员对工具没有信心” 的恶性循环。

总结

AI 在个人层面的高效令人振奋,但企业的整体效率并未出现预期的跃升。问题主要集中在模型能力、信息孤岛与工作流程三方面。前两者可以通过技术投入与资源建设逐步改善,而工作流程的变革则需要企业在思维模式与协作方式上完成真正的转变。

TLDR

笔者推测Palantir起初以支持定制代码运行为基础,在构筑自己部署平台(Apollo,Palantir GitHub上也有很多开发者构建、Lint工具)的同时,逐渐抽象出Dataset、本体、Function、Action API,打造了坚实的Foundry平台,让应用从定制化开发逐步“长在平台上”。最终,Palantir 推出人工智能平台(AIP),实现数据驱动的智能决策。

前言

近年来,Palantir 无疑成为数据分析领域的焦点之一。作为一家以解决复杂问题为核心的公司,Palantir 为政府、国防和企业客户提供了强大的数据整合与分析能力。Palantir 的核心产品 Foundry 是一个面向数据整合与分析的平台,它如何从最初的定制化开发逐渐演变为如今的通用数据智能平台?笔者尝试基于公开资源推测梳理 Palantir Foundry技术平台的演进路线,分享一些分析与推测。本文仅代表个人观点,欢迎读者交流探讨。

阶段0 定制代码运行

从Palantir的Offering来看,其核心始终是为客户解决复杂问题,拥有大量的FDE。合理推测Palantir最早其实以定制代码运行交付作为基础,通过高度定制化的软件开发满足客户在政府、国防和企业领域的特定需求。

阶段0,此时都处于定制开发状态。

阶段1 从定制代码运行到Palantir平台运行

正如《人月神话》中所说,优秀的程序员都会有自己的library库,优秀的定制开发商也倾向于提炼可复用的技术框架。

对于定制代码来说,我们把定制代码分为编写态和运行态

  • 编写态,对应Palantir Code Repositories,可以看到Palantir的很多东西,其实跟Git很相似,有分支、合并等等。
  • 运行态,将Palantir Code Repositories的代码构建运行,支持多种触发方式,比如通过API调用来执行,定时执行等。
    Apollo 平台进一步支持多环境部署(如云和边缘)。

阶段2 数据的平台化存储和管理

当开发工作逐渐迁移到 Palantir 平台后,数据的存储和管理成为下一个重点。如果代码已经运行在平台上,那么数据为什么不能也存储在平台中呢?

Palantir 在这一阶段引入了 Dataset 和本体(Ontology)模型,构建了平台化的数据管理能力。Dataset 作为数据的核心容器,支持结构化和非结构化数据的存储;本体则定义了数据之间的语义关系,为数据提供了更高级的抽象层。此外,Palantir 接入了时序数据库,增加了对时间序列数据的支持,满足了金融、工业等领域对实时数据处理的需求。

同时,也把数据集的变更增加为一个触发条件。例如,当某个 Dataset 发生变化时,平台可以自动触发预定义的操作,如运行一段代码或更新其他数据集。

阶段3 抽象Action Function

在本体已经定义了DataSet以及数据集之间关系的基础上,通过Action、Function的定义,同时Action、Function可以通过拖拉拽简单地生成,无需书写代码。对于难以无码的复杂逻辑,还可以通过定制代码来书写。

其实Workflow和Pipeline都是在更高层次、更简便地操作代码的手段而存在,底层实现上:

  • Pipeline = Datasets+Builds+Schedules
  • Workflow = Schedules + Builds + Jobs

阶段 4:AIP 的智能决策赋能

在Foundry坚实的基础上,Palantir 2023 年推出了 AIP(人工智能平台)整合大语言模型(LLM)与 Foundry 数据,自动化复杂决策。其核心功能包括:

  • 自然语言处理:用户通过对话界面查询数据或生成分析,如“预测下季度库存需求”。
  • 自动化工作流:基于 Ontology,AIP 驱动智能决策,例如优化供应链或调度资源。
  • 实时推理:结合时序数据,AIP 支持动态预测,如医疗资源分配或工业故障检测。

总结


图:笔者设想的企业使用Foundry路线图

本文分析了Palantir Foundry的技术实现路径,笔者认为Palantir Foundry 的技术演进展现了一个从“定制”到“平台原生”的清晰路径。应用从分散的定制代码,逐步迁移到平台上运行,扎根于平台的数据和触发机制,最终成为完全依赖平台功能的原生应用。

令人深思的经历

曾经历过这样的事情,平台侧要求应用提供满足平台特有格式的交付件,经过多次协商,最终还是应用侧与平台侧一起开会,由平台侧帮助应用侧输出。

另一件事,Kubernetes Yaml以其独特、强大的合并属性能力闻名于江湖。应用侧对Kubernetes Yaml不熟悉,新手想要把环境上的Yaml导出直接作为标准交付件,虽然也行,但是包含了很多噪音,环境上的id、环境上的annotation、时间戳等等。

私有化格式的交付困境

越来越多的软件将自己定位为”平台”,无论是微信、飞书这样的国民应用,还是各类企业级软件。但平台交付的过程中,一个普遍存在的问题是:许多平台要求合作伙伴或第三方开发者使用其私有化的交付格式。这种私有化格式往往存在诸多问题:

  • 学习成本高,难以掌握。
  • 文档不完善,依赖平台方支持。
  • 迁移困难,形成供应商锁定。
  • 最终往往仍需平台方投入人力协助。

软件交付应该标准化

软件交付应该使用标准的格式,这有助于降低合作伙伴的接入成本,提高自身的可扩展性,尤其在AI辅助研发的现状下,采用标准的格式更有利于AI理解和生成代码。

交付件 标准格式 使用场景
Java库 Jar包 作为依赖库被其他Java项目引用和集成,需要发布到Maven仓库。
应用镜像 标准镜像包 以容器方式交付,确保运行的一致性。(但如x86、armv8、armv7)的差异依然存在。
应用部署(I层资源已具备) helm、docker compose 商用场景多用Helm包,单机伪集群/组合方式多用docker compose。
应用部署及I层资源创建 Terraform 需要交付底层基础设施或云服务的场景,如整个应用运行环境。

如果实在要使用私有的格式,可以对标准格式做一些裁剪/扩展(Kubernetes的annotation),将标准格式转化到私有格式。

在软件开发中,健壮的异常处理是编写高质量代码的关键。本文将探讨现代编程语言中的通用异常处理方法,帮助你优雅地处理异常并写出健壮的代码。我们将不拘泥于某种语言,而是讨论一些普遍适用的策略。

异常链概述

现代编程语言通常将异常视为一条单向链表,链表中的节点包含根本原因和相关的上下文信息。例如:

graph TD
    C --> D[MicroServiceError, call user service failed]
    B --> C[DatabaseError, select * from user failed]
    A --> B[HttpError, http://localhost:6379 failed]
    A[SocketError, localhost, 6379 connect failed]

异常就这么向外传播也不错,但是抽象是会泄露的,正常的时候顺风顺水,异常就需要判断一下,比如一个很常见的需求,文件已存在异常,就当做成功处理,用Java来写就是这样

1
2
3
4
5
if (exception instanceof FileAlreadyExistsException) {
log.info("file already exists");
return SUCCESS;
}
throw exception;// or wrap it

综上来看,我们对现代编程语言的需求就是,能组织异常链,判断异常是否是某类异常,把异常用字符串的形式打印出来。

当我们在构筑一个library的时候,应该尽可能保持完整的异常链,除非你认为这个异常在library内可以处理,比如上面的情况。并且应该在项目的README,或者项目的某个文件中,详细地列出本library可能抛出的异常,以及异常的含义。

我们在opengemini-client-go中就有这样的例子,我们在errors.go中定义了所有可能的异常,以及异常的含义。

有些时候,我们构筑的不是library,出于隐藏内部实现或者是向终端用户隐藏逻辑上的低级错误,我们会对异常进行处理,比如常见的

1
2
3
4
5
6
if (exception instanceof DuplicateKeyException) {
log.info("duplicate key");
return new ServiceException("already exists");
}
// many if else
throw new ServiceException("unknown error"); // or just internal error

题外话,由于Java只能判断本级的异常类型,你会经常看到getCause的代码,比如Apache Pulsar项目中的

1
2
3
4
if (exception.getCause() != null
&& exception.getCause() instanceof PulsarClientException.InvalidServiceURL) {
throw new MalformedURLException(exception.getMessage());
}

包括层次一多,甚至可以看到递归代码

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 static Throwable mapToBkException(Throwable ex) {
if (ex instanceof CompletionException || ex instanceof ExecutionException) {
return mapToBkException(ex.getCause());
}

if (ex instanceof MetadataStoreException.NotFoundException) {
BKException bke = BKException.create(BKException.Code.NoSuchLedgerExistsOnMetadataServerException);
bke.initCause(ex);
return bke;
} else if (ex instanceof MetadataStoreException.AlreadyExistsException) {
BKException bke = BKException.create(BKException.Code.LedgerExistException);
bke.initCause(ex);
return bke;
} else if (ex instanceof MetadataStoreException.BadVersionException) {
BKException bke = BKException.create(BKException.Code.MetadataVersionException);
bke.initCause(ex);
return bke;
} else if (ex instanceof MetadataStoreException.AlreadyClosedException) {
BKException bke = BKException.create(BKException.Code.LedgerClosedException);
bke.initCause(ex);
return bke;
}

return ex;
}

Go在这里易用性做的不错,支持了errors.Iserrors.As,可以判断异常链中是否包含某个异常,也可以直接获取异常链中的异常。不过如果异常链里面有两个一模一样类型的异常,你想精准取到其中一个就比较困难,不过这在实际场景中非常少见。

这里,我们说异常链发生了变更,那么什么时候打印日志也比较明确了,当异常链发生变更的时候打印,保证完整的堆栈信息用于问题分析。这也可以保证在一条链的过程中,有且仅有一次打印日志。

在异常链发生终止,比如转化为http content,或者是print到console的时候,要不要打印日志呢?这个问题有些见人见智,这取决于你的用户在report问题的时候,会不会携带http content或者是console output,如果不会,那么你就需要打印日志,如果会,那么你就不需要打印日志。

Java里面,比起将底层的error抛出,我们更倾向于定义一个符合本library抽象层级的异常,并在方法的签名中只返回这个异常,一方面使得下层library的异常如果发生变化,本library依然是编译兼容的,另一方面也更符合抽象层级。

但是在Go里面,事情就更复杂一些,我愿意称之为类型的细化具备传染性,一旦你将某个方法的签名不返回interface,而是返回一个具体的类型,比如

1
2
3
4
5
6
func (c *Client) CallService() (Result, *ServiceError) {
if failed {
return nil, &ServiceError{Code: 500, Message: "service error"}
}
return result, nil
}

然后有一个方法调用了它

1
2
3
4
5
6
7
8
9
10
11
12
13
func main() {
err := MakeFriend()
if err != nil {
panic(err)
}
}

func (c *Client) MakeFriend() (Result, error) {
err := io.Read("friend_list.txt")
if err != nil {
return nil, err
}
return CallService()

这下就麻烦了,当*ServiceError转化为error, nil的ServiceError指针转化为error的时候就不是nil了,这很致命,是的,这非常致命。即使CallService()成功了,main函数还是会panic。

把这个叫做传染性还是比较准确的,异步的代码、鸿蒙的ets都具备一样的性质,他们会不断向上传播,我对这个词还是比较满意。

综上,Go里面,我们可以构筑具体的异常,但是在不能确保上层一直都是用这个细化类型的情况下,接口还是返回error interface。

漫谈了许多,我简单做一个总结

  • 现代编程语言的异常是一条链
  • 现代编程语言应该具备构筑异常链,判断异常是否是某类异常,异常打印的能力
  • 设计符合抽象层级的异常
  • 构筑一个library的时候,尽可能保持完整的异常链,在项目的README,或者项目的某个文件中,详细地列出本library可能抛出的异常,以及异常的含义
  • 在异常链发生变更的时候进行日志打印

消费组名称

  • 共享消费者使用微服务名称,比如(DeviceManager)
  • 广播消费者使用微服务名称+唯一标识,比如
  • kubernetes部署场景下可以将pod名称的唯一部分作为唯一标识,比如下图的nginx可以使用5d4f5c59f8-7hztx作为唯一标识
    1
    2
    3
    4
    5
    $ kubectl get pod
    NAME READY STATUS RESTARTS AGE
    nginx-deployment-5d4f5c59f8-7hztx 1/1 Running 0 2d3h
    nginx-deployment-5d4f5c59f8-xvbnm 1/1 Running 0 2d3h
    redis-5f67c8d8c9-4g2h3 1/1 Running 0 10h
    • pod的IP地址
    • UUID

数据库表

  • 数据库表名使用单数。
  • 数据库的主键,要考虑对应实体物理上是否唯一。
  • 数据库可以分为多个列组合唯一、单列唯一、是否有唯一索引、是否有二级索引。

Unicode起源

ASCII

ASCII(American Standard Code for Information Interchange)是一种字符编码标准,它使用7位二进制数来表示128个字符,包括大小写字母、数字、标点符号、控制字符等。ASCII编码是由美国国家标准协会(ANSI)制定的,于1963年发布,是最早的字符编码标准之一。

ASCII不够用了

随着计算机不仅仅用于英文,而是用于全球各种语言,ASCII编码已经不能满足需求,针对不同语言的编码方案也应运而生,这其中诞生了很多编码方案,比如GB2312、BIG5、ISO-8859等,这些字符集典型的就是将ASCII的最高位利用起来,将7位扩展到8位,这样就可以表示256个字符。比如ISO-8859-1就是将ASCII的最高位利用起来,表示了拉丁字母。ISO-8859-5表示了西里尔字母。

这些字符集各自不包含全部的字符,而且不兼容,这就导致了字符集混乱。这导致在一个文件中混用多种字符成为了不可能完成的事情。而Unicode改变了这一切,它的愿景就是Unicode官网中说到的。

1
Everyone in the world should be able to use their own language on phones and computers.

在早期,Unicode曾想过固定使用16位来表示字符,这就是UCS-2编码,也是UTF-16的前身,后面发现固定16位字符还是不够用,这才发展成了我们现在熟知的Unicode。

Unicode介绍

Unicode是一个文本编码标准。Unicode通过一个唯一的数字来定义每个字符,不管平台、程序或语言。这个数字叫做码点(code point)。Unicode码点是从0x000000到0x10FFFF(十六进制),书写上通常使用U+打头,跟上至少4位十六进制数(不足则补0),如U+0041(字母A)、U+1F600(emoji 😀),理论上,Unicode可以定义1114112个字符。

Unicode的码点跟字符是怎么对应的呢?Unicode将这些码点分成了若干个区段,每个区段称为一个平面(plane),每个平面包含65536(对应低位的0x0000~0xffff)个码点。Unicode总共有17个平面,编号从0到16。Unicode的码点分布如下:

平面编号 码点区间 英文缩写 英文名 中文名
0 号平面 U+000000 - U+00FFFF BMP Basic Multilingual Plane 基本多文种平面
1 号平面 U+010000 - U+01FFFF SMP Supplementary Multilingual Plane 多文种补充平面
2 号平面 U+020000 - U+02FFFF SIP Supplementary Ideographic Plane 表意文字补充平面
3 号平面 U+030000 - U+03FFFF TIP Tertiary Ideographic Plane 表意文字第三平面
4 号平面 ~ 13 号平面 U+040000 - U+0DFFFF / 已分配,但尚未使用 /
14 号平面 U+0E0000 - U+0EFFFF SSP Supplementary Special-purpose Plane 特别用途补充平面
15 号平面 U+0F0000 - U+0FFFFF PUA-A Private Use Area-A 保留作为私人使用区 (A区)
16 号平面 U+100000 - U+10FFFF PUA-B Private Use Area-B 保留作为私人使用区 (B区)

中文、英文均在0号平面,详细的分配可以参考Unicode的RoadMap

那么Unicode先定义了码点和字符之间的对应关系,但是如何存储在磁盘上,如何在网络中传输,这就引入了编码方式,编码方式决定了Unicode的码点如何转换为字节流。这就是Unicode定义的三种编码方式:UTF-32、UTF-16、UTF-8。

UTF-32(32-bit Unicode Transformation Format)

在介绍完Unicode之后,UTF-32是最简单、最容易想到的一种编码方式,直接将Unicode的码点以32位整数的方式存储起来。其中Rust的字符类型char,就使用32位值来表示Unicode字符。

但是这种方式也有很显然的缺点,就是浪费空间,实际Unicode的范围,只需要21位就可以表示了,变长编码就应运而生。

UTF-16(16-bit Unicode Transformation Format)

这里我想给大家讲一个背景知识,编码方案的扩展,通常会尝试去兼容旧的编码方案,这使得新的编辑器可以打开旧的文件,如果没有用到新的字符,那么新的文件也可以被旧的编辑器打开。这使得演进更加平滑,更易落地。

那就不得不先说一下UCS-2编码方案,如前所述,UCS-2想通过固定16位来表示字符,虽然它最终失败了,但是也影响了很多的系统,比如Windows、Jdk。

UTF-16编码就以兼容UCS-2编码、变长为两个目标,UTF-16的编码规则

  • ① 对于码点小于等于U+FFFF的字符,直接使用16位表示,兼容UCS-2
  • ② 对于码点小于等于U+10FFFF的字符,使用两个16位表示

这个补丁机制也常被人称作是surrogate。

对于变长编码来说,对于文件中的任意一个字符,怎么能判断出来这是场景①的字符,还是场景②的第一个字符?抑或是场景②的第二个字符?

Unicode给出的答案是,通过在BMP中舍弃U+D800到U+DFFF的码点,这个区间被称为代理对(surrogate pair),这个区间的码点不会被分配给字符,这样就可以通过这个区间来判断是场景①还是场景②。如果读取的时候,发现前两个字节是D8到DB,那么就是场景②的第一个字符;如果是DC到DF,那么就是场景②的第二个字符;否则就是场景①的字符。

  • 高代理(High-half surrogates):范围是0xD800~0xDBFF,二进制范围为1101 1000 0000 ~ 1101 1111 1111,这也代表着高代理的前六位一定是110110。
  • 低代理(Low-half surrogates):范围是0xDC00~0xDFFF,二进制范围为1101 1100 0000 ~ 1101 1111 1111,这也代表着低代理的前六位一定是110111。

那么聪明的读者应该分析出来了,使用两个16位表示,由于存在代理对的固定部分,剩余的有效位还剩下20位。这20位恰好可以覆盖从U+010000到U+10FFFF的码点范围。由于U+0000-U+FFFF已经在场景①中覆盖,通过将码点减去0x10000,范围就变成了0x000000~0x0FFFFF,恰好是20位整数。

UTF-8(8-bit Unicode Transformation Format)

UTF-8的编码规则

  • ① 对于码点小于等于U+007F的字符,直接使用8位表示,兼容ASCII。
  • ② 对于码点小于等于U+07FF的字符,使用两个8位表示,其中有效位为11位。
  • ③ 对于码点小于等于U+FFFF的字符,使用三个8位表示,其中有效位为16位。
  • ④ 对于码点小于等于U+10FFFF的字符,使用四个8位表示,其中有效位为21位。
  • ⑤ 使用n个字节(n>1)来表示一个字符时,第一个字节的前n位都是1,第n+1位是0,后面的字节的前两位都是10

那么对于一个字节,就可以通过首位是不是1,来判断是1个字节还是n个字节,再通过第二个字节判断是否是首位,最后通过首位来判断字节的个数。

由于UTF-8的有效位最大可达21位,这也就使得UTF-8不用像UTF-16那样减去0x10000。

通过兼容ASCII,最短只用1个字节,这使得UTF-8成为了堪称最流行的编码方式,如果不需要兼容UCS-2,那么几乎可以说UTF-8是最好的选择,堪称当前事实上的标准。值得一提的是,UTF-8的主要设计者,也是Unix的创始人之一,Go语言的设计者之一,Ken Thompson

扩展知识

JDK17中英文字符集内存占用量降低了一半

读者可能会觉得JDK17中中文字符内存占用降低一半是从UTF-16切换到UTF-8导致的,但实则不然,对于JDK来说,切换一种编码方式可谓是伤筋动骨,JDK17通过了JEP254提案,通过添加一个标志位,如果字符串的字符都是ISO-8859-1/Latin-1字符,那么就使用一个字节进行存储。

本文包含,Gin项目推荐布局,一些最佳实践等等。

Gin项目推荐布局

假设项目名称叫Hearth

  • xxx、yyy代表大块的业务区分:如用户、订单、支付
  • aaa、bbb代表小块的业务区分:如(用户的)登录、注册、查询
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
example/
|-- cmd/
| |-- production/
| |-- hearth.go
| |-- local/
| |-- hearth_local.go
|-- pkg/
| |-- apimodel/ 存放所有的ApiModel,用oapi-codegen解析uadp yaml来生成
| |-- boot/
| |-- boot.go //装备Struct,用于Lauch整个项目
| |-- handler/
| |-- xxx/
| |-- xxx_aaa_handler.go
| |-- xxx_bbb_handler.go
| |-- yyy/
| |-- yyy_model.go
| |-- yyy_aaa_handler.go
| |-- yyy_bbb_handler.go
| |-- xxx/
| |-- xxx_aaa_model.go // 存放持久化model,如数据库表,消息中间件结构,redis结构等
| |-- xxx_aaa_service.go
| |-- yyy/
| |-- yyy_bbb_model.go
| |-- yyy_bbb_service.go
| |-- ignite/
| |-- ignite.go
| |-- ignite_test.go
| |-- influx/
| |-- influx.go
| |-- influx_test.go
|-- docker-build/
| |-- scripts/
| |-- start.sh
|-- Dockerfile

放弃的布局方式

此种布局比较适合独立的包,对api结构体的操作复用较差

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
example/
|-- cmd/
| |-- production/
| |-- hearth.go
| |-- local/
| |-- hearth_local.go
|-- pkg/
| |-- boot/
| |-- boot.go //装备Struct,用于Lauch整个项目
| |-- handler/
| |-- xxx/
| |-- xxx_model.go // 将大块业务的model也放在这里,可以使用oapi-codegen来生成结构体
| |-- xxx_aaa_handler.go
| |-- xxx_bbb_handler.go
| |-- yyy/
| |-- yyy_model.go
| |-- yyy_aaa_handler.go
| |-- yyy_bbb_handler.go
| |-- xxx/
| |-- xxx_aaa_model.go // 存放持久化model,如数据库表,消息中间件结构,redis结构等
| |-- xxx_aaa_service.go
| |-- yyy/
| |-- yyy_bbb_model.go
| |-- yyy_bbb_service.go
| |-- ignite/
| |-- ignite.go
| |-- ignite_test.go
| |-- influx/
| |-- influx.go
| |-- influx_test.go
|-- docker-build/
| |-- scripts/
| |-- start.sh
|-- Dockerfile

SaaS服务全局级别的功能

前端调用SaaS服务一个全局级别的接口

sequenceDiagram
    participant User as 用户
    participant Frontend as 前端
    participant Backend as 后端

    User->>Frontend: 点击页面
    Frontend->>Backend: 请求当前功能集
    Backend->>Backend: 返回当前功能集
    alt 用户有权限
        Backend->>Frontend: 返回全局数据
        Frontend->>User: 显示数据
    else 用户无权限
        Backend->>Frontend: 返回错误信息
        Frontend->>User: 显示错误信息
    end

SaaS服务用户级别的功能

前端调用SaaS服务一个用户权限的接口

sequenceDiagram
    participant User as 用户
    participant Frontend as 前端
    participant Backend as 后端

    User->>Frontend: 点击页面
    Frontend->>Backend: 查看用户权限
    Backend->>Backend: 验证用户权限
    alt 用户有权限
        Backend->>Frontend: 返回用户项目数据
        Frontend->>User: 显示数据
    else 用户无权限
        Backend->>Frontend: 返回错误信息
        Frontend->>User: 显示错误信息
    end

前提

Mysql8.0.X版本,且核心配置如下

1
2
3
gtid_mode=ON
binlog_format=row
slave_skip_errors=all

数据不一致的根本原因在于MySQL在设计上不具备分布式系统的完整语义,这导致主从复制在面对网络分区和延迟时无法保持数据一致性。(又不可能采取全同步的模式,那就变成一个CP系统了)。根据数据冲突的内容,如果是**“不同主键,不触发唯一键约束的数据冲突”**,那么后续很容易可以同步到一致。如果触发了主键或者唯一键的冲突,无法互相同步,场景会变得复杂一些,简而言之,只有当后续的操作可以同时在主/备两个数据库中抹平这个差距,数据才能恢复,并且约束越多,抹平也就变得愈困难。举例

  • 仅存在主键约束,数据内容不同,通过下次操作主键(update/delete),则可以恢复
  • 数据库自增主键(两条数据主键不同),触发了唯一字段约束,后续的操作要同时抹平主键、唯一字段、其他内容才能恢复一致(比如根据相同的条件删除掉这条数据等)

下文将分别以插入为例讨论这几个场景,用红色叉号代表同步延迟或者断开。

注:由于Mysql主备同步时会将upsert类的sql转换为实际执行的insert、update语句,也就是说upsert的语义在主备同步不稳定/切换时,容易丢失。

不同主键,不触发唯一键约束的数据冲突

设想表结构,仅有一个name字段,且name为主键。比如我们先在MysqlA中插入了数据name=tt,假设发生了切换,又向MysqlB插入了数据name=wtt。

mysql-case1-insert-data

这就导致MysqlA与MysqlB里面的数据存在着不一致,但是一旦同步恢复,数据就会一致。

mysql-case1-sync-success

仅主键约束,内容不一致冲突

表结构,拥有两个字段,name为主键,age为字段。

同样,插入了两条数据,导致冲突。

mysql-case2-insert-data

即使MysqlA和MysqlB之间同步恢复,后续insert语句也会由于主键冲突同步失败。

mysql-case2-sync-fail

这种不一致要等到后续对主键进行update操作后,才能恢复一致

mysql-case2-recovery

包含主键、唯一约束在内的冲突场景

主键为数据库自增主键,其中一个库为奇数,另一个库为偶数。同时还有唯一约束name

mysql-case3-insert-data

这时候插入数据,就会导致不一致,并且主键也不相同,由于业务不感知主键,使用不存在则更新的语法也会导致主键不一致。

mysql-case3-upsert-data

可以预想到即使恢复同步,MysqlA和MysqlB数据也无法一致。

mysql-case3-sync-fail

在这种场景下,任何针对id的SQL操作都无法在双方数据库中成功同步。例如,MysqlB数据库中不存在id为0的记录,而MysqlA中不存在id为1的记录,导致同步操作失败。

想要恢复一致,可以通过业务唯一约束来删除记录或者是根据业务约束把Mysql主键id也一并更新(不过这很困难,一般这种业务是不会直接操作id的)

那么可能会有人有疑问,为什么不像之前那样,用name作为唯一主键呢?

答:业务的需求多种多样,而且如果唯一约束由多个字段组成,使用Mysql自增主键是唯一的选择。

总结

本文探讨了Mysql异步复制模式下的数据不一致问题,容易在什么时候产生,什么时候恢复。总的来说,业务如果只有一个唯一主键,出现不一致的概率更小。如果业务用数据库自增作为主键,同时伴有唯一约束的插入操作(如upsert等),更容易出现长期的不一致。

WebFlux是Spring 5引入的新的响应式编程框架,它提供了一种基于反应式流的编程模型,可以用于构建高性能、高吞吐量的Web应用程序。

防止大量请求堆积

限制同一时间的并发处理个数

由于WebFlux可以处理大量的请求,如果后端处理较慢(如写db较慢等),可能会导致大量的请求堆积,可以通过限制同一时间的并发处理个数来防止请求堆积。

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
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilter;
import org.springframework.web.server.WebFilterChain;
import reactor.core.publisher.Mono;

import java.util.concurrent.Semaphore;

@Component
public class ConcurrencyLimitingFilter implements WebFilter {
private final Semaphore semaphore;

public ConcurrencyLimitingFilter() {
this.semaphore = new Semaphore(10);
}

@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
if (semaphore.tryAcquire()) {
return chain.filter(exchange)
.doFinally(sig -> semaphore.release());
} else {
exchange.getResponse().setStatusCode(HttpStatus.TOO_MANY_REQUESTS);
return exchange.getResponse().setComplete();
}
}
}

配置超时时间

网络编程中,任何操作都应该有超时时间。WebFlux允许大量的请求进入,如果不设置超时时间,可能会导致大量的请求排队处理(可能客户端早已放弃),可以通过统一Filter来设置最大超时时间。

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
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.web.server.ServerWebExchange;
import org.springframework.web.server.WebFilter;
import org.springframework.web.server.WebFilterChain;
import reactor.core.publisher.Mono;

import java.time.Duration;
import java.util.concurrent.TimeoutException;

@Slf4j
@Component
public class WebRequestTimeoutFilter implements WebFilter {
@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
return chain.filter(exchange)
.timeout(Duration.ofSeconds(10))
.onErrorResume(TimeoutException.class, e -> {
log.error("Request timeout", e);
return Mono.error(new ResponseStatusException(HttpStatus.GATEWAY_TIMEOUT, "Request timeout"));
});
}
}
0%