下午面DX,听到过至今最为离谱的面试官的建议,建议我去实习下再面。

记录下,头一次到技术三面。

抓着我场景题薅,我寻思洗屁屁选手也不玩这个嘛,不是轮子造的6就完事了嘛,QaQ。还是我的杠不够大。怪我之前没准备Qwq嘛=.=

总结:心态开花,深深的无力感,想主动终止面试的感觉~

让你设计一个订单号生成服务,该怎么做?

https://blog.csdn.net/Z70769691/article/details/136486086

当设计订单号生成服务时,我们需要考虑唯一性、数据量、可读性、基因法、可扩展性、高性能和高可用性等多个方面。根据这些考虑,一个简单的订单号生成服务设计方案可以采取以下措施:

  • 使用Snowflake算法或第三方分布式ID生成器,确保生成的订单号在分布式系统中唯一且有序。
  • 将订单号由多个参数组成,如时间戳、商户编号、订单类型等,以满足不同业务需求。
  • 将生成的订单号存储在缓存系统中,如Redis,以避免频繁生成订单号。
  • 设计可扩展的配置系统,允许根据业务需求自定义订单号的生成规则。
  • 使用分布式锁等机制,避免多个请求同时生成相同的订单号。
  • 设计高性能的生成器,支持高并发的生成订单号请求,例如采用多线程、异步方式提高系统的性能和响应速度。
  • 对于生成失败的订单号请求,采用重试机制,避免因网络或其他因素导致的生成失败。

接口性能优化方案

接口性能优化的 15 个技巧

解决接口幂等性问题

该业务场景涉及防止重复提交的情况,比如电商系统中用户重复点击下单按钮,或者支付请求被多次发送到服务器。为了确保每次请求只处理一次,避免重复订单或重复支付,需要解决 接口幂等性 问题。

在这个场景中,使用了 一锁、二判、三更新 的方法来确保接口幂等性:

1. 一锁:加分布式锁,防止并发请求

  • 目的:防止同一请求在短时间内被并发处理。通过给每个请求添加一个唯一标识符(如 request.identifier),使用分布式锁来控制同一时间只能有一个请求在处理。
  • 实现:使用注解 @DistributeLock,根据 request.

2.二判判断是否已经处理过(幂等判断)

  • 目的:在加锁后,判断该请求是否已经处理过,避免重复操作。如果已经处理过,直接返回处理结果,避免重复工作。
  • 实现:查询数据库或缓存,判断订单是否已经存在。如果订单存在,则表示该请求已经处理过,直接返回“已处理”状态。

3. 三更新:执行更新操作,确保数据持久化

  • 目的:如果请求没有被处理过,则执行实际的更新操作(例如创建订单、支付等),确保业务逻辑正确执行,并将结果持久化到数据库中。
  • 实现:调用业务逻辑更新数据库,完成订单生成或支付处理。

订单超时取消的11种方式

https://baijiahao.baidu.com/s?id=1779294431144339134&wfr=spider&for=pc

定时任务

关键点

  • 使用 XXL-JOB 实现定时调度。
  • 通过数据库查询找到到期未关闭的订单。
  • 逐个处理这些订单并更新其状态为 “closed”。

这个方案的优点也是比较简单,实现起来很容易,基于Timer、ScheduledThreadPoolExecutor、或者像xxl-job这类调度框架都能实现,但是有以下几个问题:

1、时间不精准。 一般定时任务基于固定的频率、按照时间定时执行的,那么就可能会发生很多订单已经到了超时时间,但是定时任务的调度时间还没到,那么就会导致这些订单的实际关闭时间要比应该关闭的时间晚一些。

2、无法处理大订单量。 定时任务的方式是会把本来比较分散的关闭时间集中到任务调度的那一段时间,如果订单量比较大的话,那么就可能导致任务执行时间很长,整个任务的时间越长,订单被扫描到时间可能就很晚,那么就会导致关闭时间更晚。

3、对数据库造成压力。 定时任务集中扫表,这会使得数据库IO在短时间内被大量占用和消耗,如果没有做好隔离,并且业务量比较大的话,就可能会影响到线上的正常业务。

4、分库分表问题。 订单系统,一旦订单量大就可能会考虑分库分表,在分库分表中进行全表扫描,这是一个极不推荐的方案。

所以,定时任务的方案,适合于对时间精确度要求不高、并且业务量不是很大的场景中。如果对时间精度要求比较高,并且业务量很大的话,这种方案不适用。

Redisson方案

使用 RDelayedQueue 创建延迟任务

RDelayedQueueRedisson 提供的延迟队列,它允许将元素以指定的延迟时间放入队列中,直到时间到期才会被移入目标队列。具体来说,Redisson 会将数据存储到 Rediszset 中,并且启动一个延时任务,当时间到达时,数据会被移到一个普通队列中,等待消费者处理。

  • 步骤:
    1. 获取或创建一个普通的 Redis 队列(如 RQueue),这个队列用于存放延迟到期后的元素。
    2. 根据普通队列创建一个 RDelayedQueue,并通过 offer 方法将元素添加到延迟队列中,同时指定延迟时间(如 30 分钟)。

这样,Redisson 会自动在 Redis 后台处理延迟逻辑,确保元素在指定时间后进入目标队列。

消费队列中的到期元素

当延迟时间到期时,RDelayedQueue 会将元素移动到普通队列中,等待被消费。可以通过轮询或后台线程的方式,不断从普通队列中取出到期的元素进行处理。比如,订单到期未支付时,可以将其状态更新为“已关闭”。

如何设计一个购物车功能?

https://blog.csdn.net/Z70769691/article/details/136481957

如果你的业务量突然提升100倍QPS你会怎么做?

