首页 / 文章 / 导致 JavaScript 在真实流量环境下失效的七个隐藏假设

导致 JavaScript 在真实流量环境下失效的七个隐藏假设

了解在JavaScript代码投入生产后,哪些关于类型、重试、并发性、响应顺序、数据量、缺失数据以及内存状态的假设会导致问题。

3164 词

大多数在生产环境中出问题的 JavaScript 代码并非存在语法错误那样的明显错误。它们在仅存在于开发者笔记本电脑上的特定条件下是正常的:数据量极小、响应速度很快、只有单一且谨慎的用户、仅一个进程在运行。以下是这七种隐藏条件,以及它们在真实流量下的失效方式,还有具体的设计措施,帮助你将这些问题转化为明确的、经过测试的决策,而非在故障发生时才意外发现。

为何本地开发环境难以作为可靠预测依据

开发环境异常宽容。数据库中仅存少量数据行,请求能在几毫秒内返回,没人会重复点击任何内容,所有服务都运行在同一台机器或可靠的局域网中。在这种环境下编写的代码数周内看起来都很稳定:函数结构清晰,每个承诺都会被正确处理,测试套件全部通过,控制台也保持安静。

系统是修改输入数据而非语言本身。这些记录是由该应用程序的五个不同版本生成的,格式并不统一。用户会双击屏幕,因为感觉画面卡住了。响应返回的顺序与请求发送的顺序不同。第三方 API 速度缓慢,会返回不完整的响应数据或超时。同一服务的多个实例会在同一时间更新相同的行。那些原本应在本地填写的字段则显示为 ""null、两个版本前就已废弃的枚举值,或是本应包含数字却实际为字符串的形式。

这些措施并不会在代码部署后使其变差。它们只是去除了那些让薄弱边界看起来安全的条件。一个有益的习惯是,不要只问“这对预期输入有效吗?”,而应开始思考代码在时间、数量、所有权、数据格式及故障处理方面隐含的假设。这些假设很多都是完全合理的。风险在于不把这些假设明确说出来,直到真实用户证明它们是错误的。

1. 值并不总是具有其表面所示的类型

在 JavaScript 中,一个值可能会跨越多个边界,并在每个边界处失去其原始类型。数字形式的数据库 ID 一旦出现在 URL 中就会变成字符串。复选框会以 "true""false" 的形式传送到服务器。空输入则会变成 "",而后端期望的是 null。ISO 时间戳以纯文本形式传输,仅仅因为其外观类似 Date 对象,就会被当作 Date 对象来处理。

在本地开发环境中这种情况很少出现,因为请求的双方都由同一位开发者编写,而且测试用的数据也已经根据要测试的功能进行了相应设置。而在生产环境中,输入数据可能来自较旧的移动客户端、浏览器的原生表单行为、合作伙伴的系统集成、过期的缓存数据,以及有人通过管理工具手动编辑的记录。

类型强制转换会生成看似正确的结果

那些棘手的错误,是JavaScript选择进行看似合理的转换而非抛出异常的情况。"10"使用>与数字比较时没有问题,但"10" + 1却会得到"101"。字符串"false"被视为真值,因此原本用于关闭某功能的标志反而可能使其启用。在某个辅助函数中空字符串被视为“缺失”,而在另一个函数中则被当作有效值。

程序不会崩溃,但分页功能可能会跳过错误的页面,被禁用的选项可能变得可选择,或者因为一方是数字而另一方是字符串,userId === record.ownerId会悄无声息地失败。团队们便针对出现的每个问题分别打补丁,结果代码库中逐渐积累了多种略有不同的规范化规则。

在边界处一次性完成规范化

强大的系统会在值跨越特定边界时进行转换,之后便不再更改。在业务逻辑处理之前,查询参数和请求体会被解析为经过验证的内部类型。来自外部 API 的响应会被映射到稳定的领域模型中。表单输入会经过有意识的转换,而非依赖值的真值或运算符强制转换机制。这样做的目的并非将 JavaScript 变成一种严格类型的语言,而是确保应用程序的其他部分无需猜测某个值的含义。

TypeScript仅能记录代码的预期结构,但类型在运行时会被清除,因此无法检测实际传输的数据内容。即使参数类型定义得再完善,处理函数仍可能接收到任意数据。更安全的做法是使用运行时模式与静态类型来描述相同的契约,理想情况下让类型由模式直接生成。像Zod这样的模式库使这成为可能,因为同一个定义既能用于运行时验证,又能生成TypeScript类型。

2. 一次点击并不等同于一次执行

