前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >软件系统可扩展性的10个关键因素

软件系统可扩展性的10个关键因素

作者头像
用户5166556
发布2023-09-07 09:21:36
6740
发布2023-09-07 09:21:36
举报

作为可靠软件设计原则的一部分,这篇文章将重点关注可扩展性——这是构建健壮、面向未来的应用程序的最关键元素之一。

在当今数据和用户不断增加的世界中,软件需要做好适应更高负载的准备。忽视可扩展性就像在薄弱的地基上建造一座美丽的房子 - 它最初可能看起来很棒,但最终会在压力下崩溃。

无论您正在构建企业系统、移动应用程序甚至是个人使用的东西,如何确保您的软件能够顺利应对增长?即使在流量高峰和高使用率期间,可扩展的系统也能提供出色的用户体验。不可扩展的应用程序在最好的情况下会令人沮丧,在最坏的情况下会变得无法使用或在负载增加时完全崩溃。

在这篇文章中,我们将探讨设计高度可扩展架构的 10 个关键领域。通过掌握这些概念,您可以开发能够大规模部署的软件,而无需进行昂贵的返工。您的用户将会感谢您构建的应用程序,让他们今天和明天(当您的用户群增长 10 倍时)都感到高兴。

水平与垂直缩放

水平缩放与垂直缩放

可扩展性的首要关键概念之一是理解水平扩展和垂直扩展之间的区别。水平扩展意味着通过向系统添加更多机器或节点来增加容量。例如,添加更多服务器以支持应用程序增加的流量。

垂直扩展涉及增加现有节点的能力,例如升级到具有更快 CPU、更多 RAM 或增加存储容量的服务器。

一般来说,水平扩展是首选,因为它通过冗余提供了更高的可靠性。如果一个节点发生故障,其他节点可以接管工作负载。水平扩展还提供了更大的灵活性,可以根据需要逐渐扩展。通过垂直扩展,您需要完全升级硬件才能处理增加的负载。

然而,当 CPU 密集型数据处理等特定任务需要增加计算能力时,垂直扩展可能会很有用。总体而言,可扩展架构采用垂直和水平扩展方法的组合来随着时间的推移调整系统资源需求。

负载均衡

通过添加服务器进行水平扩展后,您需要一种在这些节点之间均匀分配请求和流量的方法。这就是负载平衡的用武之地。负载平衡器位于服务器前面,可以有效地将传入请求路由到服务器。

这可以防止任何单个服务器变得不堪重负。负载均衡器可以实现不同的算法,例如轮询、最少连接或 IP 哈希来确定如何分配负载。更先进的负载均衡器可以检测服务器健康状况并自适应地将流量从故障节点转移开。

负载均衡可最大限度地提高资源利用率并提高性能。它还提供高可用性和可靠性。如果服务器出现故障,负载均衡器会将流量重定向到剩余的在线服务器。这种冗余使您的系统能够适应单个服务器的故障。

与自动扩展一起实施负载平衡可以让您的系统顺利、轻松地扩展。您的应用程序可以轻松处理较大的流量变化,而不会遇到容量问题。

数据库扩展

随着应用程序使用量的增长,支持系统的数据库可能会成为瓶颈。有多种技术可以扩展数据库以满足高读/写负载。然而,数据库是大多数系统中最难扩展的组件之一。

数据库选择

选择合适的数据库对于有效扩展数据库系统至关重要。它取决于多种因素,包括要存储的数据类型和预期的查询模式。不同类型的数据(例如指标数据、日志、企业数据、图形数据和键/值存储)具有不同的特征和要求,需要量身定制的数据库解决方案。

对于指标数据,高写入吞吐量对于记录时间序列数据至关重要,像 InfluxDB 或 Prometheus 这样的时间序列数据库可能更适合,因为它们优化了存储和查询机制。另一方面,为了处理大量非结构化数据(例如日志),NoSQL 数据库(例如 Elasticsearch)可以提供高效的索引和搜索功能。

对于需要严格 ACID(原子性、一致性、隔离性、持久性)事务和复杂关系查询的企业数据,像 PostgreSQL 或 MySQL 这样的传统 SQL 数据库可能是正确的选择。相比之下,对于需要简单读写操作的场景,Redis 或 Cassandra 等键/值存储可以提供低延迟的数据访问。