应对 QPS 突然提升 100 倍的场景,需要从多个维度进行优化和扩展,确保系统能够支撑大流量的冲击。核心理念是 分而治之,通过负载均衡、缓存、数据库优化、异步处理等手段,将流量和计算分散到多个组件中,避免单点压力。同时,通过限流、熔断等保护机制,防止系统崩溃。

不用redis分布式锁, 如何防止用户重复点击?

前端防重:按钮禁用

  • 思路:在前端用户界面上,通过禁用按钮的方式来防止用户在短时间内多次点击。
  • 实现
    • 当用户点击按钮后,立即将按钮置为不可点击状态,并显示加载状态。
    • 在请求完成或出现错误时,再恢复按钮的可点击状态。
  • 优点:简单直接,解决了绝大多数用户的误操作问题,减少了后台的压力。
  • 缺点:仅在前端层面限制,无法完全防止恶意用户通过脚本或其他工具重复发送请求。

后端防重:接口幂等性

  • 思路:确保接口的幂等性,保证同一个请求无论被调用多少次,结果都是相同的。
  • 实现
    • 唯一请求 ID:在每次请求时生成一个唯一 ID(如订单号、请求ID等),后端在处理请求时,检查这个 ID 是否已经处理过。如果已经处理,则忽略后续的请求。
    • 数据库唯一约束:对于关键性操作(如创建订单、支付等),在数据库层面添加唯一约束,防止多次重复执行。
  • 优点:从根本上解决了重复请求的问题,确保数据一致性和幂等性。
  • 缺点:需要在业务逻辑和数据库设计上做额外处理。

布隆过滤器。

库存扣减如何避免超卖和少卖?

  1. 数据库层面的解决方案

1.1 悲观锁

  • 思路:在扣减库存时,直接锁住库存记录,防止其他事务并发修改。
  • 实现
    • 使用数据库的 SELECT ... FOR UPDATE 语句,在查询库存时锁住相应的行,直到事务提交或回滚,其他事务在此期间无法操作该记录。
  • 优点:保证在高并发情况下的安全性,避免超卖。
  • 缺点:性能较差,尤其在高并发场景中,较多的锁竞争可能导致系统吞吐量下降。

1.2 乐观锁

  • 思路:通过版本号或时间戳机制,在更新库存时检查数据是否被其他事务修改过,只有版本号一致时才进行扣减。
  • 实现:
    • 在库存表中增加一个 version 字段,每次更新库存时,检查 version 是否与查询时的一致,若一致则更新并将 version + 1。否则表示数据已被修改,更新失败,需重试操作。
  • 优点:不会锁住记录,性能较高,适用于读多写少的场景。
  • 缺点:在高并发场景下可能会有较多的重试,需根据业务场景设计重试策略。

1.3 数据库唯一约束

  • 思路:通过数据库的唯一约束,确保库存不会被扣减到负数。

  • 实现

    • 直接在 SQL 语句中进行扣减库存的操作,条件是库存大于 0。例如:

      1
      UPDATE inventory SET stock = stock - 1 WHERE product_id = ? AND stock > 0;
    • 利用 SQL 的原子性保证库存不会被扣减到负数。

  • 优点:简单有效,利用数据库的原子操作,避免超卖。

  • 缺点:数据库的性能压力较大,适合库存量不大的场景。

  1. 分布式事务控制

2.1 基于消息队列的最终一致性

  • 思路:通过消息队列确保订单和库存的扣减逻辑解耦,保证在分布式场景下的一致性。
  • 实现:
    • 用户下单时,订单服务生成订单并发送扣减库存的消息到消息队列。
    • 库存服务消费消息,扣减库存。
    • 若扣减库存失败,重新回滚或重试该操作。
  • 优点:系统解耦,适合高并发、大规模分布式系统,能处理复杂的场景。
  • 缺点:需要处理消息的幂等性和消息丢失等问题,增加了系统复杂度。

2.2 TCC(Try-Confirm-Cancel)模式

  • 思路:在处理订单和库存时使用三阶段事务,确保事务的一致性。
  • 实现:
    • Try 阶段:预扣库存,不进行真正的扣减,只是锁定库存。
    • Confirm 阶段:订单确认后,正式扣减库存。
    • Cancel 阶段:若订单未确认,释放预扣的库存。
  • 优点:保证分布式事务的一致性,避免超卖或少卖。
  • 缺点:实现复杂,适合高并发、大规模的分布式系统。

3.缓存与数据库一致性

3.1 缓存预扣减

  • 思路:通过缓存快速响应库存请求,减少数据库压力,但需处理缓存与数据库不一致的问题。
  • 实现
    • 在缓存中存储库存数据,用户请求扣减库存时,先扣减缓存中的库存,再异步更新数据库中的库存。
    • 通过消息队列或定时任务来同步数据库和缓存中的库存。
  • 优点:性能较高,适合高并发场景,减轻数据库压力。
  • 缺点:缓存和数据库的一致性比较难保证,可能会出现超卖或少卖的情况,需处理缓存的失效和同步问题。

3.2 缓存与数据库双写一致性

  • 思路:保证缓存和数据库的库存数据同步更新,避免出现不一致的情况。
  • 实现:
    1. 先删除缓存,再更新数据库:在更新数据库的同时删除缓存中的数据,确保下次访问时从数据库中读取最新数据。
    2. 先更新数据库,再更新缓存:在更新数据库后,立即更新缓存中的数据,确保一致性。
  • 优点:能够在一定程度上保证数据一致性,减少缓存失效问题。
  • 缺点:如果并发控制不当,依然可能出现超卖问题。

如何用Redis实现朋友圈点赞功能?

需求分析

  • 点赞操作:用户能够对朋友圈的某条动态进行点赞。
  • 取消点赞:用户可以取消之前的点赞。
  • 查询点赞数:可以快速查询某条动态的点赞人数。
  • 查询用户是否点赞:可以查询某个用户是否对某条动态点赞。