界面上只有一个按钮,因此人们很容易认为只会发起一个请求:用户点击,服务器处理,然后响应确认结果。手动测试也强化了这种认知,因为开发者只需点击一次并耐心等待。

真实用户和实际网络的行为各不相同。有人会因为没有看到加载提示而再次点击;移动应用在连接中断后会重新尝试;代理或负载均衡器在短暂故障后会重发请求;消息队列则会在工作进程完成任务但未确认之前重新发送该任务。原本看似只有一个入口点的情况,现在却有多种方式导致同一操作被执行两次。

重复操作真正会造成危害的情况

重复读取数据通常无害,但重复创建账户、预订库存、进行支付、发送邀请或生成导出文件则不然:这会导致出现重复的记录、多封邮件、库存被扣除两次,或是客户被收费两次。

在首次点击后禁用按钮虽能提升用户体验,但并不能确保万无一失。客户端可能被绕过,请求也可在界面之外重新发起,而且该服务的两个实例都可能接收到相同的逻辑操作。前端防护仅是一种礼节性措施,真正需要保证一致性的是后端和数据库。

设计能够容忍重复操作的写入机制

重要的写入操作应当基于“会被多次尝试”这一前提来设计:

  • 一个在多次尝试中保持稳定的幂等键,可使服务器将它们视为同一个逻辑操作。
  • 数据库中的唯一性约束即便在两个并发请求都通过了应用层的“是否存在?”检查时,也能防止数据重复。
  • 通过记录已处理消息的ID列表,队列消费者可以跳过那些已经处理过的事件。

关键在于,超时或错误响应并不能证明操作已经失败。服务器可能在客户端放弃等待后就已经完成了任务。如果在没有为该操作定义稳定标识的情况下重复尝试,就会将这种不确定性转化为重复处理。一个可靠的系统并非能够完全避免所有重复操作,而是即便发生重复,也不会改变该操作的最终含义。关于服务器端的实现机制,可在了解 Node.js POST 接口中的幂等性键一文中获得更深入的阐述。

3. 使用 await 的代码仍可能出现竞态条件

async/await 使得函数看起来像是一系列有序的步骤:加载记录、检查其状态、更新记录,然后返回。由于每一步都会被等待,因此流程看起来是受控的。然而,这种控制仅存在于单次调用之内。

当某个调用在 await 处挂起时,运行时可以自由地执行同一函数的另一个请求处理程序、事件回调或任务。两次调用都可以读取相同的状态,都判定该操作是允许的,并且都会进行写入操作。单看每一步都是正确的,但合在一起却违反了业务规则。

服务器端的检查后执行竞态条件

想象一个审批接口,它先加载待处理的项,然后将其标记为已批准。两名管理员在几秒钟的间隔内打开同一页面并点击审批。在任一更新操作完成之前,两次请求都读取到了 pending 状态。最终的结果看起来可能没问题,但如果该操作还会发送通知或记录审计日志,那么这些副作用就会发生两次。

浏览器中的相同竞态条件

在用户界面中,其形态类似搜索框。首先会发起对旧查询的请求,随后再发起对新查询的请求,而新的响应会先到达。屏幕上会短暂显示正确的结果,之后旧响应到来并覆盖这些结果。两次请求都成功了,且状态更新所使用的数据也都是有效的。唯一缺失的是关于哪次请求仍有权限更新屏幕的决策。

await不会加锁,也不会冻结之前读取过的值,更不会对同一函数的调用进行排队。它只是暂停当前执行,以便其他任务能够继续进行。

选择保护机制

