Sriniously-第一性原理后端开发笔记-全-
Sriniously 第一性原理后端开发笔记(全)
001:从基本原理出发的后端路线图 🗺️
在本节课中,我们将要学习一个全面的后端开发学习路线图。这个路线图旨在帮助你理解后端系统的核心概念,而不仅仅是学习某个特定的语言或框架。我们将从最基础的网络通信开始,逐步深入到复杂的系统设计、安全性和运维概念。
概述
后端工程的范围非常广泛。当我说后端工程时,它远不止构建一组CRUD API。我认为后端工程是关于构建可靠、可扩展、容错、可维护的代码库和高效系统。
如今,开始学习后端开发有很多资源,至少有上百种。但你如何决定学习什么?如何确定优先级?如何看清大局,理解所有这些不同的概念何时以及如何结合在一起?这就是为什么人们需要花费数年时间才能理解这些概念和原理。
人们通常从有限范围的培训开始,无论是大学、训练营还是一个简单的CRUD课程。他们最终通过试错和在其他开发者的帮助下,在此基础上进行构建。
我是一名后端工程师。当我刚开始时,我也面临过这种挣扎。我必须不断搜索资源,向其他开发者学习。我阅读了许多关于后端开发的书籍,研究了数百个开源代码库,以了解行业人士是如何构建系统的。这是一个非常耗时的过程。
第二个问题是,人们通常从特定的语言或框架(如Express、Spring Boot或Ruby on Rails)的角度开始学习后端开发。这样做的问题是,你会通过特定语言和生态系统的视角来看待要解决的问题,而这其中存在盲点。
想象一下,你必须切换到不同的语言。假设你使用Ruby on Rails多年,有一天你的公司出于性能原因决定迁移到Go。在这种情况下,如果你不理解底层系统,你能转移多少知识?
因此,我决定整理一个全面的视频系列,这些视频基于后端系统的基础概念,这些概念来自我多年来阅读的各种书籍和开源代码库。
我们将从后端系统在幕后如何工作的高层理解开始。我们将看看来自浏览器的请求如何流经不同的网络节点、防火墙或互联网,如何路由到位于远程AWS服务器上的后端服务器,以及它如何响应请求。我们将了解TCP/IP协议栈的样子,这应该能让我们对系统如何通信、客户端如何与服务器通信以及服务器如何响应有一个相当生动的认识。
网络与协议
上一节我们介绍了后端系统的宏观视角,本节中我们来看看网络通信的基础——HTTP协议。
从那里,我们将继续理解HTTP协议。我们将看看它扮演的角色,通信如何通过HTTP建立,HTTP原始消息是什么样子,以及什么是HTTP头部。我们将探讨头部的作用,不同类型的头部,如请求头部、响应头部、通用头部和安全头部。
我们将研究不同类型的HTTP方法:GET、POST、PUT、DELETE,以及何时使用它们、它们的语义和背后的原理。
我们将探讨什么是CORS流程以及它是如何工作的。我们将看看简单请求与预检请求有何不同,以及预检请求从浏览器到服务器再返回浏览器的流程是怎样的。
我们将研究HTTP响应,它的结构,以及服务器返回的不同状态码,何时返回哪种类型的代码,以及最常用的HTTP状态码。
然后,我们将探讨HTTP缓存。以下是不同类型的HTTP缓存技术:
- ETag:使用实体标签进行缓存验证。
- Max-Age:通过
Cache-Control头部设置资源的最大缓存时间。
我们将研究HTTP/1.1、HTTP/2.0和HTTP/3.0之间的差异以及它们之间的区别。
我们将探讨客户端和服务器之间如何使用不同的头部进行内容协商。我们将看看HTTP中持久连接是如何工作的。
我们将研究HTTP压缩,不同类型的压缩技术,如GZIP、Deflate和Brotli,以及哪种是常用技术。

我们将探讨其安全方面,即SSL/TLS和HTTPS。
路由