实现选型

Redis 提供了适合实现点赞功能的数据结构,常见的选择有 SetHash

  • Set:集合类型,自动去重,可以存储点赞的用户 ID,适合存储哪些用户对某条动态进行了点赞。
  • Hash:可以用于存储每条动态的点赞数,适合快速查询总的点赞数。

实现方案

1 点赞操作

使用 Redis 的 Set 数据结构来存储点赞用户。以 postId 作为 key,userId 作为集合中的元素。Redis 的 Set 能够保证同一个用户对同一条动态只能点赞一次。

  • 操作SADD 命令添加用户 ID 到某条动态对应的点赞集合中。

示例:

1
SADD post:123:likes user:456
  • post:123:likes:表示动态 ID 为 123 的点赞集合。
  • user:456:代表用户 456。

2 取消点赞

取消点赞时,可以使用 Redis 的 SREM 命令将用户 ID 从点赞集合中移除。

示例:

1
SREM post:123:likes user:456
  • SREM 会将 user:456post:123:likes 集合中移除,表示用户取消了对该动态的点赞。

3 查询点赞数

使用 Redis 的 SCARD 命令来获取某条动态的点赞人数。

示例:

1
SCARD post:123:likes
  • SCARD 返回 post:123:likes 集合中元素的数量,即点赞的用户数。

4 查询用户是否点赞

可以使用 Redis 的 SISMEMBER 命令检查用户是否对某条动态点赞。

示例:

1
SISMEMBER post:123:likes user:456
  • SISMEMBER 返回 1 表示用户已经点赞,返回 0 则表示用户没有点赞。

消息队列使用拉模式好还是推模式好?

拉模式(Pull)

消费者主动从消息队列中拉取消息。

推模式(Push)

消息队列主动向消费者推送消息。

总结:

  • 拉模式 更适合 消费者处理能力不均衡、负载波动大 的场景,具有 流量控制 的优势,但可能存在 时延 问题。
  • 推模式 更适合 实时性要求高、负载较为均衡 的场景,具有 低时延 的优点,但可能导致 消费者过载

折中方案:推拉结合

有些消息队列系统(如 Kafka)允许 推拉结合 的模式:

  • 长轮询(Long Polling):消费者发起拉取请求,若队列没有消息,则保持连接等待,直到有消息时才返回。这种方式结合了推模式的低时延和拉模式的流量控制优势。
  • 批量推送:队列可以预先批量推送若干条消息,消费者按需处理,减少频繁的推送压力。

一个订单,在11:00超时关闭,但在11:00也支付成功了,怎么办?

当订单在 11:00 超时关闭的同时,用户却在 11:00 成功支付了,出现这种并发问题的原因是订单超时关闭和支付成功几乎同时发生,且没有进行有效的并发控制。为了避免这种冲突,系统需要确保支付和订单状态变更的操作是原子性的,并且能够正确处理这种竞态条件。

要避免订单同时超时关闭和支付成功的并发冲突,可以采用以下关键措施:

  1. 数据库事务和乐观锁:确保订单状态的修改是原子操作,支付和超时关闭不能同时修改订单状态。
  2. 分布式锁:使用 Redis 分布式锁,确保支付和关闭操作在同一时刻只能成功一个。
  3. 优先处理支付回调:系统优先处理支付成功的回调,避免支付成功的订单被错误关闭。
  4. 消息队列的最终一致性:通过消息队列确保支付和关闭操作的顺序性,保证系统的最终一致性。
  5. 补偿机制:在支付成功但订单已关闭的情况下,系统应具备自动退款或订单恢复的能力。

线上接口如果响应很慢如何去排查定位问题呢?

监控与日志分析

监控工具

  • 思路:使用APM(应用性能管理)工具或监控平台(如 Prometheus、Grafana、New Relic、Datadog 等)来监控接口的性能指标,快速识别问题的来源。
    • 监控指标:CPU、内存、磁盘 I/O、网络带宽、数据库查询时间、第三方服务响应时间等。
    • 慢请求情况:重点查看慢请求次数、平均响应时间、错误率等关键性能指标。
  • 优点:可以全局观察系统的运行状态,快速发现性能瓶颈或异常行为。

日志分析

  • 思路:通过分析日志(如 Nginx 访问日志、应用日志、数据库日志等),查找异常慢请求的具体时间、请求参数、异常堆栈等信息。
    • 应用日志:查看接口的调用链路日志,分析每个步骤的耗时情况(可以使用分布式链路追踪工具,如 Zipkin、Jaeger)。
    • 异常日志:查找是否有超时、错误或异常的情况。
  • 优点:通过日志可以明确看到请求的执行过程,便于定位慢点。

安装arthas,arthas就是一个命令工具,他通过字节码插桩的方式来统计接口耗时的,很多公司的生产环境也都是可以用的,包括我司的生产环境都是可以用的,目前没发现什么副作用。

40亿个QQ号码如何去重?

在处理超大规模数据时,Bitmap(位图) 是一种非常高效的去重方法,特别适用于去重和判重问题。对于你提到的40亿个QQ号码去重问题,Bitmap 是一种合适的解决方案,因为 QQ 号码是整数,且我们知道其取值范围(通常是 10 位以内的正整数)。