在选择数据库之前,必须彻底评估应用程序的具体要求及其数据特征。有时,数据库的组合(多语言持久性)可能是最有效的策略,根据其优势将不同的数据库用于应用程序的不同部分。最终,正确的数据库选择可以显着影响系统的可扩展性、性能和整体成功。

垂直缩放

只需在单个数据库服务器上投入更多资源(例如 CPU、内存和存储)即可暂时缓解增加的负载。在研究扩展数据库的高级概念之前,应该先尝试一下。此外,垂直扩展使您的数据库堆栈变得简单。

然而,单个服务器可以扩展的规模存在物理上限。此外,单一数据库仍然是一个单点故障 - 如果增强的服务器出现故障,对数据的访问也会发生故障。

这就是为什么除了数据库服务器硬件的垂直扩展之外,采用水平扩展技术也至关重要。

复制

复制通过跨多个数据库实例复制数据来提供冗余并提高性能。对领导节点的写入将被复制到只读副本。可以从副本提供读取服务,从而减少主服务器上的负载。此外,复制可以跨冗余服务器复制数据,从而消除单点故障风险。

分片

分片将数据库划分为多个较小的服务器,使您可以根据需要流畅地添加更多节点。

分片或分区涉及按特定标准(例如客户 ID 或地理区域)将数据库拆分为多个较小的数据库。这允许您通过添加更多数据库服务器来水平扩展。

此外,还应该关注其他有助于扩展数据库的领域:

  • 架构非规范化涉及数据库中的数据重复,以减少查询中复杂连接的需要,从而提高查询性能。
  • 将频繁访问的数据缓存在快速内存缓存中可以减少数据库查询。缓存命中可以避免从速度较慢的数据库中获取数据。

异步处理

同步请求-响应周期可能会产生阻碍可扩展性的瓶颈,特别是对于长时间运行或 IO 密集型任务。异步处理将要在后台处理的工作排队,立即释放资源以供其他请求使用。

例如,提交视频转码作业可能会直接阻止 Web 请求,从而对用户体验产生负面影响。相反,转码任务可以发布到队列并异步处理。用户会立即得到响应,而转码任务则单独处理。

异步视频上传和转码示例

异步任务可以由跨多个服务器水平扩展的后台工作人员同时执行。可以监控队列大小以动态添加更多工作人员。负载分布均匀,防止任何单个工作人员不堪重负。

将工作负载从同步转移到异步使应用程序能够顺利处理流量峰值,而不会陷入困境。系统使用强大的基于队列的异步处理在负载下保持响应。

无状态系统

与有状态设计相比,无状态系统更容易水平扩展。当应用程序状态保存在数据库或分布式缓存等外部存储中而不是本地服务器上时,可以根据需要启动新实例。

相反,有状态系统需要跨实例的粘性会话或数据复制。无状态应用程序不依赖于特定服务器。请求可以路由到任何可用的资源。

外部保存状态还提供更好的容错能力。任何无状态应用程序服务器的丢失都不会产生影响,因为它不保存未持久化的关键数据。其他服务器可以无缝地接管处理。

无状态架构提高了可靠性和可扩展性。资源可以弹性扩展,同时保持与各个实例的解耦。然而,外部状态存储增加了缓存或数据库查询的开销。在设计网络规模的应用程序时需要仔细评估这些权衡。

缓存

在快速内存存储中缓存经常访问的数据是优化可扩展性的强大技术。通过处理来自低延迟缓存的读取请求,您可以显着减少后端数据库的负载并提高性能。

例如,很少更改的产品目录信息非常适合缓存。后续的产品页面请求可以从 Redis 或 Memcached 获取数据,而不会使 MySQL 存储超载。缓存失效策略有助于保持数据一致。

缓存还有益于计算量大的流程,例如模板渲染。您可以缓存渲染的输出并绕过每个请求的冗余渲染。Cloudflare 等 CDN 在全球范围内缓存并提供图像、CSS 和 JS 等静态资源。

Redis Golang 示例:

代码语言:javascript
复制
package main

import (
 "database/sql"
 "encoding/json"
 "fmt"
 "log"
 "net/http"
 "time"

 "github.com/go-redis/redis"
 _ "github.com/go-sql-driver/mysql"
)