上一节我们讨论了HTTP协议,它是客户端与服务器通信的语言。本节中,我们来看看服务器如何根据URL将请求引导到正确的处理逻辑——也就是路由。
然后,我们将继续讨论路由。我们将探讨路由如何将URL映射到服务器逻辑,以及路由和HTTP方法之间的联系。
以下是路由的不同组成部分:
- 路径参数:URL路径中的动态部分,如
/users/{id}。 - 查询参数:URL中
?后面的键值对,如?page=1&limit=10。
我们将探讨不同类型的路由:
- 静态路由:匹配固定路径,如
/about。 - 动态路由:包含参数的路由,如
/users/:id。 - 嵌套路由:路由可以包含子路由。
- 分层路由:基于目录结构的路由组织。
- 通配符路由:匹配任意路径,如
/files/*。 - 基于正则表达式的路由:使用正则表达式模式匹配路径。
我们将探讨如何使用HTTP进行API版本控制,以及不同类型的版本控制技术。
我们将看看弃用路由的最佳方式是什么,以及行业中的最佳实践。
我们将探讨路由分组的好处,以及它如何帮助进行版本控制、权限管理和共享中间件。
我们将探讨如何保护路由。
我们将探讨如何优化路由匹配性能。
序列化与反序列化
上一节我们介绍了路由,它负责将请求引导到正确的处理程序。本节中,我们来看看数据在网络上传输前如何被转换格式。
然后,我们将转向序列化和反序列化。这基本上意味着在通过网络发送数据之前,服务器如何将数据转换为特定格式,以及在从客户端接收数据后,如何将从互联网上接收到的数据转换为其自身的原生格式,这被称为反序列化。
我们将探讨为什么需要它,以及它如何帮助实现互操作性标准。
以下是序列化/反序列化中使用的不同格式:
- 基于文本的格式:如JSON或XML。
- 二进制格式:如Protocol Buffers。
我们将探讨这两种格式之间的性能差异,以及何时使用哪一种。
我们将探讨不同编程语言如何实现序列化和反序列化。
我们将探索一种流行的基于文本的序列化和反序列化格式,即JSON。我们将了解JSON的结构、不同的数据类型,如字符串、数字、布尔值、数组和对象。
我们将探讨JSON中如何处理嵌套对象和集合的序列化。
我们将探讨如何将数据反序列化为原生数据结构,例如Python字典、Go结构体或JavaScript对象。
以下是处理JSON时常见的错误:
- 处理缺失或额外的字段。
- 处理空值。
- 日期序列化问题和时区问题。
我们将探讨如何在将数据序列化为JSON之前或之后实现自定义序列化。
我们将探讨序列化和反序列化中的错误处理,例如无效数据、数据转换错误、未知字段。
我们将探讨其安全方面,如注入攻击,为什么在反序列化之前要进行验证,以及在处理数据之前使用JSON模式验证。
我们将探讨其性能方面,例如通过压缩减少序列化数据的大小,以及消除不必要的字段。
我们将探讨基于文本和二进制格式(如JSON与Protocol Buffers)之间的序列化性能差异,以及可读性与性能之间的权衡。因为在基于文本的格式中,你可以轻松检查负载,这在二进制格式中并不适用,但二进制格式更快。所以何时使用二进制格式,何时使用基于文本的格式,这是一个有效的权衡。
认证与授权

上一节我们讨论了数据格式的转换,本节中我们来看看如何确保只有合法的用户和请求才能访问系统资源。
然后,我们将继续讨论认证和授权。我们将探讨为什么使用它,不同类型的认证,如无状态和有状态层。
我们将探讨基本认证、令牌认证。我们将深入探讨会话、JWT、Cookie。
我们将深入探讨OAuth 2.0协议和OpenID Connect。
我们将探讨API密钥如何工作。
我们将探讨多因素认证如何工作。
我们将探讨什么是加盐、哈希以及授权中使用的不同加密技术。
我们将探讨ABAC、RBAC、ReBAC。
我们将探讨安全方面的最佳实践,例如保护Cookie、避免CSRF、中间人攻击等。
我们将探讨审计日志记录,这基本上意味着记录认证和授权事件以供审计和监控,例如失败的登录尝试、权限提升和对敏感资源的访问。
我们将探讨一个有趣的话题:模糊化认证相关的错误消息,防止通过详细的错误消息向攻击者泄露信息。
我们将探讨处理边缘情况,例如在不同故障模式(如速率限制、账户锁定)之间保持响应的一致性。
我们将探讨如何避免时序攻击。例如,攻击者可以利用错误响应的时间差异来推断有效的凭据。例如,错误密码的错误响应可能比有效用户名的错误响应花费更长的时间,因为检查密码必须进行某种哈希或加密技术,这需要一点时间。因此,人们可以推断出它们之间微小的时间差并猜测密码。尽管这非常困难,但我们不想留下任何安全漏洞。
验证与转换
上一节我们讨论了如何验证用户身份,本节中我们来看看如何验证用户提交的数据本身是否合法有效。
然后,下一个主题我们将探讨验证和转换。我们将探讨不同类型的验证,例如语法验证。例如,检查电子邮件是否是电子邮件格式,或者是否是有效的电话号码,或者是否是有效的日期格式。
还有语义验证。例如,出生日期不能是将来的日期,或者一个人的年龄应该在1到120岁之间。这些被称为语义验证。
然后,我们有类型验证。例如,检查输入值是否匹配预期的类型,是否是字符串、整数、数组或对象。这些类型的检查被称为类型验证。
我们将探讨验证的最佳实践是什么。我们将探讨客户端验证和服务器端验证之间的区别,以及为什么即使已经实施了客户端验证,仍然需要服务器端验证。客户端验证通过提供即时反馈来改善用户体验,但服务器端验证是真正的安全实现,因为它是业务逻辑的入口。
我们将探讨通过提前返回来减少不必要的处理,从而实现快速失败的重要性。
我们将探讨为什么要在前端验证和后端验证之间保持一致。
然后是转换。例如,类型转换,将字符串转换为数字或将数字转换为字符串。因为在查询参数或路径参数中,所有内容都是字符串。但假设我们期望一个ID字段是数字,那么在将其发送给处理程序之前,我们必须将该字符串转换为数字,这个步骤称为转换,我们必须在验证管道中处理。
还有不同的日期格式。例如,前端可能发送不同的格式,或者我们可能期望一个时间戳,这也必须在验证管道中处理。
然后是规范化。例如,将电子邮件转换为小写,或修剪字符串中的空格,或向电话号码添加国家代码。这些被称为规范化。
然后是出于安全考虑的清理。例如,我们必须清理用户提交的字符串,以防止SQL注入等攻击。
然后是复杂的验证逻辑。例如,关系验证。假设用户提交了一个表单,其中有两个字段,一个是密码,另一个是确认密码,我们必须检查这两个字符串是否相同。这就是基于关系的验证。
还有条件验证。例如,在表单中有两个字段,一个是伴侣姓名,另一个是“已婚”,这是一个布尔值(真或假)。伴侣字段可能只在“已婚”为真时才需要。这就是条件验证。我们必须进行这类检查。
然后是链式验证。例如,将字符串转换为小写,然后删除特殊字符,然后检查其长度。
然后,我们将探讨验证中的错误处理,例如向前端发送有意义的错误消息,以便用户可以修复它们。
以及在一个响应中聚合所有验证错误,以便在客户端显示。
或者模糊化错误消息。因此,与其说“无效密码”,我们不得不说“无效凭据”,以防止不同类型的攻击。
然后,我们将探讨如何优雅地处理失败的转换,例如无效的JSON和失败的速率转换,以及如何让用户知道有意义的错误消息。
然后,我们将探讨验证的性能权衡,以及如何通过提前返回、避免冗余验证来优化它。
中间件
上一节我们介绍了数据验证,它是请求处理流程中的一环。本节中,我们来看看一个更通用的、可复用的请求处理组件——中间件。
然后,我们的下一站将是中间件。我们将探讨什么是中间件以及何时使用它们。
以下是中间件的常见用例:
- 日志记录
- 认证检查
- 请求体解析
- 错误处理
我们将探讨中间件在请求周期中的角色,例如请求前中间件或请求后中间件。
我们将探讨中间件的流程,例如链式技术,中间件按顺序执行,将控制权传递给下一个中间件,直到请求到达其最终处理程序。
我们将探讨如何适当地排序中间件。例如,我们必须按照这个顺序:我们必须记录请求,我们必须检查用户是否已认证,我们必须进行验证,我们必须进行路由处理,然后我们必须进行错误处理。所以这个顺序在中间件流程中很重要。
我们将探讨中间件中的next函数如何工作,以及如何提前退出中间件。
我们将探讨中间件如何通过处理404错误来短路请求管道。
我们将探讨一些常见的中间件,例如安全中间件,它们添加安全头部,如X-Content-Type-Options、Strict-Transport-Security或Content-Security-Policy。
或者为每个请求或响应添加适当CORS头部的中间件。
以及避免CSRF攻击的中间件。
速率限制中间件。
然后,我们有认证中间件,以便在我们的应用程序中重用路由保护逻辑。
然后,我们有日志记录和监控中间件,用于请求日志记录或结构化日志记录,以便于生产环境中的可观察性或调试。
然后,我们有错误处理中间件,它们捕获并格式化应用程序级错误,以提供一致的API响应。
然后,我们有压缩或性能相关的中间件,它们基本上压缩响应体以减少通过网络发送的数据大小。
然后,我们有数据解析中间件,解析传入的请求体,如JSON、URL编码的表单和文件上传,以及用于文件上传的多部分表单数据。
然后,我们将探讨中间件的性能和可扩展性方面,例如保持中间件轻量级和高效的最佳实践是什么,确保中间件以正确的顺序应用,或者中间件顺序如何影响应用程序的性能和安全性。
请求上下文
上一节我们讨论了中间件,它们可以在请求处理过程中共享逻辑。本节中,我们来看看如何在请求的整个生命周期内安全地共享数据。
下一个主题将是请求上下文。请求上下文基本上是指通常通过应用程序中间件、控制器和服务传递的元数据。它是一种请求范围的状态,该状态仅对该请求有效。
因此,在这里我们将探讨请求的生命周期,在请求持续时间内维护状态,在不同应用程序层之间共享数据而无需耦合,以及上下文如何提供临时的请求范围状态。
我们将探讨请求上下文的不同组成部分。有请求数据,例如HTTP方法、URL、头部、查询参数和正文。
还有会话和用户信息。例如,在认证中间件中,我们获取用户信息,然后将其添加到请求上下文中。因此,对于该请求范围,用户信息被注入到上下文中。
然后,我们有跟踪和日志记录信息,例如唯一的请求ID或跟踪ID。
然后,我们有请求特定的数据,例如在请求生命周期中注入的自定义数据,如缓存数据或权限检查。
我们将探讨其用例,例如认证、速率限制、跟踪、日志记录。

我们将探讨中间件和请求上下文之间的联系。
我们将探讨不同类型的超时,请求超时、自定义超时、取消信号。
我们将探讨最佳实践,例如保持其轻量级以防止内存开销,确保在请求生命周期后清理上下文状态以防止内存泄漏,避免通过上下文紧密耦合组件或过度依赖它来传递数据。
处理器与控制器
上一节我们介绍了请求上下文,它为处理请求提供了环境。本节中,我们来看看请求的最终目的地——处理器和控制器,它们负责生成具体的响应。
然后,我们将转向处理器和控制器、MVC模式,以及什么是处理器、控制器和服务,它们各自的职责是什么。
以及如何通过中间件减少代码重复。
然后,我们将在处理器中进行集中式错误处理,以及一致的成功和错误格式,以及如何在控制器中实现它们。
然后,我们将探讨不同类型的CRUD操作,例如CRUD操作如何映射HTTP方法,以及与每个方法相关的常见API。例如,POST方法通常用于创建和提交,状态码通常是201 Created或400(如果是错误请求)。GET请求通常与获取资源列表或获取单个资源相关联。我们有PUT和PATCH来更新资源,DELETE来删除资源。
我们将探讨如何实现分页,以及如何实现搜索API。
我们将探讨如何进行排序和如何进行过滤。
我们将探讨最佳实践,例如严格验证、一致的响应格式化、限制负载大小、重命名敏感字段、错误处理、认证和授权。
然后,我们将探讨什么是REST架构,以及实现RESTful API的最佳实践。
我们将探讨围绕资源设计API的原则,并坚持HTTP语义。
以及过滤和分页的最佳实践。
我们将探讨不同类型的版本控制,如URI版本控制、头部版本控制、查询字符串版本控制、媒体类型版本控制。
我们将探讨如何以OpenAPI规范为先的理念设计API。
我们将探讨内容协商。

我们将探讨捕获异常并提供有意义的错误消息。
我们将探讨支持客户端缓存,如ETag。
以及优化大型请求和响应。
数据库
上一节我们讨论了如何设计API接口,本节中我们来看看数据如何被持久化存储——也就是数据库。
然后,我们将转向一个非常重要的主题,即数据库。我们将探讨关系型和非关系型数据库,它们之间的区别,以及何时使用哪一种。
我们将探讨一些理论概念,如ACID和CAP定理。
我们将探讨基本查询和连接,以及数据库设计最佳实践,如模式设计、索引。
我们将探讨不同的优化方法,如查询优化、缓存、连接池。
然后,我们有数据完整性,如约束和验证、事务和并发。
我们将探讨ORM如何工作,是否使用ORM,以及其中的权衡。
我们将探讨什么是数据库迁移。
业务逻辑层
上一节我们介绍了数据存储层,本节中我们来看看处理核心业务规则和流程的中间层。
然后,我们将转向业务逻辑层,也称为BLL。我们将探讨它的角色是什么,请求周期的不同层次是什么。
例如,我们有验证层、路由、中间件以及处理器和控制器,它们都属于表示层,因为它们处理用户数据,无论是接受用户数据还是发送用户数据。所以这些是表示层的一部分。

之后,我们有业务逻辑层,它是中间层,处理我们的核心业务逻辑。
之后,我们有数据访问层,它处理数据库,执行查询、插入或删除操作。业务逻辑层在幕后使用数据访问层。
我们将探讨不同的设计原则,如关注点分离、单一职责、控制反转、依赖注入。
我们将探讨业务逻辑层的组成部分。例如,我们有服务,我们有代表核心实体(如用户或订单)的领域模型。然后,我们有业务规则。然后,我们有业务验证逻辑。
我们将探讨服务层设计的最佳实践。
我们将探讨如何正确处理错误,以及如何将这些错误从我们的服务层传播到我们的表示层。
缓存
上一节我们讨论了业务逻辑,它处理核心计算。本节中,我们来看看如何通过缓存来提升系统性能。
之后,我们有缓存。我们将讨论为什么需要缓存,以及它与数据库持久化有何不同。

以下是不同类型的缓存:
- 内存缓存:如Redis、Memcached。
- 浏览器缓存:由HTTP头部控制。
- 数据库缓存:如查询缓存。
我们将探讨为什么需要客户端缓存和服务器端缓存。
以下是不同的缓存策略:
- Cache Aside:应用先查缓存,未命中则查数据库并写入缓存。
- Write Through:数据同时写入缓存和数据库。
- Write Behind:先写入缓存,稍后异步批量写入数据库。
- Read Through:缓存负责在未命中时从数据库加载数据。
以下是不同的缓存淘汰策略:
- LRU:最近最少使用。
- FIFO:先进先出。
- TTL:生存时间。
我们将探讨缓存失效的必要性,例如手动缓存失效、基于生存时间的失效或基于事件的批量失效。
我们将探讨缓存的不同级别。1级缓存是内存缓存,2级缓存是网络分布式缓存。还有分层缓存,它结合了1级和2级缓存策略,其中频繁使用的数据存储在快速的小缓存(1级缓存)中,而不太频繁使用的数据存储在较慢或较大的缓存(2级缓存)中。
我们将探讨Web应用程序的缓存是什么样子,即缓存静态资源或使用头部缓存API响应。
我们将探讨如何为数据库进行缓存,例如查询缓存,如在Redis中存储重型连接查询的结果。
我们将探讨缓存命中率和缓存未命中率,以及如何优化它们。

事务性邮件与任务队列
上一节我们介绍了缓存,它是一种提升读性能的技术。本节中,我们来看看如何处理异步和耗时的任务,比如发送邮件。
之后,我们将转向事务性邮件。我们将探讨它们的用途是什么,常见的用例有哪些。
我们将探讨事务性邮件的结构:主题、预览文本、正文头部、主要内容、行动号召、页脚。
以及如何使用不同的动态参数进行个性化设置。
然后,我们有任务队列和消息队列。我们将探讨常见的用例是什么。例如,队列可能用于发送电子邮件、处理图像文件和第三方API集成(如支付处理或Webhook),或者卸载繁重的计算,如批处理。
例如,用户点击一个按钮来“清除我所有的数据”。要清除所有用户数据,我们必须为不同的表执行不同的查询来清除所有用户数据,这可能需要一些时间。因此,我们不是阻塞请求,而是立即返回响应,并通过推入任务队列来触发一个后台作业。
我们将探讨调度的用例是什么。例如,运行数据库备份、循环通知和提醒、数据同步或维护相关问题,例如清除日志或缓存。
我们将探讨任务队列的不同组成部分,即生产者、队列、消费者、代理、后端。
我们将探讨任务依赖性的流程。例如,它可能是链式依赖,或者可能有父子关系。
我们将探讨任务组,即并发执行多个任务并等待它们全部同时完成。
我们将探讨如何处理错误并在任务队列中实现重试逻辑。
我们将探讨任务优先级和速率限制。例如,优先处理像支付处理这样的任务,而不是像发送通知这样的低优先级任务。
搜索(Elasticsearch)
上一节我们讨论了异步任务处理,本节中我们来看看如何实现高效、复杂的搜索功能。
之后,我们将转向Elasticsearch。我们将探讨为什么使用Elasticsearch,以及它在幕后是如何工作的。
我们将探讨使用的不同技术,例如倒排索引、词频和逆文档频率、段和分片。
我们将探讨Elasticsearch的用例。例如,提供输入提示体验、日志分析或社交媒体搜索,例如用户资料、帖子、评论的全文搜索。
我们将探讨如何创建和管理索引。

我们将探讨如何搜索和查询,不同类型的搜索:基本搜索、全文搜索、相关性评分。

我们将探讨如何通过区分文本字段与关键字字段、理解分析器和提升、分页来优化搜索性能。
我们将探讨一些高级搜索模式,例如过滤、聚合、模糊搜索。
然后,我们将探讨Kibana如何工作,以及如何以用户友好的方式使用Elasticsearch。
以及最佳实践,例如显式定义字段映射、优化分片数量、批量索引数据、避免通配符查询。
错误处理
在构建系统时,错误是不可避免的。本节中,我们系统地学习如何优雅地处理错误,保证系统的健壮性。
然后,我们有错误处理。我们将探讨应用程序中不同类型的错误,可能是语法错误、运行时错误、逻辑错误。
以及不同的错误处理策略,例如故障安全或快速失败、优雅降级或预防错误。
以下是错误处理的不同实践:
- 尽早捕获错误。
- 不要忽略错误。
- 使用自定义错误类型。
- 优雅地失败。
- 记录错误并使用堆栈跟踪。
我们将探讨全局错误处理程序如何工作。
我们将探讨如何适当地处理面向用户的错误,例如提供友好的错误消息和提供可操作的反馈。
我们将探讨监控和日志记录在错误处理中的重要性,以及不同的工具,如Sentry、ELK Stack。

以及不同的错误警报,如基于电子邮件的警报和基于SMS的警报。
配置管理
上一节我们讨论了如何处理运行时错误,本节中我们来看看如何管理应用程序的配置,使其更灵活、更安全。
之后,我们有配置管理。我们将探讨配置管理到底是什么,以及它如何帮助提高灵活性,并将环境特定设置与应用程序逻辑解耦。
以及用例是什么。例如,不同的环境使用配置管理,安全管理敏感数据,如API密钥、数据库密码和SSL证书,动态启用和禁用功能而无需更改代码库。
我们将探讨配置管理的最佳实践。
我们将探讨不同类型的配置,例如静态配置,如数据库凭证和API端点;动态配置,如功能标志、速率限制;以及敏感配置,如凭证、令牌、密钥。
以及配置的不同来源。例如,它可以是.env文件、JSON或YAML文件。
以及使用环境变量、命令行标志与静态文件之间的区别。
日志、监控与可观测性

上一节我们介绍了配置管理,它关乎系统的静态设置。本节中,我们来看看如何动态地了解系统的运行状态。
之后,我们有日志记录、监控和可观测性,这是一个非常重要的主题。我们将探讨日志记录、跟踪、监控和可观测性之间的区别。
我们将探讨不同类型的日志记录,如系统日志、应用程序访问日志、安全日志。
我们将探讨日志的不同级别,如调试、信息、警告、错误、致命。
我们将探讨结构化日志记录和非结构化日志记录之间的区别。
以及日志记录的最佳实践,例如集中式日志记录、日志轮换和保留、上下文化和有意义的日志记录,以及避免记录敏感数据,如密码和API密钥。
然后,我们将探讨监控。我们将探讨不同类型的监控,如基础设施监控、应用程序性能监控、正常运行时间监控。
以及监控中使用的不同工具,如Prometheus、Grafana。
以及如何通过定义阈值、创建警报来管理警报和通知,并通过仅创建可操作的警报、确保警报有意义且必要来避免警报疲劳。

然后,我们将探讨可观测性。我们将探讨可观测性的三大支柱,即日志、指标和跟踪。
以及围绕它的最佳实践。
以及日志管理的安全性和合规性。
优雅关闭
系统不仅需要正确运行,还需要能正确停止。本节中,我们来看看如何让服务平滑地关闭,不影响用户体验和数据完整性。
之后,我们将转向优雅关闭。我们将探讨为什么需要优雅关闭,以及它在幕后是如何工作的。
以及不同的用例是什么。例如,在服务器重启、云环境中扩展或服务或长时间运行的作业时可能需要它。
我们将探讨它是如何工作的,例如信号处理、SIGTERM和SIGINT信号。

以下是优雅关闭的不同步骤:
- 捕获终止信号(如SIGTERM)。
- 停止接受新的请求。
- 完成正在进行的请求处理。
- 关闭外部资源(如数据库连接、打开的文件)。
- 终止应用程序。
安全
在互联网上,安全至关重要。本节中,我们系统性地学习后端开发中常见的安全威胁和防护措施。
之后,我们将转向安全。我们将探讨后端代码库中安全的不同方面,避免不同的安全攻击,如SQL注入、NoSQL注入、CSRF、身份认证破坏、不安全的反序列化。
以及安全软件设计的原则,例如最小权限、纵深防御、默认安全、职责分离、安全设计。
然后,我们将探讨输入验证和清理的重要性。
以及速率限制、内容安全策略、CORS和SameSite Cookie。
以及监控事件的重要性。
扩展与性能
随着用户增长,系统需要能够扩展。本节中,我们来看看如何评估和提升系统的性能与扩展能力。
之后,我们有扩展和性能。我们将探讨性能的不同指标,如响应时间、资源利用率。
我们将探讨识别瓶颈、缓存和数据库优化。例如,避免N+1查询问题,确保正确使用连接,并在适当的地方使用懒加载。
然后,我们有使用数据库索引来加速对频繁查询字段的读取操作,例如对外键或搜索字段建立索引。
如何批量处理数据以最小化数据库负载并提高大数据集的性能。
如何避免内存泄漏,例如关闭文件句柄、数据库连接或清理长时间运行的进程。
通过减少负载大小和使用压缩来最小化网络开销。
我们将探讨如何进行性能测试和分析。
我们将探讨编写高性能代码的一些最佳实践,例如首先专注于编写清晰和可维护的代码,而不是过早优化;编写模块化代码,以便更容易优化单个组件而不影响整个系统。


确保如果特定资源负载过重或不可用,系统能够优雅地降级而不会崩溃。
以及如何将非关键任务(如发送电子邮件或记录日志)卸载到后台进程或任务队列,以释放资源用于更关键的操作。
并发与并行、对象存储等高级主题
后端系统面临各种复杂场景。本节中,我们简要了解并发处理、大文件管理、实时通信等高级主题。
然后,我们有并发和并行。我们将探讨并发和并行之间的区别,以及并发如何帮助I/O密集型任务,并行如何帮助CPU密集型任务。
然后,我们有对象存储和大文件。我们将探讨使用对象存储(如AWS S3)的一些常见用例,以及如何通过分块和流式传输来管理大文件。
我们将探讨多部分文件上传。
然后,我们有实时后端系统,我们将探讨WebSocket或服务器发送事件和发布/订阅架构。
测试与代码质量
高质量的代码离不开完善的测试。本节中,我们来看看如何通过测试和代码质量工具来保证软件的可靠性。
然后,我们有测试和代码质量。在这里,我们将探讨不同类型的测试:单元测试、集成测试、端到端测试、功能测试、回归测试、性能测试、负载和压力测试、用户验收测试、安全测试。
我们将探讨什么是测试驱动开发。
如何自动化CI/CD环境中的测试。
如何通过外部代码检查和格式化工具来管理代码质量。
以及代码质量和覆盖率的衡量标准是什么,例如质量指标,如圈复杂度(它通过计算代码中可能的路径数量来衡量函数的复杂性);以及可维护性指数(它基本上根据复杂性、代码行数和其他因素来量化维护代码库的容易程度)。
十二要素应用与OpenAPI标准
构建现代云原生应用需要遵循一些最佳实践。本节中,我们了解两个重要的方法论和标准。
然后,我们将探讨一组非常有趣的原则,称为十二要素应用。
之后,我们将转向OpenAPI标准。我们将探讨为什么需要这些标准,以及为什么我们应该坚持使用它。


它的好处是什么。


用例是什么,例如文档自动化。
以及围绕它的生态系统,如Swagger Codegen和Postman。
以及历史,Swagger到OpenAPI的过渡,以及当前活跃的不同版本是什么。
以及OpenAPI文档的关键概念是什么。例如,有API路径、请求和响应定义、参数、模式。
以及OpenAPI文档的结构是什么,有元数据、路径、组件、安全定义和响应。
我们将探讨OpenAPI 3.0和3.1的新特性是什么。
围绕OpenAPI的工具是什么,例如Swagger UI、代码生成、Postman。
最佳实践是什么,例如避免重复和坚持标准。


我们将探讨一种非常有趣的开发方法,即API优先开发,你先定义你的OpenAPI标准或编写你的OpenAPI规范,然后才开始创建API。
Webhook
API通常是客户端主动请求数据,但有时服务器需要主动通知客户端。本节中,我们来看看这种模式——Webhook。
之后,我们将转向Webhook。我们将探讨Webhook的用例是什么,例如事件通知、第三方集成。
我们将探讨API与Webhook针对同一用例的区别。例如,对于API,我们可能必须使用轮询(客户端发起),而Webhook是推送(服务器发起)。
以下是Webhook的关键组成部分:
- Webhook URL:接收事件的端点。
- 事件触发器:触发Webhook的条件。
- 负载:发送的数据。
- HTTP方法:通常是POST。
- 响应处理:如何处理接收方的响应。
围绕Webhook的最佳实践之一,例如Webhook签名验证、使用HTTPS、快速响应重试逻辑、日志记录。
如何用Ngrok等工具测试Webhook。
现实世界的用例,如Stripe支付处理、GitHub Webhook、Slack、Discord等。
DevOps概念
现代后端开发与运维紧密相连。本节中,我们简要了解后端工程师应该熟悉的DevOps核心概念。
最后,我们将探讨后端工程师应该熟悉的一些DevOps概念。例如,一些核心概念,如持续集成、持续交付、持续部署。
DevOps实践,如基础设施即代码、配置管理、版本控制。
围绕DevOps的不同工具。例如,使用Docker创建容器,或使用Kubernetes编排容器,以及CI/CD流水线。
以及如何扩展你的服务,水平扩展与垂直扩展。
以及不同的部署策略,如蓝绿部署、滚动部署等。
以上就是全部内容。这些是我们在接下来的30或40个视频中将要涵盖的所有概念。
请保持关注。
总结

本节课中我们一起学习了后端开发的完整路线图。我们从后端工程的定义出发,强调了构建可靠、可扩展系统的重要性,而不仅仅是编写API。我们逐一介绍了从网络基础(HTTP/TCP)到核心后端组件(路由、序列化、认证、数据库),再到高级主题(缓存、搜索、安全、性能、DevOps)的每一个关键领域。这个路线图旨在帮助你建立系统性的理解,超越特定语言或框架的局限,为成为一名优秀的后端工程师打下坚实的基础。
002:踏上真正的后端工程师之路 🛣️
在本节课中,我们将要学习本系列视频的总体结构和学习路径。我们将明确学习目标,并了解如何从哲学原理过渡到具体实现,最终构建出生产级的后端系统。

在深入核心内容之前,需要明确你对本系列视频的期望以及如何从中学习。
让我们明确学习目标。



在你当前观看的这个特定播放列表中,我的目标是向所有人讲述后端工程的故事、其背后的哲学、核心问题、内部工作原理以及不同组件和机器之间的协作。


这种叙述方式将帮助你看到一个生产级后端系统的全貌,并帮助你理解那些被你日常使用的语言、运行时、框架和库所抽象掉的概念。
让我们把它写下来。
因此,这个播放列表是关于原理和哲学的。

这是迈向成为一名具备语言无关技能的后端工程师的第一步。

这些技能超越了特定的框架、ORM或你每天使用的任何特定库。

一旦你打好了基础,开始看到每个后端应用背后的通用模式,并理解了哲学思想以及各个概念如何连接在一起,那时就是讨论具体实现的最佳时机。


我们将看到所有这些原则如何共同作用,创建一个完整的后端应用。


以及它们在真实代码中的样子和工作方式。所以,让我们把它写下来:实现。


在这个阶段,我们必须选择我们的编程语言和生态系统。目前,我计划发布两个版本:一个使用 Node.js,另一个使用 Golang。因为这是我拥有第一手经验并每天使用的两种语言。

这个阶段将是另一个播放列表。我们将选取每个原理,并在特定的语言及其周边生态中进行深入探讨。因此,本播放列表中的大多数视频,在下一个播放列表中都会有一个对应的、针对具体实现的视频。
例如,在关于数据库的原理中,我们会讨论数据库、驱动、迁移以及后端工程师日常处理的所有相关概念。那么,Node.js 和 Golang 的播放列表将包含该原理的实现,我们会深入探讨例如使用 JavaScript 驱动 Postgres.js 或 Golang 驱动 PGX 来连接 PostgreSQL,并涵盖围绕它的每一个概念。



在第三阶段,所有内容将汇聚在一起。


我们所有的概念、所有针对特定语言的深入探讨、所有的哲学思想,都将在这里整合。我们将从头到尾构建符合行业标准和最佳实践的生产级项目。




我们将构建好几个这样的项目,你可以选择跟随学习。
让我们把它写下来:生产级项目。


在这段学习旅程结束时——如果你决定参与并内化了所有知识,且跟随完成了所有项目——你应该可以自信地称自己为一名后端工程师。

你将能够走出去,构建真实的、可扩展的系统。

这些系统可以从零用户起步,扩展到百万用户,并且是能够被长期维护的系统。

明确了这些期望,让我们开始吧。




本节课中我们一起学习了本系列教程的三阶段学习路径:原理与哲学、具体语言实现 以及 生产级项目构建。我们明确了学习目标是掌握超越特定框架的语言无关技能,并最终能够构建可扩展、可维护的真实后端系统。
003:什么是后端,它们如何运作以及我们为何需要它们? 🖥️
在本节课中,我们将要学习后端的核心概念。我们将探讨后端的传统定义,并通过一个实际的网络请求流程,来理解请求是如何从浏览器出发,经过DNS、防火墙、反向代理等组件,最终到达后端服务器的。我们还将对比前端与后端运行环境的根本差异,并深入分析为什么某些关键逻辑必须放在后端执行。
什么是后端? 🔍
根据传统定义,后端是一台计算机,它监听一个开放的端口(例如80或443),等待HTTP、WebSocket、gRPC或其他类型的请求。这台计算机可以通过互联网访问,以便客户端或其他前端应用能够连接到它,并根据请求类型向其发送或接收数据。我们称之为“服务器”,因为它提供或服务于某种内容,无论是静态文件(如图片、JavaScript或HTML文件)还是JSON数据。它同时也接收来自客户端发送的数据。
这是一个关于后端是什么以及它如何工作的合理定义。但为了让你获得一个更全面的视角,真正从物理层面看到其背后的组件是如何工作的,让我们来梳理整个流程。
后端请求流程全解析 🛣️
我有一个部署在AWS上的后端服务器。我们以此为例。以下是示例数据,后端服务器正在运行。
我们再次打开浏览器,并开启网络工具面板。刷新页面并禁用缓存,以便获得正确的状态码。这就是从我们浏览器发起,到达服务器,并收到响应的请求。我们将在后续视频中详细讲解请求和响应,现在先看看整个流程是怎样的。

让我们追踪一下请求是如何从浏览器出发并到达服务器的。
第一步:域名解析
首先看到的是域名。例如 backenddemo.sriniously.xyz,这是一个子域名。我们首先应该查看DNS服务器。
这是我的DNS服务器配置,其中定义了不同类型的记录。DNS本身是一个庞大的主题,这里只介绍基础知识。简单来说,DNS有不同的记录类型,你可以使用A记录指向一个特定的IP地址,也可以使用CNAME记录指向一个特定的域名或子域名。
在我的现有域名和子域名配置中,需要关注的是这部分。这里有两个A记录,其中一个是 backenddemo,它指向一个特定的IP地址。这个IP地址来自哪里呢?它来自AWS中的一个EC2实例。
第二步:到达云服务器

这是我的AWS控制台,进入EC2实例列表。这就是已部署的实例。这里显示的公共IP地址,正是我们刚才在DNS配置中看到的。因此,特定的子域名 backenddemo 指向这个IP地址,我们的请求通过这个IP到达EC2实例。
在请求到达我们的服务器(即这台计算机)之前,它会经过一个AWS原生的防火墙文件。这个文件需要允许某些类型的请求通过。
查看分配给AWS实例的安全组。基本上,通过安全组,我们可以指定允许哪些端口通过,哪些端口可以在互联网上被访问。可以看到,我们允许了三种不同的端口。我们使用其中一个端口通过终端或命令提示符登录到AWS实例以执行各种操作。而HTTPS和HTTP端口是这里的关键。
我们的请求到达域名服务器,域名服务器指向AWS实例的IP,请求通过该IP到达AWS实例。在进入计算机之前,请求会经过这些防火墙。如果我们不允许443端口(用于HTTPS流量)或80端口(用于HTTP流量),AWS会在此处阻止请求,我们的请求将无法到达服务器。这是一个重要的环节。
第三步:反向代理
最终,请求到达我们的计算机(AWS实例)。请求到达后,我们使用了一种叫做“反向代理”的技术。这意味着有一个服务器位于其他服务器之前,以便我们可以从一个集中位置管理不同类型的重定向或配置,而无需更改每个服务器的配置。

为此,我们使用了Nginx。配置文件看起来是这样的。这里有很多内容,但需要关注的部分是:我们使用Certbot自动分配SSL证书(在此演示中无需担心)。重点是,它在我们的AWS实例上监听80端口,并将请求重定向到443端口(即HTTPS请求)。这部分由Certbot管理。
我们配置的部分是这个。这里的意思是:定义服务器名(即我们的域名 backenddemo.sriniously.xyz)。任何到达此域名的请求(已经由DNS服务器路由至此实例)都将被处理。我们指定,所有到达此域名的请求,Nginx配置都会将它们重定向到 localhost:3001,这是我们的Node服务器运行的端口。
这是最终的重定向。如果我们查看进程列表(使用 pm2 list 管理进程),可以看到有两个进程在运行,一个用于前端,另一个用于后端。Node服务器就是最终的一环。
我们可以验证这一点:在实例内部调用 localhost:3001/users,会得到相同的响应。因此,从这个实例的角度看,我们的Node服务器运行在localhost上,我们使用Nginx和域名通过互联网将请求路由到本地服务器。
流程总结
总结一下,我们的请求从浏览器开始,经过以下步骤:
- 到达DNS服务器。
- 到达AWS服务器。
- 经过防火墙。
- 到达AWS实例。
- 请求到达Nginx。
- 最终被转发到
localhost:3001的最终服务器。
请求经过所有这些“跳转”,最终才到达我们的服务器。当我们在本地开发时,可以直接在浏览器中打开 localhost:3001/users 看到相同的响应,在AWS实例内部也是如此。
现在,你应该对请求的样子以及它如何在互联网上传输并到达我们的服务器有了一个清晰的概念。
为什么我们需要后端? 🤔
我将举例说明。想象一下,你正在浏览Instagram动态,看到朋友的帖子,像往常一样,你点了“赞”。在另一端,你的朋友会收到一个通知,显示你赞了他的帖子。
那么,在你点击“赞”按钮和你的朋友收到通知之间,到底发生了什么?这就是后端概念发挥作用的地方。
你点击“赞”按钮,应用程序向服务器发送一个请求。服务器处理该请求,识别用户(找到你的名字或ID),然后持久化保存“点赞”这个动作的数据。服务器通常需要将信息保存在某种数据库中。
接着,服务器检查帖子所属的用户ID,触发某种动作,向该用户发送通知。最终,你的朋友在手机上收到了通知。

在你点击“赞”按钮和你的朋友收到通知之间发生的所有交互,必须有一个某种形式的服务器——一个中央计算机,它必须拥有所有用户的各种信息。因为你的应用程序是根据你的需求、个人资料、你关注的人以及你账户上可以执行的所有操作来设计和定制的。同样,你的朋友也只能收到针对他们的通知。
但是,服务器必须拥有所有类型的信息,所有类型的状态,因此它必须是集中式的。
如果我们尝试浓缩并剥离后端职责和用途,归结为一个词,那就是:状态。获取数据、接收数据以及将数据持久化存储在某个地方的需求,任何及所有涉及数据的操作都与此相关。

你可能会问:为什么不把所有事情都放在前端做呢?前端不也是某种设备或计算机吗?为什么不在这里连接数据库,为什么不在这里执行服务器做的所有事情?既然一切都是分布式的,理论上性能会更好,对吗?这是一个非常好的问题。

前端与后端的根本差异 ⚖️
为了理解为什么不能在前端使用所有这些功能,我们必须看看前端在幕后实际上是如何工作的。就像我们之前的后端演示一样,让我们做一个从前到后的前端工作流程演示。
这是一个部署在同一个AWS EC2实例上的Next.js应用程序。再次打开网络工具,刷新页面。

我们看到浏览器获取的第一个文档。它调用这个域名。查看响应,它是一个HTML文件。浏览器获取HTML文件以及所有资源,例如所有的JavaScript、图片、字体和CSS文件。所有这些不同的内容都是在获取了主要的HTML文件后,通过不同的请求分别获取的。
在我们的DNS记录中,我们看到有一个针对这个子域名 frontenddemo.sriniously.xyz 的条目,它指向这个特定的IP地址。追踪下去,我们到达这里,也就是AWS EC2实例,可以看到这是同一个公共IP地址。

和之前一样,端口443和80必须被允许,以便HTTPS和HTTP流量能够通过防火墙到达我们的服务器。最终,请求到达我们的EC2实例。
查看Nginx配置,它看起来是这样的。大部分内容与后端服务器相同,我们有相同的服务器块配置,但不同之处在于:这里我们监听 frontenddemo 这个域名,所有到达此处的流量,我们都重定向到 localhost:3000(而不是后端的3001)。最终,请求到达我们的前端Next.js服务器,服务器提供文件(JS文件、CSS文件和HTML文件),这些文件通过网络发送到我们的浏览器。
在经历了所有这些之后,一旦我们收到主要的HTML文件,浏览器会遍历所有这些资源(JavaScript文件、CSS文件和字体),并逐一获取它们。一旦获取了所有CSS(我们在这里可以看到),它就会绘制窗口。这就是我们获得所有样式(黑色背景、这些字体和按钮样式)的方式。
一旦浏览器获取了所有的JavaScript文件,它就会为按钮和页面上任何我们有的交互“水合”所有事件监听器。例如,如果你点击这个按钮,它会跳转到另一个页面,这是因为浏览器获取了所有JavaScript,添加了所有事件监听器,按钮才开始工作。
关键差异
你注意到什么了吗?你编写的所有前端逻辑,你写的所有JavaScript,都被浏览器从我们的服务器获取,并在我们客户端的机器上由浏览器执行。浏览器是我们的运行时环境。
相比之下,我们之前看到的传统后端,在AWS EC2实例中,我们发送一个请求,服务器处理请求并返回结果,实际的处理发生在服务器上。这与前端正好相反,前端是发送代码,但由浏览器运行代码,无论执行什么逻辑,都是由浏览器运行的。这是这里需要注意的一个关键区别。

为何后端逻辑不能放在前端? 🚫
现在,我们在这里发现了几个问题。

首先,浏览器运行时通常是沙盒环境,这意味着它们与我们的操作系统、进程和文件系统是隔离的。代码只能访问有限的资源,例如DOM(文档对象模型)、浏览器API(如本地存储或Cookie)以及外部API(但前提是外部API设置了所有必需的头部)。我们还没有详细讲解CORS(将在未来的视频中深入探讨),但你可以将CORS理解为浏览器的一项安全策略,它限制JavaScript代码调用与当前域名不同的外部API。
例如,我们的前端应用在 frontenddemo.sriniously.xyz 这个域名下,我们只能获取或调用同一域名下的资源或外部API。如果我们尝试调用不同的域名,浏览器会因为CORS策略而阻止请求。当然有办法绕过这一点(通过设置头部),我们稍后会探讨。
如果你仔细想想,所有这些沙盒化和安全限制都是有道理的。因为本质上,浏览器所做的是从远程服务器获取代码并在用户的浏览器中执行它。如果不够谨慎或隔离不足,远程代码可以轻易访问用户计算机上的数据或文件,这不是一件好事。
想象一下,你访问一个网站,完全不知道其前端代码是什么。如果浏览器没有隔离环境,该代码可以轻易访问你的文件系统,复制你所有的文件、敏感信息,并将其发送到他们的服务器。这是一个非常可怕的想法,这就是为什么浏览器有所有这些安全策略。
回到我们最初的问题:为什么我们不能在前端编写后端逻辑?
- 安全原因:浏览器的安全策略限制非常严格。而后端通常需要访问底层文件系统(无论是写入日志文件还是访问环境变量),浏览器不允许这样做。这对后端服务器来说是一个巨大的限制。
- 调用外部API的限制:除非该API设置了所有适当的CORS头部,否则你不能随意调用外部API。由于我们无法控制所有外部API,这也是一个重大的阻碍,因为后端服务器通常需要连接到其他服务器并从多个地方获取数据。
- 数据库连接:服务器运行时可以访问所有原生数据库驱动程序(例如PostgreSQL的
pg、MongoDB驱动等),这使其能够高效地与数据库通信。这些驱动程序被设计为能够在可以处理套接字连接、处理二进制数据并维护持久连接的环境中工作,而浏览器无法做到这些。我们稍后将探讨后端服务器如何与数据库通信,但简而言之,后端服务器维护一个到数据库服务器的连接列表(通常称为连接池),这样它就不必为每个请求反复创建和销毁连接。因为后端服务器每秒会收到成千上万的请求,如果每个请求都执行连接和销毁逻辑,数据库服务器将不堪重负。驱动程序以能够维护连接列表的方式编写,而浏览器并非设计用于维护与数据库的持久连接。即使可以,每个用户都需要打开自己到数据库的直接连接,这也会使数据库服务器因连接过多而不堪重负。此外,在浏览器环境中也没有简单的方法来管理连接池或执行高效的查询。 - 计算能力:我们使用的前端应用无处不在,可能是智能手机、台式机、笔记本电脑,甚至可能是一台只有256MB内存和单核处理器的电脑。用户可能没有足够的计算能力来执行一些繁重的业务逻辑,事情会开始卡顿,有时甚至会因负载而崩溃。因此,如果是一个服务于大量客户端的集中式后端服务器,我们可以随时轻松地增加其内存和CPU,轻松应对负载。
我们可以继续列出更多原因,但这应该能让你清楚地认识到,将后端逻辑放在前端并不是一个好主意——假设我们首先能够做到的话。
总结 📝
本节课中,我们一起学习了后端的核心定义。我们通过追踪一个网络请求的完整生命周期,深入了解了后端如何运作,包括DNS解析、防火墙规则、反向代理(如Nginx)以及最终的应用服务器。
更重要的是,我们对比了前端与后端运行时环境的根本差异:前端代码在用户浏览器中执行,受限于沙盒环境和安全策略;而后端代码在服务器上执行,拥有对系统资源、数据库和外部服务的完全访问能力。
基于这些差异,我们分析了为什么关键的业务逻辑、数据处理、数据库操作和复杂计算必须放在后端,这主要出于安全、功能完整性(如数据库连接)、性能和可控性的考虑。

现在,在开始我们的后端工程学习之旅之前,这是一个非常好的认知起点。理解了“是什么”和“为什么”,我们才能更好地探索“怎么做”。在接下来的课程中,我们将从这些第一性原理出发,动手构建我们自己的后端系统。
004:学习后端工程第一性原理的优势 🚀
在本节课中,我们将探讨从第一性原理学习后端工程所带来的具体优势。理解这些优势将帮助你明白,为何掌握核心原理比单纯学习特定框架或语法更为重要。
想象你是一名新入职的软件工程师。或许你是一名前端开发工程师。
你被要求修复后端代码库中的一个错误。
你可能会面临一些挑战。后端可能使用你不熟悉的语言编写。
或者,后端使用的语言你虽然了解,但更大的问题是你该从何处着手。你如何在代码的复杂性中定位问题而不迷失方向。
或者,想象你被要求从头开始创建一个API。
你如何形成对代码库的心智模型,遵循标准,并确保不会破坏现有功能。一种可能的情况是。
你是一名使用TypeScript或Go的后端工程师,突然被要求转向使用另一种语言。
例如Rust或Python。现在,你如何快速上手,而不必花费数小时查阅不同库的文档。

例如FastAPI或Pydantic,对于Rust,可能是Axum或其他库。
也可能是像SQLAlchemy或Diesel这样的ORM。所以问题是,你如何在新环境中应用现有知识,而不是重新发明轮子。
优势一:快速理解现有代码 🧠
这正是从第一性原理学习后端变得无比宝贵的地方。
将复杂系统分解为其最基本、最通用的组成部分的能力,为你提供了巨大优势。
例如,纵观全局。当你进入一个现有的代码库时。
你不会被其结构或工程复杂性所淹没。
你可以在心智上将系统的不同部分分离出来,并以隔离的方式处理它们。你将能够识别核心逻辑。
路由层、数据库连接以及过度设计的部分。通过过滤掉这些“噪音”。
你可以自信地开始进行修改或修复错误。你可能在高级工程师或首席技术官身上注意到这一点。
他们只需查看任何代码或特定代码库,就能迅速对正在发生的事情或错误可能的位置有一个清晰的了解。因为人脑非常擅长识别模式。
高级工程师或首席技术官,他们下意识地掌握了这些模式。因此他们实际上不必刻意使用这种方法。但我的问题是,为什么要等待多年的经验?你可以从第一天起就刻意练习,并在大约六个月或一年内精通此道。
优势二:更快地融入新环境 ⚡
第二点是更快地融入。当你理解了后端工程背后的第一性原理时。
例如HTTP如何工作、数据库如何与API交互、或请求如何流经中间件。
你可以深入任何语言或框架,并找到自己的方法。
你不再需要花费数小时阅读特定库的文档。
一旦你掌握了身份验证、路由、中间件和数据库交互背后的核心概念。

语法就是次要的。你将能够穿透噪音,专注于逻辑而非语法。
这使你能够比仅仅关注语法或语言特定功能时,更快地对代码库建立起深刻的熟悉感。
优势三:在新项目中开发更快 🏗️
第三点是在新项目中开发更快。当你从头开始一个新项目时。
拥有基于第一性原理的后端知识,能帮助你以惊人的速度和精确度推进。
你将能够以生产质量的代码更快地创建MVP,因为你是基于对系统需求的深刻理解来工作。
而不是仅仅遵循样板教程。你将知道如何构建路由、设置数据库连接,以及实现缓存、错误处理或日志记录等关键功能,而无需不断查阅文档。
优势四:减少语法疲劳 😊
第四点是减少语法疲劳。学习一门新语言本身就足以让人不知所措。
但如果你在掌握了语法之后,不确定接下来该学习什么概念,或者不知道如何应用该语法来解决实际的后端工程问题,就可能导致挫败感甚至倦怠。
第一性原理减少了这种语法疲劳,因为一旦你理解了基本的构建模块。
在不同语言之间切换就不再是一项艰巨的任务。你明白你要解决的问题是什么。
现在只是应用正确的语法和库的问题。例如。
你是一名Node.js开发者,想转型为Rust后端工程师。
你该怎么做?你显然会寻找一个关于如何用Rust创建后端的完整端到端项目教程。
至少需要四到五个小时。并且它还必须保持所有的生产质量标准。现在。
问题是,Rust是一门相对较新的语言。
我们拥有的资源数量,尤其是基于项目的资源。
远不如Node.js那么多。那么你如何精通它呢?你可能会不断为此担忧。
我找不到任何项目。我找不到任何好的资源。你擅长基本语法。
知道如何在Rust中处理不同的数据结构和编写基本程序。
但你如何跨越那个门槛,真正构建一个端到端的、生产质量的项目呢?
这就是第一性原理发挥作用的地方。想象一下,你理解了后端的不同层次。

例如,你从路由开始,然后到中间件,接着是数据库交互。
还有日志记录、错误处理和缓存代码等所有部分。
你清楚地理解了所有这些不同的组件,无论是在概念上还是在Node.js的实现中。
那么下一步是什么?你了解Rust的基本语法。
所以你按照社区推荐的项目布局启动一个Rust项目。
然后你分别针对每个组件进行实现。你知道生产质量的路由代码应该是什么样子。
你知道生产质量的验证代码应该是什么样子。
你知道数据交互、仓库模式、处理器以及身份验证和授权的代码应该是什么样子。
你了解所有优秀的模式。现在你只需要将你的Rust语法转换成那种模式。

假设你想处理验证。你去查找如何在Rust中进行验证。你很可能会找到一个库或某种实现验证的标准库模式。
现在你知道了语法,并且已经了解了最佳实践和模式,你将它们结合起来。现在你有了一个Rust中生产质量的验证模块。
然后你对每个模块重复这个模式。
例如身份验证以及所有其余的模块逻辑。
很快,在两三天内,你将拥有一个功能齐全、生产质量的Rust代码库。
优势五:为正确的工作选择正确的工具 🔧
第五点是为正确的工作选择正确的工具。这是我看到工程师们每天都会面临很多问题的地方。
我们被自己的标签所束缚。我们认为自己是一名Node.js后端开发者,或者是一名Ruby后端开发者。当我们面临一个必须构建具有非常高并发需求或非常低延迟需求的模型的需求时。
我们就被困在自己通常使用的语言中,而无法自信地去选择最合适的工具。通过理解后端工程解决的核心问题——数据持久化、安全性、可扩展性——你将获得为正确工作选择正确工具的能力。
你将不再受限于你的框架、语言或库。你将确切地知道该使用哪种工具、哪种语言和哪种框架。你将理解何时使用像Redis进行缓存、PostgreSQL处理关系型数据、MongoDB处理非结构化数据,或者Kafka进行实时事件流处理是合理的,而与你当前正在使用的技术栈无关。
优势六:更高的就业能力 💼
最后一点是更高的就业能力。这是当今快速变化的技术环境中我们大多数人所追求的。
能够跨语言和框架应用你的后端知识,使你变得极其多才多艺,从而更具就业竞争力。雇主希望雇佣那些能够批判性、独立地思考,能够加入任何团队并迅速贡献价值的工程师。
通过掌握后端原理,你将成为那种适应性强的工程师,不受特定语言或技术栈的限制。
而是拥有在任何环境中解决问题的能力。
好消息是,你不需要等待多年的经验来培养这些技能。
你可以从今天开始刻意练习。通过专注于每个后端系统中都相同的核心概念,如路由、数据库或身份验证。
你可以建立自己的内在指南针,用于探索新的领域。
目标不仅仅是当问题出现时去解决它,而是要自信且高效地解决。
随着时间的推移,你将培养出一种处理任何后端代码库或项目的自然直觉。
无论它最初看起来多么陌生。从第一性原理学习后端,将你从一个框架特定的开发者提升为一名真正的软件工程师。

一个不受特定技术栈或工具集限制的工程师。
而是理解后端工程所解决的核心问题。
这种自由使你能够轻松探索新的语言、框架和架构。
并使你在任何工程团队中都成为宝贵的资产。
因此,无论你是一名希望扩展技能的前端开发人员,还是一名想要转型到新语言的后端工程师,从第一性原理学习后端都将极大地加速你的成长,并赋予你在任何环境中构建自己健壮、可扩展和可维护系统的能力。
那么,这些原理到底是什么呢?当我说原理时。
我指的不是一系列规则。所谓第一性原理,我指的是一些基础构建块或基础组件,无论代码库大小,其余部分始终围绕它们展开。
这是一幅后端工程领域的通用地图。


它将帮助你找到方向。我们将从下一个视频开始探索这张地图。
005:后端工程师的HTTP入门,一切从这里开始 🚀
在本节课中,我们将要学习HTTP协议,这是浏览器与服务器之间进行数据交换的核心媒介。我们将从第一性原理出发,探讨HTTP的核心概念、消息结构、关键组件(如方法、状态码、头部)以及重要的交互模式(如CORS和缓存)。理解这些是构建和理解现代后端系统的基础。
概述
HTTP协议是客户端(如浏览器)与服务器通信的主要方式。虽然存在其他协议,但HTTP因其广泛使用而成为我们的焦点。本节将介绍HTTP的无状态性和客户端-服务器模型这两个核心理念,并深入探讨HTTP消息的各个组成部分。
HTTP的核心理念
无状态性 (Statelessness)
无状态性意味着HTTP协议本身不记忆过去的交互。每个HTTP请求都携带服务器处理它所需的所有信息(如头部、URL和方法)。服务器响应后,便会“忘记”这个请求。当客户端发起新请求时,服务器将其视为一个全新且无关的事件。
这种设计带来了两个主要好处:
- 简单性:服务器无需存储会话信息,简化了架构。
- 可扩展性:请求可以轻松地分布到多个服务器上,因为没有单个服务器需要跟踪会话状态。
由于HTTP是无状态的,开发者通常需要实现状态管理技术(如Cookie、会话或令牌)来维持需要连续性的交互(例如用户登录或购物车)。我们将在本系列后续内容中探讨这些技术。
客户端-服务器模型
在典型的HTTP请求流中,总是存在一个客户端和一个服务器。
- 客户端:通常是Web浏览器或应用程序,通过向服务器发送请求来发起通信。客户端负责提供服务器所需的所有信息,如URL、请求方法和头部。
- 服务器:托管资源(如网站、API),等待来自客户端的请求。服务器收到请求后进行处理,并发送回适当的响应(如网页、数据、错误消息)。
需要记住的是,通信总是由客户端发起,目的是从服务器获取某种响应。在我们的讨论中,可以安全地假设HTTP和HTTPS是可互换的,因为HTTPS本质上是更安全的HTTP版本,增加了加密等安全特性。
HTTP消息结构
HTTP消息分为请求消息(由客户端发送)和响应消息(由客户端从服务器接收)。让我们看看它们的结构。
一个HTTP请求消息示例如下:
GET /api/resource HTTP/1.1
Host: example.com
User-Agent: Mozilla/5.0
Authorization: Bearer token123
Content-Type: application/json
{"key": "value"}
一个HTTP响应消息示例如下:
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: max-age=3600
{"data": "some response"}
在高层次上,请求消息包含:
- 请求方法 (如
GET,POST) - 资源URL
- HTTP版本 (如
HTTP/1.1) - 主机 (域名)
- 请求头部 (一系列键值对)
- 空行 (分隔头部和主体)
- 请求主体 (客户端要发送给服务器的数据)
响应消息包含:
- HTTP版本
- 状态码 (如
200) - 状态码描述 (如
OK) - 响应头部
- 空行
- 响应主体
HTTP头部详解
头部是HTTP消息中非常重要的部分,它们是提供请求或响应元数据的键值对。
可以将HTTP头部类比为包裹上的邮寄标签。我们不会把收件人地址写在包裹里面,而是写在包裹外面,这样快递员在传输过程中无需反复打开包裹就能快速获取必要信息。HTTP头部的作用类似,它为服务器和客户端提供了一种快速检查和处理请求/响应元数据的方式。
以下是主要的头部类别:
-
请求头部:由客户端发送,提供关于请求本身的信息。例如:
User-Agent: 标识客户端类型(浏览器、移动应用等)。Authorization: 发送认证凭证(如Bearer令牌)。Accept: 指定客户端期望接收的内容类型(如application/json)。
-
通用头部:可用于请求和响应,提供关于消息本身的元数据。例如:
Date: 消息日期。Cache-Control: 缓存控制指令。Connection: 控制连接是否保持活动状态。

-
表示头部:主要处理被传输资源的表示形式。例如:
Content-Type: 描述媒体类型(如application/json,text/html)。Content-Length: 资源大小(字节)。Content-Encoding: 指定编码(如gzip)。ETag: 用于缓存的唯一标识符。
-
安全头部:用于增强请求和响应的安全性。例如:
Strict-Transport-Security (HSTS): 强制使用HTTPS。Content-Security-Policy (CSP): 限制可加载内容的来源。X-Frame-Options: 防止页面被嵌入iframe。Set-Cookie属性 (HttpOnly,Secure): 保护Cookie安全。
关于HTTP头部,有两个重要的理念:
- 可扩展性:HTTP高度可扩展,可以通过添加或自定义头部来适应新技术和用例,而无需修改底层协议。
- 远程控制:HTTP头部充当客户端的“远程控制”,允许客户端向服务器发送指令或偏好设置,从而影响服务器的响应或处理方式。例如,通过
Accept头部进行内容协商,或通过Cache-Control控制缓存。
HTTP方法
HTTP方法定义了客户端希望对服务器资源执行的操作意图,为每种类型的动作提供了清晰的语义。
以下是核心的HTTP方法:
- GET:用于从服务器获取数据,不应修改服务器上的任何内容。
- POST:用于在服务器上创建新数据。通常包含请求主体。
- PATCH:用于部分更新数据。请求主体包含要更新的字段。
- PUT:用于完全替换资源。请求主体应包含完整的新资源表示。
- DELETE:用于从服务器删除资源。
一个相关的概念是幂等性。幂等方法意味着多次调用会产生相同的结果。
- 幂等方法:
GET,PUT,DELETE。例如,多次获取同一资源(GET)结果相同;多次完全替换同一资源(PUT)结果相同;删除一个资源(DELETE)后,再次删除结果相同(资源已不存在)。 - 非幂等方法:
POST。多次提交创建请求(POST)通常会产生多个新资源,结果不同。
此外,还有一个 OPTIONS 方法,主要用于CORS(跨源资源共享)流程中,客户端用它来查询服务器对跨源请求的支持能力。开发者通常不会直接使用它,但会在浏览器开发者工具的“网络”选项卡中看到它作为“预检请求”出现。

跨源资源共享
CORS是一种安全机制,允许Web应用从不同域(源)的服务器请求资源。浏览器默认遵循同源策略,限制从一个源加载的网页与另一个源的资源进行交互。CORS通过特定的HTTP头部来安全地启用这种跨源请求。
CORS流程主要分为两种:简单请求 和 预检请求。
简单请求流程
简单请求需满足特定条件(如使用 GET、POST、HEAD 方法,且仅包含简单头部)。流程如下:
- 客户端(前端,如
example.com)向不同源的服务器(如api.example.com)发送请求。浏览器自动添加Origin头部。 - 服务器检查
Origin是否在其允许的CORS策略内。 - 如果允许,服务器在响应中包含
Access-Control-Allow-Origin: example.com(或*)头部。 - 浏览器检查响应头部。如果
Access-Control-Allow-Origin与请求源匹配,则允许响应通过;否则,浏览器会阻止响应并抛出CORS错误。
预检请求流程
当请求不满足简单请求条件时(例如使用 PUT、DELETE 方法,包含非简单头部如 Authorization,或 Content-Type 为 application/json),浏览器会先发送一个 预检请求 (OPTIONS 方法)。
预检请求流程如下:
- 浏览器自动发送一个
OPTIONS方法的请求到目标URL,并包含Origin、Access-Control-Request-Method(询问是否支持PUT等方法)和Access-Control-Request-Headers(询问是否支持Authorization等头部)头部。 - 服务器响应预检请求,状态码通常为
204 No Content,并在响应头部中声明其CORS策略,例如:Access-Control-Allow-Origin: example.comAccess-Control-Allow-Methods: PUT, DELETEAccess-Control-Allow-Headers: Authorization, Content-TypeAccess-Control-Max-Age: 86400(指示浏览器可以缓存该预检结果多久,避免重复预检)
- 浏览器检查预检响应。如果所有条件都满足(源、方法、头部都被允许),则继续发送原始的“实际”请求(如
PUT)。 - 服务器处理实际请求并返回最终响应。
理解CORS的底层机制对于前后端调试和构建安全的API至关重要。
HTTP状态码
HTTP状态码是三位数字代码,用于以标准化的方式快速传达请求的结果。客户端无需解析响应体即可判断请求状态。
状态码根据首位数字分类:
- 1xx (信息性):例如
100 Continue(服务器已收到请求头,客户端可继续发送请求体),101 Switching Protocols(协议升级,如切换到WebSocket)。 - 2xx (成功):
200 OK:请求成功。201 Created:资源创建成功(常用于POST请求)。204 No Content:请求成功,但无内容返回(常用于DELETE请求或OPTIONS预检请求)。
- 3xx (重定向):
301 Moved Permanently:资源已永久移动到新URL。302 Found:资源临时位于不同URL。304 Not Modified:资源未修改,客户端可使用缓存版本(与缓存头配合使用)。
- 4xx (客户端错误):
400 Bad Request:请求格式错误或包含无效数据。401 Unauthorized:请求需要认证,但凭证缺失或无效。403 Forbidden:服务器理解请求,但拒绝授权(即使已认证)。404 Not Found:请求的资源不存在。405 Method Not Allowed:请求方法不被目标资源支持。409 Conflict:请求与服务器当前状态冲突(如创建重复资源)。429 Too Many Requests:客户端发送的请求过多(常用于限流)。
- 5xx (服务器错误):
500 Internal Server Error:服务器内部错误。501 Not Implemented:服务器不支持请求的功能。502 Bad Gateway:作为网关或代理的服务器从上游服务器收到无效响应。503 Service Unavailable:服务暂时不可用(如维护、过载)。504 Gateway Timeout:网关或代理服务器未能及时从上游服务器收到响应。

掌握这些常见状态码对于API设计和问题排查非常有帮助。
HTTP缓存

HTTP缓存是一种存储响应副本以供重用的技术,可以减少对服务器的重复请求,从而提升加载速度、节省带宽并降低服务器负载。

核心缓存头包括:
Cache-Control:指定缓存策略,如max-age=10(缓存10秒)。ETag:响应内容的哈希标识符。当资源变化时,ETag也会改变。Last-Modified:资源最后修改时间。
缓存工作流程示例:
- 首次请求资源,服务器返回
200 OK及响应体,并附带Cache-Control,ETag,Last-Modified头部。 - 客户端再次请求同一资源时,会在请求头中带上
If-None-Match(值为之前收到的ETag)和If-Modified-Since(值为Last-Modified)。 - 服务器检查资源是否变化。如果
ETag匹配或资源未修改,则返回304 Not Modified,且不包含响应体。客户端则使用本地缓存。 - 如果资源已更新,服务器返回
200 OK及新的响应体和新的ETag/Last-Modified。
虽然现代前端库(如React Query)提供了更强大的客户端缓存方案,但理解HTTP层面的缓存机制仍然很有价值。
内容协商与压缩
内容协商是客户端和服务器就数据交换的最佳格式达成一致的机制。

主要类型:
- 媒体类型协商:客户端通过
Accept头(如application/json,application/xml)指定期望的格式。 - 语言协商:客户端通过
Accept-Language头(如en-US,es)指定期望的语言。 - 编码协商:客户端通过
Accept-Encoding头(如gzip,deflate)指定支持的压缩编码。
HTTP压缩是内容协商的一部分,用于减少传输数据的大小。当客户端在 Accept-Encoding 中声明支持 gzip 时,服务器可以用 gzip 压缩响应体,并在响应头中设置 Content-Encoding: gzip。浏览器收到后会解压。这能显著节省带宽,尤其对于大型响应。

持久连接与大数据处理
持久连接
在HTTP/1.0中,每个请求/响应周期都需要建立新的TCP连接,效率低下。HTTP/1.1引入了持久连接(默认启用),允许在单个TCP连接上发送多个请求和响应,减少了建立和关闭连接的开销。这是通过 Connection: keep-alive 头部(或默认行为)实现的。
处理大请求与响应

- 发送大请求(如文件上传):使用
multipart/form-data内容类型。请求体被分成多个部分(“part”)传输,每个部分由在Content-Type头中定义的boundary字符串分隔。这允许高效地上传大型二进制文件。 - 接收大响应:服务器可以使用分块传输编码或类似技术流式传输数据。例如,设置
Content-Type: text/event-stream并保持连接打开(Connection: keep-alive),服务器可以持续向客户端发送数据块,直到传输完成。这对于实时数据或大文件下载非常有用。
安全传输:SSL/TLS与HTTPS

虽然应用层开发者不直接配置这些,但了解其概念很重要。
- SSL/TLS:是用于在客户端和服务器之间建立加密链路的协议,保护传输中的数据(如密码、信用卡号)不被窃听或篡改。SSL是旧版本,现已基本被更安全的TLS取代。
- HTTPS:即 “HTTP over TLS”。当使用HTTPS时,客户端和服务器之间的所有通信都经过TLS加密。这是通过服务器提供的数字证书来建立信任和加密连接的。
简而言之,HTTPS = HTTP + 加密(TLS)。

总结
本节课我们一起深入学习了HTTP协议,这是后端开发的基石。我们从无状态性和客户端-服务器模型这两个核心理念出发,详细剖析了HTTP消息的结构,包括请求/响应行、各种功能的头部(如用于CORS、缓存、内容协商的头部)、定义操作意图的HTTP方法以及传达结果的状态码。
我们还探讨了关键的交互模式,如跨源资源共享的流程、利用头部实现缓存和内容协商的机制,以及处理大文件和保持连接效率的方法。最后,我们简要了解了HTTPS背后的安全层(TLS)。

理解这些组件如何协同工作,能够帮助您构建健壮的后端系统、高效地调试问题,并为学习更高级的网络概念打下坚实的基础。虽然HTTP协议本身还在演进(如HTTP/2、HTTP/3),但本节涵盖的原理和核心概念是持久不变的。
006:后端中的路由是什么?请求如何找到归途 🧭
在本节课中,我们将要学习后端开发中的一个核心概念:路由。我们将了解HTTP方法与路由如何协同工作,以及不同类型的路由(如静态路由、动态路由、嵌套路由等)是如何运作的。通过本教程,你将清晰地理解请求是如何被服务器映射到特定处理逻辑的。
概述:意图与目的地
在上一节中,我们讨论了不同的HTTP方法及其在HTTP语义中的重要性。这些HTTP方法描述了你的意图,即你想对特定资源执行什么操作,例如获取、添加、更新或删除数据。
而路由的作用,则是表达请求的目的地。你需要告诉服务器,你希望将你的意图发送到哪里,或者说,你希望对哪个资源执行操作。

例如,一个请求的方法是 GET,意图是从服务器获取数据。其路由路径或URL是 /api/users。服务器则会返回一个用户数组。这里,GET 表达了“获取”这个动作,而 /api/users 则指明了“从用户资源获取”这个目的地。
服务器将你的意图(HTTP方法)和目的地(路由路径)结合起来,映射到特定的处理程序或一组指令上,执行所有业务逻辑和数据库操作,并返回数据。
总结来说,路由本质上就是将URL参数映射到服务器端逻辑的过程。
静态路由
首先,我们来探讨最基本的路由类型:静态路由。
以下是静态路由的一个例子:
- 方法:
GET - 路由:
/api/books - 含义: 获取书籍列表。
另一个例子:
- 方法:
POST - 路由:
/api/books - 含义: 创建一本新书。
请注意,在这两个请求中,路由部分 /api/books 是相同的。服务器通过组合 HTTP方法 和 路由路径 来形成唯一的键,从而映射到不同的处理程序。GET /api/books 和 POST /api/books 是两个完全不同的路由,它们永远不会冲突。
之所以称之为静态路由,是因为路由路径本身是固定的、不变的字符串(如 /api/books),不包含任何可变部分。它总是返回相同类型的数据响应。
动态路由与路径参数
上一节我们介绍了静态的、固定的路由。本节中,我们来看看当路由路径需要包含可变信息时该怎么办,这就是动态路由。
考虑以下请求:
- 方法:
GET - 路由:
/api/users/123 - 含义: 获取ID为
123的用户的详细信息。
在这个例子中,路由路径的一部分(123)是动态的,它代表一个具体的用户ID。服务器可以从路由路径中提取这个ID值,然后执行相应的操作(例如从数据库查询该用户)。
这种动态部分被称为路径参数或路由参数。在服务器端,路由匹配通常使用特定的占位符(如 :id)来表示这个动态部分。
代码示例:路由匹配
// 假设的服务器端路由定义
router.get('/api/users/:id', (request, response) => {
const userId = request.params.id; // 提取路径参数,例如 ‘123’
// ... 根据 userId 执行逻辑
});
在上面的代码中,:id 是一个占位符,可以匹配像 123 这样的任何字符串。当请求 /api/users/123 到达时,它会被路由到这个处理程序,并且 123 的值可以通过 request.params.id 获取。
这种设计使得API端点具有语义化的可读性:GET /api/users/123 清晰地表达了“获取ID为123的用户数据”的意图。
查询参数
我们已经学习了如何通过路径参数在URL中传递数据。但有时,我们希望在请求中附加一些额外的、可选的信息,特别是对于 GET 请求(它通常没有请求体)。这时就需要用到查询参数。
观察以下请求:
- 方法:
GET - 路由:
/api/search?q=some+value - 含义: 搜索内容为 “some value”。
在这个URL中,/api/search 是基础路由。问号 ? 之后的部分就是查询参数。它们以 key=value 的形式出现,多个参数之间用 & 连接,例如 ?page=2&limit=20&sort=asc。
查询参数的主要用途包括:
- 过滤数据:例如
?category=electronics - 分页:例如
?page=2&limit=20 - 排序:例如
?sort=price&order=desc - 搜索:例如
?q=keyword
与路径参数用于标识特定资源(如 /users/123)不同,查询参数通常用于修饰主查询,提供额外的选项或元数据。在服务器端,可以通过解析URL的查询字符串部分来获取这些值。
嵌套路由
在实际的RESTful API设计中,为了清晰地表达资源之间的关系,我们经常会用到嵌套路由。
嵌套路由并不是一种独立的路由类型,而是一种组织路由的常见实践。它通过将资源层级关系体现在URL路径中,使API的语义更加明确。
请看以下一组逐渐深入的嵌套路由示例:
GET /api/users- 含义: 获取所有用户列表。
GET /api/users/123- 含义: 获取ID为
123的单个用户。
- 含义: 获取ID为
GET /api/users/123/posts- 含义: 获取ID为
123的用户发布的所有帖子。
- 含义: 获取ID为
GET /api/users/123/posts/456- 含义: 获取ID为
123的用户发布的、ID为456的特定帖子。
- 含义: 获取ID为
每一级嵌套都表达了更具体的语义。服务器会为每一层嵌套定义相应的路由处理程序。这种结构在资源间存在从属关系(如“帖子属于用户”)时非常有用,能使API结构清晰且符合直觉。
路由版本控制
随着应用的发展,API可能需要进行不兼容的更改(例如响应格式变化)。为了平稳地管理这种变更,引入了路由版本控制。
观察以下两个请求:
GET /api/v1/productsGET /api/v2/products
v1 和 v2 就是版本标识。当API需要重大更新时,可以创建新版本(如 v2)的路由,同时保留旧版本(v1)一段时间。
这样做的好处是:
- 清晰表达意图:明确区分不同格式的API。
- 平稳迁移:前端开发者有足够的时间将调用从
v1迁移到v2。 - 向后兼容:在迁移期间,旧客户端可以继续使用
v1而不会中断。 - 有序弃用:可以在未来某个时间点宣布弃用
v1,并最终移除它。
这是一种维护API长期稳定性和开发者友好性的重要实践。
通配路由(Catch-All Route)
最后,我们需要处理那些与已定义的所有路由都不匹配的请求。这就是通配路由或兜底路由的作用。
当用户请求了一个不存在的端点(例如 GET /api/nonexistent)时,如果服务器没有处理,可能会返回不友好的错误(如 404 Not Found 的默认页面)。
通过设置一个通配路由(例如 /* 或 /api/*),我们可以捕获所有未被前面路由规则匹配的请求,并返回一个统一的、友好的错误信息。
示例响应:
{
"error": "Route not found",
"message": "The requested endpoint does not exist."
}
这提升了API的健壮性和用户体验,确保客户端总能收到结构化的错误反馈,而不是原始的服务器错误。
总结
本节课中,我们一起深入学习了后端路由的核心概念。
我们首先了解到,路由是HTTP方法(表达“做什么”)和路由路径(表达“对谁做”)的组合,它将客户端请求映射到服务器端的处理逻辑。
我们探讨了静态路由(固定路径)和动态路由(包含路径参数)。路径参数(如 /users/:id)用于在URL中标识特定资源。
接着,我们学习了查询参数(如 ?page=2),它通常用于 GET 请求,以传递过滤、分页、排序等额外选项。
我们还介绍了嵌套路由,它通过路径层级清晰地表达资源间的从属关系(如 /users/123/posts)。
为了管理API的演进,我们了解了路由版本控制(如 /api/v1/ 和 /api/v2/),这是实现平稳、向后兼容的API变更的关键策略。
最后,我们认识了通配路由,它用于优雅地处理所有未匹配的请求,提供友好的错误响应。

理解这些路由概念是构建清晰、可维护且强大的后端API的基础。现在,你已经具备了深入后端代码库并理解其路由结构所需的知识。
007:序列化与反序列化 📡
在本节课中,我们将要学习后端开发中的一个核心概念:序列化与反序列化。我们将探讨客户端与服务器之间如何通过一种共同的标准格式来交换数据,使得不同语言和环境的系统能够相互理解。
概述:客户端与服务器的通信
上一节我们介绍了网络通信的基本模型。本节中我们来看看数据在传输过程中是如何被理解和处理的。
我们通常有一个客户端,例如浏览器(如Chrome),以及一个服务器,它可能运行在本地主机或远程云服务(如AWS、GCP、Azure)上。客户端与服务器通过网络通信手段进行交互,例如HTTP(传统的REST API端点)、gRPC或WebSocket。在本系列中,我们主要关注基于HTTP的通信。
假设客户端是一个JavaScript应用(如React、Angular等框架构建的应用)。为了向服务器发送请求,客户端需要发起一个HTTP请求。一个典型的HTTP POST请求结构如下:
POST /api/endpoint HTTP/1.1
Host: example.com
Content-Type: application/json
{
"key": "value"
}
客户端通过请求体将数据发送给服务器,服务器则返回一个响应。关键在于,客户端(JavaScript应用)和服务器(例如Rust应用)使用完全不同的数据类型和语言特性。那么,数据如何在网络传输后被对方正确理解呢?
网络通信基础:OSI模型
在深入讨论数据传输之前,了解OSI模型是有益的。OSI模型将网络通信分为七层,从顶层的应用层到底层的物理层。虽然深入每一层(如数据包、帧)超出了本教程范围,但我们需要一个高层面的理解:数据在发送端从应用层开始,经过各层封装,最终通过物理层传输;在接收端则反向解封装,最终到达应用层。
对于后端工程师而言,需要建立的思维模型是:我们只需关心应用层的数据格式。客户端和服务器约定一个共同的标准格式。发送方将自身的数据结构转换为这种格式,接收方则将该格式转换回自身能理解的数据结构。这个“转换”过程就是序列化与反序列化。
什么是序列化与反序列化?
序列化是将数据从特定语言或环境的结构转换为一种通用的、标准化的格式的过程。反序列化则是其逆过程,将通用格式的数据转换回特定环境的结构。
核心公式可以概括为:
- 序列化:
特定语言数据结构 -> 通用标准格式(如JSON) - 反序列化:
通用标准格式(如JSON) -> 特定语言数据结构
这种机制使得数据能够跨越不同编程语言和平台的界限进行传输和存储。
为什么选择JSON?
后端领域技术繁多。为了聚焦最常用、最核心的技术,我们做出以下选择:
- 通信协议:选择HTTP,因为REST API仍是客户端与服务器之间最常见的通信方式。
- 数据库:选择PostgreSQL,它是一种非常流行的关系型数据库。
- 序列化标准:选择JSON,因为它可能是80%场景下的首选,尤其在HTTP通信中。
序列化标准主要分为两类:
- 文本格式:人类可读,如JSON、YAML、XML。
- 二进制格式:更紧凑高效,如Protocol Buffers、Avro。
本课程将专注于文本格式中的JSON,因为它广泛应用于HTTP通信、配置文件、日志记录等场景。
JSON详解
JSON(JavaScript Object Notation)是一种轻量级的数据交换格式。它虽然源自JavaScript,但已成为语言无关的通用标准。
一个典型的JSON对象如下所示:
{
"name": "Alice",
"age": 30,
"isStudent": false,
"hobbies": ["reading", "cycling"],
"address": {
"country": "India",
"phone": 1234567890
}
}
以下是JSON的核心规则:
- 整个结构包裹在花括号
{}中。 - 数据以键值对形式组织。
- 键必须是字符串,且用双引号包裹。
- 值可以是:
- 字符串(双引号包裹)
- 数字
- 布尔值(
true/false) - 数组(方括号
[]包裹) - 另一个JSON对象(嵌套)
null
实战演示:查看HTTP请求与响应中的JSON
让我们通过一个实际的API调用来观察序列化与反序列化的流程。假设我们向服务器发送一个创建书籍的POST请求。

客户端发送的请求(序列化后的JSON):
{
"id": 101,
"title": "The Rust Programming Language",
"author": "Steve Klabnik"
}
客户端将内部的JavaScript对象转换(序列化)为上述JSON字符串,并通过HTTP请求体发送。
服务器接收并处理:
服务器(例如Rust后端)接收到这个JSON字符串后,将其解析(反序列化)为内部的Rust结构体(如 struct Book),然后执行业务逻辑(如将书籍存入数据库)。
服务器返回的响应(序列化后的JSON):
{
"books": [
{"id": 101, "title": "The Rust Programming Language", "author": "Steve Klabnik"},
{"id": 102, "title": "Deep Work", "author": "Cal Newport"}
]
}
服务器将处理结果(如一个书籍列表)转换(序列化)为JSON格式,通过HTTP响应体发回。
客户端处理响应:
客户端收到JSON响应后,将其解析(反序列化)为JavaScript数组或对象,并用于更新用户界面。
这个完整的循环——客户端序列化请求、服务器反序列化请求并处理、服务器序列化响应、客户端反序列化响应——清晰地展示了序列化与反序列化在前后端通信中的核心作用。
总结
本节课中我们一起学习了后端开发中的序列化与反序列化。
- 我们理解了客户端与服务器需要通过一种共同的标准格式(如JSON)来交换数据。
- 我们定义了序列化是将内部数据结构转换为通用格式的过程,而反序列化是其反向过程。
- 我们选择了JSON作为重点学习的文本序列化格式,并掌握了其基本语法规则。
- 我们通过一个实战示例,观察了JSON数据在HTTP请求与响应中的完整流动过程,从而将抽象概念具象化。

序列化与反序列化是后端工程师确保数据能在不同系统间正确流通的基础技能。掌握它,你就掌握了打开跨平台数据交换大门的钥匙。
008:认证与授权 🔐
在本节课中,我们将学习后端开发中至关重要的两个概念:认证与授权。我们将从历史背景出发,理解其演变过程,然后深入探讨现代认证授权的核心组件、类型、工作原理以及相关的安全考量。
概述
认证与授权是构建安全应用系统的基石。简单来说:
- 认证 是回答“你是谁?”的问题,即验证主体的身份。
- 授权 是回答“你能做什么?”的问题,即确定主体在特定上下文(如平台、操作系统)中的权限和能力。
接下来,我们将从历史脉络开始,逐步拆解这两个概念。
认证的历史脉络
上一节我们概述了认证与授权的定义,本节中我们来看看认证是如何从人类社会早期发展到今天的。
前工业社会:基于信任的隐式认证
在人口较少的前工业社会,认证是隐式的。一个人的身份等同于其在社区中的声誉。例如,一位德高望重的长者可以为他人作保,交易通过握手达成。这种方法依赖于人际间的信任,但无法扩展到熟人圈子之外。
中世纪:物理令牌与密码学萌芽
随着社会规模扩大,需要不依赖于个人熟识的身份证明。这导致了蜡封的出现。蜡封作为早期的认证令牌,其原理是 “你所拥有的东西”。然而,蜡封容易被伪造,这标志着首次有记录的认证绕过攻击。
工业革命:共享秘密与密码短语
电报的发明带来了对安全通信的需求。操作员使用预先商定的密码短语,这演变为早期的共享秘密,其原理转变为 “你所知道的东西”。
计算时代:数字密码与安全存储
20世纪60年代,MIT的研究人员在CTSS系统中首次引入了多用户系统的密码概念。最初密码以明文存储,直到一次意外打印密码文件的事件暴露了其脆弱性,这催生了安全的密码存储机制,如哈希算法。
哈希算法的核心公式是:
hash = cryptographic_hash_function(plaintext_password)
它将任意长度的输入(明文密码)转换为固定长度的、不可逆的输出(哈希值)。
现代密码学与协议
20世纪70年代,非对称密码学(如Diffie-Hellman密钥交换)的出现,为现代认证协议(如Kerberos)奠定了基础。这些协议依赖于公钥基础设施。
多因素认证与生物识别
20世纪90年代,为应对暴力破解攻击,多因素认证 兴起。它结合了多种认证因素:
- 你知道的:密码、PIN码。
- 你拥有的:智能卡、OTP生成器。
- 你固有的:指纹、视网膜扫描等生物特征。
21世纪至今:现代框架与未来展望
云计算、移动设备和API架构的兴起,催生了更先进的认证框架,例如OAuth 2.0、JWT、零信任架构和无密码认证。未来的方向可能包括去中心化身份(基于区块链)、行为生物识别和后量子密码学。
现代认证的核心组件
了解了认证的历史后,我们现在聚焦于构成现代认证体系的三个核心技术组件。
会话
HTTP协议本质上是无状态的,每个请求都是独立的。为了在动态网站(如电商购物车、保持登录状态)中维持用户状态,引入了会话机制。
会话的工作流程如下:
- 会话创建:用户登录后,服务器生成唯一的会话ID,并将用户相关数据(如用户ID、角色、购物车内容)存储在持久化存储(如数据库或Redis)中。
- ID传递:服务器将会话ID通过Cookie发送给客户端(浏览器)。
- 状态维持:客户端在后续请求中自动携带此Cookie。服务器通过会话ID从存储中检索用户数据,从而识别用户。
- 会话过期:会话通常设有有效期,过期后需要重新认证。


会话存储的演进:从服务器文件 -> 数据库 -> 分布式内存存储(如Redis),以满足可扩展性和性能需求。
JSON Web令牌
随着分布式系统的发展,维护海量会话数据成本高昂,跨服务器/区域同步会话数据也带来延迟和一致性问题。这催生了JWT。

JWT是一种无状态的机制,用于在各方之间安全地传输声明(Claims)。其关键创新在于自包含:令牌本身包含了用户数据和一个用于验证的密码签名。
一个JWT由三部分组成,以点号分隔:
header.payload.signature
以下是JWT结构的代码示例:
// Header (Base64Url编码)
{
"alg": "HS256",
"typ": "JWT"
}
// Payload (Base64Url编码)
{
"sub": "1234567890", // 用户ID
"name": "John Doe",
"iat": 1516239022, // 签发时间
"role": "admin"
}
// Signature
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret_key
)
JWT的优势:
- 无状态/可扩展:服务器无需存储会话,易于水平扩展。
- 自包含:减少数据库查询。
- 便携性:可轻松通过HTTP头、URL参数传递。
JWT的挑战:
- 令牌失效:一旦签发,在过期前难以主动使其失效。
- 令牌撤销:无法便捷地撤销单个令牌(除非更改所有用户依赖的密钥)。
一种折衷方案是混合方法:在验证JWT后,额外查询一个存储中的“黑名单”来检查令牌是否被撤销。但这在一定程度上牺牲了无状态的优势。
Cookie
Cookie是服务器指示客户端(浏览器)存储一小段数据(如会话ID或JWT)的机制。它是实现认证流程自动化的关键。
Cookie的工作流程:
- 用户认证成功后,服务器在响应中设置一个Cookie(例如
Set-Cookie: session_id=abc123)。 - 浏览器自动保存此Cookie。
- 此后,浏览器向同一域发出的每个请求都会自动携带这个Cookie。
- 服务器从请求中读取Cookie,获取认证令牌(会话ID或JWT),进而验证用户身份。
重要安全特性:HttpOnly标志可以防止JavaScript访问Cookie,有助于防范XSS攻击。
认证的主要类型
我们已经了解了核心组件,现在来看看如何将它们组合成不同的认证类型。
状态认证
状态认证依赖于服务器端存储的会话状态。
工作流程:
- 客户端发送用户名/密码。
- 服务器验证凭证,生成会话ID,将
{session_id: user_data}存入Redis,并通过Cookie将会话ID发回客户端。 - 客户端后续请求自动携带Cookie。
- 服务器用会话ID从Redis查找用户数据,完成认证。
优点:集中控制,可实时管理/撤销会话,安全性较高。
缺点:服务器存储开销大,在分布式架构中同步复杂,可扩展性受限。
无状态认证
无状态认证的核心是自包含的令牌,服务器无需存储会话。
工作流程:
- 客户端发送用户名/密码。
- 服务器验证凭证,使用密钥签发一个包含用户信息的JWT,并将其返回给客户端(可通过Cookie或响应体)。
- 客户端在后续请求的
Authorization头中携带JWT(如Bearer <token>)。 - 服务器用密钥验证JWT签名并解码出用户信息。
优点:无服务器存储,天生适合分布式和微服务架构,扩展性强。
缺点:令牌难以主动撤销,若密钥泄露影响大。
选择建议:
- 状态认证:适用于传统Web应用,对会话控制要求高。
- 无状态认证:适用于API、微服务、移动应用后端。
- 混合方案:对Web端用状态认证,对API/移动端用无状态认证。
API密钥认证
API密钥用于机器对机器的通信,为程序化访问提供凭证。
工作流程:
- 用户在平台UI上生成一个API密钥(加密随机字符串)。
- 该密钥被赋予特定权限和有效期。
- 客户端(另一个服务器或脚本)在请求头(如
X-API-Key: <key>)中携带此密钥访问API。 - 服务器验证密钥的有效性和权限。
优点:简单易用,专为自动化、无UI交互的场景设计。
用途:第三方集成、服务器间调用、提供外部API服务。
OAuth 2.0 与 OpenID Connect
OAuth 2.0解决的是授权委托问题:让一个应用能代表用户访问其在另一个服务中的资源,而无需分享密码。
核心角色:
- 资源所有者:用户。
- 客户端:想要访问资源的应用。
- 资源服务器:托管用户资源的服务(如Google服务器)。
- 授权服务器:颁发访问令牌的服务。
OAuth 2.0授权码流程简化版:
- 客户端将用户重定向到授权服务器。
- 用户在授权服务器上登录并同意客户端的权限请求。
- 授权服务器将用户重定向回客户端,并附上一个授权码。
- 客户端用授权码向授权服务器交换访问令牌。
- 客户端使用访问令牌访问资源服务器上的受保护资源。
OpenID Connect 在OAuth 2.0之上增加了认证层。它在流程中引入了一个ID令牌(一个JWT),其中包含了用户的身份信息(如ID、姓名、邮箱)。这就是“使用Google登录”等功能背后的技术。
用途:单点登录、第三方应用授权、集中身份管理。
授权:角色与权限控制
认证解决了“你是谁”,接下来我们看授权,即“你能做什么”。
授权最常见的模型是基于角色的访问控制。
RBAC核心概念:
- 角色:如
用户、管理员、版主。 - 权限:如
读文章、写文章、删除文章、访问管理后台。 - 分配:将权限分配给角色,再将角色分配给用户。
工作流程:
- 用户认证后,服务器从其令牌或数据库查询中确定其角色。
- 在处理具体请求(如
DELETE /api/articles/123)时,服务器检查该用户角色是否拥有执行此操作所需的权限。 - 如果拥有权限,请求继续;否则,返回
403 Forbidden错误。
通过RBAC,可以灵活地管理不同用户群体对系统资源的访问能力。
安全实践要点
在实现认证授权时,必须注意以下安全细节。
1. 模糊化认证错误信息
为了防止攻击者通过错误信息推断有效账户,认证失败时应返回通用提示。
错误做法:
- “用户名不存在”
- “密码错误”
- “账户已锁定”
正确做法:
- 统一返回:“用户名或密码错误”。
2. 防范计时攻击
在认证逻辑中,比较用户名是否存在和比较密码哈希是否匹配,两者的执行时间可能有细微差异。攻击者可以通过测量响应时间来推断账户的有效性。
防御措施:
- 使用恒定时间比较函数:例如,在密码哈希比较时,使用语言提供的安全比较函数(如Node.js的
crypto.timingSafeEqual)。 - 引入人工延迟:在认证逻辑中,无论失败在哪一步,都通过
sleep等方式增加一个随机但固定的延迟,使响应时间趋于一致。
总结
本节课中,我们一起深入学习了后端开发中的认证与授权:
- 概念区分:认证是验明身份(你是谁),授权是分配权限(你能做什么)。
- 历史演进:从基于信任的隐式认证,发展到密码、多因素认证,直至现代的OAuth、JWT等协议。
- 核心组件:会话用于维持有状态;JWT用于无状态、可扩展的令牌传递;Cookie是浏览器自动管理令牌的载体。
- 认证类型:根据场景选择状态认证、无状态认证、API密钥认证或OAuth 2.0/OpenID Connect。
- 授权模型:RBAC是最常用的授权模型,通过角色和权限管理访问控制。
- 安全实践:实施模糊错误信息和防御计时攻击等措施,是构建健壮认证授权系统不可或缺的一环。

理解这些原理和权衡,是每一位后端工程师设计安全、可扩展应用系统的基础。在接下来的课程中,我们将探讨数据验证与转换。
009:验证与转换 🔧
在本节课中,我们将要学习后端开发中至关重要的两个概念:验证与转换。它们是确保API数据完整性和安全性的核心机制。我们将了解它们在后端架构中的位置、具体作用、不同类型,以及如何正确实施。
后端架构回顾 🏗️
上一节我们介绍了后端开发的基础概念,本节中我们来看看验证与转换在架构中的位置。
在一个典型的后端架构中,我们通常有不同的执行层。最底层通常是仓库层,它主要负责与数据库的交互,包括执行查询、插入、删除等操作。这个数据库可以是传统的关系型数据库,也可以是Redis或其他类型的持久化存储。
在仓库层之上是服务层。这一层负责执行业务逻辑,例如调用一个或多个仓库层方法、向不同设备发送通知、给用户发送邮件、存储数据或发起网络请求等。一个典型的服务方法会调用仓库层的一个或多个方法来与数据库交互。
服务层之上是控制器层。这一层调用服务层中定义的方法来执行业务逻辑。它负责处理所有API预期要完成的工作和返回的数据。控制器层调用与服务特定API关联的方法,并将服务层返回的数据通过HTTP连接返回给用户。
我们将控制器层与服务层分离,是为了将与HTTP相关的逻辑(例如返回什么错误码、成功码、数据格式、以及我们即将看到的验证)和数据相关的逻辑分离到不同的层中。控制器层处理所有来自客户端和发送给客户端的数据,并在内部调用服务层。
因此,一个典型的API调用流程是:请求到达控制器层,控制器层与服务层交互,服务层再与仓库层交互,最终服务层返回的数据被返回给发起API调用的用户。
验证与转换的位置 🎯
现在,让我们明确验证与转换具体发生在哪里。

理想情况下,验证与转换发生在这个位置:当客户端发送的数据(通常是一个JSON负载)到达服务器,经过路由匹配算法找到对应的控制器方法后,在控制器层开始执行业务逻辑或调用服务方法之前,我们首先进行验证与转换。

为什么需要验证与转换? 🤔
想象一下,我们的服务器面向全球各地的用户。验证与转换的核心思想是:在客户端发送的任何数据(JSON负载、查询参数、路径参数、请求头等)进入我们的服务器逻辑之前,确保它们符合API预期的格式。
例如,一个API期望在请求体JSON中有一个名为name的字符串字段,长度在5到200个字符之间。在数据到达控制器层入口点时,我们会执行验证管道。这个管道(可能是一个中间件或工具函数)会根据我们提供的模式(Schema)检查JSON的所有字段。它会检查是否存在name字段,类型是否为字符串,长度是否在限制范围内。如果任何一项检查失败,它会立即向客户端返回错误(例如400 Bad Request),而不会执行后续的业务逻辑或数据库操作。
这样做的好处是避免系统在意外状态下运行或崩溃。如果没有验证,客户端发送了错误类型的数据(例如name字段是数字0),数据可能会一路传递到仓库层。当仓库层尝试执行数据库插入操作时,由于数据库列定义了text类型约束,插入数字0会导致数据库调用失败,最终客户端可能收到一个模糊的500内部服务器错误。这提供了很差的用户体验。通过入口点的验证,我们可以确保数据在结构上符合业务逻辑的要求,并在不符合时给出清晰、准确的错误响应。
验证的类型 📝
验证有多种类型,根据需求可以非常具体或宽松。以下是三种最常见的类型:
以下是三种主要的验证类型:
-
语法验证:验证提供的数据是否符合特定的结构模式。
- 示例:验证字符串是否为有效的电子邮件格式(如
user@domain.com)、电话号码格式(如国家代码+数字)或日期格式(如YYYY-MM-DD)。 - 核心概念:检查数据格式是否正确。
- 示例:验证字符串是否为有效的电子邮件格式(如
-
语义验证:验证提供的数据在逻辑上是否有意义。
- 示例:验证出生日期是否在未来(这没有意义),或年龄是否为合理的数字(如1到120之间)。
- 核心概念:检查数据含义是否合理。

- 类型验证:验证数据的基本类型是否匹配。
- 示例:验证字段是否为字符串、数字、布尔值、数组或特定结构的嵌套对象。
- 核心概念:检查数据类型是否匹配。
什么是转换? 🔄
在数据到达控制器层之前,它需要经过验证与转换管道。验证确保数据符合结构,而转换意味着我们可能需要对用户提供的数据执行一些操作。
一个常见的例子是处理查询参数。例如,一个分页API的端点可能是 /bookmarks?page=2&limit=20。查询参数的值在到达服务器时默认都是字符串类型。然而,我们的验证规则可能要求 page 和 limit 是数字。如果直接验证,会因为类型不匹配而失败。
这时就需要转换(或称为类型转换)。服务器有责任在验证之前,将字符串 "2" 和 "20" 转换为数字 2 和 20。这个过程就是转换。转换也可能发生在验证之后,目的是将数据调整为服务层期望的最终格式。
通常,我们将验证和转换放在同一个管道中,这样所有关于输入数据的逻辑都集中在一处,便于管理和维护。
前端验证 vs 后端验证 ⚖️
这是一个重要的概念,有时会产生混淆。在典型应用中,前端表单会进行验证(例如,检查输入框内容是否符合要求)。前端验证的目的是为了用户体验,它能即时给用户反馈。
然而,后端验证的目的是为了安全性和数据完整性。服务器必须进行严格的验证,因为:
- 服务器可能有多种客户端(Web应用、移动应用、API测试工具如Postman)。
- API测试工具等客户端没有前端验证界面。
- 恶意用户可以绕过前端直接向API发送请求。

因此,绝不能用前端验证替代后端验证。后端验证是强制性的安全措施,而前端验证是可选的用户体验增强。在设计API时,必须尽可能严格和具体地实施服务器端验证逻辑,而不必考虑客户端验证的具体实现。
总结 📚
本节课中我们一起学习了后端开发中的验证与转换。

- 位置:验证与转换发生在请求到达控制器层后、执行业务逻辑前。
- 目的:确保输入数据的结构、类型和语义符合API要求,保障数据完整性和系统安全。
- 验证类型:主要包括语法验证(格式)、语义验证(逻辑)和类型验证(数据类型)。
- 转换:在验证前后对数据进行处理(如类型转换),使其符合服务层期望的格式。
- 关键区别:前端验证用于提升用户体验,是可选且可被绕过的;后端验证用于保障安全与数据完整性,是强制且必须的。


记住这些规则和指南,将帮助你设计出更健壮、更安全的API。
010:控制器、服务、仓库、中间件和请求上下文详解 🧩
在本节课中,我们将学习后端架构中的三个核心概念:控制器/服务/仓库模式、中间件以及请求上下文。我们将深入探讨每个组件的作用、它们如何协同工作,以及为什么这种分层设计对于构建可维护、可扩展的后端应用至关重要。
请求生命周期与分层架构
上一节我们介绍了HTTP协议和路由机制。本节中,我们来看看一个请求到达服务器后,在内部是如何被处理的。我们称之为“请求生命周期”。
客户端发送一个HTTP请求到服务器。操作系统将该请求转发到服务器监听的端口(例如3000或4000)。这是请求进入服务器的入口点。

服务器收到请求后,首先进行路由匹配。根据请求的URL路径(如 /users 或 /users/123),路由算法将其映射到一个预定义的处理器(Handler)或控制器(Controller)。
控制器/处理器层
控制器是请求生命周期的第一站。它的主要职责是处理与HTTP协议直接相关的逻辑。
以下是控制器层的主要职责:

-
数据提取与反序列化:从请求对象中提取数据。对于GET请求,提取查询参数;对于POST、PUT、PATCH等请求,提取请求体。由于客户端发送的数据通常是JSON格式,控制器需要将其反序列化为服务器编程语言的原生数据结构(例如Go的
struct、Python的字典或类)。这个过程也常被称为“绑定(Binding)”。- 代码示例(伪代码):
// 在Go中,将JSON请求体绑定到结构体 var book Book if err := c.ShouldBindJSON(&book); err != nil { c.JSON(400, gin.H{"error": "Bad Request"}) return }
- 代码示例(伪代码):
-
数据验证与转换:对反序列化后的数据进行验证,确保其格式正确、包含必填字段且无恶意内容。验证通过后,可以进行数据转换,例如设置默认值、格式化数据等,以便下游处理。
- 公式/逻辑:
如果验证失败,控制器应直接返回输入数据 -> [验证规则] -> 有效数据 -> [转换逻辑] -> 标准化数据400 Bad Request响应并终止请求。
- 公式/逻辑:

- 调用服务层:将经过验证和转换的数据传递给服务(Service)层进行核心业务逻辑处理。


- 构造并发送响应:接收服务层返回的处理结果,根据业务逻辑的成功与否,决定返回给客户端的HTTP状态码(如200成功、201创建、400客户端错误、500服务器错误)和响应体数据。

服务层
控制器处理完协议层事务后,将核心业务逻辑委托给服务层。服务层是应用程序的业务逻辑核心。
服务层的特点如下:


- 与HTTP解耦:服务层函数不应感知HTTP上下文。它只接收数据,处理业务规则,并返回结果。
- 编排与协调:一个服务方法可以调用多个仓库(Repository)方法,组合外部API调用,发送邮件或通知等。它负责协调完成一个完整的业务用例。
- 返回处理结果:将业务处理的结果(成功数据或业务错误)返回给控制器。

仓库层
仓库层负责所有与数据持久化相关的操作,主要是数据库交互。它是服务层与数据库之间的抽象层。
仓库层的职责非常单一:

- 构造并执行数据操作:接收服务层传递的数据,构造具体的数据库查询语句(如SQL),执行插入、查询、更新或删除操作。
- 返回原始数据:将数据库操作的结果(如查询到的记录集)返回给服务层。一个仓库方法通常只负责一种类型的数据操作。

总结这三层的关系:控制器负责协议(HTTP),服务层负责业务逻辑,仓库层负责数据访问。这种分离使得代码职责清晰,易于测试和维护。
中间件:请求生命周期的守卫者与加工者
在理解了核心处理流程后,我们来看看中间件(Middleware)如何嵌入到这个生命周期中,提供横切关注点的功能。
中间件是在请求到达最终处理器之前或之后执行的特殊函数。它们像一道道关卡或加工站,分布在请求生命周期的各个“边界”上。
一个典型的中间件函数接收三个参数:
request: 请求对象。response: 响应对象。next: 一个函数,用于将执行权传递给下一个中间件或处理器。
中间件的核心能力是:它可以读取和修改请求/响应,并且可以选择是否通过调用 next() 将请求传递下去,或者直接发送响应终止请求。

为什么使用中间件?


主要目的是减少代码重复和实现横切逻辑。许多功能需要对每个请求都执行,如果将这些逻辑写在每个控制器里,会导致大量重复代码。
以下是常见的中间件用例:
- CORS(跨域资源共享):检查请求来源,如果是允许的域名,则在响应头中添加相应的CORS头部。
- 安全头部:为每个响应添加安全相关的HTTP头部(如Content-Security-Policy)。
- 身份验证:验证请求中的令牌(如JWT),如果无效则返回
401 Unauthorized;如果有效,则将用户信息(如用户ID、角色)附加到请求上下文中,供后续使用。 - 速率限制:基于IP地址或其他标识,限制客户端在特定时间内的请求频率,超过限制则返回
429 Too Many Requests。 - 日志记录:记录每个请求的路径、方法、客户端IP等信息,用于监控和调试。
- 全局错误处理:捕获应用程序中任何地方抛出的未处理异常,将其转换为结构化的错误响应(如500状态码和错误信息)返回给客户端。这个中间件通常放在执行链的最后。
- 数据压缩:对大的响应体进行压缩(如gzip),减少网络传输量。
中间件的顺序至关重要。例如,CORS和日志中间件通常放在最前面,而错误处理中间件放在最后面。身份验证中间件需要在需要保护的业务路由之前执行。
请求上下文:请求级别的共享状态
在介绍身份验证中间件时,我们提到它可以将用户信息存储起来供后续使用。这个存储空间就是请求上下文(Request Context)。

请求上下文是一个与单个HTTP请求生命周期绑定的存储对象(通常是键值对形式)。它在请求开始时创建,在请求结束时销毁。

请求上下文的作用

它允许在同一请求流经的不同组件(中间件、控制器、服务)之间安全地共享数据,而无需通过函数参数显式传递,从而降低耦合度。


请求上下文的典型用途包括:

- 存储用户身份信息:身份验证中间件验证用户后,将用户ID、角色等信息存入上下文。后续的控制器或服务层可以直接从中取出使用,确保数据来源可信(而非来自可能被篡改的客户端请求)。
- 传递请求ID:在请求入口处生成一个唯一ID(如UUID)并存入上下文。在整个请求处理过程中,无论是记录日志还是调用其他内部服务,都可以携带这个ID,便于实现全链路追踪和调试。
- 传递取消信号与超时:可以设置请求的截止时间或取消通道。当客户端断开连接或请求超时时,可以通过上下文通知所有正在进行的下游操作(如数据库查询、外部API调用)及时终止,释放资源。
公式/概念:
请求上下文 = 键值对存储 (作用域: 单个请求)
访问者: 所有处理该请求的中间件、处理器、服务

总结
本节课中我们一起学习了后端架构中三个紧密相关的核心概念。

- 控制器、服务、仓库模式为我们提供了一种清晰的分层架构:控制器处理HTTP协议,服务实现业务逻辑,仓库管理数据访问。这种分离提升了代码的可维护性、可测试性和可扩展性。
- 中间件是处理横切关注点的强大工具,如身份验证、日志、压缩等。它们通过可插拔的方式在请求生命周期的特定阶段执行,有效减少了代码重复。
- 请求上下文作为请求级别的状态容器,使得在同一请求的不同处理阶段之间安全、便捷地共享数据成为可能,是连接中间件与业务逻辑的重要桥梁。

理解这三者如何协同工作,是设计和构建健壮后端应用程序的基础。下一节,我们将深入探讨API设计的最佳实践与RESTful架构原则。
011:完整的REST API设计 🚀
在本节课中,我们将深入探讨REST API设计的完整流程。我们将从历史背景和核心概念讲起,逐步深入到具体的URL设计、HTTP方法、幂等性,并通过一个项目管理的实际案例,演示如何设计一套完整、直观且符合标准的API接口。学完本节,你将能够独立设计出结构清晰、易于维护的RESTful API。
历史背景与核心概念
上一节我们介绍了HTTP协议的基础知识,本节中我们来看看REST API设计背后的历史与核心思想。
REST的起源



1990年,蒂姆·伯纳斯-李启动了万维网项目,旨在全球范围内分享知识。他发明了URI、HTTP协议、HTML、第一个Web服务器和浏览器等关键技术。然而,随着用户数量的指数级增长,万维网面临可扩展性危机。
1993年左右,罗伊·菲尔丁(Apache HTTP服务器项目的联合创始人)提出了六大约束来解决可扩展性问题:
- 客户端-服务器分离:客户端处理用户界面,服务器管理数据存储和业务逻辑。
- 统一接口:建立组件间通信的标准方式,包含资源标识、通过表述操作资源、自描述消息和超媒体作为应用状态引擎四个子约束。
- 分层系统:架构由层次组成,每层只能与紧邻的下层交互,便于引入负载均衡器和代理服务器。
- 缓存:服务器必须明确标记响应是否可被缓存,以减轻服务器负载。
- 无状态:每个客户端请求必须包含服务器处理该请求所需的所有信息。服务器不存储任何客户端上下文。
- 按需代码(可选):服务器可以通过传输可执行代码(如JavaScript)临时扩展客户端功能。
2000年,罗伊·菲尔丁在其博士论文中将这种Web架构风格命名为“表述性状态转移”,即REST。
什么是REST?
REST(Representational State Transfer)名称包含三个核心部分:
- 表述(Representation):网络上的资源(数据或对象)可以有不同的表现形式,如JSON、XML或HTML,具体取决于客户端需求。
- 示例:一个
用户资源,对API客户端可能以JSON表示,而对Web浏览器则可能以HTML表示。
- 示例:一个
- 状态(State):指特定资源的当前状况或属性。资源的状态可以通过其表述在客户端和服务器之间转移。
- 示例:一个
购物车资源的状态包括其中的所有商品、商品数量和总价。
- 示例:一个
- 转移(Transfer):指资源表述在客户端和服务器之间的移动,通过标准的HTTP方法(如GET、POST、PUT、DELETE)进行。
- 示例:当你向服务器发送GET请求获取网页时,就是在使用GET方法将资源的表述从服务器转移到客户端。
综合来看,REST描述了一种架构风格:1) 资源有多种表述格式;2) 这些资源的状态可以在客户端和服务器间转移;3) 客户端和服务器通过共享这些资源表述进行通信,并且整个系统遵循特定的约束以实现可扩展性。
API设计基础:URL与路由
理解了REST的背景后,我们开始学习API设计的具体实践。首先从URL的结构和路由设计开始。
一个典型的URL高级结构如下:
https://api.example.com/v1/books?limit=10&page=1#introduction
- 方案(Scheme):
https - 权限/域名(Authority/Domain):
api.example.com(通常API使用api子域名) - 路径/资源(Path/Resource):
/v1/books(v1是版本,books是资源) - 查询参数(Query Parameters):
?limit=10&page=1 - 片段(Fragment):
#introduction
在设计API路由时,需遵循以下标准:
- 资源名称使用复数形式:无论操作单个还是多个资源,路径中的资源名都应使用复数。
- 正确:
GET /api/v1/books(获取所有书籍) - 正确:
GET /api/v1/books/123(获取ID为123的书籍) - 错误:
GET /api/v1/book/123
- 正确:
- 使用连字符
-而非空格或下划线:确保URL在不同环境下的可读性和一致性。- 示例:书名“Harry Potter”应转换为slug:
harry-potter。
- 示例:书名“Harry Potter”应转换为slug:
- 路径表示层级关系:斜杠
/表示资源间的层级关系。- 示例:
/organizations/{org_id}/projects表示获取某个特定组织下的所有项目。
- 示例:
HTTP方法与幂等性
设计好路由结构后,我们需要为不同的操作分配合适的HTTP方法。理解“幂等性”这个概念对于正确选择方法至关重要。
幂等性是指一个操作无论执行一次还是多次,所产生的副作用(对服务器状态的改变)都是相同的。


以下是主要的HTTP方法及其幂等性分析:

- GET:用于从服务器检索数据。是幂等的。多次执行相同的GET请求不会改变服务器状态。
- PUT:用于完整替换服务器上的资源。是幂等的。用相同载荷多次调用,资源最终状态相同。
- PATCH:用于部分更新服务器上的资源。是幂等的。用相同载荷多次调用,资源最终状态相同。
- DELETE:用于删除服务器上的资源。是幂等的。第一次调用删除资源,后续调用因资源不存在而返回错误,但未产生新的副作用。
- POST:通常用于创建新资源或执行自定义操作。不是幂等的。多次调用通常会产生多个新资源或重复执行操作。


PUT vs PATCH:
- 使用
PATCH来更新资源的部分字段。 - 使用
PUT来完全替换服务器上资源的整个表述。 - 在实践中,
PATCH更常用,因为部分更新更符合单页面应用的需求。
POST用于自定义操作:对于不属于CRUD(创建、读取、更新、删除)范畴的自定义操作(例如“发送邮件”、“归档项目”),应使用POST方法,因为它在REST规范中是开放式的。
实战:设计一个项目管理平台的API
现在,我们将理论应用于实践,为一个假设的项目管理平台(类似Jira)设计API接口。我们假设已完成需求分析和数据库设计,主要资源包括:组织、项目、任务。
我们将使用API客户端工具(如Insomnia或Postman)来设计接口,专注于设计而非具体实现。
1. 组织资源接口设计
以下是针对组织资源设计的一套完整CRUD及自定义操作接口。
创建组织 (POST)
- 方法:
POST - 路径:
http://localhost:3000/organizations - 请求体 (JSON):
{ "name": "Org 1", "status": "active", "description": "Some description" } - 成功响应 (201 Created):
{ "id": "generated_id", "name": "Org 1", "status": "active", "description": "Some description", "createdAt": "timestamp", "updatedAt": "timestamp" } - 说明: 创建资源成功返回
201状态码及新创建的实体。
获取组织列表 (GET)
- 方法:
GET - 路径:
http://localhost:3000/organizations - 查询参数(可选):
page=1:页码(默认1)limit=10:每页数量(默认10)sortBy=createdAt:排序字段(默认createdAt)sortOrder=desc:排序顺序(默认desc)status=active:过滤条件(例如按状态过滤)
- 成功响应 (200 OK):
{ "data": [ { /* 组织对象 */ }, { /* 组织对象 */ } ], "total": 50, "page": 1, "totalPages": 5 } - 说明: 列表接口应支持分页、排序和过滤。即使没有数据,也返回
200和空数组,而非404。
获取单个组织 (GET)
- 方法:
GET - 路径:
http://localhost:3000/organizations/{org_id} - 成功响应 (200 OK): 返回单个组织对象。
- 资源不存在响应 (404 Not Found): 当请求的ID不存在时返回。
更新组织 (PATCH)
- 方法:
PATCH - 路径:
http://localhost:3000/organizations/{org_id} - 请求体 (JSON): 包含要更新的字段。
{ "status": "archived" } - 成功响应 (200 OK): 返回更新后的组织对象。
删除组织 (DELETE)
- 方法:
DELETE - 路径:
http://localhost:3000/organizations/{org_id} - 成功响应 (204 No Content): 删除成功,响应体为空。
自定义操作:归档组织 (POST)
- 方法:
POST - 路径:
http://localhost:3000/organizations/{org_id}/archive - 成功响应 (200 OK 或 201 Created): 根据操作实际是否创建了新资源返回相应状态码和结果。
- 说明: 归档可能涉及更新状态、发送通知、清理子资源等一系列操作,因此作为自定义端点。


