译者 | 刘汪洋
审校 | 重楼
在现代数字化环境下,单纯构建一个具备基本功能的系统已无法满足更高的应用需求。我们需要开发在高负载环境下能够稳定且高效扩展的系统。
众多开发者和架构师的实践证明,系统可扩展性的提升往往伴随着独特的挑战。即使是微小的效率问题,在放大到百万倍的负载下,也可能导致系统陷入瘫痪。那么,怎样才能确保你的应用程序在任何负载下都能快速响应呢?
本文将详细介绍构建可扩展系统时的性能优化策略。我们会探讨一些适用于各种代码库的通用策略,无论是前端还是后端,也不论使用何种编程语言。这些策略不仅限于理论层面;它们已在全球一些最具挑战性的技术环境中经过实际应用和验证。作为 Facebook 团队的一员,我亲自参与了将这些优化技术应用于多个项目中,包括 Facebook 的轻量级广告创建体验和 Meta 商务套件。
因此,无论你是在打造下一个大型社交网络、企业级软件套件,还是仅仅想要优化个人项目,我们在此讨论的策略都将成为你工具箱中的宝贵资产。现在,让我们开始探索吧。
预取
预取是一种基于预测用户行为的性能优化技术。设想用户正在与应用程序交互,系统能够预测用户的下一步操作,并提前获取相关数据。这种方法能够创造一种无缝体验:当数据被需要时,它几乎能够即刻被获取,从而使应用程序显得更加迅速和响应灵敏。主动在需求出现之前获取数据能够显著提升用户体验,但如果过度使用,可能会导致资源浪费,如带宽、内存甚至处理能力的浪费。Facebook 在其需要依赖机器学习的复杂操作中大量使用预取,例如在“好友建议”功能中。
何时进行预取?
预取涉及在用户明确表达需求之前,主动向服务器发送请求以检索数据。尽管这看起来很有吸引力,但开发者必须确保在效率和资源使用之间取得平衡。
A.优化服务器响应时间(后端代码优化)
在实施预取之前,首先应确保服务器响应时间已经得到优化。后端代码优化可以通过以下方式实现更佳的服务器响应时间:
- 精简数据库查询,以缩短检索时间。
- 确保复杂操作能够并发执行。
- 减少重复的 API 调用,避免重复获取相同的数据。
- 剔除不必要的计算过程,以避免减慢服务器响应。
B.确认用户意图
预取的核心是对用户下一步操作的预测。然而,预测有时可能不准确。如果系统为用户从未访问的页面或功能预获取数据,就会造成资源的浪费。因此,开发者应采用机制来评估用户意图,例如跟踪用户行为模式或检查用户的活跃参与度,以确保数据仅在有高概率被使用的情况下被获取。
如何实现预取
预取可以在任何编程语言或框架中实现。以 React 为例,来展示预取的实现方法。
考虑一个简单的 React 组件。该组件一旦完成渲染,就会触发一个 AJAX 调用来预先获取数据。当用户点击该组件中的按钮时,第二个组件会使用这些预先获取的数据:
import React, { useState, useEffect } from 'react';
import axios from 'axios';
function PrefetchComponent() {
const [data, setData] = useState(null);
const [showSecondComponent, setShowSecondComponent] = useState(false);
// 组件渲染完成后立即预取数据
useEffect(() => {
axios.get('https://api.example.com/data-to-prefetch')
.then(response => {
setData(response.data);
});
}, []);
return (
<div>
<button onClick={() => setShowSecondComponent(true)}>
Show Next Component
</button>
{showSecondComponent && <SecondComponent data={data} />}
</div>
);
}
function SecondComponent({ data }) {
// 在这个组件中使用预取的数据
return (
<div>
{data ? <div>Here is the prefetched data: {data}</div> : <div>Loading...</div>}
</div>
);
}
export default PrefetchComponent;
在上述代码示例中,PrefetchComponent组件在渲染之后立刻进行数据获取。当用户点击按钮时,SecondComponent组件会展示,使用的是之前预先获取的数据。
记忆化
在计算机科学中,“不要重复自己”原则是优秀编码习惯的核心。此原则也是性能优化的有效手段,正是记忆化技术的基础。记忆化建立在这样一个观点上:重复执行某些操作可能会消耗大量资源,尤其是当这些操作的结果不经常发生变化时。那么,为什么要重复执行已经完成的工作呢?
记忆化通过缓存计算结果来提升应用程序的性能。当同一计算再次被请求时,系统会先检查结果是否已在缓存中。如果已缓存,就直接从缓存中提取结果,省去了实际计算的步骤。从本质上讲,记忆化涉及到对之前结果的存储(由此得名)。这对于计算成本高且经常被同样的输入调用的函数来说尤为有效。这就好比一个学生解决了一个复杂的数学问题,并在书的边缘记下了答案。如果未来的考试中出现了同样的问题,学生可以简单地查看书边的笔记,而不必重新解决这个问题。
何时使用记忆化?
记忆化并非适用于所有情况。在某些场景下,记忆化可能会导致更多的内存消耗。因此,正确识别何时使用这种技术至关重要:
- 数据变化不频繁时: 对于那些输入一致时返回结果也一致的函数,尤其是计算密集型的函数,使用记忆化是理想选择。这确保了在随后相同的调用中不会浪费一次计算的努力。
- 数据不太敏感时: 在考虑使用记忆化时,安全性和隐私问题也是不可忽视的重要因素。虽然缓存所有内容看似诱人,但并不总是安全的。例如,支付信息、密码及其他个人详细信息这类数据永远不应缓存。然而,像社交媒体帖子的点赞数和评论数这类较为无害的数据,可以安全地进行记忆化以提升性能。
如何实现记忆化
在 React 中,我们可以利用 useCallback 和useMemo等钩子来实现记忆化。让我们来看一个简单的例子:
import React, { useState, useCallback, useMemo } from 'react';
function ExpensiveOperationComponent() {
const [input, setInput] = useState(0);
const [count, setCount] = useState(0);
// 模拟一个计算开销很大的操作
const expensiveOperation = useCallback((num) => {
console.log('Computing...');
// 模拟耗时长的计算
for(let i = 0; i < 1000000000; i++) {}
return num * num;
}, []);
const memoizedResult = useMemo(() => expensiveOperation(input), [input, expensiveOperation]);
return (
<div>
<input value={input} onChange={e => setInput(e.target.value)} />
<p>Result of Expensive Operation: {memoizedResult}</p>
<button onClick={() => setCount(count + 1)}>Re-render component</button>
<p>Component re-render count: {count}</p>
</div>
);
}
export default ExpensiveOperationComponent;
在这个示例中,expensiveOperation函数模拟了一个计算密集型任务。我们使用useCallback钩子来确保在每次组件渲染时,这个函数不会被重新定义。此外,useMemo钩子被用来存储expensiveOperation的结果,这样,即使组件重新渲染,如果输入没有变化,就不会重复执行这个计算。
并行获取
并行数据获取是指同时获取多个数据集,而非逐个获取。这就好比在超市结账时,有多个收银员同时服务,而不仅仅是一个:顾客能更快得到服务,排队时间缩短,整体效率得到提升。在数据处理领域,鉴于很多数据集之间互不相关,因此并行获取能显著加快页面加载速度,尤其适用于检索复杂数据所需时间较长的场景。
何时使用并行获取?
- 当各数据集独立且获取过程复杂时: 若所需获取的数据集之间无依赖关系,并且检索每个数据集耗时较长,此时并行获取能有效提高处理速度。
- 后端应用广泛,前端使用需谨慎: 尽管在后端,通过提升服务器响应速度,并行获取能发挥显著效果,但在前端使用时需格外小心。过多的并行请求可能会加重客户端负载,影响用户体验。
- 优先处理网络请求: 若数据获取涉及多个网络请求,最佳做法是优先处理一个主要请求,并在前端展示,同时在后台并行处理其他请求。这样做可确保最重要的数据首先被获取,同时其他次要数据也在后台并行地进行加载。
如何使用并行获取
在 PHP 中,随着现代扩展和工具的发展,实现并行处理变得更为简便。以下是一个使用concurrent {}代码块的基本示例:
<?php
use Concurrent\TaskScheduler;
require 'vendor/autoload.php';
// 假设这些是一些从各种来源获取数据的函数
function fetchDataA() {
// 模拟延迟
sleep(2);
return "Data A";
}
function fetchDataB() {
// 模拟延迟
sleep(3);
return "Data B";
}
$scheduler = new TaskScheduler();
$result = concurrent {
"a" => fetchDataA(),
"b" => fetchDataB(),
};
echo $result["a"]; // Outputs: Data A
echo $result["b"]; // Outputs: Data B
?>
在此示例中,fetchDataA 和 fetchDataB 分别代表两个数据检索函数。通过运用concurrent {}代码块,这两个函数可同时执行,从而缩短了获取这两个数据集的总耗时。
延迟加载
延迟加载是一种设计模式,其核心思想是仅在真正需要时才加载数据或资源。与预先加载所有内容不同,延迟加载只载入初始视图所需的必要内容,随后根据需求加载额外资源。这类似于一家餐厅仅在顾客点特定菜品时才开始烹饪,而非预先准备所有菜肴。例如,在网页中,模态框的数据只有在用户点击按钮打开模态框时才被加载。通过这种方式,可以将数据的获取推迟到实际需要的时刻。
如何实现延迟加载
有效实现延迟加载的关键在于,要确保在数据获取过程中向用户提供清晰的反馈,以优化用户体验。常见的做法是在数据检索时展示一个旋转的加载动画,这样用户就能明白他们的请求正在被处理,即便数据暂时还不可用。
React 中的延迟加载示例
以下是一个 React 组件中实现延迟加载的示例。此组件只在用户点击按钮以查看模态框内容时获取数据:
import React, { useState } from 'react';
function LazyLoadedModal() {
const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
const fetchDataForModal = async () => {
setIsLoading(true);
// 模拟一次 AJAX 获取数据的调用
const response = await fetch('https://api.example.com/data');
const result = await response.json();
setData(result);
setIsLoading(false);
setIsModalOpen(true);
};
return (
<div>
<button onClick={fetchDataForModal}>
Open Modal
</button>
{isModalOpen && (
<div className="modal">
{isLoading ? (
<p>Loading...</p> // 这里可以使用旋转圈或加载动画
) : (
<p>{data}</p>
)}
</div>
)}
</div>
);
}
export default LazyLoadedModal;
在这个例子中,只有当用户点击“打开模态框”按钮后,才会开始获取模态框的数据。在此之前,不会发起不必要的网络请求。一旦开始获取数据,便会显示加载信息(或旋转器),以示用户请求正在处理。
结论
在当今快速的数字时代,响应时间的每一毫秒都十分重要。用户寻求快速响应,而企业无法承受让用户等待的后果。性能优化已成为提供优质数字体验的必要条件,而不仅仅是一种优化。
通过预取、记忆化、并行获取和延迟加载等技术,开发者能有效提升应用性能。虽然这些策略在应用和方法上有所不同,但它们共同的目标是确保应用程序能够尽可能高效和快速地运行。
重要的一点是,不存在一劳永逸的解决方案或“银弹”。每个应用程序都有其独特之处,性能优化应结合对应用程序需求的深入理解、对用户期望的认识,以及正确技术的有效应用。这是一个持续改进和学习的过程。
译者介绍
刘汪洋,51CTO社区编辑,昵称:明明如月,一个拥有 5 年开发经验的某大厂高级 Java 工程师,拥有多个主流技术博客平台博客专家称号。
原文标题:Performance Optimization Strategies in Highly Scalable Systems,作者:Hemanth Murali