const (
 dbUser     = "your_mysql_username"
 dbPassword = "your_mysql_password"
 dbName     = "your_mysql_dbname"
 redisAddr  = "localhost:6379"
)

type Product struct {
 ID    int    `json:"id"`
 Name  string `json:"name"`
 Price int    `json:"price"`
}

var db *sql.DB
var redisClient *redis.Client

func init() {
 // Initialize MySQL connection
 dbSource := fmt.Sprintf("%s:%s@/%s", dbUser, dbPassword, dbName)
 var err error
 db, err = sql.Open("mysql", dbSource)
 if err != nil {
  log.Fatalf("Error opening database: %s", err)
 }

 // Initialize Redis client
 redisClient = redis.NewClient(&redis.Options{
  Addr:     redisAddr,
  Password: "", // No password set
  DB:       0,  // Use default DB
 })

 // Test the Redis connection
 _, err = redisClient.Ping().Result()
 if err != nil {
  log.Fatalf("Error connecting to Redis: %s", err)
 }

 log.Println("Connected to MySQL and Redis")
}

func getProductFromMySQL(id int) (*Product, error) {
 query := "SELECT id, name, price FROM products WHERE id = ?"
 row := db.QueryRow(query, id)
 var product Product
 err := row.Scan(&product.ID, &product.Name, &product.Price)
 if err != nil {
  return nil, err
 }
 return &product, nil
}

func getProductFromCache(id int) (*Product, error) {
 productJSON, err := redisClient.Get(fmt.Sprintf("product:%d", id)).Result()
 if err == redis.Nil {
  // Cache miss
  return nil, nil
 } else if err != nil {
  return nil, err
 }

 var product Product
 err = json.Unmarshal([]byte(productJSON), &product)
 if err != nil {
  return nil, err
 }

 return &product, nil
}

func cacheProduct(product *Product) error {
 productJSON, err := json.Marshal(product)
 if err != nil {
  return err
 }

 key := fmt.Sprintf("product:%d", product.ID)
 return redisClient.Set(key, productJSON, 10*time.Minute).Err()
}

func getProductHandler(w http.ResponseWriter, r *http.Request) {
 productID := 1 // For simplicity, we are assuming product ID 1 here. You can pass it as a query parameter.

 // Try getting the product from the cache first
 cachedProduct, err := getProductFromCache(productID)
 if err != nil {
  http.Error(w, "Failed to retrieve product from cache", http.StatusInternalServerError)
  return
 }

 if cachedProduct == nil {
  // Cache miss, get the product from MySQL
  product, err := getProductFromMySQL(productID)
  if err != nil {
   http.Error(w, "Failed to retrieve product from database", http.StatusInternalServerError)
   return
  }

  if product == nil {
   http.Error(w, "Product not found", http.StatusNotFound)
   return
  }

  // Cache the product for future requests
  err = cacheProduct(product)
  if err != nil {
   log.Printf("Failed to cache product: %s", err)
  }

  // Respond with the product details
  json.NewEncoder(w).Encode(product)
 } else {
  // Cache hit, respond with the cached product details
  json.NewEncoder(w).Encode(cachedProduct)
 }
}

func main() {
 http.HandleFunc("/product", getProductHandler)
 log.Fatal(http.ListenAndServe(":8080", nil))
}

战略性地利用缓存可以减轻基础设施的压力,并在您添加更多缓存服务器时横向扩展。缓存最适合具有重复访问模式的读取密集型工作负载。它与数据库分片和异步处理一起提供了可扩展性收益。

网络带宽优化

对于分布在多个服务器和区域的分布式架构,优化网络带宽利用率是可扩展性的关键。网络调用可能成为瓶颈,对吞吐量和延迟施加限制。

压缩和缓存等带宽优化技术可减少网络跳数和传输的数据量。压缩 API 和数据库响应可最大限度地减少带宽需求。

通过 HTTP/2 的持久连接允许通过一个开放通道发出多个请求。这减少了往返开销,提高了资源利用率,并避免了 HTTP 队头阻塞。然而,HTTP/2 仍然受到 TCP 队头阻塞的困扰。因此,我们现在甚至可以使用通过 QUIC 完成的 HTTP/3,而不是 TCP 和 TLS,并且它避免了 TCP 队头阻塞。