2. 项目资源接口设计


项目资源的接口模式与组织资源完全一致,遵循相同的设计规范。
创建项目 (POST)
- 方法:
POST - 路径:
http://localhost:3000/projects - 请求体:
{ "name": "Project Alpha", "organizationId": "org_id_here", "status": "planned", "description": "Project description" }
获取项目列表 (GET)
- 方法:
GET - 路径:
http://localhost:3000/projects - 查询参数: 支持与组织列表相同的
page,limit,sortBy,sortOrder, 过滤等参数。
获取、更新、删除单个项目
- 路径模式:
http://localhost:3000/projects/{project_id} - 分别使用
GET、PATCH、DELETE方法。
自定义操作:克隆项目 (POST)
- 方法:
POST - 路径:
http://localhost:3000/projects/{project_id}/clone - 说明: 克隆操作可能复制项目及其所有任务,并执行其他业务逻辑。
API设计最佳实践总结

本节课中我们一起学习了REST API设计的完整流程。最后,我们来总结一下设计优秀API的关键原则:
- 从UI/UX设计开始:基于用户交互流程来识别核心资源和操作,这能帮助你设计出更符合实际使用场景的API。
- 保持一致性:
- 整个API的URL结构、命名规范(始终使用复数资源名、小写、连字符)、JSON字段命名(使用驼峰式)必须统一。
- 相同类型的操作(如所有列表接口)应支持相同的参数集(分页、排序、过滤)。
- 提供合理的默认值:
- 列表接口:默认页码
page=1,默认每页数量limit=10/20,默认按createdAt降序排序。 - 创建接口:为可选字段设置符合业务逻辑的默认值(如新组织状态默认为
active)。
- 列表接口:默认页码
- 使用恰当的HTTP语义:
GET用于读取,POST用于创建和自定义操作,PATCH用于部分更新,DELETE用于删除。- 返回合适的HTTP状态码:
200(成功)、201(创建成功)、204(删除成功无内容)、404(资源未找到)、400(客户端错误)等。
- 设计直观的端点:
- CRUD端点模式清晰。
- 自定义操作端点使用
POST方法,并置于特定资源路径下(如/resource/{id}/action)。
- 提供交互式文档:使用Swagger/OpenAPI等工具创建和维护API文档,为集成者提供清晰的参考和测试环境。
- 先设计,后实现:在编写任何业务代码之前,先用API设计工具(如Postman, Insomnia)完整地设计出API接口。这有助于你从API消费者角度思考,做出更优的设计决策。