Bitmap 去重的基本思路

  1. Bitmap 的定义
    • Bitmap 是一种利用位(bit)来表示数据状态的结构。一个 bit 只能表示两种状态(0 或 1),所以我们可以用一个 bit 来表示某个特定的 QQ 号码是否存在。
    • 对于一个给定的 QQ 号码,我们可以将其映射到一个特定的 bit 位上,bit 为 1 表示该 QQ 已经存在,0 表示不存在。
  2. 内存消耗估算
    • 40亿个 QQ 号码的范围大概是 040亿 之间(假设 QQ 号码范围为 32 位无符号整数)。
    • 因为一个 bit 位可以表示一个号码的状态,40 亿个号码需要 40亿 / 8 字节,即大约 500 MB 的内存。相对于存储 40 亿个整数(需要约 16 GB 内存),这种方式显著减少了内存使用量。

Bitmap 去重步骤

  1. 初始化 Bitmap
    • 创建一个足够大的 Bitmap 数组,假设 QQ 号码不超过 40 亿,我们需要一个能表示 40 亿个 bit 的位数组。
  2. 将 QQ 号码映射到 Bitmap
    • 针对每个 QQ 号码,将其作为一个整数,直接将其值作为位图中的下标,将对应 bit 设为 1。
    • 如果该 bit 位已为 1,说明该 QQ 号码已经存在;如果为 0,则表示该号码是第一次出现。
  3. 去重操作
    • 遍历所有的 QQ 号码,依次将每个号码映射到 Bitmap 中对应的 bit 位上进行检查和标记。
    • 每处理一个号码后,将其 bit 设为 1,以表示该 QQ 号码已经处理过。
  4. 输出去重结果
    • 遍历完所有号码后,通过检查 Bitmap 中为 1 的 bit 位,可以得到所有不重复的 QQ 号码。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import array

# 假设最多有 40 亿个 QQ 号码
MAX_QQ_NUMBER = 4000000000