CDN 分发通过在边缘位置缓存资产来拉近数据与用户的距离。通过从附近提供内容,可以减少通过昂贵的长途线路传输的数据。

Gzip Go 语言示例:

代码语言:javascript
复制
package main

import (
 "github.com/labstack/echo/v4"
 "github.com/labstack/echo/v4/middleware"
)

func main() {
 e := echo.New()

 // Middleware
 e.Use(middleware.Logger())
 e.Use(middleware.Recover())
 e.Use(middleware.Gzip()) // Add gzip compression middleware

 // Routes
 e.GET("/", helloHandler)

 // Start server
 e.Logger.Fatal(e.Start(":8080"))
}

func helloHandler(c echo.Context) error {
 return c.String(200, "Hello, Echo!")
}

总体而言,扩展需要整体视图,不仅包括计算和存储,还包括网络连接。通过最小化跃点、压缩、缓存等来优化带宽使用对于构建高吞吐量和低延迟的大型系统非常有价值。

渐进增强

渐进增强是一种有助于提高 Web 应用程序可扩展性的策略。我们的想法是首先构建核心功能,然后逐步增强功能浏览器和设备的体验。

例如,您可以开发基本的 HTML/CSS 网站以确保在任何浏览器上均可访问。然后,您可以添加高级 CSS 和 JavaScript,以逐步改进具有 JS 支持的现代浏览器的交互。

首先提供基本的 HTML 可以提供快速的“交互时间”,并且适用于所有平台。随后加载增强功能以优化体验而不会阻塞。这种平衡的方法在利用能力的同时扩大了影响范围。例如,Qwik将这一概念融入到框架的基础中。

分阶段逐步增强也有助于可扩展性。简单的页面需要更少的资源并且可以更好地扩展。您可以在需要时添加更高级的功能,而不是提前对每个可能的用例进行过度设计。

总体而言,渐进式增强允许 Web 应用程序根据设备功能和用户需求,从基本功能有效扩展到高级功能。

优雅的降级

与渐进增强相反,优雅降级涉及从高级体验开始,并在检测到约束时缩减功能。这使得应用程序在面临资源限制时可以流畅地缩小规模。

例如,图形丰富的应用程序可能会检测到低功耗移动设备,并适应将高级视觉效果降级为更基本的演示。或者,后端系统可能会在峰值负载期间限制非必要的操作以维持核心功能。

即使在次优条件下,优雅降级也能保留关键的用户工作流程。由于带宽、设备功能或流量峰值等限制而导致的错误被最小化。该体验仍然有效,而不是灾难性地失败。

功能降级是一个有价值的工具,应该在产品功能的初始开发过程中纳入并规划。自动或手动停用功能的能力对于在各种情况下保持系统正常运行至关重要,例如系统过载、迁移或意外的性能问题。

当系统遇到高负载或因流量过多而不堪重负时,动态停用非关键功能可以减轻压力并防止完全服务故障。这种对功能降级的巧妙利用可确保核心功能保持运行并防止整个应用程序出现级联故障。

在数据库迁移或更新期间,功能降级可以帮助维护系统稳定性。通过暂时禁用某些功能,可以降低迁移过程的复杂性,从而最大限度地降低数据不一致或损坏的风险。迁移完成并经过验证后,可以无缝地重新激活这些功能。

此外,在特定功能中发现严重错误或安全漏洞的情况下,功能降级可能是一种有用的机制。及时关闭受影响的功能可以防止在解决问题时造成任何进一步的损坏,从而确保整个系统的完整性。

总体而言,将功能降级纳入产品设计和开发策略的一部分,使系统能够优雅地处理具有挑战性的情况,增强弹性,并在不利条件下保持不间断的用户体验。

构建设备检测、性能监控和限制等优雅的降级机制可以提高应用程序在扩展或缩减时的弹性。可以根据实时约束和优先级将资源动态调整到最佳水平。

代码可扩展性

可扩展性最佳实践主要关注基础设施和架构。但编写良好且优化的代码也是扩展的关键。即使在强大的基础设施上,次优代码也会阻碍性能和资源利用率。

紧密的循环、低效的算法和不良的数据结构访问可能会使服务器陷入困境。像微服务这样的架构增加了并行性,但也会加剧这些低效率。