记住,一个优秀的后端工程师不仅要实现功能,更要设计出直观、一致、易于集成和维护的API接口。遵循这些原则和模式,你将能够创建出专业级的RESTful API。
012:精通Postgres数据库管理 🗄️

在本节课中,我们将要学习数据库的核心概念,特别是在后端系统中的角色。我们将从为什么需要数据库开始,逐步深入到数据库管理系统(DBMS)的类型,并最终聚焦于PostgreSQL,学习其数据模型设计、查询编写、索引、触发器和迁移等关键实践。

为什么需要数据库?
在构建后端系统时,与数据库交互和处理数据是最重要且最频繁的操作之一。理解围绕它的所有概念对于高效工作至关重要。
首先,我们需要回答一个根本问题:为什么需要数据库?从核心上讲,数据库是一种在不同会话间持久化信息的方式。持久化意味着以某种方式存储数据,使得即使在创建它的程序停止后,数据依然存在。
例如,考虑一个待办事项列表应用。你添加条目,勾选已完成的任务等。当你执行一些操作(如创建项目或勾选项目)后关闭应用,再次打开时,你会发现所有信息、你离开时的数据状态都保持不变。这就是我们所说的持久化。信息必须在经过相当长的时间后或跨越不同的物理位置,仍以预期的相同状态存在。如果没有持久化,每次打开应用时,你都必须创建新的待办事项,并且会丢失之前创建的所有任务和进度。
什么是数据库?
当我们说“数据库”这个词时,它的含义非常广泛。从最简单的意义上讲,任何结构化的存储都可以被视为数据库。例如,你手机中的联系人列表就可以被认为是一个数据库。同样,作为开发者,你一定熟悉浏览器提供的本地存储概念。我们可以在开发者工具的“应用”选项卡中查看本地存储,它有一些条目。这是一种键值存储格式。它还有会话存储、Cookie存储等。所有这些不同类型的存储机制也被视为数据库。甚至一个你用来记笔记并稍后查阅的简单文本文件,也可以被认为是一个非常基础的数据库。
如果我们试图从所有这些例子中总结出模式,那么数据库基本上是指某种持久化系统,它提供了创建、读取、更新和删除数据的方法。这是数据库的一个非常基础和高级的定义,并不局限于我们在后端系统上下文中通常所说的“数据库”。在后端系统、服务器或典型的开发者上下文中,当我们提到数据库时,我们指的是基于磁盘的数据库。
为什么是基于磁盘的数据库?
基于磁盘的存储,无论是传统硬盘驱动器(HDD)还是现代固态硬盘(SSD),与其他存储方式(如RAM)相比,相对便宜。RAM,也称为主内存,与基于磁盘的替代方案相比非常快。但问题是,基于RAM的存储相对昂贵,并且我们没有像磁盘那样大的供应量。如果你检查你的系统配置,大多数人拥有8GB、16GB或32GB的RAM,高端消费者可能最多有64GB或128GB的RAM。但当涉及到硬盘(二级存储)时,由于硬盘相对便宜,大多数人拥有从512GB到2TB的存储空间。
你可以看到这种模式:在存储空间方面,我们的主内存(RAM)有限,但基于磁盘的内存(二级存储)空间很大。权衡在于,RAM在数据检索或保存方面非常快,而基于磁盘的存储由于数据存储和获取的方式,相对较慢。这就是为什么我们使用缓存机制(如Redis或内存缓存)——当我们谈论缓存时,我们谈论的是存储在RAM或主内存中,因为从缓存中获取或保存数据非常快。相比之下,从基于磁盘的数据库(二级存储)中获取数据则较慢。
但对于数据库来说,最重要的是我们需要更多空间,并且可以在速度方面做出一定权衡。这就是为什么大多数传统数据库(如关系型或非关系型数据库)都基于磁盘存储或二级存储,它们实际在那里存储数据。它们存储数据的格式、方式以及围绕它的所有算法,是一个非常技术深入的领域,我们不会在本视频中涵盖。我们只想让这些内容与后端工程师在应用层面相关。你需要知道的唯一一点是:像Redis这样的缓存技术将数据存储在主内存或RAM中;而像PostgreSQL或MongoDB这样的传统数据库(我们接下来会讲到)等关系型或非关系型数据库,则将数据存储在磁盘或二级内存中,因为基于磁盘的存储以较低的价格提供了更大的容量,但代价是较低的速度。
数据库管理系统(DBMS)
现在,我们来到一个称为DBMS(数据库管理系统)的术语。仅仅将数据存储在某种磁盘存储中是不够的。我们当然还需要不同的方式来检索、修改或删除这些数据。由于我们处理的是数百GB甚至数千GB的数据,我们需要这些创建、读取、更新和删除(CRUD)操作,并且要以非常高效的方式进行。因此,我们有了称为DBMS的软件系统,其唯一职责就是高效地向客户端或用户提供所有这些CRUD操作。当然,它们还有许多其他职责,如安全性、扩展数据库系统和负载均衡等。但在一个很高的层面上,它们有两个主要职责:存储数据和向客户端或用户提供不同的操作,主要是CRUD操作。
DBMS的一些职责包括:
- 数据组织:需要高效地组织数据,以便获取、更新和创建更多数据等操作都是高效的。
- 访问:如前所述,必须提供执行CRUD操作的方法。
- 完整性:这是一个技术术语,但基本上意味着数据的准确性和有效性。例如,在电子商务平台中,我们存储客户的订单详情,每个订单都有支付信息。在数据库中,我们将支付金额存储为一个数字。DBMS软件有责任维护数据的完整性,以确保没有人可以在该字段中插入任何非数字内容。如果有人尝试存储一个字符串,操作应该失败。
- 安全性:基本上意味着保护数据免受未经授权的访问。数据库软件有不同的用户和角色,以保护对数据的访问。
为什么需要DBMS软件?
为什么我们不直接使用简单的文本文件或任何类型的文件,将所有数据以文本格式存储在其中呢?在数据库概念出现之前或当人们开始构思数据库概念时,人们就是这样开始的。他们尝试将所有数据存储在文本文件中。
将数据存储在文本文件中有几个问题:
- 解析问题:如果我们将数据存储在文本文件中,每次你想找到一个特定的数据点(例如,客户数据库),你都必须编写应用程序代码来解析文本文件。你需要读取文件,分割行,比较每个字段。这个过程非常慢,而且容易出错。如果出现问题,可能会损坏数据。
- 缺乏结构:文本文件没有正式的结构。你无法强制数据必须采用这种特定结构。文本文件非常灵活,你可以以任何格式存储任何数量的文本。这使得很难强制执行数据一致性。你无法强制执行诸如“此字段只能包含数字”之类的规则。
- 并发问题:如果两个人同时尝试更新同一个文本文件,谁的更新将被视为合法,谁的更新将被丢弃?显然,最后更新文本文件的人,其更新才会持久化。但无法保证结果的一致性。当两个不同的用户尝试同时修改相同的数据时,你需要数据库软件内部的某种并发机制来高效、准确地管理整个交互,以提供一致的结果。而简单的文本文件根本无法做到这一点。
由于所有这些挑战和简单地将数据库存储在文本文件中的局限性,人们提出了像DBMS这样的软件。
DBMS的类型