解决方案取决于竞争发生在何处:

  • 采用条件更新,例如“当状态为待处理时将状态设置为已批准”,同时检查受影响的行数。
  • 通过必须匹配的版本列实现乐观并发控制。
  • 具有适当隔离级别的数据库事务。
  • 唯一性约束,使得第二次写入会立即失败并给出明确提示。
  • 请求取消机制,或仅允许最新结果修改共享状态的操作标识符。
  • 一种危险的误解是:只要代码按顺序读取,系统就会按顺序运行。实际上,JavaScript可以让单个函数的结构非常清晰,同时其多个副本却能并行执行。

    4. 请求不会按启动顺序完成

    由于代码是从上到下编写的,人们很容易按照创建顺序来理解异步操作:因为请求A在请求B之前发送,所以A应该先返回。但网络、缓存、数据库以及外部服务并不会做出这样的保证。

    第一个请求可能会遇到处理速度较慢的查询,而第二个请求则直接从缓存中获取。某个服务区域会立即响应,而另一个区域则会进行内部重试。即便服务器很快给出了回复,较大的数据量也需要更长时间来解析。

    过时的响应是在错误的时间提供的正确数据

    一旦处理顺序决定了当前状态,这就成了一个漏洞。搜索结果、表单验证、路由加载器、控制面板以及自动补全功能往往是受影响的对象:用户向前操作时,延迟的响应却会让界面状态倒退。这些响应中的数据本身并没有错误,只是不再与用户当前查看的内容相关。

    防抖机制可以通过减少请求的发起次数来起到帮助作用,但无法完全避免响应重叠的问题。用户可以暂停足够长的时间以触发一次请求,然后在请求仍在处理期间继续输入内容,这样旧请求仍有可能最后完成。

    确定结果的归属者

    可靠的解决方案始于明确的归属规则。在许多接口中,最新的请求应当优先处理,因此要么取消旧请求,要么为每个请求添加序列号并丢弃过时的结果。其他工作流则需要遵循先进先出原则,或为每项操作维护独立的状态。同样的归属规则也适用于加载状态和错误状态:被放弃的请求不得影响正在处理的请求的加载指示器,也不得针对用户已替换的查询显示错误信息。关于 React 特有的处理方式,请参阅具有状态归属管理的 React 搜索功能

    生产环境并不遵循 Promise 的创建顺序。您的代码必须在结果确定时判断该结果是否仍然有效。

    5. 小型集合不会一直保持小型

    当数据量只有几十条时,某些转换看起来似乎无害:比如对数组进行映射,然后为每个元素在另一个数组上调用find方法;或者用includes方法根据第二个列表过滤第一个列表;又或是按组对整个集合进行一次过滤以生成报告。

    借助测试用例,这些操作能瞬间完成,且代码长度足够短,其算法开销几乎可以忽略不计。随着生产环境数据的增加,代码的结构也不会发生变化。在对两万个记录的数组分别使用mapfind时,最多需要进行大约一亿次比较。而在循环中使用includes则意味着每条数据都需要再进行一次线性扫描。原本仅为某个团队生成的报告,突然就要在整个组织范围内运行了。

    让结构与访问模式相匹配

    数组方法本身并非问题,问题在于使用顺序结构进行重复的键查找或成员测试。只需根据 id 一次性构建一个 Map,这样每次查找的平均时间复杂度就能变为常数级。当需要判断“该元素是否存在于集合中?”时,则应使用 Set。很多时候,更好的解决方案是进行数据库关联查询,这样就不必将两个集合都加载到内存中,再在应用代码中将它们合并。

    这并不意味着要替换所有的数组。构建索引也有其成本,而对于极小的列表来说,使用 find 可能是最直接的选择。当操作频繁执行或数据量可能大幅增长时,所需的计算方式也会发生变化。

    内存与并发性同样需要考虑

    数据量也会影响内存和资源使用情况。在过滤之前加载所有行、连续调用多个会各自分配新数组的 mapfilter 方法,或者使用 Promise.all 为每个数据项创建一个 Promise,在本地环境下可能还能正常运行,但在生产环境中却会造成严重问题。这可能导致内存耗尽、数据库连接池被占满,或是事件循环被占用,从而使其他等待处理的请求速度变慢。分页、流式处理以及限制并发数是常见的解决办法。

    大多数性能问题都是因为普通代码遇到了过大的数据量。先了解预期的规模,避免进行不必要的操作,在尝试复杂的优化方案之前先对实际运行路径进行性能分析。通常最耗资源的代码正是那些看起来过于常见而让人忽略其问题的那段代码。

    6. 缺失数据并不能证明没有出现问题

    JavaScript 能让优雅的回退处理变得轻而易举。可选链操作可避免属性访问错误,空值合并运算符能提供默认值,而 catch 块则可以返回空数组。在预期并理解某种情况不存在时,这些特性非常有用。但当它们模糊了“根本没有任何内容”与“我们无法查明情况”之间的区别时,就会带来危害。

    当回退处理掩盖问题时

    由于数据库故障,仪表板查询失败,服务返回 [],而界面则愉快地显示“未找到记录”。权限查询失败时,可选链操作会返回 undefined,代码却将其视为普通的 false。格式错误的响应会产生 undefined,进而在三层嵌套后变成一个默认对象。由于应用从未崩溃,看起来很稳定,但实际上它向用户展示的却是自己并不掌握的信息。

    这些情况并非可以互相替代:

    • 执行查询后返回零条记录,与查询根本未执行的情况。
    • 缺少可选头像,与缺少用户对象的情况。
    • 出于业务原因的主动拒绝,与网络超时的情况。

    每种情况可能都需要独立的用户消息、重试策略、警报机制以及支持方案。在实际生产环境中,这类故障是常见现象而非理论上的问题:依赖服务可能宕机、权限可能发生变化、滚动部署可能会短暂产生混合版本,旧数据也可能违反新规则。如果将所有异常情况都归类为“正常”,那么这些问题就会一直隐藏不露,直到有其他更明显的信号出现才被注意到。

    先明确契约要求,再添加防御性机制

    当某个值确实可选时,应使用可选链操作符。当系统有可行的替代方案时,则应使用默认值处理。捕获错误时,需将其归类为“未找到”、“被禁止”、“不可用”或“无效”等稳定类别,但需保留原始错误原因和上下文以便日志记录与监控。优雅降级应确保应用在保持真实性的同时仍能正常使用;绝不能通过返回看似合理的值来让系统显得运行正常。

    7. 内存中的状态不会在应用各部分之间共享

    模块作用域使得内存中的状态使用起来十分方便。顶层变量可用于存储缓存、跟踪正在运行的任务、统计请求次数以实现速率限制,或记录初始化是否已完成。在本地环境中,单个进程负责处理所有请求,因此这些变量表现得就像全局应用状态一样。

    在生产环境中,同一份代码可能会在多个进程、容器、无服务器实例或不同区域中运行,而这些环境各自拥有独立的内存:

    • 在一个实例上更新的缓存,在其他实例中仍然是过期的。
    • “已初始化”标志仅能保护设置该标志的进程本身。
    • 基于内存的速率限制机制使得客户端只需连接到不同的实例就能突破限制。
    • 存储在内存中的定时器会在容器重启时消失。

    即便只有一个进程,其稳定性也远不如人们想象的那样。部署操作会触发重启,无服务器平台会冻结并回收实例,崩溃会导致所有未持久化的数据丢失,而在内存压力过大时,平台甚至可能在不给进程机会处理待完成任务的情况下直接终止它。

    为状态赋予其实际所需的范围

    内存中的状态仍然非常适合用于进程级缓存、本地优化、请求内的短期协调,以及那些丢失后可以接受的值。但当它被赋予需要系统级权限或持久性的任务时,问题就出现了。共享的速率限制通常应存储在 Redis 等集中式存储中。那些必须在重启后依然存在的任务则应放在队列或数据库中。分布式锁则需要一种所有竞争进程都能看到的机制。关键配置应来自可靠的来源,而非某个实例中的可变变量。

    需要探讨的问题是作用域和生命周期。这个状态是随每次函数调用而存在、每个用户会话而存在、每个进程而存在、每次部署而存在,还是贯穿整个系统?当进程消失时它会怎样?在大多数现代架构中,JavaScript运行时并非应用程序本身,而只是更大系统中的一个临时组成部分。

    生产环境更可靠,而非更随机

    人们很容易将生产环境描述为不可预测的。更准确的说法是,它最终提供了应用程序原本就应该处理的时机、规模、历史数据、并发用户数以及基础设施边界。JavaScript 依然遵循完全相同的规则:非空字符串被视为真值,异步函数在多次调用之间会相互重叠,数组以线性方式被搜索,而模块变量属于同一个运行时环境。意外情况往往源于那些人们从未考虑过、因而无人去测试的假设。

    可靠的代码并不会试图防范所有可能出现的场景。它会识别出那些在假设不成立时会带来巨大代价的假设,并将其明确表达出来。这些额外的代码通常很少;真正的益处在于消除了歧义。后续的开发者能够清楚地知道哪些值是有效的,哪项操作会产生结果,成功意味着什么,以及是否可以安全地重试。

    核心要点

    • 在边界处一次性解析并验证外部值,同时保持运行时模式与静态类型的一致性。
    • 将每一次重要的写入操作视为可能被多次执行的操作,并在数据存储位置确保其唯一性。
    • 请记住,await仅会暂停当前调用,并不会对系统进行序列化处理或锁定共享状态。
    • 需明确决定哪个异步结果负责当前的系统状态,包括加载状态和错误提示。
    • 应根据实际处理的数据量选择合适的数据结构与查询方式,而非仅依据测试用的示例数据。
    • 无论对用户还是监控系统而言,都应始终将“空状态”与“失败状态”视为不同的结果。
    • 按照实际需求确定状态的存储范围与持久性,并假设任何单个进程都可能意外消失。

    相关阅读