场景题思路
在构建一个系统时,除了实现业务逻辑(功能性需求)外,我们还需要关注系统如何更好地运行。非功能性设计正是解决这些“如何更好地运行”的问题。它们描述了系统在执行其功能时必须具备的特性和约束,是衡量系统质量的关键指标。常见的非功能性指标包括:
- 高性能(High Performance):系统在给定时间内处理请求的速度和效率。它通常通过以下指标衡量:
- 响应时间(Response Time):系统对用户请求做出响应所需的时间,是用户体验最直观的体现。
- 吞吐量(Throughput):系统在单位时间内处理的请求数量或事务数量。
- 并发用户数(Concurrent Users):系统能够同时处理的最大用户请求数量。
- 高可用性(High Availability):系统在长时间内保持可操作和可访问的能力,即系统能够持续提供服务而不会中断。通常通过正常运行时间百分比(如99.99%,表示每年停机时间少于52.6分钟)来衡量,是业务连续性的核心保障。
- 高并发(High Concurrency):系统在同一时刻处理多个用户请求的能力,是高性能的一个重要体现,尤其在用户量大的互联网应用中至关重要。
- 可扩展性(Scalability):系统在不改变架构的前提下,通过增加资源(如CPU、内存、硬盘或服务器实例)来处理更多负载(用户、数据或事务)的能力。可扩展性分为:
- 垂直扩展(Vertical Scaling/Scale Up):提升单个服务器的硬件配置。
- 水平扩展(Horizontal Scaling/Scale Out):增加服务器或服务实例的数量。
- 可维护性(Maintainability):系统易于修改、测试、部署、监控和故障排除的能力。一个高可维护性的系统能够降低长期运营成本,并提高开发效率。
- 安全性(Security):保护系统免受未经授权的访问、数据泄露、拒绝服务攻击(DDoS)和其他恶意行为的能力。它涉及认证、授权、数据加密、审计和安全漏洞管理等方面。
- 可靠性(Reliability):系统在指定条件下和指定时间内无故障运行的能力,关注系统在遇到错误时的恢复能力和稳定性。
- 可观测性(Observability):通过系统外部输出(如日志、指标、追踪)来理解系统内部状态的能力,对于快速定位问题和进行性能调优至关重要。
面试官在考察非功能性设计时,通常会从系统可能遇到的“卡点”或“待优化点”出发,引导你思考解决方案。例如,当提到缓存时,除了其读写速度快,其扛并发能力往往是更核心的价值,因为它能有效减轻后端服务的压力。数据库作为大多数系统的核心组件,也经常成为系统的性能瓶颈,因此针对数据库的非功能性优化是面试中的重点。
场景一: 大数据量
当系统处理的数据量非常庞大时,会面临存储和查询效率上的巨大挑战。
1.1 怎么存?
在不影响系统功能的前提下,我们需要尽可能节省存储空间,并防止单表数据量过大导致性能下降。
- 数据类型优化:选择最合适、占用空间最小的数据类型能够显著节省存储空间并提高I/O效率。例如,使用
TINYINT存储布尔值(0或1)而非VARCHAR;对于固定长度的字符串,使用CHAR而非VARCHAR;对于整数,选择能满足范围的最小类型(如SMALLINT或INT)。 - 数据库范式与反范式:
- 数据库范式(Normalization):通过分解表来消除数据冗余,保证数据一致性。例如,第三范式要求非主键列不依赖于其他非主键列。优点是数据冗余小、更新异常少;缺点是查询时可能需要多表联结,增加查询复杂度及性能开销。
- 反范式(Denormalization):为了提高查询性能,有意引入数据冗余,将一些相关数据存储在同一张表中,减少联结操作。优点是查询速度快;缺点是数据冗余大、更新时可能出现数据不一致。在非功能性设计中,通常需要在范式和反范式之间取得平衡,尤其是在读多写少的场景下,适度反范式可以显著提升查询性能。
- 数据归档与冷热分离:将不经常访问的历史数据或旧数据(冷数据)从主数据库中迁移到成本更低、访问速度相对较慢的存储介质(如HDFS、对象存储S3、归档数据库)中,只在主数据库中保留频繁访问的活跃数据(热数据)。这种策略可以减轻主数据库的存储和I/O压力,降低运营成本,并提高热数据的访问性能。
- 布隆过滤器(Bloom Filter):一种空间效率极高的概率型数据结构,用于判断一个元素是否“可能”存在于一个集合中。它通过多个哈希函数将元素映射到位数组中的多个位置并设置为1。优点是占用空间极小,查询速度快;缺点是存在一定的误判率(即查询结果为“可能存在”时,该元素可能实际不存在)且不支持删除操作。在需要大量去重或判断元素是否存在且允许一定误判率的场景(如缓存穿透防护、垃圾邮件过滤、大数据去重)下,布隆过滤器能显著减少存储和查询开销。
- 分库分表(Sharding):将一个大型数据库或单张表按照一定规则(如按用户ID、时间范围、哈希值)水平拆分为多个更小、更易管理的部分,并将这些部分分散存储在不同的数据库实例或表中。
- 分库:将不同业务模块的数据分散到不同的数据库实例中。
- 分表:将单张大表的数据分散到同一数据库实例的不同表中,或不同数据库实例的表中。 分库分表能有效分散存储压力和I/O负载,突破单机数据库的性能瓶颈,提高系统的存储容量和查询并发能力。
1.2 怎么查的快?
快速查询大数据量需要多方面的优化策略。
- 数据结构优化:
- 索引:数据库的索引是提高查询速度的关键,它允许数据库系统快速定位数据而无需全表扫描。理解不同索引结构的工作原理至关重要:
- 链表结构:作为基础数据结构,查找效率低(O(N)),通常不直接用于数据库索引,但在某些内部实现中可能作为辅助结构。
- 红黑树:一种自平衡二叉查找树,在内存中进行查找、插入和删除操作时,平均和最坏时间复杂度均为O(logN)。它适用于内存数据库或内存中的索引结构。
- B+树:数据库索引(如MySQL InnoDB的聚簇索引和辅助索引)最常用的数据结构。其特点是:所有数据都存储在叶子节点,且叶子节点通过指针连接形成一个有序链表,便于范围查询;非叶子节点只存储索引键值,不存储数据,减少了树的高度,从而减少磁盘I/O次数。B+树非常适合磁盘存储,因为其节点大小通常与磁盘页大小对齐,每次I/O能读取更多有效信息。
- 跳表(Skip List):一种概率性数据结构,通过多层有序链表实现,上层链表是下层链表的子集。查找、插入、删除的平均时间复杂度为O(logN),且实现相对简单,并发性能较好。常用于Redis等内存数据库(如Redis的ZSET底层实现之一)。
- 索引:数据库的索引是提高查询速度的关键,它允许数据库系统快速定位数据而无需全表扫描。理解不同索引结构的工作原理至关重要:
- 搜索算法:在数据库查询中,直接应用DFS(深度优先搜索)、BFS(广度优先搜索)等通用搜索算法的场景较少。更重要的是数据库内部的查询优化器会根据SQL语句和索引信息生成最优的执行计划。开发者主要通过优化索引设计、SQL语句和表结构来间接影响查询算法。
- 加一层缓存(Caching):将经常访问的热点数据存入高速缓存(如Redis、Memcached、Guava Cache)中,下次请求直接从缓存中获取数据,避免频繁访问后端数据库或服务。缓存显著降低了数据访问延迟,提升了系统吞吐量,是应对读密集型场景的有效手段。
- 分而治之的思想(Divide and Conquer):将一个大问题分解成多个小问题,分别解决后再合并结果。
- 分布式存储:通过增加存储实例(如分布式文件系统HDFS、Ceph;分布式数据库MongoDB、Cassandra)来分散数据和I/O负载。每个实例负责存储一部分数据,客户端可以并行访问多个实例。
- 分库分表:如上所述,将数据分散到多个数据库或表中,提升查询性能。一个查询可以并行在多个分片上执行,然后合并结果。
- 数据仓库/数据湖:构建专门用于分析和查询大规模数据的平台,如Hadoop、Spark、ClickHouse等。它们通常采用列式存储、大规模并行处理(MPP)架构等技术,将操作型数据库(OLTP)的分析压力解耦,专注于快速、复杂的查询。数据仓库强调结构化数据和OLAP查询,数据湖则可以存储各种格式的原始数据。
1.3 去重问题
处理大规模数据去重是一个经典问题,尤其在数据量远超单机内存时。
- 布隆过滤器:对于海量数据(如40亿个QQ号)的去重场景,若允许一定误判率,布隆过滤器是高效且内存占用极低的方案。它可以快速判断一个QQ号是否已存在,显著减少了需要进一步处理的数据量。
- 分而治之:
- 哈希分片:将大数据量分割成小块。例如,将40亿个QQ号按照其哈希值(或取模)分散到多台机器或多个文件中。每台机器或每个文件负责处理自己的那部分数据,并在本地进行去重。这种方法将去重任务并行化,提高了处理效率。
- 大数据量排序 -> 归并排序:对于需要完全去重且内存无法容纳所有数据的情况,可以采用以下步骤:
- 分片:将原始数据文件切分成多个小文件,每个小文件的大小可以在单机内存中处理。
- 局部排序与去重:对每个小文件独立进行排序,并在排序过程中进行初步去重(相邻相同元素只保留一个)。
- 多路归并排序与最终去重:利用多路归并排序的思想,将已排序的小文件合并。在合并过程中,每次从N个文件中取出当前最小的元素,并与上一个输出的元素进行比较,如果相同则跳过,实现最终的去重。这种方法适用于需要精确去重的超大数据集。
场景二: 高并发量
高并发是现代互联网系统必须面对的挑战,它要求系统能够同时处理大量的用户请求而不崩溃。
2.1 大流量 -> 防止打崩系统
- 限流(Rate Limiting):限制单位时间内允许通过的请求数量,以保护后端服务不被突发或恶意流量冲垮。常见的限流算法包括:
- 计数器法(Fixed Window Counter):最简单,但可能在窗口边界发生突发流量。
- 滑动窗口法(Sliding Window Counter):比计数器法更平滑,但实现略复杂。
- 漏桶算法(Leaky Bucket):请求以恒定速率处理,超出容量的请求会被丢弃或排队。特点是出水速度恒定,平滑流量。
- 令牌桶算法(Token Bucket):系统以恒定速率生成令牌放入桶中,请求到来时需要获取一个令牌才能被处理。如果桶中没有令牌则请求被拒绝或排队。特点是允许突发流量,只要桶中有足够的令牌。
- 熔断(Circuit Breaking):当某个下游服务出现故障(如响应超时、错误率过高、连续失败达到阈值)时,客户端不再继续调用该服务,而是直接返回失败或执行降级逻辑,避免故障蔓延导致整个系统雪崩效应。熔断器有三种状态:
- 关闭(Closed):正常状态,请求直接通过。
- 打开(Open):当错误率达到阈值时,熔断器打开,所有请求直接失败。
- 半开(Half-Open):经过一段时间后,熔断器进入半开状态,允许少量请求尝试调用下游服务,如果成功则回到关闭状态,如果继续失败则回到打开状态。
- 服务降级(Service Degradation):在系统负载过高、资源紧张或部分服务不可用时,关闭一些非核心功能,或使用简单替代方案,以牺牲部分用户体验为代价,保证核心功能的可用性和系统的整体稳定性。例如,电商大促时,可以关闭商品评论、推荐系统,只保留核心的购买流程。
- 做分布式,添加实例(Horizontal Scaling):通过增加服务器或服务实例数量来分散流量。结合负载均衡器(如Nginx、HAProxy、LVS)将大流量平均分配到每个实例上,这是应对高并发最直接有效的方法。当请求量增加时,只需简单地增加服务器即可扩展系统的处理能力。
2.2 并发安全
在高并发环境下,数据一致性和资源访问的安全性至关重要,以避免脏读、幻读、数据丢失等问题。
- 加一层缓存(Caching):将热点数据放到缓存(如Redis)中,可以显著减轻数据库的并发访问压力。由于缓存通常部署在内存中且访问速度快,其扛并发能力远高于磁盘I/O受限的数据库(如MySQL),因此是处理高并发读的理想选择。
- 分布式锁(Distributed Lock):在分布式系统中,为了保证共享资源的并发安全(例如,防止多个服务实例同时修改同一个库存数量),需要使用分布式锁。它确保在任何时刻只有一个客户端能够持有锁并访问共享资源。
- Redis 分布式锁:利用 Redis 的原子操作(如
SETNX key value,当key不存在时才设置,保证唯一性;或者使用Lua脚本保证多个命令的原子性)来实现分布式锁。然而,Redis分布式锁的实现需要注意以下问题:- 死锁:持有锁的服务崩溃,未及时释放锁。
- 锁过期:业务处理时间超过锁的过期时间,导致锁被提前释放,其他线程获取锁。
- 误删锁:一个线程释放了另一个线程的锁。
- 为了解决这些问题,通常需要精心设计和实现,例如:为锁设置合理的过期时间、使用请求ID来标识锁的拥有者、续期机制(看门狗),甚至考虑使用 Redlock 算法(一种多实例Redis分布式锁方案,但其安全性也存在争议)或 ZooKeeper、Etcd 等更专业的分布式协调服务,它们提供了更强的强一致性和可靠的锁服务。
- Redis 分布式锁:利用 Redis 的原子操作(如
- Redis 分片集群(Redis Cluster):为了提升 Redis 本身的存储容量和并发处理能力,可以部署 Redis 分片集群。它通过哈希槽(hash slot)将数据分散到多个 Redis 节点上,每个节点负责一部分数据。这样,不仅可以突破单机内存限制,还能将读写请求分散到多个节点,从而提升整体的存储容量和并发处理能力。Redis Cluster 还内置了高可用机制(主从复制和故障转移)。
场景三: 接口响应速度慢
接口响应慢是用户体验不佳的直接体现,需要定位并优化多种潜在原因。
3.1 数据库读写慢
数据库是系统中最常见的性能瓶颈之一。
- 索引失效:索引是提高查询速度的关键,但如果SQL语句编写不当或索引设计不合理,可能导致索引失效或效率低下,从而引发全表扫描。
- 索引区分度低:例如,“性别”字段只有“男”、“女”两个值,其区分度非常低。如果查询条件是
WHERE gender = '男',即使有索引,数据库优化器也可能认为扫描大部分数据和扫描索引再回表IO的成本相差不大,从而选择全表扫描,导致索引效果不佳。 - 没有做联合索引:当查询条件涉及多个字段时(例如
WHERE column_a = ? AND column_b = ?),只使用单列索引或索引顺序不当(联合索引应遵循“最左前缀原则”)可能导致索引失效或只使用部分索引,进而执行效率低下。 - SQL 语句写法问题:
- 在索引列上使用函数(如
WHERE YEAR(create_time) = 2023)。 - 使用
LIKE %xxx(前导模糊匹配)进行查询。 - 使用
OR条件(除非OR两边的列都有索引且都满足条件)。 - 使用
!=或NOT IN等负向查询。 - 隐式类型转换。
- 这些操作都可能导致数据库优化器无法使用索引,退化为全表扫描。
- 在索引列上使用函数(如
- 索引区分度低:例如,“性别”字段只有“男”、“女”两个值,其区分度非常低。如果查询条件是
- MySQL 的调优:
- 分页优化:当数据量非常大时,传统的
LIMIT offset, count方式,offset越大,查询效率越低,因为它会扫描offset + count条记录然后丢弃offset条。优化方案包括:- 使用覆盖索引,避免回表查询:
SELECT id FROM table WHERE condition ORDER BY id LIMIT offset, count,再根据id列表批量查询详细数据。 - 基于上一页最大ID进行查询:
SELECT * FROM table WHERE id > last_id LIMIT count,适用于严格递增ID的场景。
- 使用覆盖索引,避免回表查询:
- 连接数优化:MySQL 实例连接数过多会消耗大量资源,导致数据库响应变慢甚至崩溃。需要调整数据库的最大连接数配置(
max_connections)以及应用程序的连接池配置(如HikariCP、Druid),确保连接数合理且连接能够被高效复用。 - 分库分表:如前所述,解决单表数据量过大导致扫描慢、索引效率低的问题,将数据分散存储以提升查询性能。
- 加缓存:对于读多写少的热点数据,利用缓存层来提高查询速度,减少数据库压力。
- 慢查询日志分析:定期开启并分析数据库的慢查询日志,找出执行效率低的 SQL 语句,结合
EXPLAIN命令分析其执行计划,然后针对性地进行索引优化、SQL重写或结构调整。
- 分页优化:当数据量非常大时,传统的
3.2 调用下游接口耗时
- 外部服务延迟:如果你的服务需要调用一个耗时的第三方或内部下游服务,该调用时间会直接叠加到你的接口响应速度上。
- 优化调用方式:
- 异步调用:对于非实时性的下游服务调用,可以使用消息队列(如Kafka、RabbitMQ)进行异步处理,避免阻塞主流程。
- 批量调用:将多次单个调用合并为一次批量调用,减少网络I/O和下游服务处理的开销。
- 引入缓存:对下游服务的返回结果进行缓存。
- 服务降级与熔断:当下游服务响应慢或不可用时,启用降级策略(如返回默认值、使用备用数据),或通过熔断机制快速失败,避免长时间等待。
- 优化调用方式:
3.3 同步处理 vs. 异步处理
- 能用异步处理的地方用了同步处理:许多非核心业务逻辑可以异步化,避免阻塞主流程,从而显著提升用户接口的响应速度。
- 示例:用户下单后,发送短信/邮件通知、更新用户积分、生成订单报表、记录操作日志等操作,这些都不是用户实时感知强烈的核心流程。它们都可以通过消息队列(如Kafka, RabbitMQ)将任务投递给独立的消费者进行异步处理,主接口可以立即返回订单成功信息给用户,提供更好的用户体验。
- 网络问题:网络延迟、带宽限制、丢包、DNS解析慢等都可能导致接口响应慢。这需要从网络层面进行排查和优化,例如优化网络拓扑、使用CDN加速、选择更可靠的网络服务商、优化DNS解析配置等。
3.4 JVM 相关问题
- 频繁 GC (垃圾回收) 导致 STW (Stop-The-World):Java 应用程序如果频繁创建大量临时对象,会导致垃圾回收器频繁工作。在某些 GC 阶段,特别是 Full GC,会暂停所有应用线程(Stop-The-World),造成明显的卡顿,严重影响接口响应和系统吞吐量。
- 优化方法:
- 减少不必要的对象创建:例如,使用对象池、字符串常量池、避免在循环中创建大对象。
- 优化内存使用模式:减少大对象的分配、避免内存泄漏。
- 选择合适的 JVM 垃圾回收器及其参数:如G1 GC、CMS GC、Parallel GC,并根据应用特点调整堆大小、新生代老年代比例、GC线程数等参数。现代JVM的并发GC(如G1)目标就是尽可能减少STW时间。
- 优化方法:
- 线程暂停:除了 GC 导致的 STW,线程在等待锁、等待 I/O(如数据库查询、文件读写、网络请求)或因
Thread.sleep()等原因也可能暂停,影响并发性能和接口响应。- 优化方法:减少锁竞争、优化I/O操作(如使用NIO、异步I/O)、避免不必要的
sleep,以及合理使用线程池。
- 优化方法:减少锁竞争、优化I/O操作(如使用NIO、异步I/O)、避免不必要的
3.5 未充分利用多线程
- 没有考虑使用多线程可以并发处理的任务:某些业务场景下,多个独立的子任务之间没有依赖关系,可以并行执行。但如果采用单线程顺序执行,就会导致整体响应时间过长。
- 示例:购票系统处理不同类型的票(如成人票、儿童票、学生票)的校验和扣费逻辑,如果这些处理逻辑相互独立,且不涉及共享资源的强一致性要求,完全可以利用多线程并发处理,缩短总响应时间。
- 优化方法:识别出可并行化的任务,使用线程池(如
ExecutorService)管理和执行这些任务,充分利用多核CPU资源,缩短整体处理时间。
场景四: 高可用
高可用性是系统稳定运行的基石,意味着系统在面对故障时能够持续提供服务,将停机时间降至最低。
4.1 冗余与备份
- 数据备份:定期对关键数据进行备份,并确保备份数据可恢复。备份策略应包括:
- 全量备份(Full Backup):备份所有数据。
- 增量备份(Incremental Backup):只备份自上次任何类型备份以来发生变化的数据。
- 差异备份(Differential Backup):备份自上次全量备份以来发生变化的数据。
- 日志备份(Log Backup):用于实现时间点恢复(Point-in-Time Recovery)。 同时,备份数据应异地存储,并定期进行恢复演练,以验证备份的有效性。
- 服务冗余:部署多个服务实例,当某个实例故障时,负载均衡器可以将流量自动切换到其他健康实例,从而避免单点故障。这通常通过以下方式实现:
- 主备(Active-Passive):一个主实例提供服务,一个或多个备用实例处于待命状态,主实例故障时备用实例接管。
- 主从(Master-Slave)/主从复制:主节点负责写入,从节点负责读取并同步主节点数据,主节点故障时从节点可以提升为主节点。
- 多活(Active-Active):所有实例都同时提供服务,流量分散到所有健康实例,具有更好的资源利用率和故障容忍能力。
4.2 监控与预警
- 监控:建立完善的系统监控体系,实时收集和分析系统各项指标,以便及时发现潜在问题。监控内容应涵盖:
- 基础设施监控:服务器CPU、内存、磁盘I/O、网络带宽等资源利用率。
- 应用程序性能监控(APM):请求响应时间、吞吐量、错误率、GC活动、线程池状态等。
- 服务调用链追踪:记录请求在不同服务间的调用路径和耗时,用于性能瓶颈定位。
- 业务指标监控:用户注册数、订单量、支付成功率等,反映业务健康状况。
- 日志收集与分析:集中化收集和分析应用程序日志,用于故障排查和安全审计。
- 告警:设定合理的告警阈值,当系统出现异常(如错误率超过阈值、响应时间过长、资源利用率过高)或性能下降时,能够及时通过短信、邮件、电话、IM(即时通讯)等多种方式通知相关人员,保证问题尽快被感知和处理。告警的准确性和及时性是高可用的重要保障。
4.3 容灾与异地多活
- 容灾(Disaster Recovery):设计系统在发生重大灾难(如机房断电、火灾、地震、区域网络中断)时,能够快速恢复服务的能力。容灾的核心指标是:
- 恢复点目标(RPO - Recovery Point Objective):可接受的数据丢失量,即系统恢复后,数据可能回溯到的时间点。
- 恢复时间目标(RTO - Recovery Time Objective): 服务从中断到恢复正常运行所需的最大时间。 容灾方案通常包括:数据备份与恢复、多数据中心部署(同城双活、两地三中心)、灾备演练等。
- 异地多活:在不同地理位置的数据中心部署相同服务,所有数据中心都对外提供服务,处理各自区域的流量。当一个区域发生故障时,流量可以无缝或快速切换到其他健康的区域,实现最高级别的可用性。异地多活的挑战在于数据在不同数据中心之间的一致性维护(如最终一致性、强一致性选择)和流量的智能路由。
4.4 Redis 集群高可用
Redis 作为关键的缓存和数据存储服务,其高可用性方案非常重要。
- 主从复制(Master-Slave Replication):一个主节点负责写入和读取,多个从节点负责读取,从节点异步复制主节点的数据。
- 优点:读写分离可以提升读性能和吞吐量;当主节点故障时,可以手动或自动将一个从节点提升为新主节点,实现数据备份和部分高可用。
- 缺点:主节点故障时需要人工干预或借助外部工具(如Sentinel)进行故障转移;从节点复制是异步的,可能存在少量数据丢失风险。
- 哨兵模式(Redis Sentinel):一个或多个 Sentinel 进程组成的集群,用于监控 Redis 主从节点。
- 监控服务:Sentinel 会持续检查主节点和从节点是否正常运行。
- 自动故障转移:当主节点发生故障时,Sentinel 之间会进行协商,投票选举一个新的从节点作为主节点,并通知所有客户端进行连接切换,从而实现 Redis 的自动高可用。它解决了主从复制模式下主节点故障需要人工干预的问题。
- Redis Cluster:Redis 官方提供的分布式解决方案,它通过分片(Sharding)和主从复制结合的方式,提供数据的自动分片、高可用和可扩展性。
- 数据分片:Redis Cluster 将所有数据划分为16384个哈希槽(hash slot),每个节点负责其中一部分槽。数据根据键的哈希值映射到对应的槽,从而分布到不同的节点。
- 高可用:每个主节点可以有多个从节点。当主节点发生故障时,集群中的其他节点会投票选举一个健康的从节点接替成为新的主节点。
- 可扩展性:可以方便地添加或移除节点,实现数据的自动迁移和负载均衡。