在很高的层面上,我们有两种主要类型:关系型和非关系型。
关系型数据库意味着一个将数据组织在表和列中的数据库系统。不同表之间的关系使用外键等概念来定义。关系型数据库的一些关键特性包括:
- 数据是结构化的,并被插入到具有预定义模式的数据库中。你不能随意将任何类型的数据插入数据库。该数据必须具有特定的模式,这意味着它必须对应一个表,并且该表有非常严格的模式。你必须事先定义表的所有列及其数据类型。一切都需要预先定义。你不能随意操作。这是一个非常严格的系统。
- 由于这种严格的模式强制,它提供的优势是数据完整性。这意味着在任何时间点,你都可以确信数据的状态。你知道特定列的数据类型是什么,不同表之间的关系是什么。你表中的数据始终具有一致且准确的状态。
为了与这种数据库交互,我们通常使用SQL(结构化查询语言)。
一些关系型数据库的例子包括:MySQL、PostgreSQL、SQL Server等。
非关系型数据库则不同。虽然关系型数据库要求你在将数据放入数据库之前拥有一个一致的模式,但非关系型数据库不强制执行任何此类要求。你可以放入任何类型的数据。在关系型领域,表在非关系型领域(如MongoDB)中称为集合。在关系型领域,每一行称为一行,在非关系型或MongoDB中称为一个文档。在关系型领域,每一行都具有相同类型的数据,行的结构总是相同的。但在非关系型领域,每个文档可以遵循不同的结构。这是NoSQL领域的主要优势(有时也是劣势)。
优势显然是它具有非常灵活的模式。因此,如果你在进行某种原型设计,希望快速推进,不想花时间弄清楚数据库模式、强制模式和维护它,那么你可以使用像MongoDB这样的数据库,无需考虑数据库模式即可快速推进,可以动态推送数据,获取任何类型的数据等。在某些场景下,这种灵活性可能是一个优势。
如何选择:关系型 vs. 非关系型?
让我们通过例子来理解。
关系型数据库用例:客户关系管理(CRM)软件。它需要维护关于客户、联系人、销售机会详情等准确且一致的数据。这些关键数据更适合放在关系型系统中。像PostgreSQL这样的关系型数据库是一个非常好的选择,因为它提供了强大的数据完整性,并允许复杂的查询和分析客户之间的关系。
非关系型数据库用例:内容管理系统(CMS)。CMS用于将内容从远程站点推送到不同的内容分发系统。例如,一个博客平台。每次你想添加新文章时,你只需登录CMS系统,以Markdown格式编写博客或文章并保存。下次有人刷新你的网站时,新文章就会被获取。在这种用例中,CMS需要存储的内容并不是真正结构化的。一篇文章可以包含图片、代码块、YouTube嵌入等。内容可以是多种类型。因此,在非关系型数据库(如MongoDB)中存储这种动态内容非常有意义,因为你事先不知道将要存储的所有不同类型的数据,你只是想接收所有内容并存储在那里。
然而,尽管像MongoDB这样的数据库提供了很大的灵活性,它们也可能在数据完整性方面带来挑战,因为通常缺乏关系型数据库的强大约束和关系。由于数据的模式和完整性不是在数据库级别强制执行的,你必须在应用程序级别完成,这增加了代码的复杂性,当然也更容易出错,因为应用程序代码经常更改,很容易遗漏某些东西或引入新的错误。
为什么选择PostgreSQL?
现在我们必须做出选择。市场上有许多选项。我们有关系型和非关系型数据库。在关系型数据库中,我们有MySQL、PostgreSQL、SQL Server等许多不同的产品,它们都提供大致相同的功能,并且都可以在生产系统中扩展。因此,我们必须在这里做出选择。我们将继续使用哪种数据库?在这种情况下,选择PostgreSQL非常有意义,原因如下:

- 开源且免费:它不是专有软件,完全开源。
- 遵循SQL标准:PostgreSQL DBMS遵循SQL标准,因此你可以在PostgreSQL系统上运行任何SQL查询,并且其执行方式与在MySQL或SQL Server等不同数据库系统上相同。未来,如果你想将数据库迁移到不同的系统,由于PostgreSQL符合SQL标准,并且你所有的迁移语句、创建、更新、获取语句都是以标准SQL格式编写的,那么你只需更改数据库并进行一些小的修改,就可以轻松地从PostgreSQL切换到MySQL。
- 非常可扩展:它提供了许多功能。PostgreSQL文档大约有100页长,涵盖了典型SaaS可能遇到的几乎所有用例。它还有一个非常好的基于扩展的系统,因此你可以根据自己的需求进行定制。
- 以其可靠性和可扩展性而闻名。
- 具有非常好的JSON支持:如前所述,选择像MongoDB这样的非关系型数据库的主要原因之一是你可以存储任何类型的数据。当我们说“任何类型的数据”时,我们大多指的是JSON,因为在JSON中你可以存储任何类型的数据(数字、字符串、嵌套的JSON、数组等)。你可以在NoSQL数据库(如MongoDB)中将任何类型的JSON存储为文档。同样,由于PostgreSQL提供了JSON数据类型,并且对JSON字段有非常好的索引和查询能力,因此没有理由仅仅为了动态数据而选择不同的数据库。你可以将PostgreSQL与JSON字段一起用于动态数据的需求。例如,在内容管理系统的例子中,如果你有来自用户的某种内容,并且你想将其保存在数据库中,但对该内容没有严格的模式,你可以使用典型的JSON模式,将来自用户的所有内容存储在那里,当你想渲染时,可以从那里获取并渲染。没有必要仅仅因为动态数据的需求而切换到非关系型数据库。


由于所有这些特性,PostgreSQL几乎是首选。根据我的观察,许多初创公司和大型公司都坚持使用PostgreSQL,并且PostgreSQL通常是他们的首选。即使你自己做研究,也会发现很多文章说MySQL有很多性能优势等。但除非你服务于数百万用户并且想要优化应用程序的特定瓶颈,否则你真的不需要考虑是应该选择MySQL还是PostgreSQL。由于PostgreSQL丰富的功能集和出色的JSON支持,PostgreSQL应该成为你几乎所有项目的首选。

这就是为什么在本视频中,我们将使用PostgreSQL。我们将要学习的关于数据库的所有内容,都将在PostgreSQL的背景下进行。
注意:关于SQL和PostgreSQL基础知识的免费资源已经有很多。SQL是我们用来查询的语言,而PostgreSQL是我们执行SQL查询的数据库系统软件。YouTube上有很多关于SQL和PostgreSQL基础知识的课程。因此,为了避免重复相同的内容,我们不会在本视频中再次介绍创建表、SELECT、ORDER BY、GROUP BY等SQL基础知识以及PostgreSQL的基础知识。我们将节省时间,只关注与后端系统非常相关的一些概念,而将基础知识留给你在其他地方学习。
因此,如果你想的话,现在可以暂停此视频,只需在YouTube上搜索“SQL basics”,你会找到从1小时到20小时不等的视频,PostgreSQL基础知识也是如此。你可以根据自己的舒适度和所需的全面性选择一个视频观看,然后回到本视频。这是进入下一节的前提条件。
PostgreSQL数据类型简介

在开始设计数据库之前,了解PostgreSQL中可用的不同数据类型非常重要。我们将进行一个非常高级的介绍。
以下是一个创建表的查询示例,展示了各种数据类型:
CREATE TABLE data_types_demo (
id SERIAL PRIMARY KEY,
small_int SMALLINT,
normal_int INTEGER,
big_int BIGINT,
decimal_num DECIMAL(10, 2),
real_num REAL,
double_num DOUBLE PRECISION,
fixed_char CHAR(10),
var_char VARCHAR(255),
text_field TEXT,
is_active BOOLEAN,
birth_date DATE,
meeting_time TIME,
created_at TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE,
duration INTERVAL,
unique_id UUID,
json_data JSON,
jsonb_data JSONB,
tags TEXT[],
ip_address INET,
mac_address MACADDR,
point_location POINT,
xml_data XML
);
以下是主要数据类型的简要说明:
- SERIAL/BIGSERIAL:自动递增的整数类型。通常用作表的主键ID。
BIGSERIAL容量更大。 - 整数类型:
SMALLINT、INTEGER、BIGINT。它们都是整数,只是容量不同:SMALLINT<INTEGER<BIGINT。 - 小数类型:
DECIMAL/NUMERIC和REAL/DOUBLE PRECISION。DECIMAL(10,2)表示总共最多10位数字,小数点后固定2位。它用于需要高精度的场合,如价格。REAL/DOUBLE PRECISION是浮点数,处理速度更快,但可能存在微小精度误差。适用于精度要求不高的科学计算或测量值。
- 字符串类型:
CHAR、VARCHAR、TEXT。CHAR(10):固定长度,不足会填充空格。已过时,不推荐常规使用。VARCHAR(255):可变长度,最大255个字符。255是来自MySQL的惯例,在PostgreSQL中无特殊意义。TEXT:可变长度,无限制(实际有非常大上限)。PostgreSQL官方推荐使用TEXT类型,它与VARCHAR在性能上无显著差异,且更灵活,无需预先定义长度。
- 布尔类型:
BOOLEAN,存储TRUE或FALSE。 - 日期时间类型:
DATE:仅日期。TIME:仅时间。TIMESTAMP:日期和时间。TIMESTAMP WITH TIME ZONE:日期、时间和时区信息。INTERVAL:时间间隔,如10 days。
- UUID:通用唯一标识符,常用于主键,因其友好性和唯一性。
- JSON/JSONB:用于存储JSON数据。
JSONB(JSON Binary)是二进制格式,查询和索引效率更高,推荐使用JSONB。 - 数组:可以存储任何数据类型的数组,如
TEXT[]。 - 其他类型:如网络地址(
INET)、MAC地址(MACADDR)、几何点(POINT)、XML等,不常用。
数据库迁移
在生产系统中,你不能只是打开一个像TablePlus这样的图形化软件并开始编写查询。因为如果出现问题,或者即使没有出现问题,也无法跟踪随时间推移对特定数据库应用了哪些更改,或者是谁应用的。无法控制数据库在不同时间点的状态或版本。
因此,大多数数据库系统都遵循一种称为数据库迁移的模式。数据库迁移基本上是不同文件(例如 1.sql、2.sql)的集合,这些文件按顺序包含SQL语句。你使用一个命令行工具(如 dbmate 或 go-migrate)按顺序执行这些文件中的所有SQL语句。
迁移文件通常按顺序命名(如数字序列或时间戳)。迁移工具有两个主要部分:
- 向上迁移:包含要对数据库进行的更改(如创建表、创建索引)。
- 向下迁移:包含如何撤销向上迁移中的更改(如删除表、删除索引)。这用于在出现问题时回滚到先前状态。
迁移工具通常会在你的数据库中创建一个特殊的表(如 schema_migrations)来跟踪当前已应用的迁移版本。
为什么需要迁移?
- 跟踪数据库更改:随时间推移跟踪数据库更改。你有一个文件夹,其中包含提交到版本控制系统(如Git)的文件。随着时间的推移,你不断向同一文件夹添加新的迁移文件,从而跟踪数据库模式的所有更改。
- 回滚:如果出现问题,可以回滚到特定版本。
设计项目管理平台数据库
现在,我们将开始为项目管理平台设计数据库。我们将编写迁移来创建表,并理解围绕它的一些概念。
我们将创建一个迁移文件来定义表结构。以下是核心表的设计思路:
- 用户表 (
users):存储用户基本信息。id(UUID, 主键)email(TEXT, 唯一,非空)full_name(TEXT, 非空)password_hash(TEXT, 非空)created_at,updated_at(TIMESTAMP WITH TIME ZONE)
- 用户资料表 (
user_profiles):与用户表是一对一关系,存储用户的额外信息(头像、简介、电话)。使用user_id作为主键和外键。 - 项目表 (
projects):存储项目信息。id(UUID, 主键)name(TEXT, 非空)description(TEXT)status(枚举类型:active,completed,archived,默认active)owner_id(UUID, 外键引用users.id,删除时限制ON DELETE RESTRICT)created_at,updated_at
- 任务表 (
tasks):存储任务信息,与项目是多对一关系。id(UUID, 主键)project_id(UUID, 外键引用projects.id,删除时级联ON DELETE CASCADE)title(TEXT, 非空)description(TEXT)priority(INTEGER, 检查约束priority BETWEEN 1 AND 5,默认 1)status(枚举类型:pending,in_progress,completed,cancelled,默认pending)due_date(DATE)assigned_to(UUID, 外键引用users.id,删除时置空ON DELETE SET NULL)created_at,updated_at
- 项目成员表 (
project_members):实现项目和用户之间的多对多关系(链接表)。project_id(UUID, 外键引用projects.id,删除时级联)user_id(UUID, 外键引用users.id,删除时级联)role(枚举类型:owner,admin,member,默认member)created_at,updated_at- 主键:
(project_id, user_id)复合主键,确保唯一性。
关系总结:
- 一对一:
users与user_profiles。通过在user_profiles中使用user_id作为主键实现。 - 一对多:
projects与tasks。通过在tasks中使用project_id作为外键实现。 - 多对多:
projects与users。通过链接表project_members实现,该表包含两个外键并形成复合主键。
编写查询:对应后端API
上一节我们设计了数据库模式,本节中我们来看看如何为典型的后端API编写SQL查询。
1. 获取所有用户 (GET /api/users)
此API需要返回用户列表及其资料信息。
-- 使用左连接确保即使没有资料的用户也被返回
-- 使用 jsonb_build_object 将用户资料转换为JSON对象
SELECT
u.*,
jsonb_build_object(
'avatar_url', up.avatar_url,
'bio', up.bio,
'phone', up.phone
) AS profile
FROM users u
LEFT JOIN user_profiles up ON u.id = up.user_id
ORDER BY u.created_at DESC;
说明:
FROM users u:从用户表开始,u是别名。LEFT JOIN user_profiles up ON u.id = up.user_id:左连接用户资料表,连接条件是用户ID相等。jsonb_build_object(...) AS profile:将资料行的字段构建成一个JSON对象,并命名为profile字段。ORDER BY u.created_at DESC:按创建时间降序排序,最新的在前。
2. 获取单个用户 (GET /api/users/:id)
此API根据用户ID返回特定用户及其资料。
-- 使用参数化查询防止SQL注入
SELECT
u.*,
jsonb_build_object(
'avatar_url', up.avatar_url,
'bio', up.bio,
'phone', up.phone
) AS profile
FROM users u
LEFT JOIN user_profiles up ON u.id = up.user_id
WHERE u.id = :user_id; -- :user_id 是参数占位符
说明:
WHERE u.id = :user_id:添加条件过滤特定用户。:user_id是一个参数占位符。在实际后端代码中,你会通过数据库驱动安全地传入动态值,这是防止SQL注入的关键。
3. 创建用户 (POST /api/users)
此API用于创建新用户。