# 创建位图,数组大小为 40 亿位 / 8 = 500MB,使用 array 模块创建位数组
bitmap = array.array('B', [0] * (MAX_QQ_NUMBER // 8))

def set_bit(bitmap, number):
"""将 QQ 号码映射到位图并设置为 1"""
byte_index = number // 8 # 找到对应的字节
bit_index = number % 8 # 找到对应的 bit 位
bitmap[byte_index] |= (1 << bit_index) # 通过位运算将该 bit 位置为1

def check_bit(bitmap, number):
"""检查 QQ 号码是否已经存在"""
byte_index = number // 8 # 找到对应的字节
bit_index = number % 8 # 找到对应的 bit 位
return (bitmap[byte_index] & (1 << bit_index)) != 0

# 模拟 QQ 号码列表
qq_numbers = [1234567890, 987654321, 1234567890, 2233445566]

# 去重过程
for qq in qq_numbers:
if not check_bit(bitmap, qq):
print(f"QQ {qq} 是新的,处理它")
set_bit(bitmap, qq)
else:
print(f"QQ {qq} 已经存在,跳过它")

解释

  1. Bitmap 数组:我们使用 array.array 来创建一个存储字节的数组,因为每个字节有 8 个 bit,可以表示 8 个 QQ 号码的状态。
  2. set_bit 函数:将 QQ 号码映射到位图中,通过位运算将对应的 bit 设为 1。
  3. check_bit 函数:检查某个 QQ 号码是否已经存在,如果对应的 bit 为 1,说明该号码已经处理过。

从B+树的角度分析为什么单表2000万要考虑分表?

在数据库设计中,当单表的数据量达到 2000万 级别(甚至更大)时,查询性能和维护成本可能会变得非常高。这时,分表(Sharding) 成为一种常用的优化手段。为了深入理解为什么需要分表,我们可以从 B+ 树 的角度来分析这个问题。

B+ 树在数据库索引中的作用

B+ 树是大多数关系型数据库(如 MySQL 的 InnoDB 引擎)中常用的 索引结构。在大数据量查询时,索引的性能直接决定了查询的效率。而 B+ 树的查询效率与树的高度密切相关。

B+ 树的高度与查询效率

  • 查询复杂度:B+ 树的查询复杂度是 O(log_d n),其中 d 是 B+ 树的阶数,n 是数据行数(即索引中数据项数量)。
  • B+ 树的高度:当数据量增大时,B+ 树的高度会增加。每增加一层,查询需要访问的节点数增多,磁盘 I/O 次数也随之增加,从而导致查询性能下降。

为什么单表 2000 万行需要分表?

  1. B+ 树高度增加,查询效率下降

B+ 树的高度直接影响查询效率。假设一个 B+ 树节点可以存储 1000 个指针(即阶数 d=1000),那么:

  • 1000 行数据,B+ 树高度为 1(即根节点可以直接指向叶子节点)。
  • 100万行数据,B+ 树高度为 2
  • 2000万行数据,B+ 树高度可能达到 4

随着数据量的增加,B+ 树的高度也逐渐增加。每增加一层,查询时的磁盘 I/O 次数增加,查询速度变慢。在 2000 万行的数据规模下,查询可能需要 4 次或更多的磁盘 I/O,而每次 I/O 都会增加查询的延迟。

  1. 磁盘 I/O 成本

B+ 树的性能瓶颈大多源于磁盘 I/O,因为即使在 SSD 上,磁盘 I/O 仍然是相对耗时的操作。随着数据量的增大,索引页和数据页可能无法完全缓存在内存中(尤其是在数据量远超内存大小的情况下)。而 分表 可以减少单个表中数据的规模,降低 B+ 树的高度,并减少磁盘 I/O 次数。

  1. 表扫描的代价

当索引无法覆盖查询条件时,数据库可能会进行 表扫描。在 2000 万行的数据表中,表扫描的代价是非常大的,特别是在复杂查询或没有合适索引时,查询的性能会急剧下降。分表后,每个表的数据量减少,表扫描的代价也相应降低。

  1. 数据修改的开销

B+ 树不仅用于查询,还用于 插入删除更新 操作。当数据量过大时,这些操作的开销也会增大。比如,当插入新数据时,可能需要调整 B+ 树的结构,甚至进行 节点分裂。在数据量较大时,插入和更新操作会频繁导致 页分裂页合并,进一步增加磁盘 I/O 的开销和系统负担。

  1. 数据库锁争用

在大表上进行并发操作时,数据库的锁争用可能会加剧。无论是行锁、页锁还是表锁,大数据量的表中,操作粒度往往较粗,导致锁的冲突增多,降低了并发性能。而分表可以将这些并发操作分散到不同的表上,减少锁争用。

  1. 数据库维护和备份成本
  • 维护成本:随着数据量的增加,数据库的维护成本(如索引重建、表优化等)会显著增加。对于一个 2000 万行的大表,索引重建或表优化操作可能耗费大量时间和资源,甚至影响系统的可用性。而分表后,维护操作可以分别在更小的数据集上执行,每次操作的时间和资源开销都会减少。
  • 备份和恢复:大表的备份和恢复操作也会耗费大量时间。分表后,备份和恢复可以在更小的数据集上进行,提升效率并减少对业务的影响。

分表的优势

通过分表(水平分表、垂直分表或分区表),可以将 2000 万行数据分散到多个表中。这样做的好处包括:

  1. 减少单表数据量:每个表的数据量减少,B+ 树的高度降低,查询效率提高。
  2. 减少磁盘 I/O:每个查询只需要访问一个小表,减少了磁盘 I/O 次数。
  3. 并发性能提升:通过将数据分散到多个表中,数据库可以更好地处理并发查询和更新,减少锁争用。
  4. 维护成本降低:每个表的数据量较小,维护、索引重建、备份等操作变得更加高效。

项目中,如果日志打印成为瓶颈,该如何优化?

在项目中,如果日志打印成为性能瓶颈,可以采取以下几种优化策略:

  1. 异步日志
  • 原理: 将日志记录操作从主线程中剥离,通过异步方式写入日志文件或发送到日志服务。
  • 优点: 减少对主线程的阻塞,提高应用的响应速度。
  • 实现: 使用异步日志库(如Log4j 2的异步Appender)或自定义异步队列来处理日志记录。
  1. 日志级别优化
  • 原理: 根据日志级别动态调整日志输出,避免不必要的日志记录。
  • 优点: 减少日志记录的频率,降低I/O开销。
  • 实现: 在生产环境中使用较低的日志级别(如INFOWARN),避免频繁的DEBUGTRACE日志。
  1. 批量写入
  • 原理: 将多个日志记录批量写入文件或发送到日志服务,减少I/O操作的次数。
  • 优点: 提高I/O效率,减少系统调用的开销。
  • 实现: 使用支持批量写入的日志库(如Logback的AsyncAppender)或自定义批量写入机制。
  1. 日志文件分离
  • 原理: 将不同模块或级别的日志写入不同的文件,避免单个文件过大或频繁写入。
  • 优点: 减少文件锁竞争,提高日志写入效率。
  • 实现: 配置日志库将不同模块或级别的日志输出到不同的文件或目录。
  1. 日志压缩与归档
  • 原理: 定期压缩和归档旧日志文件,减少磁盘空间占用和I/O负载。
  • 优点: 减少磁盘I/O,延长磁盘寿命。
  • 实现: 使用日志库的归档功能(如Log4j的RollingFileAppender)或自定义脚本进行日志压缩和归档。
  1. 减少日志内容
  • 原理: 精简日志内容,避免记录不必要的信息。
  • 优点: 减少日志文件大小,降低I/O开销。
  • 实现: 优化日志格式,只记录关键信息,避免冗长的日志输出。
  1. 使用高性能日志库
  • 原理: 选择性能更高的日志库,减少日志记录的开销。
  • 优点: 提高日志记录的效率,减少对系统资源的占用。
  • 实现: 评估并替换为性能更好的日志库(如Log4j 2、Logback等)。
  1. 日志缓存
  • 原理: 在内存中缓存日志记录,定期或达到一定数量后批量写入磁盘。
  • 优点: 减少频繁的磁盘I/O操作,提高日志记录效率。
  • 实现: 使用支持缓存的日志库(如Log4j 2的MemoryAppender)或自定义缓存机制。
  1. 日志聚合与集中管理
  • 原理: 将日志发送到集中式的日志管理系统(如ELK、Splunk等),减少本地日志文件的写入压力。
  • 优点: 集中管理日志,减少本地I/O负载,便于日志分析和监控。
  • 实现: 配置日志库将日志发送到远程日志服务,或使用日志聚合工具。
  1. 性能测试与监控
  • 原理: 定期进行性能测试,监控日志记录的性能瓶颈,及时调整优化策略。
  • 优点: 确保优化措施的有效性,持续改进日志记录的性能。
  • 实现: 使用性能测试工具(如JMeter、Gatling)和监控工具(如Prometheus、Grafana)进行性能分析和监控。

如何做平滑的数据迁移?

平滑的数据迁移(即在不中断服务或最小化中断的情况下,将数据从一个系统迁移到另一个系统)是复杂的操作,特别是在高并发或生产环境中。为了避免对业务造成影响,通常需要设计一套 蓝绿发布灰度迁移 方案,并确保在迁移过程中数据的一致性、完整性和迁移后的可用性。

下面是如何进行平滑数据迁移的核心步骤和策略:

  1. 确定迁移目标和策略

首先要清楚以下几点:

  • 迁移的来源和目标:确定是从一个数据库迁移到另一个数据库,还是从单表迁移到分库分表,或者从旧的存储系统迁移到新的存储系统。
  • 迁移的范围:明确需要迁移的数据量和数据类型,避免不必要的数据迁移。
  • 迁移策略:
    • 全量迁移:一次性迁移所有数据。
    • 增量迁移:迁移过程中持续同步新增、修改的数据,确保数据实时一致。
    • 混合迁移:先全量迁移历史数据,再增量同步实时变化的数据。
  1. 设计双写机制(双写方案)

为了实现迁移的平滑过渡,通常会设计 双写机制,即在迁移过程中,数据同时写入到旧系统和新系统中。这样可以确保在迁移过程中,无论是旧系统还是新系统的数据都保持一致。

双写的实现步骤:

  • 更新写操作:将写入操作同时发送到旧系统和新系统,确保新写入的数据在两个系统中都存在。
  • 读操作策略:
    • 读旧系统:在刚开始迁移时,读操作仍然从旧系统中读取。
    • 读新系统:当新系统中的数据逐步同步完毕,可以逐步切换读操作到新系统。
    • 读新旧双系统:如果新旧系统在迁移过程中都有部分数据,可以从新旧系统中合并读取数据,或者优先读取新系统,找不到时再查询旧系统。
  1. 全量数据迁移

在迁移开始时,通常需要先将 历史数据全量迁移 到新系统。这部分数据不会再发生变更,因此可以一次性迁移过去。

全量迁移的注意事项

  • 分批迁移:为了避免一次性迁移导致的系统压力过大,通常会将全量数据分批次迁移。比如按时间段、按主键范围、按分页等方式进行分批迁移。
  • 并行迁移:可以通过多线程或多进程并行迁移,提升数据迁移的速度。
  • 避免锁表:如果源系统是数据库,谨慎使用全表扫描,避免触发锁表操作,影响正常业务。

示例:

假设需要将 orders 表从旧数据库迁移到新数据库,可以按订单创建时间进行分批迁移:

1
2
-- 假设每次迁移 1 万条订单
SELECT * FROM orders WHERE created_at BETWEEN '2023-01-01' AND '2023-01-31' LIMIT 10000;

迁移过程中,可以对每批数据进行校验,确保数据正确迁移。

  1. 增量数据迁移(实时同步)

全量数据迁移完成后,仍然需要处理在迁移过程中新增、修改的数据。这部分数据通常通过 增量同步 来完成,确保新系统中的数据与旧系统保持一致。

增量同步的常见方法

  • 基于时间戳或版本号的增量同步:通过查询大于某个时间戳或版本号的增量数据来同步。例如,记录每条数据的 update_time,只同步 update_time 大于上次同步时间的数据。
  • 基于数据库日志(binlog)同步:对于 MySQL 等数据库系统,可以通过解析数据库的 binlog(事务日志),获取实时的增量数据变化。工具如 DebeziumCanal 等可以帮助实时捕获数据库的增量变化。

示例:

假设使用 update_time 来跟踪增量数据:

1
2
-- 查询在某个时间戳之后发生变化的订单
SELECT * FROM orders WHERE update_time > '2023-09-01 12:00:00';
  1. 数据校验

在迁移过程中,数据的一致性和完整性是至关重要的。因此,必须定期进行数据校验,确保旧系统和新系统中的数据保持一致。

校验措施

  • 行数校验:定期比较旧系统和新系统中相同表的行数,确保数量一致。
  • 数据哈希校验:可以对特定字段(如关键字段)进行哈希计算,比较旧系统和新系统中相同数据的哈希值,确保数据内容一致。
  • 抽样校验:对迁移后的数据进行抽样验证,确保数据在目标库中的准确性。
  1. 灰度切换

采用 灰度切换 的方式逐步迁移业务流量,确保迁移过程平滑过渡,避免一次性切换导致风险。

灰度切换步骤

  1. 小流量验证:开始时可以将一部分读写流量切换至新系统,验证新系统的性能和数据一致性。

  2. 逐步扩展流量:如果小流量验证通过,可以逐步增加新系统的流量,直到完全切换至新系统。

  3. 监控和报警:在灰度切换的过程中,必须实时监控新系统的性能、数据一致性和故障率,确保切换过程中的问题能及时发现和处理。

  4. 最终数据清理和完整迁移

当灰度切换完成后,旧系统的读写操作全部切换至新系统,此时可以停止旧系统的写操作。

最终清理步骤

  1. 增量数据的最后一次同步:确保所有旧系统中的增量数据都已经完全同步到新系统。

  2. 关闭旧系统的读写操作:最终确认新系统数据完全迁移后,关闭旧系统的写入口。

  3. 系统验证:再次进行数据校验,确保新系统中的数据完整一致。

  4. 旧系统下线:旧系统可以逐步下线,或者用于备份。

  5. 回滚机制

在迁移过程中,必须设计好 回滚机制,以应对迁移过程中可能出现的异常情况。确保在新系统出现问题时,能够快速恢复到旧系统,不影响业务的正常运行。

回滚机制设计

  • 切换开关:在业务系统中设计开关,能够随时切换回旧系统。
  • 双写机制的撤销:如果在新系统中出现问题,能够快速停用新系统的写入并恢复旧系统的写入。
  1. 监控和报警

在迁移过程中,必须设置完善的 监控报警机制,实时监控以下指标:

  • 数据同步延迟
  • 迁移进度
  • 新系统的性能和负载
  • 新系统和旧系统的数据一致性

一旦发现异常,及时触发报警并采取相应措施。

总结

平滑的数据迁移需要综合考虑数据一致性、性能和业务连续性。一个典型的平滑迁移方案包含以下关键步骤:

  1. 设计双写机制,保证新旧系统数据一致。
  2. 先全量迁移 历史数据,再 增量实时同步 新数据。
  3. 进行数据校验,确保迁移的数据准确无误。
  4. 灰度切换,逐步将流量切换至新系统。
  5. 设计回滚机制,确保万一迁移失败可以快速恢复原系统。

通过这些步骤,能够确保在不中断业务的情况下,顺利完成数据迁移。

什么是蓝绿发布,如何实现蓝绿发布?

蓝绿发布的介绍

蓝绿发布(Blue-Green Deployment)是一种常用的应用程序发布策略,旨在 最小化部署过程中对用户的影响,并提供一种相对安全的回滚机制。

核心思想:

  • 在蓝绿发布中,“蓝色”和“绿色”代表两个独立的生产环境。
    • 蓝色环境:当前正在对外提供服务的版本。
    • 绿色环境:新版本的应用程序部署的环境。
  • 当你准备发布一个新版本时,你将新版本部署到 绿色环境,而不会影响蓝色环境的运行。
  • 一旦绿色环境部署完成并经过测试,流量会从蓝色环境切换到绿色环境,使绿色环境开始对外提供服务。
  • 如果新版本(绿色环境)出现问题,可以快速将流量切回蓝色环境,确保服务的可用性。

蓝绿发布的实现

  1. 使用负载均衡器实现蓝绿切换
  2. 使用容器编排工具(如 Kubernetes)
  3. 使用云平台的蓝绿发布支持

数据库中间件?

通常,为了解决可用性问题,在技术架构领域通常有如下两种解决方案:

  • 读写分离
  • 分库分表

读写分离的原理:

第一,在编写业务接口时,要通过在接口上添加注解来指示运行时应该使用的数据源。例如,@SlaveofDB 表示使用 Slave 数据库,@MasterOfDB 表示使用主库。

第二,当用户发起请求时,要先经过一个拦截器获取用户请求的具体接口,然后使用反射机制获取该方法上的注解。举个例子,如果存在 @SlaveofDB,则往线程上下文环境中存储一个名为 dbType 的变量,赋值为 slave,表示走从库;如果存在 @MasterOfDB,则存储为 master,表示走主库。

第三,在 Dao 层采用 Spring 提供的路由选择机制,继承自 AbastractRoutingDataSource。应用程序启动时自动注入两个数据源 (master-slave),采用 key-value 键值对的方式存储。在真正需要获取链接时,根据上下文环境中存储的数据库类型,从内部持有的 dataSourceMap 中获取对应的数据源,从而实现数据库层面的读写分离

分库分表

MyCat和ShardingJDBC

服务端代理模式(MyCat) 特性 客户端代理模式(ShardingJDBC)
独立的中间件服务器 代理位置 应用程序客户端
独立部署,需要额外的服务器 部署方式 嵌入到应用程序中,无需额外部署
在代理服务器端解析、路由和改写 SQL 处理 在应用程序端解析、路由和改写
应用程序通过 MyCat 与数据库通信 通信模式 应用程序直接与数据库通信
增加了网络开销,可能增加响应时间 性能 低延迟,减少网络开销
可通过增加代理服务器来扩展 扩展性 应用程序需要各自配置数据库路由
集中管理,易于维护,但代理层可能成为瓶颈 维护复杂度 多节点需要统一管理配置
大规模分布式数据库和高并发场景 适用场景 轻量级分布式数据库场景

总结:如何选择 MyCat 还是 ShardingJDBC?

MyCat(服务端代理模式) 特性 ShardingJDBC(客户端代理模式)
独立的中间件服务,应用通过 MyCat 访问数据库 部署模式 嵌入式,应用程序直接与数据库交互
大规模分布式数据库集群 适用系统规模 中小型分库分表场景
支持跨库 JOINGROUP BY 等复杂查询 复杂查询支持 不支持跨库 JOIN,适合简单查询
在服务端进行 SQL 改写和路由 在客户端进行
增加一次网络跳转 性能和网络开销 无额外网络开销,性能较好
分片和路由逻辑集中在 MyCat 服务器 集中管理 每个应用节点都需要维护分片和路由配置
大规模高并发、高负载场景,复杂 SQL 操作 适用场景 轻量级分库分表,单库读写分离,简单 SQL 场景
中间件服务需要额外维护 维护复杂度 无需额外的中间件维护,集成难度较低

分布式定时调度框架?

Quartz,ElasticJob和XXL-JOB。

Quartz

【本部分摘自极客】

Quartz 集群部署如下图所示:

image-20240923210108816

各个 Quartz 调度节点之间并不通信。

在 Quartz 中,节点默认每隔 20s 会查询数据库中的 QRTZ_TRIGGERS【QRTZ_TRIGGERS 是 Quartz 调度框架在数据库中存储触发器信息的一个表】,不断地去获取并和其他节点抢占 Trigger。一旦该节点获取了 Trigger 的控制权,本次任务的调度就由调度器执行。

具体的抢占逻辑是,调度器尝试获取 TRIGGER_ACCESS 锁,成功获取锁的调度器执行本次调度,未获取锁的调度器进行锁等待,一旦获取锁的调度器释放锁,其他调度器就可以接管。具体的流程如下图所示:

image-20240923210127168

定义任务类

任务类需要实现 org.quartz.Job 接口,并实现 execute() 方法,任务的逻辑写在 execute() 方法中。

1
2
3
4
5
6
7
8
9
10
import org.quartz.Job;
import org.quartz.JobExecutionContext;
import org.quartz.JobExecutionException;

public class MyJob implements Job {
@Override
public void execute(JobExecutionContext context) throws JobExecutionException {
System.out.println("Executing my job at: " + new java.util.Date());
}
}

配置调度器和任务

接下来,创建调度器(Scheduler),配置任务(JobDetail)以及触发器(Trigger),并启动调度器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import org.quartz.*;
import org.quartz.impl.StdSchedulerFactory;

public class QuartzExample {
public static void main(String[] args) throws SchedulerException {
// 创建调度器 Scheduler
Scheduler scheduler = StdSchedulerFactory.getDefaultScheduler();

// 定义任务 JobDetail
JobDetail jobDetail = JobBuilder.newJob(MyJob.class)
.withIdentity("myJob", "group1")
.build();

// 定义触发器 Trigger
Trigger trigger = TriggerBuilder.newTrigger()
.withIdentity("myTrigger", "group1")
.startNow() // 立即执行
.withSchedule(SimpleScheduleBuilder.simpleSchedule()
.withIntervalInSeconds(5) // 每 5 秒执行一次
.repeatForever()) // 持续执行
.build();

// 将任务和触发器绑定到调度器
scheduler.scheduleJob(jobDetail, trigger);

// 启动调度器
scheduler.start();

// 让调度器运行 30 秒后关闭
try {
Thread.sleep(30000);
} catch (InterruptedException e) {
e.printStackTrace();
}

// 关闭调度器
scheduler.shutdown();
}
}

运行结果

运行上面的代码后,MyJob 会每隔 5 秒执行一次,并输出当前的时间:

1
2
3
4
Executing my job at: Mon Sep 23 10:05:01 UTC 2024
Executing my job at: Mon Sep 23 10:05:06 UTC 2024
Executing my job at: Mon Sep 23 10:05:11 UTC 2024
...

基于数据库悲观锁的分布式调度机制就存在明显的性能瓶颈,无法支持快速发展的业务了。

ElasticJob

ElasticJob 是一个分布式调度解决方案,专注于在分布式环境中执行任务调度。它由 当当网 开发,最初是为了解决在分布式系统中高效调度任务的问题,并提供了任务分片、容错、任务状态监控等功能。相比于传统的单机任务调度工具(如 Quartz),ElasticJob 更加适合在分布式架构中使用,特别是在微服务或多节点架构中。

ElasticJob 主要有两个核心版本:

  1. ElasticJob-Lite:轻量级、无中心化的分布式任务调度框架,适合对服务中心化依赖较低的场景。
  2. ElasticJob-Cloud:云原生的任务调度解决方案,支持更复杂的任务调度(ElasticJob-Cloud 已停止维护,官方推荐使用 ElasticJob-Lite + Kubernetes 来替代)。

XXL-JOB

底层原理:

  • 调度中心负责任务的触发和分发:通过 HTTP 协议将任务分发给执行器执行,并根据任务的反馈结果更新任务状态。
  • 执行器负责任务的执行:执行器从调度中心接收任务,执行任务后反馈执行结果。执行器具备自行注册、负载均衡、任务分片、故障转移等功能。
  • 任务调度以 Quartz 为基础:调度中心通过 Quartz 实现任务的定时触发,并支持多种调度策略(单机、广播、分片等)。
  • 任务执行过程支持高可用、容错与监控:通过执行器心跳检测、任务失败重试、任务故障转移等机制,确保任务在分布式环境中的高可用性和可靠性,并提供任务日志和报警机制帮助运维人员监控任务执行过程。

调度中心与执行器之间的工作流程如下:

image-20240923211856857

执行流程:

​ 1.任务执行器根据配置的调度中心的地址,自动注册到调度中心

​ 2.达到任务触发条件,调度中心下发任务

​ 3.执行器基于线程池执行任务,并把执行结果放入内存队列中、把执行日志写入日志文件中

​ 4.执行器消费内存队列中的执行结果,主动上报给调度中心

​ 5.当用户在调度中心查看任务日志,调度中心请求任务执行器,任务执行器读取任务日志文件并返回日志详情

Q:XXL-JOB的分布式体现在哪里?

A:

  1. 调度中心的集群化部署:调度中心支持集群部署,多个调度中心实例通过共享数据库实现任务调度的高可用性和数据一致性。
  2. 执行器的分布式部署:执行器可以在多个物理节点上部署,调度中心根据任务调度策略将任务分发到不同的执行器节点执行。
  3. 任务的分片执行:通过分片机制,任务可以并行执行在多个节点上,提高任务执行效率。
  4. 广播执行模式:同一个任务可以在多个执行器节点上同时执行,适合需要在多个节点上同步执行的场景。
  5. 执行器的自动注册与故障转移:执行器节点可以动态加入或退出集群,调度中心能够实时感知故障并进行任务的故障转移,保证任务的高可用性。
  6. 任务调度策略的灵活性:支持单机执行、分片执行、广播执行等多种调度策略,满足不同场景下的分布式任务调度需求。
  7. 数据库共享与日志管理:通过共享数据库和分布式日志管理,保证了任务调度信息和执行日志的统一管理与监控。

短链接生成器

短URL生成器,也称作短链接生成器,就是将一个比较长的URL生成一个比较短的URL,当浏览器通过短URL生成器访问这个短URL的时候,重定向到访问原始的长URL目标服务器,访问时序图如下:

  1. 用户client程序可以使用短URL生成器 Fuxi 为每个长URL生成唯一的短URL,并存储起来。
  2. 用户可以访问这个短URL,Fuxi 将请求重定向到原始长URL。
  3. 生成的短URL可以是Fuxi自动生成的,也可以是用户自定义的。用户可以指定一个长URL对应的短URL内容,只要这个短URL还没有被使用。
  4. 管理员可以通过web后台检索,查看Fuxi的使用情况。
  5. 短URL 有有效期(2年),后台定时任务会清理超过有效期的URL,以节省存储资源,同时回收短URL地址链接资源。

系统需要保持高可用,不因为服务器、数据库宕机而引起服务失效。2. 系统需要保持高性能,服务端80%请求响应时间应小于5ms,99%请求响应时间小于20ms,平均响应时间小于10ms。

短URL应该是不可猜测的,即不能猜测某个短URL是否存在,也不能猜测短URL可能对应的长URL地址内容。

(极客时间)短 URL 生成器设计

image-20240927223114039