代码分析器有助于识别热点和瓶颈。重构代码以更好地扩展,优化 CPU、内存和 I/O 资源使用。跨线程分布处理还可以提高多核服务器的利用率。

不可扩展代码示例(每个请求的线程):

即使在强大的基础设施上,低效的代码也会阻碍可扩展性。例如,为每个请求分配一个线程并不能很好地扩展 - 服务器将在高负载下耗尽线程。

异步/事件驱动编程和非阻塞 I/O 等更好的方法提供了更高的可扩展性。Node.js 使用此模型在单个线程上有效地处理许多并发请求。

虚拟线程或 goroutine 也比线程池更具可扩展性。虚拟线程是轻量级的,由运行时管理。例如 Go 中的 goroutine 和 Python 中的绿色线程。

与有限的操作系统线程相比,数十万个 goroutine 可以同时运行。运行时自动将 goroutine 复用到真实线程上。这消除了线程生命周期开销和线程池的资源限制。

尽管有基础设施,但精心构造的代码可以最大限度地提高异步处理、虚拟线程和最小化开销,这对于大规模应用程序至关重要。

每个任务的虚拟线程的 Java 示例:

代码语言:javascript
复制
import java.io.*;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class VirtualThreadServer {

    public static void main(String[] args) {
        final int portNumber = 8080;
        try {
            ServerSocket serverSocket = new ServerSocket(portNumber);
            System.out.println("Server started on port " + portNumber);

            ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

            while (true) {
                // Wait for a client connection
                Socket clientSocket = serverSocket.accept();
                System.out.println("Client connected: " + clientSocket.getInetAddress());

                // Submit the request handling task to the virtual thread executor
                executor.submit(() -> handleRequest(clientSocket));
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    static void handleRequest(Socket clientSocket) {
        try (
            BufferedReader in = new BufferedReader(new InputStreamReader(clientSocket.getInputStream()));
            PrintWriter out = new PrintWriter(clientSocket.getOutputStream(), true)
        ) {
            // Read the request from the client
            String request = in.readLine();

            // Process the request (you can add your custom logic here)
            String response = "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\nHello, this is a virtual thread server!";

            // Send the response back to the client
            out.println(response);

            // Close the connection
            clientSocket.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

注意如果你想运行上面的代码,请确保你安装了 java 20,将代码复制到VirtualThreadServer.java并使用 运行它java --source 20 --enable-preview VirtualThreadServer.java

正如基础设施需要扩展一样,代码也需要扩展。高效的代码可确保服务器在负载下以最佳方式运行。无论周围的架构如何,过载的服务器都会削弱可扩展性。优化代码并扩展基础架构以获得最佳结果。

结论

扩展软件系统以应对增长对于长期成功至关重要。我们探索了水平扩展、负载平衡、数据库分片、异步处理、缓存和优化代码等关键技术,以设计高度可扩展的架构。

虽然扩展需要持续的努力,但尽早投资于可扩展性将防止未来出现痛苦的瓶颈。提前考虑您的容量需求,而不是事后考虑。构建冗余、监控使用情况、增量扩展以及跨多个节点分配负载。

凭借强大的自适应设计,即使使用量激增 10 倍或 100 倍,您的软件也可以继续让客户满意。规模规划将使您的应用程序与因增长而崩溃的众多应用程序区分开来。尽管需求不断增加,但只要您的平台保持同样快速、可用和可靠,您的用户就会留下来。

本文参与 腾讯云自媒体分享计划,分享自微信公众号。
原始发表:2023-08-05,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 云原生技术爱好者社区 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 水平与垂直缩放
  • 负载均衡
  • 数据库扩展
    • 数据库选择
      • 垂直缩放
        • 复制
          • 分片
          • 异步处理
          • 无状态系统
          • 缓存
          • 网络带宽优化
          • 渐进增强
          • 优雅的降级
          • 代码可扩展性
            • 不可扩展代码示例(每个请求的线程):
            • 结论
            相关产品与服务
            数据库
            云数据库为企业提供了完善的关系型数据库、非关系型数据库、分析型数据库和数据库生态工具。您可以通过产品选择和组合搭建,轻松实现高可靠、高可用性、高性能等数据库需求。云数据库服务也可大幅减少您的运维工作量,更专注于业务发展,让企业一站式享受数据上云及分布式架构的技术红利!
            领券
            问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档