-- 插入用户,并返回创建的行
INSERT INTO users (email, full_name, password_hash)
VALUES (:email, :full_name, :password_hash)
RETURNING *;

说明:
INSERT INTO ... VALUES ...:插入新行。RETURNING *:返回刚插入的整行数据,方便API响应。
4. 更新用户资料 (PATCH /api/users/:id/profile)
此API用于部分更新用户资料。
-- 根据 user_id 更新用户资料表,只更新提供的字段
UPDATE user_profiles
SET
bio = COALESCE(:bio, bio), -- 如果:bio参数为NULL,则保持原值
phone = COALESCE(:phone, phone),
avatar_url = COALESCE(:avatar_url, avatar_url)
WHERE user_id = :user_id
RETURNING *;
说明:
UPDATE ... SET ...:更新指定字段。COALESCE(:bio, bio):如果传入的:bio参数不为空,则使用新值;否则保持原来的bio值。这实现了部分更新。WHERE user_id = :user_id:指定更新哪条记录。RETURNING *:返回更新后的行。
5. 带过滤、排序和分页的查询
对于列表API,通常需要支持过滤、排序和分页。
-- 假设前端传递了 filter_letter, sort_by, sort_order, page, limit 参数
SELECT
u.*,
jsonb_build_object(
'avatar_url', up.avatar_url,
'bio', up.bio,
'phone', up.phone
) AS profile
FROM users u
LEFT JOIN user_profiles up ON u.id = up.user_id
WHERE (:filter_letter IS NULL OR u.full_name ILIKE :filter_letter || '%') -- 按姓名首字母过滤
ORDER BY
-- 动态排序字段和顺序
CASE :sort_by
WHEN 'email' THEN u.email
WHEN 'full_name' THEN u.full_name
ELSE u.created_at -- 默认排序字段
END
-- 动态排序顺序
CASE WHEN :sort_order = 'ASC' THEN 1 ELSE 0 END ASC,
CASE WHEN :sort_order = 'DESC' THEN 1 ELSE 0 END DESC
LIMIT :limit OFFSET (:page - 1) * :limit; -- 分页
说明:
WHERE (:filter_letter IS NULL OR ...):如果过滤参数为空,则忽略过滤条件;否则应用过滤。ILIKE:不区分大小写的模糊匹配。:filter_letter || '%'匹配以该字母开头的姓名。ORDER BY CASE ... END:根据:sort_by参数动态选择排序字段。LIMIT :limit OFFSET ...:实现分页。OFFSET跳过前面页的数据。
注意:在实际后端代码中,动态查询的构建会更复杂,通常使用查询构建器或ORM来安全地组合SQL片段和参数。
索引
上一节我们介绍了如何编写基本查询,本节中我们来看看如何通过索引优化这些查询的性能。
索引是数据库中的一个数据结构,用于加速数据检索。你可以把它想象成书的索引:不用翻遍整本书来找到某个主题,你可以直接查看索引,找到主题对应的页码。
为什么需要索引?
没有索引,当数据库执行 WHERE、JOIN 或 ORDER BY 操作时,可能需要进行全表扫描,逐行比较,这在数据量大时非常慢。索引通过创建特定列的排序副本(及其在磁盘上的位置)来工作,使数据库能够快速定位行。
何时创建索引?
考虑为以下情况的列创建索引:
- 主键和外键:通常自动索引。
- 经常用于
WHERE子句的列。 - 经常用于
JOIN条件的列。 - 经常用于
ORDER BY或GROUP BY的列。
权衡:
索引会提高查询速度,但会降低插入、更新和删除的速度,因为索引本身也需要维护。同时,索引占用额外的磁盘空间。
为我们的项目管理平台创建一些索引:


-- 在迁移文件中添加索引创建语句
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_created_at_desc ON users(created_at DESC);
CREATE INDEX idx_tasks_project_id ON tasks(project_id);
CREATE INDEX idx_tasks_assigned_to ON tasks(assigned_to);
CREATE INDEX idx_tasks_status ON tasks(status);
CREATE INDEX idx_tasks_created_at_desc ON tasks(created_at DESC);
CREATE INDEX idx_project_members_project_id ON project_members(project_id);
CREATE INDEX idx_project_members_user_id ON project_members(user_id);


说明:
idx_users_email:加速按邮箱查找用户或基于邮箱的连接。idx_users_created_at_desc:加速按创建时间降序获取用户列表的查询。idx_tasks_project_id:加速根据项目ID查找任务的查询(例如,“获取某项目的所有任务”)。idx_tasks_assigned_to:加速根据负责人查找任务的查询。idx_tasks_status:加速按状态过滤任务的查询。idx_tasks_created_at_desc:加速按创建时间降序获取任务列表。idx_project_members_*:加速在链接表上基于项目或用户ID的查询。
触发器




触发器是一种数据库对象,当特定事件(如 INSERT、UPDATE、DELETE)在表上发生时,会自动执行一段预定义的代码(函数)。
一个常见的用例是自动更新 updated_at 时间戳字段,这样我们就不必在每次更新操作时手动设置它。
为我们的表创建触发器:


-- 首先,创建一个函数,用于将 updated_at 设置为当前时间戳
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ language 'plpgsql';

-- 然后,为每个需要此功能的表创建触发器
CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_user_profiles_updated_at BEFORE UPDATE ON user_profiles
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_projects_updated_at BEFORE UPDATE ON projects
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_tasks_updated_at BEFORE UPDATE ON tasks
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_project_members_updated_at BEFORE UPDATE ON project_members
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
说明:
CREATE OR REPLACE FUNCTION ...:定义一个触发器函数。NEW代表正在被更新(或插入)的新行。CREATE TRIGGER ... BEFORE UPDATE ON ...:在指定表的每次更新操作之前,为受影响的每一行执行该函数。- 现在,每当更新这些表中的任何行时,
updated_at字段都会自动设置为当前时间戳。
数据填充
在开发环境中,为了测试,我们通常需要向数据库插入一些初始测试数据。这个过程称为“播种”。
我们可以创建一个单独的迁移文件来执行播种:
-- 使用公共表表达式(CTE)使插入更清晰
WITH inserted_users AS (
INSERT INTO users (email, full_name, password_hash)
VALUES
('alice@example.com', 'Alice Brown', 'hash1'),
('bob@example.com', 'Bob Smith', 'hash2'),
('charlie@example.com', 'Charlie Davis', 'hash3')
RETURNING id, email
),
inserted_profiles AS (
-- 基于插入的用户创建资料
SELECT ... -- 具体插入逻辑
),
inserted_projects AS (
-- 插入项目
SELECT ... -- 具体插入逻辑
)
-- 可以继续插入任务、项目成员等
SELECT 'Seeding completed';
运行此迁移后,数据库中将拥有可用于开发和测试的样本数据。
总结
在本节课中,我们一起学习了后端开发中数据库管理的核心知识。我们从数据库的基本概念和必要性出发,探讨了基于磁盘存储的原因,并介绍了数据库管理系统(DBMS)的职责和主要类型(关系型 vs. 非关系型)。我们详细说明了为什么PostgreSQL是一个强大的选择,特别是其开源、SQL标准兼容、可扩展性以及出色的JSON支持。
我们深入实践,设计了项目管理平台的数据库模式,包括用户、资料、项目、任务和项目成员表,并阐释了一对一、一对多和多对多关系的实现方式。我们学习了如何为典型的CRUD API编写SQL查询,包括使用JOIN关联数据、WHERE过滤、ORDER BY排序、LIMIT/OFFSET分页,以及至关重要的参数化查询来防止SQL注入。


为了提升性能,我们介绍了索引的概念,了解了何时以及为何创建索引,并为我们设计的表创建了相应的索引。我们还使用触发器自动化了updated_at时间戳的更新,减少了应用程序代码的负担。最后,我们了解了数据库迁移的重要性,它帮助我们版本化、可追溯地管理数据库模式变更,并通过数据填充为开发环境准备测试数据。

掌握这些概念和实践,你将能够为后端系统设计健壮的数据模型,编写高效安全的查询,并实施必要的优化和维护策略,从而构建出可靠、可扩展的应用程序。
013:缓存,一切背后的秘密 🔍

在本节课中,我们将要学习缓存的核心概念。缓存是构建高性能后端应用的关键技术之一。我们将从定义出发,通过现实世界的例子,深入探讨其工作原理、不同层级以及在后端开发中的具体应用。
什么是缓存?
用一句话来简化:缓存是一种机制,用于减少执行某些工作所需的时间和精力。
更技术性的定义是:缓存本质上是保存主要数据的一个子集。根据数据的使用情况、访问频率、下一次被访问的概率等多种参数,我们将这个数据子集存放在一个访问速度更快、耗时更少、也更省力的位置。因此,从技术上讲,缓存是一种机制,通过它我们可以减少检索或执行某种操作所需的时间和精力。
这种单一的技术是众多高性能应用中的一个巨大影响因素。
为什么缓存如此重要?💡

为了理解缓存的重要性及其对应用性能的影响,让我们来看几个例子。
示例一:谷歌搜索
几乎所有人都使用过谷歌搜索。当你在搜索栏中输入查询并按下回车时,会发生什么?

谷歌搜索引擎会处理这个查询。他们有一个非常复杂的算法和工作流,通常涉及对数十亿网页的爬取、索引和排名。整个过程计算成本非常高。

试想一下,像“今天天气如何”这样的查询,每天会被搜索数百万次。如果不实施缓存机制,谷歌服务器将需要为每一个查询重新计算所有结果,这将导致极高的延迟和服务器负载。
谷歌的解决方案是使用分布式内存缓存系统。它将搜索结果(由各种排名算法生成)存储在缓存服务器中。当用户搜索时,系统首先检查该特定查询的结果是否已在缓存中。

- 缓存命中:如果找到,则立即返回结果。从缓存中检索数据非常快。
- 缓存未命中:如果未找到,则走正常的工作流程(爬取、索引、排名),获取结果后,将其缓存起来,以便下次相同查询时可以直接使用。
示例二:Netflix 流媒体
Netflix 如何向全球数百万用户交付数百TB的内容,同时保持低缓冲和低服务器负载?

答案是使用 CDN(内容分发网络)。Netflix 在美国等地有自己的源服务器,存储着实际的电影文件。同时,它在全球各地战略性地部署了边缘服务器。
这些边缘服务器缓存了内容的一个子集(例如,根据地区观看趋势)。当印度用户请求一部电影时,请求会被路由到离印度最近的边缘服务器。
- 如果电影在该边缘服务器的缓存中,则直接从此处交付,延迟极低。
- 如果不在缓存中,边缘服务器会从美国的源服务器获取内容,缓存起来,然后交付给用户,并为后续请求服务。

这就是缓存:将主要存储(源服务器)的一个子集,放置在一个访问速度快得多的位置(边缘服务器)。

示例三:X(Twitter)趋势话题
X(原 Twitter)如何实时识别趋势话题?它需要分析全球数百万条推文,这涉及复杂的机器学习算法和处理海量数据,计算成本极高。

如果每次用户访问“趋势”版块都触发这个计算,服务器将在几分钟内崩溃。

X 的解决方案是缓存。趋势话题不会在几秒或几分钟内改变。因此,X 每隔几分钟运行一次趋势检测算法,然后将结果存储在一个内存键值存储数据库(如 Redis)中。当用户请求趋势数据时,直接从缓存中读取并返回,实现了即时加载。



从以上三个例子中,我们可以看到一个共同模式:每当我们希望避免重复进行繁重计算或传输大量数据时,就会使用缓存。
缓存的层级 🏗️

在软件工程,特别是后端开发中,你通常会遇到三个层级的缓存。


1. 网络层缓存


这主要涉及两种常见用例:
a) 内容分发网络
CDN 的核心思想是将内容缓存在地理上最接近最终用户的服务器(边缘节点)上,以最小化延迟和源服务器负载。
工作流程简述:
- 用户请求一个资源(如图片、视频)。
- 浏览器发送 DNS 查询来解析域名。
- CDN 的 DNS 系统将请求路由到最近的 PoP(接入点),这是一个包含多个边缘服务器的区域。
- 边缘服务器检查请求的内容是否在缓存中。
- 缓存命中:直接返回内容。
- 缓存未命中:从源服务器获取内容,缓存它,然后返回给用户。
- CDN 使用 TTL(生存时间) 来决定缓存内容应保留多久。
b) DNS 查询

DNS 也重度依赖缓存来减少查询延迟。当你在浏览器输入 example.com 时:
- 操作系统缓存:首先检查本地 DNS 缓存。
- 浏览器缓存:如果未命中,浏览器检查自己的 DNS 缓存。
- 递归解析器缓存:由 ISP 或公共 DNS(如 Google DNS)提供,它也有自己的缓存。如果都没有,它才会递归查询根域名服务器 -> 顶级域名服务器 -> 权威域名服务器来获取 IP。
- 权威服务器缓存:某些权威服务器也可能实现缓存。

这种多层缓存确保了绝大多数 DNS 查询都能被快速解析。

2. 硬件层缓存

如果你有计算机科学背景,可能已经熟悉 CPU 缓存(L1, L2, L3)。这些是位于 CPU 和主内存之间的小型、高速存储器,用于缓存频繁使用的数据和指令,以加速计算。

对我们后端开发更重要的是 RAM(随机存取存储器) 与硬盘的对比:
- RAM/主内存:通过电信号直接访问数据,速度极快,但容量有限且具有易失性(断电数据丢失)。
- 硬盘/二级存储:通过机械部件(如磁头)访问数据,速度较慢,但容量大且具有非易失性(数据持久保存)。
内存数据库(如 Redis)正是利用了 RAM 的高速访问特性。它们在 RAM 中存储数据以实现快速读写,同时通过机制(如定期快照)将数据持久化到硬盘,以保证数据安全。

3. 软件/应用层缓存

这是后端开发者直接交互的缓存,通常指 内存键值数据库,如 Redis、Memcached。


为什么叫“内存键值 NoSQL 数据库”?
- 内存:数据存储在 RAM 中,访问快。
- 键值:数据结构简单,通过唯一的键来存取对应的值(值可以是字符串、列表、JSON等)。
- NoSQL:不像关系型数据库有严格的表结构,更加灵活。

缓存策略与淘汰策略 ⚙️
在使用内存缓存时,需要了解两个核心策略。
缓存策略


a) 惰性缓存 / 缓存旁路
这是最常见的策略。当读取数据时:
- 先检查缓存。
- 如果命中,直接返回。
- 如果未命中,则从主数据库读取,将结果写入缓存,然后返回。
公式表示:读请求 -> 检查缓存 -> 命中则返回 / 未命中则查DB -> 写入缓存 -> 返回

b) 通写缓存
当写入数据时:
- 同时更新主数据库和缓存。
- 后续的读请求可以直接从缓存中获取最新数据。
优点:缓存数据总是最新的。
缺点:写操作开销更大,因为需要写两个地方。


淘汰策略

由于缓存容量有限,当缓存满时,需要决定移除哪些旧数据以腾出空间。这就是淘汰策略。
- 无淘汰:不配置策略,缓存满时新数据写入会报错。
- LRU:淘汰最近最少使用的数据。系统跟踪每个键的最后访问时间。
- LFU:淘汰最不经常使用的数据。系统跟踪每个键的访问频率。
- TTL:基于生存时间淘汰。为每个键设置过期时间,到期自动删除。
Redis 在后端开发中的用例 🛠️
了解了原理,我们看看 Redis 在实际后端工程中的典型应用场景。
1. 数据库查询缓存
场景:一个涉及多表连接和聚合的复杂 SQL 查询,计算密集且被频繁调用。
解决方案:将查询结果缓存到 Redis 中,并设置一个合理的 TTL(例如1小时)。后续请求直接返回缓存结果,极大减轻数据库压力。
代码示例思路:
# 伪代码
def get_complex_report():
cache_key = “complex_report:2023-10”
cached_data = redis.get(cache_key)
if cached_data:
return json.loads(cached_data)
else:
# 执行复杂数据库查询
data = db.execute_complex_query()
# 将结果缓存1小时
redis.setex(cache_key, 3600, json.dumps(data))
return data
2. 会话存储
场景:用户登录后,需要存储会话信息(如用户ID、权限),并在每次请求时快速验证。
解决方案:将会话令牌(Session Token)及其对应的用户信息存储在 Redis 中。验证时直接从 Redis 读取,速度快,且能避免对主数据库的冲击。


3. 外部 API 响应缓存
场景:你的后端需要调用一个外部天气 API 来获取数据,但该 API 有调用频率限制或按次收费。
解决方案:将天气 API 的响应缓存起来(例如缓存1小时)。在这1小时内,所有用户请求都使用缓存数据,大幅减少对外部 API 的调用。
4. 速率限制
场景:需要限制某个 IP 地址或用户在短时间内调用 API 的次数,防止滥用。
解决方案:使用 Redis 作为计数器。
工作流程:
- 中间件从请求头中提取客户端 IP。
- 以 IP 为键,在 Redis 中创建一个计数器,并设置一个时间窗口(例如1分钟)。
- 每次请求,计数器加1。
- 如果计数器值超过限制(例如50次),则返回
429 Too Many Requests错误。 - 时间窗口过期后,计数器自动重置。
优点:Redis 的高速读写特性使得速率限制检查对 API 延迟影响极小,并且将计数负载从主数据库分离。



总结 📚

在本节课中,我们一起深入探讨了缓存这一核心后端技术。

我们首先定义了缓存:它是一种通过存储数据子集于高速位置,来减少数据访问时间和计算成本的机制。接着,我们通过谷歌、Netflix 和 X 的现实案例,理解了缓存在构建高性能、可扩展应用中的关键作用。


然后,我们剖析了缓存的三个主要层级:网络层(CDN, DNS)、硬件层(CPU缓存, RAM)和软件/应用层(Redis, Memcached)。我们重点学习了应用层缓存的核心策略:惰性缓存与通写缓存,以及当缓存空间不足时的淘汰策略(LRU, LFU, TTL)。


最后,我们探讨了 Redis 这类内存数据库在后端开发中的具体用例,包括数据库查询缓存、会话存储、外部 API 缓存和速率限制。理解这些原理和用例,将帮助你在实际开发中做出更合理的技术决策,有效提升应用性能。

现在,你可以选择一个 Redis 的兼容库(如 Node.js 的 ioredis),开始动手实践,亲身体验从内存数据库存取数据与从传统关系型数据库存取的速度差异。
014:任务队列与后台作业 🚀
在本节课中,我们将要学习后台作业或后台任务。如果你是一名后端开发者,理解后台作业对于构建可扩展且响应迅速的应用或后端服务至关重要。

什么是后台任务?🤔
后台任务本质上是任何在请求-响应生命周期之外运行的代码片段。在我们的系统中,客户端发出请求,服务器返回响应。任何运行于这个完整的客户端-服务器交互或请求-响应生命周期之外的逻辑或工作流,我们都称之为后台作业或后台任务。

这意味着,在请求-响应生命周期之外执行的操作,不需要立即发生。这不是一个需要调用后立即响应的关键任务。它不是同步的,因此我们可以将其卸载到一个单独的进程中,按照我们编程设定的方式去完成。



为什么需要后台任务?💡



让我们来看一个例子。假设你有一个SaaS平台,用户前来注册。他们输入邮箱、用户名、密码等信息,前端会向你的后端服务器发起一个API调用。服务器会进行各种处理,如邮箱验证、密码长度和复杂度检查等。

成功后,服务器需要向该用户发送一封验证邮件。这是许多平台的标准流程,用于验证用户确实拥有其提供的邮箱地址。
这里的重点是:用户注册,后端完成初始处理后,需要发送邮件。发送邮件这个任务,就是我们通常要卸载到后台进程的工作流示例。



原因在于,发送邮件通常依赖于第三方服务(如Resend、Mailgun等)。你的后端需要构造邮件内容(例如一个HTML模板),填入验证链接或代码,指定收件人、发件人、主题等信息,然后向邮件服务商的API发起另一个调用。

同步处理的潜在问题 ⚠️
如果我们同步地处理邮件发送(即在同一个请求-响应周期内),会面临问题:
- 邮件服务可能不可靠:我们无法控制第三方服务器,它可能因流量激增、服务宕机等原因响应缓慢或失败。
- 糟糕的用户体验:
- 如果API调用失败且没有妥善的错误处理,整个注册API可能失败。
- 即使注册API成功,但邮件发送失败,前端却告诉用户“验证邮件已发送”,用户将无法收到邮件,体验极差。


异步处理(后台任务)的优势 ✅

现在,让我们看看异步的工作流程:
- 用户注册,后端完成初始处理(如生成验证码)。
- 后端将发送邮件所需的所有信息序列化(例如转换成JSON格式)。
- 后端将这个任务推入一个队列中,然后立即返回成功响应(如200或201状态码)。
- 前端立刻收到响应,并显示“验证邮件已发送”的提示。
- 在系统的另一侧,消费者(或工作者)进程会从队列中取出这个任务。
- 消费者将任务数据反序列化,获取所有必要信息。
- 消费者执行发送邮件的实际代码,调用邮件服务商的API。
- 邮件发送成功,用户收到邮件。

这种方式的优势:
- 提升响应速度:主API调用快速返回,不因外部服务延迟而阻塞。
- 内置重试机制:如果任务失败(例如邮件服务暂时不可用),任务队列框架(如Celery、BullMQ)通常支持自动重试,并可采用指数退避等策略(例如1分钟后重试,失败则2分钟后重试,以此类推)。这大大提高了任务最终成功的概率。
- 改善用户体验:用户操作得到即时反馈。


后台任务的常见类型 📋


以下是典型SaaS应用中常被卸载到后台的任务类型:
- 发送邮件:如验证邮件、欢迎邮件、密码重置邮件等。这是最经典的例子。
- 处理图片或视频:用户上传媒体文件后,进行缩放、转码、优化以适应不同设备和网络条件。
- 生成报告:例如,在项目管理应用中,定期(每日、每周、每月)生成PDF或Excel报告并发送给用户。
- 发送推送通知:向用户的移动设备发送通知。这通常涉及调用操作系统提供商(如Google、Apple)的推送服务API。
- 清理和维护任务:例如,定期清理数据库中过期的用户会话记录。
- 批量数据处理:如用户账户删除操作,需要安全地删除用户在所有相关数据库中的大量数据。
- 链式任务:一系列有依赖关系的任务。例如,视频上传后,需要先转码,然后才能并行生成缩略图和字幕。
- 定时任务:在特定时间或间隔重复执行的任务,如发送每日报告。

任务队列如何工作?⚙️

任务队列是管理和分发后台作业的系统,是实现上述工作流程的核心引擎。




其核心思想涉及三个主要部分:
- 生产者:你的应用程序代码(如Node.js、Python、Go)。它创建任务,将所需数据序列化,并将任务入队到队列中。公式表示为:
生产者 -> 序列化任务 -> 入队 - 队列:也称为代理。它是一个临时存储区域,负责保存任务,直到有工作者准备好处理它们。常用技术包括RabbitMQ、Redis(使用Pub/Sub或列表)、Amazon SQS等。
- 消费者/工作者:运行在独立进程中的程序。它出队任务,反序列化数据,并执行注册的任务处理函数(处理器)。公式表示为:
消费者 <- 出队任务 <- 反序列化 -> 执行处理器

关键机制:
- 确认:任务成功后,消费者会向队列发送确认信号,队列随后删除该任务。
- 可见性超时:任务被消费者取出后,会进入“处理中”状态并隐藏一段时间。如果消费者在此超时时间内未确认(可能因崩溃),队列会使任务重新可见,以便其他消费者处理,防止任务丢失。

设计考量与最佳实践 🛠️

在设计和实现后台任务系统时,需要考虑以下几点:
- 幂等性:任务应被设计成可以安全地多次执行而不会产生副作用。这对于重试机制至关重要。例如,删除用户账户的任务应在数据库事务中执行,以便失败时可以回滚。
- 健壮的错误处理与日志记录:任务在独立进程中运行,必须有完善的错误捕获和日志记录,便于调试和监控。
- 监控与告警:需要监控队列长度、任务成功率/失败率、工作者健康状态等指标。使用如Prometheus、Grafana等工具建立仪表盘和告警。
- 可扩展性:设计应支持水平扩展。当任务量增加时,能够方便地添加更多工作者实例。
- 顺序保证:如果任务需要按特定顺序执行,需选择支持有序传递的队列或自行设计顺序逻辑。
- 速率限制:如果任务调用外部API,需实施速率限制,避免超过外部服务的限制或产生过高费用。
最佳实践建议:
- 保持任务小而专注:一个任务只做一件事。这便于调试、监控、重试和扩展。
- 避免长时运行任务:如果任务执行时间很长,应将其分解为多个更小的、可管理的子任务。
- 实施完善的错误处理与日志记录:这是调试和确保系统可靠性的基石。
- 持续监控队列长度与工作者健康:设置告警,以便在队列积压或工作者异常时能及时响应。
总结 📝

本节课中,我们一起学习了后台任务的核心概念。我们了解到,后台任务是构建可扩展、可靠且响应迅速的后端应用的重要组成部分。它们允许你将耗时和非关键的操作异步化,从而提升用户体验、防止请求超时,并为依赖外部服务或繁重处理的操作提供了强大的重试机制。通过理解生产者-队列-消费者模型、常见任务类型以及关键的设计考量,你已掌握了在后端系统中有效利用任务队列的基础知识。
015:使用Elasticsearch进行全文检索,实现极速搜索 🔍
在本节课中,我们将要学习全文检索的核心概念,理解为什么传统的数据库搜索在数据量大时会变得缓慢,并探索像Elasticsearch这样的专用搜索引擎是如何解决速度、相关性和容错性等问题的。我们将通过一个简单的比喻和实际演示来直观地理解这些概念。
概述:传统搜索的困境
想象一下,现在是2005年,你在一家快速发展的电商公司担任软件工程师。公司大约有5000个产品,你的任务是编写一个数据库查询,允许用户搜索产品。你可能会写出类似 SELECT * FROM products WHERE name LIKE ‘%laptop%’ 的查询。这种方式简单直接,用户搜索“laptop”就能得到一些结果,一切都很简单。
然而,随着公司飞速发展,产品数量激增至数百万。这时,那个曾经在50毫秒内返回结果的简单 LIKE 查询,现在可能需要30秒才能完成。用户和经理都感到沮丧,他们不仅要求搜索更快,还希望更智能:例如,搜索“laptop”时,优先显示MacBook Pro而不是笔记本电脑包;同时,还要能容忍用户的拼写错误(如“laptp”)。这些需求共同催生了像Elasticsearch这样的专用搜索引擎。
上一节我们介绍了传统搜索面临的挑战,本节中我们来看看搜索引擎是如何从底层原理上解决这些问题的。
从图书管理员到倒排索引
我们可以把你的PostgreSQL或任何关系型数据库想象成一个图书管理员。你向管理员询问某本书的位置,他能准确地知道每本书放在哪里。但这种方式有一个致命的缺陷:如果你要寻找一本主题为“机器学习”的书,管理员必须从图书馆的第一个书架开始,逐本检查每一本书的标题和内容,直到找到所有相关的书。这个过程在小型图书馆可能只需几分钟,但如果图书馆有上千万甚至上亿本书(就像拥有海量数据的数据库),这个过程将变得极其缓慢,可能需要数天。
此外,这位“管理员”没有“相关性”的概念。它可能先找到一本只在最后一页提到“机器学习”的书,然后才找到一本名为《机器学习导论》的书。对于用户来说,后者显然更相关,应该优先显示。
在数据库中,使用 WHERE name LIKE ‘%keyword%’ 进行查询,其行为就完全像这位图书管理员。数据库必须扫描表中的每一行,对每个文本字段进行逐字符的模式匹配。这种方式虽然全面,但** painfully slow**(极其缓慢),并且无法判断结果的相关性。
革命性的思想:倒排索引
为了解决搜索的速度和相关性难题,计算机科学家们从20世纪60年代就开始研究信息检索领域。一个革命性的想法诞生了:倒排索引。
这个核心概念其实很简单。我们不再像图书管理员那样,通过遍历所有书籍(文档)来查找关键词。相反,我们在存储书籍时,就预先为书中的每个单词建立一个索引。这个索引记录了每个单词出现在哪些书中,以及具体出现在哪些位置。
例如,单词“machine”可能出现在《机器学习导论》的第1、15、23页,以及《机器时代》的第5、89页。单词“learning”则出现在《机器学习导论》的第1、16、24页,以及《深度学习基础》的某些页面。
这样,我们就建立了一个从 单词 到 文档 的映射,而不是从文档中查找单词。我们“倒置”了搜索的方向,因此称之为“倒排索引”。当用户搜索“machine learning”时,系统可以迅速从索引中分别找到包含“machine”和“learning”的文档列表,然后通过交集运算找到同时包含这两个词的文档。
这个简单的概念,正是Elasticsearch等全文搜索引擎的强大动力之源。Elasticsearch底层使用的关键技术是 Apache Lucene,一个基于倒排索引的文本搜索库。
Elasticsearch的优势:速度与相关性
拥有了倒排索引,我们的“图书管理员”(搜索引擎)速度得到了质的飞跃。但Elasticsearch带来的另一个巨大优势是相关性评分。
它不仅仅返回包含关键词的文档,还会计算每个文档与查询的相关程度,并据此排序。例如:
- 词频:一个词在单个文档中出现的次数越多,该文档的相关性可能越高。
- 字段加权:一个词出现在标题字段,通常比出现在描述字段更具相关性,而描述字段又比正文内容更相关。在Elasticsearch查询中,我们可以自定义这种加权规则。
- 文档长度:词在短文档中出现可能比在长文档中出现更具显著性。
Elasticsearch使用一种名为 BM25 的算法来综合考虑这些因素,计算出相关性得分。这使得搜索结果不仅快,而且“聪明”,能够将最可能符合用户意图的结果排在前面。

实际应用场景与选择
在实际开发中,全文搜索有广泛的应用场景:

以下是Elasticsearch的一些典型应用:
- 智能补全:像Google或Amazon的搜索框,输入时实时给出建议。
- 容错搜索:当用户输入“laptp”时,能自动纠正并返回“laptop”的搜索结果。
- 日志管理与分析:著名的ELK栈(Elasticsearch, Logstash, Kibana)利用Elasticsearch快速检索和分析海量日志数据。

那么,作为后端工程师,我们该如何选择呢?



以下是你的两个主要选项:
- 使用数据库内置的全文搜索:如PostgreSQL本身就提供了功能强大的全文搜索模块。如果你的需求不特别复杂,且希望技术栈统一,这是一个好选择。
- 使用专用搜索引擎:如Elasticsearch。如果你的公司已经在使用ELK栈管理日志,那么将其用于业务搜索也顺理成章。它功能更强大、更专业,特别适合构建复杂的、对相关性要求高的搜索体验。
对于大多数工程师而言,掌握数据库的深度知识是必须的,因为它是后端工作的核心。而对于Elasticsearch,你通常不需要精通其所有内部原理(如BM25算法的具体实现)。更重要的是知道在什么场景下应该使用它,并能够查阅文档和示例代码来快速实现功能。在需要极致搜索体验时,它就是你的得力工具。
演示:传统搜索 vs Elasticsearch
为了直观对比,我们进行一个简单的演示。我们有一个包含5万条产品评论的数据集,字段包括review(评论文本)和sentiment(情感倾向)。
我们分别在PostgreSQL和Elasticsearch中建立了索引。
搜索查询对比:
- PostgreSQL (传统方式):
SELECT * FROM reviews WHERE review ILIKE ‘%keyword%’ - Elasticsearch:使用其JSON查询DSL进行全文检索。
性能结果:
- 搜索关键词“laptop”:
- Elasticsearch 耗时约 1秒。
- PostgreSQL 耗时约 3-4秒。
- 搜索一个更常见的词(返回约8000条结果):
- Elasticsearch 耗时约 500毫秒。
- PostgreSQL 耗时约 7.5秒。
尽管返回的结果数量相同,但Elasticsearch的速度显著快于使用ILIKE的传统关系型数据库查询。随着数据量的增长,这种差距会愈加明显。

总结
本节课中我们一起学习了全文检索的演进历程和核心原理。我们从传统数据库LIKE查询的局限性出发,理解了在海量数据下对搜索速度、相关性和容错性的迫切需求。接着,我们探讨了革命性的倒排索引概念,它通过预先建立从词到文档的映射,极大地提升了搜索效率。在此基础上,像Elasticsearch这样的工具利用BM25等算法实现了智能的相关性评分,让搜索结果不仅快,而且准。

作为后端开发者,你需要知道,当面临复杂的搜索需求时,除了优化数据库,还可以选择数据库内置的全文搜索功能或引入Elasticsearch这类专用引擎。掌握何时以及如何运用这些工具,将为你的应用带来质的飞跃。
016:错误处理与构建容错系统 🛡️
在本节课中,我们将学习后端开发中至关重要的一个方面:错误处理与构建容错系统。错误不是需要解决的问题,而是构建应用程序的正常组成部分。每个开发者都需要理解错误必然会发生,关键在于做好准备去检测、修复它们,并构建能够优雅应对的系统。
概述
后端系统运行在一个充满不确定性的环境中。数据库查询有时会失败,外部API可能超时,用户可能发送错误数据,业务逻辑也会遇到意外的边界情况。因此,问题不在于错误是否会发生,而在于当错误发生时你将如何处理。本节不讨论具体工具或框架,而是专注于构建容错系统所需的心态和核心策略。
错误类型
作为后端工程师,在日常工作中可能会遇到多种类型的错误。理解这些类型是有效处理它们的第一步。
逻辑错误
逻辑错误是最常见且最危险的一类。它们不会直接导致应用崩溃,但会使应用执行错误的操作。代码本身运行正常,但结果不正确或出乎意料。
例如,在一个电商SaaS应用中,如果折扣逻辑错误导致对同一订单应用了两次折扣,甚至产生负的运费,应用并不会崩溃,但平台却在每一笔订单上持续亏损。这类错误可能潜伏数周甚至数月而不被发现,造成持续损失。
逻辑错误通常发生在以下场景:
- 误解需求:在与客户或产品经理沟通时,可能记录并实现了非预期的需求。
- 算法实现错误:在复杂的业务逻辑(如基于用户行为的动态折扣算法)中,一个微小的计算错误可能导致重大损失。
- 未考虑边界情况:在支付、折扣等工作流中,未预料到特定的用户行为可能导致问题。
逻辑错误可能损坏数据,并在一段时间内导致错误的业务决策而不被察觉。
数据库错误
由于大多数后端应用严重依赖数据库,数据库错误可能导致整个系统瘫痪。这类错误范围很广。
以下是几种常见的数据库错误:
- 连接错误:当应用无法与数据库通信时发生。这可能是由于网络中断、数据库服务器过载或连接池耗尽所致。连接池是一种优化手段,它维护一组到数据库服务器的开放TCP连接,以避免为每个新请求都进行完整的TCP握手。连接池设置不当也可能引发错误。当数据库连接失败时,应用基本无法正常运行。
- 约束违反错误:当尝试执行违反数据库规则的操作时发生。例如:
- 唯一性约束违反:尝试创建已存在邮箱的用户。
- 外键约束违反:尝试在
orders表中插入一条记录,其customer_id在customers表中不存在。
这类错误的根源通常在于验证层不够健壮。虽然像唯一性检查这类错误无法完全避免(只有数据库知道某个值是否唯一),但我们可以通过改进错误格式化,向用户返回友好的提示信息(如“该邮箱已存在,请尝试其他邮箱”)。
- 查询错误:当SQL语句格式错误时发生。例如,表名拼写错误(
SELECT * FROM custmers;),或者查询过于复杂导致超时。 - 死锁:当多个数据库操作相互等待,形成循环依赖时发生,这种情况尤其棘手。
外部服务错误
现代SaaS应用通常依赖许多外部服务,如支付处理器、邮件提供商、云存储(如S3)、Redis缓存或身份验证服务(如Auth0、Clerk)。每一个外部依赖都是一个潜在的故障点,而你对其几乎没有控制权。
外部服务失败的原因包括:
- 网络问题:连接超时、DNS故障、网络分区等。互联网并不完美,必须为此类问题做好计划。
- 认证错误:外部服务因凭证错误、令牌过期或权限不足而拒绝请求。即使使用外部认证服务,如果集成不当(如在日志中暴露敏感信息),仍可能引发安全问题。
- 速率限制:大多数外部API都有速率限制。如果你的应用因某些原因(如用户活动激增、逻辑错误)在短时间内发送过多请求,就会收到
429 Too Many Requests错误。应对策略包括实现指数退避等重试机制。 - 服务中断:外部服务因意外事故或计划维护而宕机。这是最不可避免的情况。应用需要优雅地处理此类错误,例如使用后备方案(如当Redis宕机时切换到内存缓存)。
输入验证错误
这类错误由用户发送不符合系统规则的数据引起。验证层是抵御不良或恶意输入的第一道防线,它在请求入口点进行检测并抛出错误。
常见的验证规则包括:
- 格式验证:检查邮箱、电话号码、日期等是否符合预期格式。
- 范围验证:检查数字是否在允许范围内,字符串长度是否合规,数组元素数量是否满足要求等。
- 必填字段验证:检查执行特定操作所必需的字段是否存在。
验证错误通常返回400 Bad Request状态码。与其他难以控制或检测的错误(如外部依赖错误、逻辑错误)相比,验证错误是最容易预期和处理的,因为我们明确知晓数据的规则。
配置错误
配置错误可能阻止应用启动,或使生产环境行为异常。它们通常在开发、预发布和生产环境之间迁移时出现。
例如,在开发环境中添加了一个新的环境变量(如OPENAI_API_KEY),但在部署到生产环境时忘记添加。有两种可能的情况:
- 最佳情况:应用启动时验证所有必需的环境变量。如果缺失,应用启动失败。在蓝绿部署等策略下,旧版本仍在运行,服务不会中断。
- 最坏情况:应用启动时不验证配置。当某个API处理器尝试使用该缺失的配置变量(如调用OpenAI服务)时,会在运行时出错,用户收到
500错误。
因此,最佳实践是在服务器启动前验证所有必需的配置变量,宁愿让应用在启动时失败,也不要让它在运行时对用户出错。
预防与检测策略
上一节我们介绍了可能遇到的各种错误类型,本节我们来看看如何主动预防和检测它们。在我看来,最佳的错误处理策略始于错误发生之前。
主动错误检测
在错误造成实际损害之前发现它们,这是构建健壮系统的关键。健康检查是实现这一目标的基础。
- 基础健康检查:通常暴露一个端点(如
/health或/status),返回200 OK状态码表示服务运行正常。但这仅能检查服务是否在运行,还不够。 - 数据库健康检查:测试数据库连接、查询性能和数据完整性。运行一个有代表性的查询,检查其响应时间是否在正常范围内,这比简单的“ping”更有意义。
- 外部服务健康检查:验证与关键外部服务的连接和功能。
- 支付处理器:定期执行测试交易。
- 邮件服务:向内部邮箱发送测试邮件。
- 身份验证服务:生成并验证测试令牌。
- 核心功能检查:确保配置已正确加载、生产环境必需的缓存已预热、内部数据结构一致。
所有这些构成了主动错误检测体系,确保我们为最坏情况做好准备,并拥有预防和修复的方法。
监控与可观测性
监控与可观测性是一个庞大的主题,它帮助我们在错误发生时快速检测并提供足够的调试上下文。
关键原则包括:
- 不要只跟踪错误率:同时监控可能预示问题的性能指标。性能下降通常是系统即将故障的早期征兆。
- 全面覆盖:监控设置应覆盖应用的各个部分,包括HTTP错误、数据库错误、外部服务故障和业务逻辑错误。
- 跟踪业务指标:监控关键业务指标,如成功交易率、成功验证次数等。即使错误率正常,业务指标的突然下降也可能暗示存在技术问题。
- 良好的日志实践:实施结构化日志(如JSON格式),便于解析和添加元数据。使用Grafana、Loki等日志聚合工具进行可视化分析和存储。
监控与可观测性是构建容错系统的关键组成部分,后续课程将深入探讨相关工具和实践。
错误处理哲学与策略
了解了错误类型和检测方法后,本节我们探讨一些在处理错误和构建健壮系统时始终有益的核心理念和策略。
即时错误响应
当错误发生时,你的即时响应决定了它是否会演变成重大故障。策略取决于错误类型和上下文。
- 可恢复错误:例如发送邮件失败、数据库连接池暂时耗尽。对于此类网络错误或临时资源问题,重试机制和指数退避策略效果很好。但需注意,不要给已经压力很大的系统增加额外负担。
- 不可恢复错误:最佳策略是遏制和优雅降级。例如,切换到缓存、禁用非核心功能、提供备用方案,以限制损害范围。
错误恢复策略
恢复策略取决于错误性质和功能的关键性。
- 自动恢复:可以处理许多无需人工干预的错误。例如,自动重启失败的服务、清理损坏的缓存、切换到备份系统。这些策略需要精心设计,有时可能使问题恶化,因此需要通过试错找到适合特定服务的方法。
- 手动恢复:某些错误需要人工判断和决策。应为此类流程编写文档,确保团队成员知晓,并进行测试,以便在压力情况下能快速执行。
数据恢复策略至关重要,因为数据是应用中最宝贵的资产。这包括在关键时刻备份、从备份恢复、重放事务日志和使用专门的恢复工具。
传播控制
并非所有错误都应在发生时立即处理。有时需要将错误传播到更高层级,以获得更多上下文(类似于“调用栈”的概念)。
在大多数编程语言(如JavaScript、Python)中,我们使用try-catch进行异常处理。通过异常处理层次结构,我们可以捕获低级异常,用足够的上下文包装它们,然后冒泡到高级异常。这样我们就能掌握更多业务信息,从而记录适当的数据、向前端返回有意义的错误,或在必要时触发恢复策略。这种模式通常被称为全局错误处理。
错误边界
错误边界是阻止错误进一步传播的最后防线。在微服务架构中,错误边界能防止一个服务中的错误影响其他服务。为实现这一点,我们应使用独立的进程、实现超时机制以保护服务边界,并使用消息队列(如RabbitMQ)来解耦服务,实现异步通信,避免一个服务的故障导致另一个服务失败。
全局错误处理:最终安全网 🎯
现在,让我们深入探讨我最喜欢的错误处理机制之一:全局错误处理。这是一个在设置后端应用初期需要投入大量精力的模块,但它是一次性投入,长期回报巨大。
工作原理
在一个典型的后端架构中,请求流经路由层 -> 处理器(负责反序列化、验证) -> 服务层(协调业务逻辑) -> 仓库层(执行具体的数据库操作)。
全局错误处理的目标是:无论错误在哪个层级(仓库层、服务层、处理器层)发生,也无论是什么类型的错误,都将其冒泡到一个全局错误处理中间件中统一处理。
实战示例
假设我们有一个图书管理平台,并创建一个添加新书的API端点。
场景一:验证错误
- 错误发生点:处理器层。
- 原因:用户发送的书名超过500字符,违反验证规则。
- 全局处理:中间件识别为验证错误,返回
400 Bad Request,并附带具体的错误信息(如“书名不能超过500字符”)。
场景二:唯一约束违反错误
- 错误发生点:仓库层。
- 原因:尝试插入的书名在数据库中已存在。
- 全局处理:中间件识别为数据库唯一约束错误,返回
400 Bad Request,消息为“该书已存在”。
场景三:资源不存在错误
- 错误发生点:仓库层。
- 原因:查询特定ID的图书,但数据库返回“无此记录”。
- 全局处理:中间件识别为“无行返回”错误,推断请求的资源不存在,返回
404 Not Found。
场景四:外键约束违反错误
- 错误发生点:仓库层。
- 原因:插入新书时,提供的
author_id在作者表中不存在。 - 全局处理:中间件识别为外键约束错误,返回
404 Not Found,消息为“指定的作者不存在”。
核心优势
- 更健壮、更安全:将所有错误处理逻辑集中在一处,避免了在分散的代码中遗漏处理某些错误类型。如果没有全局处理,一个未在仓库层妥善处理的唯一约束错误可能最终以含糊的
500 Internal Server Error返回给用户,而不是明确的“已存在”提示。 - 减少冗余:无需在每个仓库方法、服务方法中重复编写相同的错误类型检查和格式化逻辑,代码更简洁,bug更少。
全局错误处理中间件就像是应用的最终安全网,它定义了平台在整个生命周期中可能面对的所有错误类型及其处理规则。
安全考量 🔒
在错误处理中,安全是至关重要的方面。最后,我们讨论两个与安全密切相关的要点。
谨慎暴露错误信息
必须严格控制暴露给用户或消费者的错误信息细节,避免泄露可能危及平台或用户安全的内容。
- 避免泄露内部细节:不要将数据库错误信息(如表名、约束名、索引详情)直接返回给用户。攻击者可能利用这些信息发起更高级的攻击(如SQL注入)。在默认错误处理逻辑中(例如最终捕获到未知错误时),应返回通用消息,如“内部服务器错误”,而不是原始错误信息。
- 遵循安全最佳实践:以登录端点为例。错误的做法是分别返回“用户不存在”和“密码错误”。这会让攻击者分两步破解账户:先通过错误信息枚举出存在的用户邮箱,再针对该邮箱暴力破解密码。正确的做法是始终返回统一的模糊信息,如“邮箱或密码无效”。
安全日志记录
即使在内部日志中,也要避免记录敏感信息。
- 不要记录敏感数据:切勿在日志中记录用户的密码、完整信用卡号、API密钥等。在发生数据泄露时,这些日志可能成为攻击者的宝库。
- 使用关联标识:在记录错误时,使用用户ID而非邮箱等直接标识符,并记录足够的关联ID以便追踪上下文。



总结
在本节课中,我们一起学习了后端错误处理与构建容错系统的核心思想。我们首先认识到错误是系统运行的常态,关键在于做好准备。接着,我们详细探讨了各种错误类型:逻辑错误、数据库错误、外部服务错误、输入验证错误和配置错误,并理解了它们的特点和成因。
然后,我们转向预防与检测,强调了主动错误检测的重要性,包括健康检查、监控与可观测性。在错误处理策略部分,我们讨论了根据错误类型(可恢复/不可恢复)采取不同的即时响应,以及错误恢复、传播控制和设立错误边界等哲学。
我们深入分析了全局错误处理中间件这一强大模式,它作为最终安全网,能集中、统一地处理所有错误,使系统更健壮并减少代码冗余。最后,我们着重讨论了错误处理中的安全考量,包括谨慎暴露错误信息和安全记录日志,以保护平台和用户免受侵害。

构建容错系统不仅关乎技术实现,更是一种贯穿始终的思维方式。通过预见失败、主动检测、优雅处理和持续学习,我们可以创造出即使在逆境中也能保持韧性的可靠后端服务。
017:生产级配置管理 🛠️
在本节课中,我们将要学习后端开发中一个至关重要的主题:配置管理。我们将探讨什么是配置管理、为什么它如此重要、不同类型的配置、存储配置的不同方式,以及如何安全地管理配置。
什么是配置管理?
配置管理是一种系统性的方法,用于组织、存储、访问和维护后端应用程序的所有设置。你可以将其视为应用程序的DNA,它决定了你的代码在不同环境中如何运行。
当大多数人听到“配置管理”时,首先想到的是存储数据库密码、数据库的安全连接URL、安全认证密钥、JWT密钥或外部服务的API密钥(如邮件发送服务)。然而,这种想法忽略了配置管理的许多其他方面。这就像说汽车只关乎发动机,发动机固然重要,但你仍然忽略了汽车90%的其他功能。
配置管理涵盖了许多内容,从应用程序如何启动、如何连接到外部服务,到它在不同环境下的行为、是否记录日志、在哪里记录日志、在哪里发送性能指标和业务指标,以及为当前部署版本启用或禁用哪些功能。配置管理有很大的范围,在本节中,我们将探讨配置管理的不同方面。
配置的类型
并非所有配置都是相同的。理解不同类型的配置数据对于选择存储机制、安全措施和访问模式至关重要。以下是后端应用中常见的配置类型。


1. 应用程序设置
这是最常见的配置类型,包括:
- 日志级别:例如,开发环境设为
DEBUG,生产环境设为INFO。 - 服务器端口:应用程序运行的端口号。
- 连接池大小:用于优化数据库连接。
- 超时值:例如HTTP请求的超时时间。
2. 数据库配置
这包括应用程序连接数据库所需的所有信息:
- 主机地址
- 端口号
- 用户名
- 密码
- 数据库名称
这些参数可以组合成一个连接URL,例如:
postgresql://username:password@host:port/database_name

3. 外部服务配置
这包括与第三方服务集成的配置:
- 电子邮件服务API密钥(如Mailchimp、SendGrid)
- 支付处理器API密钥(如Stripe)
- 身份验证服务API密钥(如Clerk)

4. 功能开关
功能开关允许我们动态启用或禁用应用程序的特定功能,常用于A/B测试或分阶段发布。例如,可以控制新结账流程仅对美国用户启用。


5. 其他配置类型
- 基础设施配置:与DevOps相关的设置。
- 安全配置:如JWT密钥、会话密钥。
- 性能调优参数:如Go语言中设置的最大CPU数量。
- 业务规则:需要在应用层面集中管理的业务逻辑规则。
配置的存储源
根据安全性、速度和环境需求,配置可以存储在不同的地方。以下是常见的存储方式。
1. 环境变量
这是最常见的方式,通常通过一个名为 .env 的文件在本地管理,并使用库(如Node.js的 dotenv)加载到操作系统的环境中。在容器化部署(如Kubernetes)中,环境变量可以在部署时从云服务(如HashiCorp Vault、AWS参数存储)获取并注入。
2. 配置文件
配置可以存储在文件中,常见的格式有:
- YAML:支持注释,结构清晰,被广泛使用。
- JSON:不支持注释,但易于机器解析。
- TOML:一种较新的配置格式,也日益流行。
3. 键值存储
使用如Redis、Consul或etcd等工具存储配置。它们轻量级、简单,类似于环境变量,但提供了集中管理和动态更新的能力。
4. 专用云服务
大型或分布式系统通常会使用专门的云服务来集中管理配置和密钥,例如:
- HashiCorp Vault
- AWS参数存储
- Azure密钥保管库
- Google Secret Manager
这些服务提供了加密存储、安全传输和细粒度访问控制等企业级功能。
5. 混合策略
在实际应用中,通常会采用混合策略,从多个源按优先级加载配置。例如,优先从AWS参数存储加载,其次从 config.yaml 文件加载,最后再考虑环境变量。
不同环境下的配置
为什么不同环境需要不同的配置?答案很简单:每个环境都有其独特的优先级。

- 开发环境:优先级是开发人员生产力和调试能力。配置可能允许更详细的日志和更宽松的安全设置。
- 测试环境:优先级是自动化验证和质量保证。配置应支持各种测试场景。
- 预发布环境:优先级是尽可能模拟生产环境的功能和行为,以便提前发现问题,同时也要最小化云成本。
- 生产环境:优先级是可靠性、安全性和性能。配置必须经过优化并确保安全。

例如,数据库连接池的大小在不同环境可能不同:
- 开发环境:
maxPoolSize = 10 - 生产环境:
maxPoolSize = 50(应对高流量) - 预发布环境:
maxPoolSize = 20(平衡功能模拟与成本)


通过集中管理配置,我们可以在不修改应用程序代码的情况下,通过改变配置来调整应用在不同环境下的行为。
配置安全最佳实践

安全性是配置管理的核心。以下是一些必须遵循的最佳实践。
1. 切勿硬编码密钥
绝对不要将数据库URL、API密钥等敏感信息直接写在源代码中。
2. 使用云密钥管理服务
尽可能使用像HashiCorp Vault、AWS Secrets Manager这样的服务。它们提供静态加密(存储时加密)和传输中加密,并已处理好密钥轮换等复杂问题。
3. 实施访问控制
遵循最小权限原则,为不同角色的团队成员分配仅够其工作的配置访问权限。例如,前端开发者不需要访问数据库生产密码。
4. 定期轮换密钥
定期更新API密钥、JWT密钥等敏感配置,以降低泄露风险。
5. 始终验证配置
这是最重要的一点。在应用程序启动时,必须验证所有加载的配置。检查必填项是否存在、格式是否正确、值是否在允许范围内。可以使用验证库来实现,例如:
- TypeScript: Zod
- Go: go-playground/validator
- Python: Pydantic
验证可以避免因缺少或错误的配置导致生产环境出现难以排查的故障。
总结
本节课中,我们一起学习了后端开发中的生产级配置管理。我们了解到,配置管理远不止存储密码,它涵盖了控制应用程序行为的方方面面。我们探讨了不同类型的配置(应用设置、数据库、外部服务、功能开关等),以及存储这些配置的不同源(环境变量、文件、键值存储、云服务)。我们还强调了根据不同环境(开发、测试、预发布、生产)调整配置的重要性,并深入讨论了确保配置安全的关键实践,尤其是始终验证配置这一核心原则。

通过系统化地管理配置,你可以避免“配置混乱”,确保应用在不同环境中行为一致,并显著提升系统的安全性和可维护性。
018:日志记录、监控与可观测性 📊
在本节课中,我们将要学习后端开发中至关重要的实践领域:日志记录、监控与可观测性。这些实践是确保现代分布式系统健康、稳定运行的关键。我们将探讨它们各自的概念、作用以及如何协同工作,帮助你构建一个易于理解和调试的生产级系统。
概述
日志记录、监控与可观测性是一个宏大的主题。其中每一个部分——无论是日志记录、监控还是可观测性——都值得拥有独立的深入讨论。但这也属于一个没有绝对固定规则的领域,更像是一个实践光谱。大多数公司和个人开发者都在一个光谱范围内实施这些实践。我们无法断言某个产品或公司完全遵循了所有最佳实践,因为并不存在这样的绝对标准。因此,在接触行业中各种不同的术语、实践和工具时,无需感到畏惧。
为什么需要这些实践?
在现代互联网环境中,我们的后端应用通常运行在分布式环境中,部署在不同的服务器和区域,服务于全球用户。在这种场景下,我们需要实践、工具和方法论来追踪所有服务和基础设施中发生的情况。
“追踪”意味着关注几个核心参数。我们可以将其归结为一些重要的方面。
核心概念解析
上一节我们介绍了为什么需要这些实践,本节中我们来看看它们具体是什么。
日志记录
日志记录基本上是记录。它记录应用程序中发生的所有事件。这些实践同样适用于前端应用,但我们的讨论将限于后端。
日志记录是指记录后端应用中所有重要事件、可疑事件、安全相关事件等。我们会为每个事件附加一些元数据,例如触发请求的用户ID、请求延迟、调用的具体方法或函数等。这些元数据在理解系统行为时至关重要。因此,整个方法论的第一部分就是日志记录,即记录请求生命周期和应用执行过程中的所有事件。
监控
监控的含义正如其名:我们希望通过某种方式持续追踪系统的状态。这包括后端应用及其各组件的状态,例如服务器的CPU、内存使用率、每秒处理的请求数、数据库连接池的状态等。
监控意味着拥有关于系统的实时数据(这里的“实时”通常指有几秒到几分钟的延迟,以避免压垮监控系统)。它关注的是系统当前的健康与性能状态。
可观测性
可观测性本身包含许多其他实践。理论上,可观测性有三大支柱。一个后端系统只有在具备这三个组件时,才能被称为是可观测的。
以下是这三个支柱:
- 日志:我们已经了解,是记录重要事件的记录。
- 指标:这与监控密切相关,我们稍后会详细讨论。
- 追踪:你可以将追踪想象成事务。它意味着我们能够追踪一个请求从一个系统(如前端、负载均衡器或后端应用)发起后,所经过的所有组件(如处理器层、服务层、验证层、仓库层、数据库层)。追踪记录了请求的完整路径。


可观测性是一个相对现代的概念。传统的错误预防和捕获主要依赖监控,但监控只能告诉你“有问题”。而可观测性不仅能告知问题,还能借助日志、指标和追踪,精确地指出“问题出在哪里”。

三者如何协同工作?

上一节我们分解了各个概念,本节中我们来看看它们如何协同工作,形成一个强大的调试工作流。

它们协同工作,因为各自产生一个在调试和理解系统时有用的具体参数:
- 日志告诉我们发生了什么。
- 监控(指标) 告诉我们关于系统的模式和趋势。
- 可观测性(追踪) 揭示了不同组件之间的交互。
假设你已经在生产系统中工作,并实施了恰当的日志、监控和可观测性,工作流通常如下:
- 告警触发:我们设置了一些告警参数(例如,错误率超过80%)。当条件满足时,我们会在Slack等工具中收到消息:“你的API服务出现问题,请检查。”
- 查看指标:我们转向指标仪表板。指标是我们可以追踪的关于系统的各种实时或历史参数。例如:
- 已处理的请求数
- 失败的请求数(例如,状态码大于200的请求)
- 业务指标(如创建的待办事项数量)
- 深入日志:从指标中,我们可以看到错误率确实超过了80%。系统通常会显示与这些指标相关的日志,即所有失败的日志条目。
- 分析追踪:点击某条具体的错误日志(例如,一条500错误日志),系统会展示与该日志关联的追踪信息。它会显示这个请求从哪个函数开始,经过了哪些函数,最终在哪个具体点失败。
通过这个完整的工作流,你可以精确地定位问题所在并立即进行调试。这就是在后端系统中实施完整的日志、监控和可观测性实践所带来的核心好处。
深入核心概念
接下来,我们将更详细地探讨日志记录、监控和可观测性中的不同概念。但在开始之前,我们先简要了解一下本视频的赞助商。
(注:此处省略了关于赞助商Sealla平台的详细介绍,因其为推广内容,与核心教程无关。)


现在,让我们回到关于日志记录的讨论。


日志记录详解


首先,在讨论日志记录时,需要记住以下几点。这仍然是一次非正式的讨论,我们将保持其实用性。
日志级别
在生产系统中,你会经常看到日志级别。当我们记录一个特定事件时,通常会为其分配一个级别。最常见的级别包括:
- DEBUG:用于开发环境,记录尽可能多的系统行为细节,便于调试和故障排除。在生产环境中通常禁用。
- INFO:记录常规应用操作和业务事件(例如,“创建了一个待办事项”),用于记录成功操作和一般信息。
- WARNING:记录介于INFO和ERROR之间的事件。这不是成功操作,但也不够关键到被视为错误。例如,用户输入错误密码导致认证失败。
- ERROR:记录所有类型的错误,如验证错误、数据库查询失败等。这是我们进行日志记录的主要原因之一。
- FATAL:非常严重的问题。记录FATAL级别日志通常意味着应用程序将停止运行并可能重启。
结构化与非结构化日志

我们通常以两种方式记录日志:
- 非结构化/控制台日志:在开发环境中使用。我们将日志以可读的、带颜色的纯文本形式输出到控制台,便于人工阅读和发现问题。
- 结构化日志:在生产环境中使用,最流行的格式是JSON。我们以JSON格式打印错误,包含状态、消息等所有参数。虽然对人类不友好,但便于日志管理工具(如ELK Stack、Loki、Promtail)进行解析和提取有价值的信息(如用户ID、请求ID)。

代码实践演示
由于我们主要讨论实践,我认为展示它们实际如何工作比单纯的理论讲解更有帮助。我们将通过一个Go语言编写的待办事项应用来演示。
(注:此处省略了具体的代码截图和逐行解释,因为重点是理解工作流而非代码本身。以下总结关键点:)
在这个应用中,我们使用了New Relic作为一站式可观测性解决方案(开源方案如Grafana + Prometheus + Jaeger也很流行)。
- 日志配置:代码中根据环境(开发/生产)动态设置日志级别(DEBUG/INFO)和格式(控制台/JSON)。
- 监控与追踪集成:通过一个中间件(New Relic middleware)对每个请求进行插桩。插桩是可观测性的关键实践,意味着测量函数的各种属性。
- 工作流示例:在
创建待办事项的服务函数中:- 从上下文中获取当前请求的事务(属于一个追踪)。
- 为这个事务添加业务属性(如用户ID、待办标题)。
- 在关键节点记录日志(如“开始创建待办”、“数据库操作成功/失败”),并关联到当前事务。
- 如果发生错误,将错误信息和操作类型添加到事务中。
- 仪表板查看:在New Relic仪表板中,我们可以:
- 查看指标:如错误率、吞吐量、平均事务时间。
- 查看日志:与特定错误相关的详细日志条目。
- 查看追踪:点击日志可以查看完整的请求追踪路径,了解请求经过了哪些组件。
这个演示展示了日志、指标和追踪如何在一个真实的应用中集成并协同工作,为开发者提供强大的调试能力。


总结
本节课中,我们一起学习了后端开发中日志记录、监控与可观测性的核心实践。
我们了解到:
- 日志记录是系统事件的日记,用于记录发生了什么。
- 监控关注系统的实时状态和健康度,通过指标量化系统行为。
- 可观测性是一个更高级的目标,它通过日志、指标和追踪三大支柱,使我们能够从外部输出推断系统内部状态,精确定位问题根源。

这些实践共同构成了一个强大的工作流:从告警触发,到查看指标趋势,再到深入分析具体日志和请求追踪,最终快速定位并解决问题。
重要的是,这些实践是在一个光谱上实施的,没有绝对的“完成”状态。你可以根据团队规模和资源,选择开源工具链(如Grafana, Prometheus, Loki, Jaeger)或商业解决方案(如New Relic, Datadog)。无论选择哪种工具,都需要开发者在代码层面进行适当的插桩和日志记录,并与基础设施团队协作配置相应的收集、存储和展示系统。


掌握这些实践,是构建和维护可靠、可维护的生产级后端系统的关键一步。
019:优雅关机 🛑

在本节课中,我们将学习一个至关重要的后端概念:优雅关机。我们将探讨为什么服务器不能突然停止,以及如何通过一系列步骤确保在重启或部署时,正在处理的请求和数据不会丢失或损坏。
想象一个非常现实的场景。你正在处理一笔关键支付交易。突然,你的服务器需要为一次部署而重启。有人向生产环境推送了代码,你的服务器需要部署自身。当然,我们有零停机部署等技术,确保新服务器(运行新代码的服务器)启动并准备好接收流量之前,现有服务器不会下线。这些机制是存在的,但在某个时刻,当新服务器准备上线、准备接收流量时,旧服务器必须关闭,必须停止接收流量,流量将切换到新服务器。我们讨论的正是这个关键时刻。假设你正处于一笔电子商务交易中,或者正在亚马逊或Flipkart上购买商品,此时亚马逊或Flipkart的服务器因某种部署需要重启。问题是,那笔支付交易究竟会怎样?它会在数字世界中丢失吗?客户会因为某种竞态条件而被重复扣费吗?作为后端工程师,你必须考虑所有这些场景。
这不是一个新问题。自服务器和后台出现以来,这个问题就一直存在。当然,我们已经有了解决方案,这个方案被称为优雅关机。正如其名,我们希望优雅地停止服务器,而不是突然停止。这就是整个想法。当然,还有一些相关的概念需要理解,以便你清楚地了解我们为什么要这样做。
进程生命周期管理
我们讨论这个是因为你的后端是一个应用程序,它将在某种服务器、某种计算机中作为一个进程运行。每个应用程序都在服务器中、在一个进程内运行。这是一个重要的事情。它在进程内运行。如果你熟悉操作系统概念,这对你来说就有意义。否则也没关系,你可以直接学习这个术语,它叫做进程。你运行的任何东西,在操作系统中运行的每个东西,都作为一个进程运行。就像所有生物一样,每个进程都有生命周期:它何时开始、如何开始、何时结束以及如何结束。所以,从某种意义上说,进程启动时它诞生,进程执行时它存活,进程终止时它死亡。这整个过程被称为进程的生命周期。
为了实现或理解优雅关机,我们必须理解这个进程生命周期,因为它与优雅关机的实现方式密切相关。这是我们的操作系统,我们的应用程序在其中运行。当你的操作系统决定是时候让你的应用程序停止运行时,它不会直接拔掉电源。它不会直接杀死进程。它遵循一个既定的协议和通信方式,来告诉进程是时候停止了,我们将遵循这些步骤,然后停止。你可以把它想象成你的操作系统和你的应用程序(在你的操作系统中作为进程运行)之间的一次对话。简化来说,你的操作系统在对话中发送一条消息,比如:“嘿,是时候停止了。”你的应用程序返回一条消息:“好的,给我几分钟或几秒钟(现实地说),然后我会停止,或者你可以停止我。”当然,这种对话不是通过文本进行的。我们这里讨论的是程序。
所以它们不理解文本。你的应用程序和操作系统之间的整个通信,是通过一个叫做信号的概念进行的。信号是Unix操作系统中的一个重要概念。当我说Unix操作系统时,我们指的是所有Linux操作系统,无论你熟悉的是Arch Linux、Ubuntu等,也包括macOS(macOS也源自Unix内核)。大多数情况下,当我们谈论服务器时,我们指的是Linux,因为99%的情况下,当我们在某个服务器、某个云提供商上部署应用程序时,它通常选择Linux操作系统来部署你的应用程序。除了像Windows服务器这样的特殊用例,你几乎看不到Windows。大多数情况下,我们使用基于Linux的操作系统来部署服务器。
Unix操作系统有这个叫做信号的概念,用于进程间通信。如果你是计算机专业的学生,你会知道这个术语。简单来说,这是一种技术,两个进程可以通过某种既定协议(你无需担心)相互通信。它的工作方式是:你的应用程序在这里运行,它在一个进程内运行。这是一个进程。你的应用程序在一个进程内运行,它注册了一些处理器。处理器是什么意思?处理器基本上可以想象成某种代码,它在后台持续运行,等待来自应用程序的某种通信、某种信号。这些用于两个进程间通信的操作系统概念被称为信号。你的应用程序注册/创建某种处理器,当这些信号到来时检测到它们,然后执行某些操作。我们将讨论它具体做什么。这些处理器基本上是在告诉你的操作系统:“当你想让我停止时,请发送这个特定的消息给我。”当然,你不能只说“停止”,那没用。那是文本,只是人类可读的。所以它必须是某种消息。我们将讨论它是哪种消息。但你的应用程序通过其处理器说:“无论何时你想让我停止,你必须发送我这个特定消息,我会适当地处理它。我会使用预定义的协议、预定义的步骤来停止自己。”
信号类型
让我们谈谈这些信号。你指的是什么?有两种主要类型的信号。我们将讨论第一种是SIGTERM,第二种是SIGINT。这部分,这个“SIG”基本上意味着信号。这些是命令,你可以说一个是用于终止,一个是用于中断。我们很快会讨论它们之间的区别。
首先谈谈SIGTERM。正如我所说,SIG的第一部分显然意味着信号,第二部分意味着终止。SIGTERM信号意味着终止,它是你的操作系统请求你的应用程序关闭的一种礼貌方式。它不是一种极端的方式,只是一种轻微的推动。想象你站着,有人从后面过来,轻轻拍拍你的肩膀说:“嘿。”你可以把SIGTERM信号想象成类似的东西。这是一个非常温和的推动,它意味着每当你的操作系统向你的应用程序发送SIGTERM信号时,意思是:“嘿,打扰一下,你能完成并离开吗?”这是一个非常温和的请求。
因此,你的应用程序、你的后端有机会完成它当前正在做的任何事情。它不必立即离开,它有一个时间窗口(我们将讨论是什么样的窗口),有几秒钟的时间来完成它当前正在做的事情。它可能在做什么?既然我们谈论的是后端,一个HTTP后端,它可能正在处理一些请求,这是你的后端主要做的事情。你的客户端、你的前端,无论你的客户端是某种应用、某种Web应用、某种浏览器扩展,无论是什么,它都会发送HTTP请求到你的后端,你的后端处理这些请求并返回某种响应。在任意时间点,你的后端可能正在处理,比如说10或12个请求。如果你的应用程序足够大,假设它在某个时间点同时处理大约50-60个请求。所以当它收到信号时,是时候完成处理这些请求了。让我们写下来它做什么:首先,完成现有请求,这是第一件事;第二,清理资源,我们将讨论清理对你的后端实际意味着什么;第三,退出。在我们讨论完SIGTERM和SIGINT信号后,我们将深入探讨第一点和第二点。但现在,让我们保持高度概括。
我们已经理解,SIGTERM是你的操作系统向你的应用程序、你的后端发出的一个非常温和的请求,要求它完成正在做的事情、清理资源并优雅地离开。部署系统或进程管理器或编排平台(如Kubernetes等)基本上都使用这种信号。基本上,任何你建立的用于管理进程的系统,可以是Kubernetes,或者像systemd或PM2(如果你熟悉PM2进程管理器)这样的东西。这些系统、这些工具使用这个信号,即SIGTERM信号,来适当地让你的应用程序完成正在做的事情、清理并优雅地离开。
我忘了另一个重要的信号,即SIGINT。我们有一种信号,正如我所说,第一部分是SIG,这个INT代表中断。这什么时候发生?如果你是一名开发者,你可能已经在使用这个了,它最著名的用例是Ctrl + C。如果你使用过任何命令行应用程序、任何基于终端的应用程序,那么你可能用过:某种进程正在运行、某种任务正在运行,如果你想立即关闭它,你可以在键盘上按Ctrl + C,那个进程会立即停止。例如,这里我在本地运行一个后端进程(当然,这是一个基于Go的后端),它当前正在运行,准备接受请求,并为任何发送请求的客户端提供服务。现在如果我想关闭它,我该怎么做?我只需在键盘上按Ctrl + C,你可以看到,当然,我们已经为这个应用程序实现了优雅关机,这就是为什么我们记录应用程序中发生的一切。但现在,让我们关注这部分,它说收到了一个信号,并且信号类型是SIGINT,即中断信号。
正如我们所见,SIGINT需要用户(开发者)按下某个键(在这种情况下是Ctrl + C)。它主要用于开发环境,因为开发者大多使用它。当进程间通信发生时,它们通常不使用它,因为基于SIGINT的信号需要某种按键(Ctrl + C)。所以它也被称为用户发起的关机。在几乎所有情况下,你都想以处理SIGTERM信号相同的方式来处理SIGINT信号。如果你想一想,这是有道理的。无论你的后端是在本地开发环境中运行,你想用Ctrl + C停止它,还是它在云提供商(比如AWS EC2实例)中运行,并且你使用了一个叫做PM2的进程管理器,并将你的后端部署在EC2实例中。当需要停止你的应用程序时,我们将以某种编程方式或手动使用PM2,PM2将向你的后端发送一个信号,这个信号将是SIGTERM信号。正如我所说,SIGINT信号将由开发者使用,因为它需要按键。SIGTERM信号将由程序使用。在这两种情况下,我们都希望以相同的方式处理我们的关机过程。是人类发起还是程序发起并不重要,重要的是我们想要关机。所以我们想要以干净、优雅的方式关机。
现在让我们谈谈最后一点,让我们用红色写下来,因为它是终止信号,即SIGKILL。正如我所说,第一部分意味着信号,第二部分是实际的终止命令。它听起来就是这样:我们想立即杀死应用程序。关于这个信号的有趣之处在于,它无法被捕获,也无法被忽略。这意味着在应用程序中,我们无法注册某种处理器,这些处理器能够执行某种任务、进行某种清理等。当它们收到SIGKILL信号时,这不会发生,因为这种特定类型的信号无法被我们的应用程序检测到。我们的应用程序没有被赋予那种权限或能力。同时它也无法被忽略,这意味着你不能说“既然我无法检测到它,我就忽略它”,这基本上意味着我们不必真的停止,但这也不会发生。所以,如果你的应用程序被发送了SIGKILL信号,它将无法检测到它,并且必须在那个特定时刻停止。其他什么都不会发生。它只是停止。这就是为什么它被称为“杀死”。你可以把它想象成核选项。这相当于不是点击系统图标然后点击关机,而是直接走到电源插头并拔掉插头。就这样,你的电脑就停了。这正是SIGKILL信号的工作方式,这也是为什么优雅关机是一个重要概念。
如果你不响应、不尊重那些礼貌的信号(我所说的礼貌信号是指那些让你完成正在做的事情、让你清理并让你优雅退出的信号),那么最终你当然会收到SIGKILL信号,所以你将不得不停止,甚至没有机会清理自己。
优雅关机的核心步骤
现在我们对三种不同的信号(礼貌信号和SIGKILL)以及具体发生了什么有了一个非常概括的了解,让我们来谈谈我们简要提到过的两个重要事情。第一个是“完成现有请求”是什么意思,第二个是“清理资源”是什么意思。这是优雅关机过程中发生的两个重要步骤。
1. 停止处理中的请求
优雅关机的第一个重要部分是停止处理中的请求。处理中的请求是什么意思?你的HTTP服务器、你的后端能够同时或并发地处理多个请求。所以当需要停止你的后端、停止你的服务器时,你的后端可能已经在处理一些请求了。可能是10-12个,也可能是数百或数千个,这取决于你服务器的规模。这就是处理中请求的意思:在特定时间已经被服务器处理的请求。
为了理解这一点,你可以想象一个餐厅。假设你和朋友去了餐厅。出于某种原因,餐厅必须关门,无论是到了打烊时间(比如晚上10:30或11点)还是其他原因。但关键是餐厅必须关门。具体会发生什么?餐厅老板不能直接走过来关掉所有的灯,也不能直接把你赶出餐厅,这不是一个好主意。相反,会发生什么?餐厅老板会让接待处或门口的人停止允许新顾客进入,对吧?停止允许新的人进入餐厅,这是第一步。你不想处理必须拒绝的新顾客。这是你必须处理的第一件事。第二件事是,他们会向所有现有顾客、所有已经在餐厅用餐的人宣布,是时候关门了,他们有,比如说15分钟、20分钟,以便他们可以吃完餐点,花点时间,15-20分钟应该足够吃完他们正在吃的东西,然后请离开餐厅,当然要付账、付小费,然后离开餐厅。这正是这种情况下的处理方式。
现在想象一下我们的后端、我们的应用程序的相同情况。我们称这个过程为连接排空。这意味着当你的应用程序收到关机信号时(现在它可能是某个进程发送的SIGTERM,也可能是开发者通过Ctrl + C发送的SIGINT),它必须做的第一件事是停止接受新连接。正如我所说,你必须做的第一件事是停止让新顾客进入餐厅。同样,你的应用程序必须做的第一件事是停止接受来自任何客户端的新连接、新请求,这样我们才能处理现有的连接、已经被服务器处理的现有请求,并让它们尽快完成。
现在,连接排空的实现显然会因我们处理的应用程序架构而异。例如,对于HTTP服务器、我们的核心后端、HTTP后端,这将意味着它必须停止接受来自任何客户端的新HTTP请求,并允许处理中的请求、现有的请求完成。同样,如果我们谈论的是数据库应用程序(正如我们在之前的视频中讨论过的,数据库也是一个后端,它可以被想象成一个后端,只是不是我们谈论HTTP后端时的那种意义,但它仍然是一个后端,仍然是一个应用程序),对于基于数据库的应用程序,它必须完成所有现有的查询或所有现有的事务,并且必须在关闭连接之前停止接受新的事务或新查询进入执行。同样,对于基于WebSocket的连接,它必须首先通知客户端它正在关闭,然后关闭套接字,它不能突然关闭套接字。因此,根据应用程序架构(HTTP后端、数据库、WebSocket),实现优雅关机的技术步骤会有所不同,但高层次的想法是相同的:停止接受新连接,停止接受新请求,完成现有的,然后关闭连接。这是一个三步过程。
这里的问题是连接排空的时机,因为你希望给现有连接足够的时间来完成它们的工作,但你不能真的无限期等待。必须有一个限制。大多数生产系统、大多数后端系统会实现某种超时机制,会有某种超时,例如30秒或60秒,这取决于你,通常常见的是30秒。这将是你的系统为你等待的最长时间,之后它将直接停止。它会给你30秒来完成你正在处理的任何请求,大多数情况下,如果你不接受新请求、不接受新连接,那么30秒应该足够完成所有现有请求。但如果由于某种原因、某种阻塞操作,你无法在这个窗口内完成,那么你将被强制停止。我们有一个备用计划。我们不能让我们的系统、我们的后端处理所有请求,并让它需要多长时间就等多长时间。我们不能让这种情况发生。必须有一个限制,而超时就是这个硬性限制。你只有这么多时间来完你正在做的事情。
选择这个超时也带来了一个非常有趣的设计考虑,因为问题是:你到底应该等多久?如果太短,你就有中断实际合法操作的风险。但如果太长,你的整个关机过程会变得非常缓慢,最终会影响你的部署速度、系统响应能力。所以超时的长短取决于你的应用程序的典型请求持续时间和你的操作要求。这不是一个硬性规定,30秒或60秒,你必须了解你的系统、你处理的请求类型。如果它是传统的普通后端,那么30秒应该足够了。但如果我们谈论的是WebSocket或其他更复杂的架构,那么你必须了解你的系统,并相应地决定一个适合你和你的系统的超时。
整个连接排空还需要你的负载均衡器和服务发现系统之间的某种协调,这意味着它还必须与你的健康检查系统以及向服务发现注册和注销一起工作。这些是稍微高级一点的东西。服务发现基本上意味着,如果你部署了一组应用程序(比如你的后端、你的数据库、你的Elasticsearch实例),部署后它们如何相互连接、如何通信,这部分工作流程是你的服务发现机制的责任。
2. 清理资源
我们谈到的第二件事是清理或资源清理。如果你在办公桌前工作,你有一个工作区,是时候离开房子或睡觉了。在你离开办公桌之前,你会做一些清理,对吧?如果你一直在喝咖啡,你会把杯子拿出来放进水槽,或者整理一些电缆。在我们离开办公桌之前,我们都有一些小小的清理工作。同样,在我们的后端应用程序上下文中,当我们说资源时,指的是像文件句柄、网络连接、数据库连接、临时文件、缓存或应用程序在执行期间获取的任何其他系统资源。它必须释放这些资源。
例如,当你的应用程序运行时、当你的后端运行时,你尝试访问文件系统中的特定位置。后端程序访问文件系统的方式是,你必须向操作系统发送信号,它为你提供该位置的句柄。这是一种协议,进程(后端应用程序)可以通过它访问底层文件系统。但概括地说,你获得了一种文件系统句柄,你必须在某个时刻释放它、清理它,否则该句柄将继续运行,你将占用越来越多的内存,这基本上意味着你将不断占用随机存取存储器,最终你会耗尽内存。因此,清理文件句柄很重要。
但我们最常见的一种资源清理是清理网络连接。我们的操作系统是中介。假设你的应用程序或后端在这里,这是互联网。你接收所有类型的请求,但所有请求在到达你的应用程序之前都要经过你的操作系统。你的操作系统是实际的驱动程序,它从你的网卡接收所有请求并提供给你。所以显然,它将拥有你所有网络连接的所有信息。通常,操作系统会限制一个进程可以同时打开的文件句柄数量(正如已经说过的)以及网络连接数量。同样,如果你在处理完网络连接后不释放它们,最终你将耗尽内存或面临某种性能问题。
如果我们谈论数据库连接,在你的应用程序关闭或后端进程关闭之前,你的后端正在处理的数据库事务必须由你的应用程序显式提交或回滚。如果你不这样做,那么这些事务可能会进入不一致状态,这可能导致死锁或数据损坏等各种问题。这就是当我们说资源清理(文件句柄、数据库连接、网络连接等)时所指的事情。
需要记住的一点是,当我们在整个优雅关机工作流程中清理资源时,我们希望以与获取资源相反的顺序清理资源。假设这里你先建立了Redis连接,然后建立了数据库连接等等。当你释放资源时,你应该按相反的顺序进行。为什么我们需要这样做?这是为了防止出现我们未清理依赖于前一个操作的资源或操作的情况。以相反于获取顺序的方式清理资源是你在实现优雅关机时必须记住的另一件事。
代码示例
我知道我们在这个系列中通常避免看代码,但只是为了给你一些背景,你不必理解这段代码。我只是想展示一个相当实用和现实的例子,这样我们就不会对优雅关机过程只有一个非常空洞的理解。我们快速回顾一下我们刚刚讨论过的所有内容,但这次我们看看代码。
正如我所说,你要做的第一件事是我们想注册某种处理器,它将等待某种信号。这就是我们正在做的。再次说明,这是Go语言代码,你不必理解,只需跟随叙述就足够了。现在,在这一步,我们使用某种上下文(这是一个Go语言概念)注册一个处理器,但我们正在等待来自操作系统的中断信号。如果我们收到一个中断信号,我们调用这个函数,这是一个关机函数,一个优雅关机函数。我们所说的优雅关机是什么意思?让我们进入这个函数内部看看。
我们首先看到什么?我们正在关闭我们的HTTP服务器。由于这是一个后端应用程序,其核心部分是HTTP引擎。我们要做的第一件事是调用一个由我们的框架(通常是任何你用于HTTP服务器的框架或库)提供的方法。当你调用它时,它们在内部、库内部停止接受额外的连接,并清理和完成它已有的现有连接,然后这个函数完成。然后我们做什么?我们关闭数据库。同样,数据库也停止接受额外的查询、额外的事务,并完成现有的,并释放它拥有的任何句柄、任何数据库连接。正如你已经知道的,后端和数据库的连接方式是通过TCP。我们可以想象这是我们的应用程序,这是我们的后端,这是我们的数据库。后端连接到数据库的方式是通过TCP,TCP协议。所以,为了让我们的后端连接到数据库,它必须有一个TCP连接,这是一个活跃的连接,这是我们的后端连接到数据库、与数据库通信的唯一方式。当然,当我们谈论数据库连接池时,我们与数据库有许多这样的活跃连接,我们从这个连接池中使用一个特定的连接与数据库通信。当我们想要关闭数据库连接时,数据库首先必须停止使用这些连接、这些路径接受新查询,它将完成已经在处理的任何现有查询、现有事务,然后我们将开始逐个关闭所有这些路径。这正是我们所说的清理数据库资源的意思。

最后,我们还在清理我们的后台作业处理服务器,它使用Redis。所以这个函数内部也关闭了我们的Redis连接。在所有这些之后,我们已经成功优雅地关机了。
如果你想看看这个,让我们启动我们的服务器。按Ctrl + C,这将向我们的后端发送一个中断信号,然后它可以启动优雅关机程序。我按了Ctrl + C,它花了一些时间,因为没有处理中的请求。这是一个在本地完成的相当简单的操作,它能够立即关闭,但我们仍然看到它花了大约一秒钟才完全关闭。所以如果你查看日志,我们可以看到,对于启动服务器,日志是:连接到数据库,启动后台作业服务器,最后我们启动了服务器。这是我们的启动日志,直到这一点,所有这些都是启动日志。然后在这时,正如你在这里看到的Ctrl + C,当我们按下Ctrl + C时,我们做的第一件事是关闭数据库连接,停止后台作业处理服务器。所有这些日志都来自一个队列,这是我们正在使用的后台作业处理库。当它收到关机信号时,当我们调用关闭所有Redis连接的方法时,无论它内部做了什么,它也记录了一些消息,比如“开始优雅关机”、“等待所有工作完成”、“全部完成”和“退出”。最后,我们记录一条消息,说服务器已正确退出。这就是我们停止服务器的方式。
总结
本节课中,我们一起学习了优雅关机的核心概念。我们首先通过一个支付交易的场景,理解了为什么服务器不能突然停止。接着,我们探讨了进程生命周期管理和操作系统通过信号(SIGTERM, SIGINT, SIGKILL)与应用程序通信的机制。
优雅关机的核心在于两个步骤:
- 连接排空:停止接受新请求,并给正在处理的请求一个有限的时间窗口(超时)来完成。
- 资源清理:按与获取相反的顺序,有序地释放文件句柄、数据库连接、网络连接等系统资源。
我们了解到,实现细节(如超时设置)需要根据具体应用架构(HTTP, 数据库, WebSocket)和业务需求来调整。最后,我们通过一个简化的代码示例,看到了优雅关机在实际编程中是如何被触发的(通过信号处理器)和执行的(依次关闭HTTP服务器、数据库等组件)。

掌握优雅关机,能确保你的应用在部署、重启时提供平滑的用户体验,避免数据丢失、重复扣费等严重问题,是构建可靠后端系统不可或缺的一环。

浙公网安备 33010602011771号