记录些Spring+题集(52)

滴滴业务中台构建实践

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

分库分表,查询优化

查询太慢的优化三种方法:

分库分表后,查询太慢了,如何优化?大致三种方法:

  • 方式一:组合使用 ES 搜索做分页,

  • 方式二:禁止跳页

  • 方式三:二次查询法

首先,分析慢的根本原因,Sharding-JDBC 做了分页修正

Sharding-JDBC从多个数据库获取分页数据,与单数据库的场景是不同的。

假设每10条数据为一页,取第2页数据。

在分片环境下获取LIMIT 10, 10,归并之后再根据排序条件取出前10条数据是不正确的。

举例说明,若SQL为:

SELECT score FROM t_score ORDER BY score DESC LIMIT 1, 2;

下图展示了不进行SQL的改写的分页执行结果:

图片

通过图中所示,想要取得两个表中共同的按照分数排序的第2条和第3条数据,理论上,应该是95和90。

图片

实际上如何?

由于执行的SQL只能从每个表中获取第2条和第3条数据,即从t_score_0表中获取的是90和80;

从t_score_0表中获取的是85和75。

因此进行结果归并时,只能从获取的90,80,85和75之中进行归并,那么,无论怎么实现,结果归并之后,都不可能获得正确的结果。

正确的做法是将分页条件改写为LIMIT 0, 3,取出所有前两页数据,再结合排序条件计算出正确的数据。下图展示了进行SQL改写之后的分页执行结果。

图片

功能和性能的冲突:从0开始的性能瓶颈

注意,这里有个大问题:

为了结果不出错,归并之前的查询,是0开始, 结果才可能是对的。

然而,查询偏移量过大的分页会导致数据库获取数据性能低下,

以MySQL为例:

图片

如果不是分库分表,这句SQL会使得MySQL在无法利用索引的情况下跳过1000000条记录后,再获取10条记录,其性能可想而知。

然而, 在分库分表的情况下(假设分为2个库),为了保证数据的正确性,SQL会改写为:

图片

即将偏移量前的记录全部取出,并仅获取排序后的最后10条记录。

这会在数据库本身就执行很慢的情况下,进一步加剧性能瓶颈。

因为原SQL仅需要传输10条记录至客户端,而改写之后的SQL则会传输1,000,010 * 2的记录至客户端。

Sharding-JDBC的优化

(1)采用流式处理 + 归并排序的方式来避免内存的过量占用。

由于SQL改写不可避免的占用了额外的带宽,但并不会导致内存暴涨。

与直觉不同,大多数人认为Sharding-JDBC会将1,000,010 * 2记录全部加载至内存,进而占用大量内存而导致内存溢出。

但由于每个结果集的记录是有序的,因此Sharding-JDBC每次仅获取各个分片的当前结果集记录,驻留在内存中的记录仅为当前路由到的分片的结果集的当前游标指向而已。对于本身即有序的待排序对象,归并排序的时间复杂度仅为O(n),性能损耗很小。

(2)Sharding-JDBC对仅落至多分片的查询进行进一步优化。

落至单分片查询的请求并不需要改写SQL也可以保证记录的正确性,因此在此种情况下,Sharding-JDBC并未进行SQL改写,从而达到节省带宽的目的。

一般情况下,性能慢,都是第一种情况。

流式查询看上去很好,但也有大大的弊端。

流式查询的弊端

采用游标查询的方式的缺点很明显。

流式(游标)查询需要注意:当前查询会独占连接必须先读取(或关闭)结果集中的所有行,然后才能对连接发出任何其他查询,否则将引发异常

执行一个流式查询后,数据库访问框架就不负责关闭数据库连接了,需要应用在取完数据后需要自己关闭。

由于MySQL_Server不知道客户端什么时候将数据消费完,而自身的对应表可能会有DML写入操作,此时MySQL_Server需要建立一个临时空间来存放需要拿走的数据

因此对于当你启用useCursorFetch读取大表的时候,会看到MySQL上的几个现象:

  1. IOPS 飙升,因为需要返回的数据需要写入到临时空间中,存在大量的 IO 读取和写入,此流程可能会引起其它业务的写入抖动

  2. 磁盘空间飙升,写入临时空间的数据会在读取完成或客户端发起 ResultSet#close 操作时由 MySQL_Server 回收

  3. 客户端 JDBC 发起 sql_query,可能会有长时间等待,这段时间为MySQL_Server准备数据阶段。但是 普通查询等待时间与游标查询等待时间原理上是不一致的:  前者是在读取网络缓冲区的数据,没有响应到业务层面;后者是 MySQL 在准备临时数据空间,没有响应到 JDBC

  4. 数据准备完成后,进行到传输数据阶段,网络响应开始飙升,IOPS 由"写"转变为"读"

如何解决呢?

优化方案1:组合使用 ES 搜索做分页

优化方案2:禁止跳页查询法

由于LIMIT并不能通过索引查询数据,因此如果可以保证ID的连续性,通过ID进行分页是比较好的解决方案

图片

或通过记录上次查询结果的最后一条记录的ID进行下一页的查询:

图片

如果不是id列, 假设排序的列为col,禁止跳页查询法的两个步骤大致如下:

(1)用正常的方法取得第一页数据,并得到第一页记录的 max_col 最大值;

(2)每次翻页,将order by col offset X limit Y; 改写成 order by col  where col>$time_max limit Y;

以保证每次只返回一页数据,性能为常量。

优化方法3:二次查询法

假设排序的列为col,二次查询法的两个步骤大致如下:

(1)SQL改写,将 order by col offset X limit Y; 改写成 order by col  offset X/N limit Y;

(2)多页返回,找到最小值col_min;

(3)between 二次查询 order by col between  col_min and col_i_max;

(4)设置虚拟col_min,找到col_min在各个分库的offset,从而得到col_min在全局的offset;

(5)得到了col_min在全局的offset,自然得到了全局的offset X limit Y;

例子:分表结构

CREATE TABLE `student_time_0` (
  `id` int(11) unsigned NOT NULL AUTO_INCREMENT,
  `user_id` int(11) NOT NULL,
  `name` varchar(200) COLLATE utf8_bin DEFAULT NULL,
  `age` tinyint(3) unsigned DEFAULT NULL,
  `create_time` bigint(20) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=674 DEFAULT CHARSET=utf8 COLLATE=utf8_bin;

有这样的三个表,student_time_0student_time_1student_time_2, 以 user_id 作为分表键,根据表数量取模作为分表依据 这里先构造点数据,

insert into student_time (`name`, `user_id`, `age`, `create_time`) values (?, ?, ?, ?)

主要是为了保证 create_time 唯一,比较好说明问题,

int i = 0;
try (
    Connection conn = dataSource.getConnection();
    PreparedStatement ps = conn.prepareStatement(insertSql)) {
    do {
        ps.setString(1, localName + new Random().nextInt(100));
        ps.setLong(2, 10086L + (new Random().nextInt(100)));
        ps.setInt(3, 18);
        ps.setLong(4, new Date().getTime());


        int result = ps.executeUpdate();
        LOGGER.info("current execute result: {}", result);
        Thread.sleep(new Random().nextInt(100));
        i++;
    } while (i <= 2000);

三个表的数据分别是 673,678,650,各个表数据不一样,

接下来,做一个这样的分页查询

select * from student_time ORDER BY create_time ASC limit 1000, 5;

student_time 对于我们使用的 sharding-jdbc 来说当然是逻辑表, sharding-jdbc 会改写为

select * from student_time ORDER BY create_time ASC limit 0, 1005;

即使如 sharding-jdbc 对于合并排序的优化做得比较好,也还是需要传输那么大量的数据,并且查询也耗时,那么有没有解决方案呢

  • 第一个办法禁止跳页,而是只给下一页,那么我们就能把前一次的最大偏移量的 create_time 记录下来,下一页就可以拿着这个偏移量进行查询

  • 第二个办法是二次查询法

这个办法的第一步跟前面那个错误的方法或者说不准确的方法一样,先是将分页偏移量平均,333,根据这个 limit 333,5  在三个表里进行查询

t0
334 10158 nick95  18  1641548941767
335 10098 nick11  18  1641548941879
336 10167 nick51  18  1641548942089
337 10167 nick3 18  1641548942119
338 10170 nick57  18  1641548942169


t1
334 10105 nick98  18  1641548939071   最小
335 10174 nick94  18  1641548939377
336 10129 nick85  18  1641548939442
337 10141 nick84  18  1641548939480
338 10096 nick74  18  1641548939668

t2
334 10184 nick11  18  1641548945075
335 10109 nick93  18  1641548945382
336 10181 nick41  18  1641548945583
337 10130 nick80  18  1641548945993
338 10184 nick19  18  1641548946294  最大

第一遍的目标是啥,查出来的最小的 create_time 和最大的 create_time 找出来,然后再去三个表里查询,其实主要是最小值,因为拿着最小值去查,以后我就能知道这个最小值在每个表里处在什么位置,

order by col between col_min and col_i_max;

那这里我也想着拿到这个条件,所以我将第一遍查出来的最小的 create_time 和最大的 create_time 找出来,然后再去三个表里查询,这里,其实主要是最小值,因为我拿着最小值去查以后我就能知道这个最小值在每个表里处在什么位置,

t0
322 10161 nick81  18  1641548939284
323 10113 nick16  18  1641548939393
324 10110 nick56  18  1641548939577
325 10116 nick69  18  1641548939588
326 10173 nick51  18  1641548939646

t1
334 10105 nick98  18  1641548939071
335 10174 nick94  18  1641548939377
336 10129 nick85  18  1641548939442
337 10141 nick84  18  1641548939480
338 10096 nick74  18  1641548939668

t2
297 10136 nick28  18  1641548939161
298 10142 nick68  18  1641548939177
299 10124 nick41  18  1641548939237
300 10148 nick87  18  1641548939510
301 10169 nick23  18  1641548939715

我只贴了前五条数据,为了方便知道偏移量,每个分表都使用了自增主键,我们可以看到前一次查询的最小值分别在其他两个表里的位置分别是 322-1 和 297-1,那么,对于总体来说,这个时间的起始位置,应该是在 322 - 1 + 334-1 + 297 - 1 = 951,那么,只要对后面的数据最多每个表查 1000 - 951 + 5 = 54 条数据,再进行合并排序就可以获得最终正确的结果。

这个就是的二次查询法。

可见,二次查询法很麻烦, 不如禁止跳页法,或者 es组合方法,直接,有效。

百度搜索引擎实现原理

  1. 网页抓取(Crawling)

    • 百度使用一种名为“蜘蛛”(或“爬虫”)的程序自动在互联网上抓取网页内容。
    • 这些蜘蛛从已知的网页出发,跟踪网页中的链接,将所访问的网页内容存储起来,并继续跟踪新发现的链接。
    • 为了保证信息的时效性和准确性,蜘蛛会定期更新已抓取的网页内容。
  2. 索引构建(Indexing)

    • 抓取到的网页内容会经过处理,提取关键词和内容摘要,构建成索引。
    • 索引就像是图书馆的卡片目录,帮助快速定位到含有特定关键词的网页。
    • 百度的索引系统非常庞大,能够处理亿级数量的网页和关键词。
  3. 搜索算法(Search Algorithm)

    • 当用户输入查询词进行搜索时,百度的搜索算法会根据索引快速找到相关的网页。
    • 搜索算法不仅考虑关键词匹配,还会根据网页的重要性、用户的地理位置、搜索历史等多种因素对搜索结果进行排序。
    • 百度的算法会不断优化,以提供更准确、更符合用户需求的搜索结果。
  4. 结果呈现(Result Presentation)

    • 搜索结果会按照算法排序展现给用户。
    • 结果页通常包括网页标题、简介、链接以及可能的图片、视频等富媒体内容。
  5. 用户交互(User Interaction)

    • 用户可以通过点击搜索结果来访问网页,百度会记录这些点击数据。
    • 用户的行为数据会被用来进一步优化搜索算法,提高搜索的相关性和用户体验。
  6. 个性化服务(Personalization)

    • 百度可能会根据用户的搜索历史、偏好等个人信息提供个性化的搜索结果。
    • 这有助于提高用户满意度和搜索效率。

搜索引擎的底层原理涉及到很多技术,例如爬虫技术、文本挖掘技术、机器学习技术等。其中,倒排索引是搜索引擎的核心技术之一,它可以快速地查找包含特定关键词的网页。此外,搜索引擎还需要对网页的质量、相关度等进行评估,以便为用户提供更加准确的搜索结果。因此,搜索引擎需要采用多种技术来处理各种各样的情况,从而实现高效、准确、可靠的搜索。

记录些美团面试题

1、redis基本数据类型,应用场景?

  1. String(字符串):适用于存储文本、数字等数据。常见用途包括缓存、计数器、会话管理等。

  2. Hash(哈希表):适合存储对象(如用户数据、商品信息)的多个字段及其值。能快速检索或修改字段值。

  3. List(列表):用于保存有序的元素序列。适用于实现消息队列、栈、发布订阅等功能。

  4. Set(集合):用于保存不重复的元素。适用于存储标签、好友列表等,也可用于计算交集、并集等操作。

  5. Sorted Set(有序集合):类似于 Set,但每个元素都有一个分数,可用分数对元素进行排序。适用于实现排行榜、优先级队列等。

  6. Bitmap(位图):适合存储布尔值,可用于追踪用户在线状态、用户活跃度等。能执行位运算以统计和查询状态。

  7. HyperLogLog:用于估算集合中不重复元素的数量,适用于统计 UV(独立访客数)等场景。

  8. Geospatial(地理空间):用于存储地理位置信息,支持距离计算和附近位置的查询。适合实现地图应用、位置服务等。

  9. Pub/Sub(发布订阅):用于实现消息发布和订阅机制,适用于构建实时通知、事件驱动系统等。

2、redis如何实现共同关注?

要实现共同关注功能(如社交网络中的好友关系),可以利用 Redis 数据结构来存储关注关系。

给个示例:

假设存在两个用户,用户 A 和用户 B,他们都可以关注其他用户。我们希望找到他们共同关注的用户。

  • 使用集合(Set)存储用户的关注列表:

用户A的关注列表:sadd userA_following userC userD userE
用户B的关注列表:sadd userB_following userD userE userF
  • 找出用户 A 和用户 B 的共同关注:

使用集合的交集操作(sinter)找出两个用户的共同关注:

sinter userA_following userB_following

该操作将返回一个集合,包含用户 A 和用户 B 共同关注的用户(在此示例中是 userD 和 userE)。

  • 可以采用类似方法查找其他用户的共同关注,或执行其他操作,如取消关注、添加新关注等。

补充:Set集合三个特殊方法 sinter 、sunion 、sdiff

redis 支持 Set集合的数据存储,其中有三个比较特殊的方法:

  • sinter key [key …] 查看一个集合的全部成员,该集合是所有给定集合的交集。

  • sunion key [key …] 查看一个集合的全部成员,该集合是所有给定集合的并集。

  • sdiff key [key …] 查看所有给定 key 与第一个 key 的差集

sinter 交集的示例
redis> SMEMBERS group_1
1) "LI LEI"
2) "TOM"
3) "JACK"

redis> SMEMBERS group_2
1) "HAN MEIMEI"
2) "JACK"

redis> SINTER group_1 group_2      # 取的是交集的数据 
1) "JACK"

sunion 并集的示例
redis> SMEMBERS songs
1) "Billie Jean"

redis> SMEMBERS my_songs
1) "Believe Me"

redis> SUNION songs my_songs       # 取的是集合的并集数据
1) "Billie Jean"
2) "Believe Me"
sdiff 差集的示例
redis> SMEMBERS peter_movies
1) "bet man"
2) "start war"
3) "2012"

redis> SMEMBERS joe_movies
1) "hi, lady"
2) "Fast Five"
3) "2012"

redis> SDIFF peter_movies joe_movies     # 取的是两个集合的差集
1) "bet man"
2) "start war"

3、redis持久化,AOF,RDB,会丢数据吗?

两种持久化策略:

  • AOF

  • RDB

1.AOF(Append-Only File)持久化:

a. AOF持久化以追加的方式记录每个写操作(包括SET、INCR等)到一个日志文件中,该文件包含了恢复数据所需的所有写操作。

b. AOF持久化可以配置为每秒同步一次(默认配置),或者根据需要更频繁地同步。

c. 由于AOF记录了每个写操作,通常情况下不会丢失数据,即使Redis宕机,也可以通过AOF文件来完全恢复数据。

2.RDB(Redis DataBase)持久化:

a. RDB持久化是通过周期性快照(快照)整个数据集到磁盘的方式。

b. RDB 文件包含特定时间点数据集的快照,因此两次快照之间的数据更改可能会丢失。

c. 默认情况下,Redis 每隔一段时间(可配置)执行一次 RDB 快照。

若 Redis 在两次快照间崩溃,可能导致数据丢失。

总的来说:

  • AOF持久化默认会丢失1s数据,也可以配置为每次都刷盘,这样不会丢失数据,因为它记录了每个写操作,但会有一定的数据恢复成本。

  • RDB持久化在快照间可能会丢失数据,但因为RDB文件只包含快照时的数据,所以通常比AOF更快速。

补充:AOF 配置

在 redis.conf 配置文件的 APPEND ONLY MODE 下:

图片

①、appendonly:默认值为no,也就是说redis 默认使用的是rdb方式持久化,如果想要开启 AOF 持久化方式,需要将 appendonly 修改为 yes。  AOF 保存文件的位置和 RDB 保存文件的位置一样,都是通过 redis.conf 配置文件的 dir 配置文件的位置.

②、appendfilename :aof文件名,默认是"appendonly.aof"

③、appendfsync:aof持久化策略的配置;

  • no表示不执行fsync,由操作系统保证数据同步到磁盘,速度最快,但是不太安全;

  • always表示每次写入都执行fsync,以保证数据同步到磁盘,效率很低;

  • everysec表示每秒执行一次fsync,可能会导致丢失这1s数据。通常选择 everysec ,兼顾安全性和效率。

④、no-appendfsync-on-rewrite:在aof重写或者写入rdb文件的时候,会执行大量IO,此时对于everysec和always的aof模式来说,执行fsync会造成阻塞过长时间,no-appendfsync-on-rewrite字段设置为默认设置为no。如果对延迟要求很高的应用,这个字段可以设置为yes,否则还是设置为no,这样对持久化特性来说这是更安全的选择。   设置为yes表示rewrite期间对新写操作不fsync,暂时存在内存中,等rewrite完成后再写入,默认为no,建议yes。Linux的默认fsync策略是30秒。可能丢失30秒数据。默认值为no。

⑤、auto-aof-rewrite-percentage:默认值为100。aof自动重写配置,当目前aof文件大小超过上一次重写的aof文件大小的百分之多少进行重写,即当aof文件增长到一定大小的时候,Redis能够调用bgrewriteaof对日志文件进行重写。当前AOF文件大小是上次日志重写得到AOF文件大小的二倍(设置为100)时,自动启动新的日志重写过程。

⑥、auto-aof-rewrite-min-size:64mb。设置允许重写的最小aof文件大小,避免了达到约定百分比但尺寸仍然很小的情况还要重写。

⑦、aof-load-truncated:aof文件可能在尾部是不完整的,当redis启动的时候,aof文件的数据被载入内存。重启可能发生在redis所在的主机操作系统宕机后,尤其在ext4文件系统没有加上data=ordered选项,出现这种现象  redis宕机或者异常终止不会造成尾部不完整现象,可以选择让redis退出,或者导入尽可能多的数据。如果选择的是yes,当截断的aof文件被导入的时候,会自动发布一个log给客户端然后load。如果是no,用户必须手动redis-check-aof修复AOF文件才可以。默认值为 yes。

AOF的优缺点

优点:

①、AOF 持久化的方法提供了多种的同步频率,即使使用默认的同步频率每秒同步一次,Redis 最多也就丢失 1 秒的数据而已。

②、AOF 文件使用 Redis 命令追加的形式来构造,因此,即使 Redis 只能向 AOF 文件写入命令的片断,使用 redis-check-aof 工具也很容易修正 AOF 文件。

③、AOF 文件的格式可读性较强,这也为使用者提供了更灵活的处理方式。例如,如果我们不小心错用了 FLUSHALL 命令,在重写还没进行时,我们可以手工将最后的 FLUSHALL 命令去掉,然后再使用 AOF 来恢复数据。

缺点:

①、对于具有相同数据的的 Redis,AOF 文件通常会比 RDB 文件体积更大。

②、虽然 AOF 提供了多种同步的频率,默认情况下,每秒同步一次的频率也具有较高的性能。但在 Redis 的负载较高时,RDB 比 AOF 具好更好的性能保证。

③、RDB 使用快照的形式来持久化整个 Redis 数据,而 AOF 只是将每次执行的命令追加到 AOF 文件中,因此从理论上说,RDB 比 AOF 方式更健壮。官方文档也指出,AOF 的确也存在一些 BUG,这些 BUG 在 RDB 没有存在。

那么对于 AOF 和 RDB 两种持久化方式,我们应该如何选择呢?

如果可以忍受一小段时间内数据的丢失,毫无疑问使用 RDB 是最好的,定时生成 RDB 快照(snapshot)非常便于进行数据库备份, 并且 RDB 恢复数据集的速度也要比 AOF 恢复的速度要快,而且使用 RDB 还可以避免 AOF 一些隐藏的 bug;否则就使用 AOF 重写。但是一般情况下建议不要单独使用某一种持久化机制,而是应该两种一起用,在这种情况下,当redis重启的时候会优先载入AOF文件来恢复原始的数据,因为在通常情况下AOF文件保存的数据集要比RDB文件保存的数据集要完整。Redis后期官方可能都有将两种持久化方式整合为一种持久化模型。

RDB-AOF混合持久化

在Redis4.0之后,在RDB和AOF两种持久化方式之外,又新增了RDB-AOF混合持久化方式。这种方式结合了RDB和AOF的优点,既能快速加载又能避免丢失过多的数据。

具体配置为:

aof-use-rdb-preamble

设置为yes表示开启,设置为no表示禁用。

当开启混合持久化时,主进程先fork出子进程将现有内存副本全量以RDB方式写入aof文件中,然后将缓冲区中的增量命令以AOF方式写入aof文件中,写入完成后通知主进程更新相关信息,并将新的含有 RDB和AOF两种格式的aof文件替换旧的aof文件。

简单来说:混合持久化方式产生的文件一部分是RDB格式,一部分是AOF格式。

这种方式优点我们很好理解,缺点就是不能兼容Redis4.0之前版本的备份文件了。

4、redis穿透击穿雪崩,解决?

缓存穿透解决措施:

a. Bloom Filter:利用布隆过滤器筛选出不在缓存中的请求,以降低对数据库的请求压力。

b. 空值缓存:虽数据库中不存在该数据,但仍将其缓存,但设置较短的过期时间,以防频繁查询。

c. 缓存空对象:当数据库查询结果为空时,也将该结果缓存,但设定较短的过期时间,避免重复查询。

缓存击穿解决措施:

a. 互斥锁:采用互斥锁保护缓存,当缓存失效时,只允许一个请求查询数据库,其他请求等待结果,避免多个请求同时击穿缓存。

b. 热点数据预热:定期或启动时预先加载热门数据至缓存,防止因突发大量请求导致缓存击穿。

缓存雪崩解决措施:

a. 缓存失效时间随机性:为缓存失效时间增加一定随机性,使缓存不会同时失效,减轻对数据库的并发请求压力。

b. 持久化缓存:利用 AOF 和 RDB 的持久化机制确保缓存数据可靠性,即使在缓存雪崩情况下,也能从持久化数据中恢复。

c. 多级缓存:采用多级缓存(如本地内存缓存、分布式缓存、CDN 等)分担缓存压力,使某一缓存层发生雪崩时,其他层仍能提供服务。

缓存更新策略:

a. 异步刷新:缓存失效后,后台异步更新缓存,避免请求等待缓存更新。

b. 加锁更新:缓存失效时,仅允许一个请求查询数据库并更新缓存,其他请求等待结果。

合理的缓存策略:

a. 根据数据访问模式和业务需求,选择合适的缓存策略,如 LRU(最近最少使用)、LFU(最不常使用)、TTL(Time To Live)等。

5、redis大Key,怎么处理?

Redis中的大Key通常指缓存键对应的值较大,可能包含大型数据结构、大量文本或二进制数据等。这类数据会导致内存占用过高,进而影响性能。给大家一些处理Redis大Key的参考方法:

拆分数据:

a. 如有条件,将大型数据拆分成多个小型数据,并分别存储在独立的键中。这能降低单个键的大小,减少内存占用。

b. 例如,若某个键存储了大型 JSON 对象,可将其拆分为多个子键,每个子键存储 JSON 对象的部分数据。

使用数据压缩:

a. 在存储文本或二进制数据之前,可对其进行压缩,在读取时再解压缩。虽然 Redis 本身不支持数据压缩,但可在应用层实现压缩和解压缩操作。

使用数据分片:

a大 Key 是由多个小 Key 组成的集合,可以使用 Redis 的数据分片或分区技术,将数据分布在多个 Redis 实例中,每个实例负责一部分数据。

这有助于减轻单个实例的内存压力。

定期清理:

a. 若大 Key 生命周期有限,可以定期清理不再需要的数据,以释放内存。

b. 使用DEL命令删除不再需要的大Key。

使用内存优化的数据结构:

对于大型集合或列表,可以考虑使用 Redis 的内存优化数据结构,如 HyperLogLog、Redis Streams 等,以降低内存占用。

数据预热和缓存策略:

如果大 Key 是在系统启动时加载的,可以实施数据预热策略,提前将热门数据加载到缓存中,以减轻启动时的内存压力。

6、redis分布式锁,Zookeeper分布式锁,怎么选择?

给大家一些选择是需要考虑的因素,在什么情况用哪种分布式锁是更优选择。

性能:

a. 通常情况下,Redis 的性能优于 ZooKeeper,因为它采用内存存储系统,而 ZooKeeper 采用磁盘存储。

b. 如果你追求高性能的分布式锁,Redis 是更好的选择。

一致性:

a. ZooKeeper 提供强一致性,适合对一致性要求较高的分布式应用,如协调和选举等。

b. Redis 的分布式锁在某些情况下可能出现失效或死锁,因为它是基于主从复制的。

部署复杂性:

a. ZooKeeper 需要独立的 ZooKeeper 集群,需进行维护和管理。

b. Redis 部署相对简单,特别是若你在应用中已使用 Redis。

可用性:

a. Redis 扩展和部署较容易,因此便于实现高可用性。

b. ZooKeeper 的部署和维护较为复杂,尤其在多数据中心环境中。

应用已有基础:

a. 若你在应用中已使用 Redis,添加 Redis 分布式锁较为容易集成。

b. 若应用已使用 ZooKeeper,选择 ZooKeeper 分布式锁更为合适。

数据持久性需求:

a. 需更强数据持久性和一致性时,可考虑使用 ZooKeeper。

b. Redis 提供持久性,但通常在性能和可用性之间作出权衡。

社区支持:

Redis 和 ZooKeeper 均有活跃的社区支持,需根据具体需求评估社区支持和文档资源。

综合考虑以上因素,若应用需高性能分布式锁且可容忍一定程度的一致性弱点,可选 Redis 分布式锁。若需强一致性和更复杂分布式协同操作,ZooKeeper 分布式锁更适合。

7、说说熔断与限流?

在分布式系统中,熔断和限流是两种关键技术,它们有助于提高系统的可用性和稳定性。

一、熔断:

熔断机制旨在防止系统出现雪崩效应。当某个服务或组件的错误率超过设定阈值,熔断器就会启动,阻止进一步的请求访问该服务,以避免更多系统部分崩溃。熔断器开启后,会定时检查服务可用性,一旦恢复正常,便关闭熔断器,允许请求再次访问。

熔断的好处包括:

  • 避免错误服务波及整个系统。

  • 减轻故障服务负担,减少不必要请求。

  • 促使系统恢复正常运行,而非持续崩溃。

1. 熔断器实现

import java.util.concurrent.TimeUnit;
public class CircuitBreaker {  
    private final ThreadPoolExecutor executor;  
    private final Semaphore permit;  
    private final long timeout;
    public CircuitBreaker(int maxThreads, long timeout, TimeUnit unit) {  
        this.executor = new ThreadPoolExecutor(maxThreads, maxThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>());  
        this.permit = new Semaphore(1);  
        this.timeout = timeout;  
    }
    public void execute(Runnable command) {  
        permit.acquire();  
        try {  
            executor.execute(command);  
        } catch (Exception e) {  
            // 处理异常,例如记录日志、发送告警等  
            System.err.println("Error occurred while executing command: " + e.getMessage());  
        } finally {  
            permit.release();  
        }  
    }
    public void reset() {  
        executor.shutdown();  
        try {  
            if (!executor.awaitTermination(timeout, TimeUnit.MILLISECONDS)) {  
                // 超过恢复时间,仍然无法恢复,则考虑进行降级或熔断  
                System.err.println("Failed to reset circuit breaker");  
            }  
        } catch (InterruptedException e) {  
            // 等待过程中出现中断,表示恢复失败  
            System.err.println("Interrupted while waiting for circuit breaker reset: " + e.getMessage());  
        }  
        executor = new ThreadPoolExecutor(executor.getPoolSize(), executor.getPoolSize(), 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>());  
    }  
}

2.使用示例

public class CircuitBreakerDemo {  
    public static void main(String[] args) {  
        CircuitBreaker circuitBreaker = new CircuitBreaker(10, 5000, TimeUnit.MILLISECONDS);
        for (int i = 0; i < 10; i++) {  
            circuitBreaker.execute(() -> {  
                System.out.println("Executing command " + i);  
                throw new RuntimeException("模拟异常");  
            });  
        }
        circuitBreaker.reset();
        for (int i = 0; i < 10; i++) {  
            circuitBreaker.execute(() -> {  
                System.out.println("Executing command " + i);  
            });  
        }  
    }  
}
二、限流:

限流机制旨在控制服务请求速率,防止系统同时承受过多请求。根据应用需求,限流可以有多种实现方式,如:

  • 固定速率限流:每秒最多允许N个请求。

  • 漏桶算法:请求以固定速率进入“桶”,若“桶”满,多余请求将被丢弃。

  • 令牌桶算法:每个请求需获取一个令牌,令牌以固定速率生成,若无令牌,请求将被拒绝。

限流的好处包括:

  • 防止大规模请求突然涌入,导致系统过载。

  • 控制系统负载,保护后端服务免受过多请求压力。

  • 提供一种方式来保护资源,如API,免受滥用和DDoS攻击。

1. 定义一个 TokenBucket 类

import java.util.concurrent.TimeUnit;
public class TokenBucket {  
    private final long capacity;  
    private final long tokensPerSecond;  
    private long lastRefillTime;  
    private long tokens;
    public TokenBucket(long capacity, long tokensPerSecond) {  
        this.capacity = capacity;  
        this.tokensPerSecond = tokensPerSecond;  
        this.lastRefillTime = System.currentTimeMillis();  
        this.tokens = capacity;  
    }
    public boolean consume(long tokens) {  
        if (tokens > capacity) {  
            return false;  
        }
        refill();  
        if (tokens <= tokens) {  
            tokens -= tokens;  
            return true;  
        } else {  
            return false;  
        }  
    }
    private void refill() {  
        long now = System.currentTimeMillis();  
        long elapsed = now - lastRefillTime;  
        long newTokens = elapsed * tokensPerSecond;  
        tokens = Math.min(capacity, tokens + newTokens);  
        lastRefillTime = now;  
    }  
}

2. 使用示例

public class Main {  
    public static void main(String[] args) {  
        TokenBucket bucket = new TokenBucket(10, 2);
        System.out.println(bucket.consume(5)); // 消耗 5 个令牌,返回 True  
        try {  
            Thread.sleep(1000);  
        } catch (InterruptedException e) {  
            e.printStackTrace();  
        }  
        System.out.println(bucket.consume(6)); // 消耗 6 个令牌,返回 False  
        try {  
            Thread.sleep(500);  
        } catch (InterruptedException e) {  
            e.printStackTrace();  
        }  
        System.out.println(bucket.consume(2)); // 消耗 2 个令牌,返回 True  
    }  
}

在这个例子中,我们创建了一个 TokenBucket 类,通过 consume 方法限制每秒请求的次数。每次调用 consume 方法时,会先检查当前令牌是否足够,如果不够,则返回 False,表示限流。如果足够,则消耗令牌并返回 True。在 refill 方法中,我们会根据时间间隔和每秒添加的令牌数来补充令牌。 这个简单的限流器可以用于保护 Java 系统的瓶颈部分,防止流量过大导致系统崩溃。在实际应用中,可以根据需求进行优化和扩展。

8、为什么kafka多用于消息中转,很少用于实时计算?

  1. 持久性存储:Kafka 作为一种持久性消息队列,将消息存储在磁盘上,确保数据不会因消费者未能及时处理而丢失。这使得 Kafka 非常适合作为消息中转,让消费者能在任意时间点访问消息,而不仅限于实时计算。

  2. 消息存储和检索:Kafka 的主要功能是存储和检索消息,而非执行复杂实时计算。它具备高吞吐量和低延迟的消息存储与检索能力,但并未提供计算框架,因此在实时计算方面相对薄弱。

  3. 数据保留:Kafka 支持根据不同策略保留消息,如时间或存储大小。这使得 Kafka 适用于长期数据存储和数据历史查询,而不仅仅是实时计算。

  4. 消息分发和复制:Kafka 具有强大的消息分发和复制机制,确保消息可靠地传递到多个消费者或订阅者。这使得 Kafka 成为消息分发的理想选择,但它并不提供实时计算所需的状态管理和处理。

9、说说kafka高可用,高吞吐?

高可用:
  1. 分布式架构:Kafka 作为一个分布式系统,可部署在多台服务器上,从而具备冗余性。当一台服务器出现故障时,其他服务器能够继续运行,确保消息流的可用性。

  2. 复制和副本:Kafka 采用分区组织消息,每个分区都有多个副本。这意味着消息在多个服务器上进行复制,以防数据丢失。若某个副本不可用,其他副本仍可提供数据。

  3. ZooKeeper:Kafka 利用 Apache ZooKeeper 管理集群元数据和协调任务。ZooKeeper 提供了分布式锁和选举机制,确保 Kafka 集群内各组件协同工作。

  4. 消费者位移:Kafka 记录了消费者在各分区的位移,这意味着即使消费者出现故障,也可以从上次中断的地方继续消费消息,避免数据丢失。

高吞吐:
  1. 分区:Kafka 通过分区实现水平扩展。每个分区可由不同服务器处理,从而平均分配负载,提高吞吐量。

  2. 批处理和零拷贝:Kafka 采用批处理机制降低磁盘和网络开销。此外,它还利用零拷贝技术将数据从生产者传输至消费者,减少 CPU 和内存开销。

  3. 压缩:Kafka 支持消息压缩,缩小数据传输尺寸,提高吞吐量。

  4. 持久性:Kafka 的消息持久化至磁盘,允许在多个消费者之间共享数据,并确保即使消费者离线,数据也不会丢失。

  5. 分布式部署:Kafka 可部署在多台服务器上,充分利用硬件资源,提供高吞吐量。

import org.apache.kafka.clients.producer.KafkaProducer;  
import org.apache.kafka.clients.consumer.KafkaConsumer;  
import org.apache.kafka.common.serialization.StringValueSerializer;
public class KafkaExample {  
    public static void main(String[] args) {  
        // 创建生产者  
        KafkaProducer<String, String> producer = new KafkaProducer<>(  
                new StringValueSerializer(),  
                new StringValueSerializer()  
        );
        // 创建消费者  
        KafkaConsumer<String, String> consumer = new KafkaConsumer<>(  
                new StringValueSerializer(),  
                new StringValueSerializer()  
        );
        // 发送消息  
        producer.send("test-topic", "Hello, Kafka!");
        // 接收消息  
        consumer.subscribe("test-topic");  
        while (true) {  
            ConsumerRecords<String, String> records = consumer.poll(100);  
            for (ConsumerRecord<String, String> record : records) {  
                System.out.printf("Received message: %s%n", record.value());  
            }  
        }  
    }  
}

综上所述,Kafka 实现高可用、高吞吐的主要手段包括:分布式架构、顺序读写、零拷贝、文件分段、批量发送和数据压缩等技术。通过这些技术和优化,Kafka 能够在大规模消息处理场景下表现出优异的性能。

10、说说kafka不重不丢?

Kafka 致力于实现不重不丢,这意味着它努力确保消息不会被重复传递,也不会在传递过程中丢失。为了实现这一目标,Kafka 采用了以下关键机制:

  1. 消息复制和副本:Kafka 使用多个副本来保存消息,每个分区的消息都有多个副本分布在不同的服务器上。这样,即使某个服务器故障,仍然可以从其他副本中获取消息。

  2. 消费者位移:Kafka 记录了每个消费者在每个分区中的位移(offset),表示消费者已经处理到哪个位置的消息。消费者可以定期提交位移,以确保它们不会重复消费消息。

  3. 生产者确认机制:Kafka 生产者在将消息发送到服务器后,会等待服务器的确认(acknowledgment)。只有当服务器确认接收到消息后,生产者才会认为消息已经成功发送。

  4. 事务支持:Kafka 提供了事务支持,允许生产者在发送消息时执行事务性操作。这意味着消息要么全部成功发送,要么一个都不发送,以确保不重不丢。

  5. 幂等性生产者:Kafka 生产者支持幂等性,即使生产者发送相同的消息多次,只有一次会生效,有助于避免重复消息。

以下是一个简单的 Java 代码示例,实现了 Kafka 生产者和消费者功能,同时展示了如何确保消息不重复和不丢失。

生产者端(Producer.java):

import org.apache.kafka.clients.producer.KafkaProducer;  
import org.apache.kafka.clients.producer.Producer;  
import org.apache.kafka.clients.producer.ProducerRecord;  
import org.apache.kafka.common.serialization.StringSerializer;
import java.time.Duration;  
import java.util.Properties;
public class Producer {  
    public static void main(String[] args) {  
        // 配置生产者参数  
        Properties props = new Properties();  
        props.put("bootstrap.servers", "localhost:9092");  
        props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");  
        props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
        // 创建生产者实例  
        Producer<String, String> producer = new KafkaProducer<>(props);
        // 发送消息  
        for (int i = 0; i < 10; i++) {  
            String message = "Hello, Kafka!" + i;  
            producer.send(new ProducerRecord<>("test-topic", message));  
            System.out.printf("Sent message: %s%n", message);  
            try {  
                Thread.sleep(1000);  
            } catch (InterruptedException e) {  
                e.printStackTrace();  
            }  
        }
        // 关闭生产者  
        producer.close();  
    }  
}

这个生产者实例使用了 KafkaProducer 类,并发送了 10 条消息到名为 test-topic 的 topic。在 main 方法中,调用 send 方法发送消息,并在发送消息后打印消息内容。生产者在运行过程中,会持续发送消息。当需要结束程序时,调用 close 方法关闭生产者。

消费者端(Consumer.java):

import org.apache.kafka.clients.consumer.KafkaConsumer;  
import org.apache.kafka.clients.consumer.Consumer;  
import org.apache.kafka.clients.consumer.ConsumerRecord;  
import org.apache.kafka.clients.consumer.ConsumerRecords;  
import org.apache.kafka.common.serialization.StringSerializer;
import java.time.Duration;  
import java.util.Collections;  
import java.util.Properties;
public class Consumer {  
    public static void main(String[] args) {  
        // 配置消费者参数  
        Properties props = new Properties();  
        props.put("bootstrap.servers", "localhost:9092");  
        props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");  
        props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");  
        props.put("enable.auto.commit", "true");  
        props.put("auto.commit.interval.ms", "1000");
        // 创建消费者实例  
        Consumer<String, String> consumer = new KafkaConsumer<>(props);
        // 订阅 topic  
        consumer.subscribe(Collections.singletonList("test-topic"));
        // 消费消息  
        while (true) {  
            ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(1000));
            for (ConsumerRecord<String, String> record : records) {  
                System.out.printf("offset = %d, key = %s, value = %s%n", record.offset(), record.key(), record.value());  
            }  
        }
        // 关闭消费者  
        consumer.close();  
    }  
}

这个消费者实例使用了 KafkaConsumer 类,并订阅了名为 test-topic 的 topic。在 main 方法中,使用 poll 方法轮询消息,并在接收到消息时打印 offset、key 和 value。消费者在运行过程中,会持续接收和处理消息。当需要结束程序时,调用 close 方法关闭消费者。

11、说说kafka消费端幂等性?

Kafka 消费者端的幂等性是指消费者能够处理来自 Kafka 主题的消息,而不会导致重复数据或意外的结果。保持幂等性对于确保数据处理的正确性和稳定性至关重要。

以下是一些建议和方法,用于实现 Kafka 消费者端的幂等性:

  1. 消费者位移管理:Kafka 消费者应恰当管理位移,以避免消息的重复处理。消费者应定期提交已成功处理的消息的位移,以确保它们不会再次消费相同的消息。

  2. 消息处理的幂等性:消费者的消息处理逻辑应是幂等的,即无论处理相同的消息一次还是多次,结果应相同。这可以通过设计消息处理逻辑来实现,例如,检查消息的唯一标识符,以避免重复插入相同的数据。

  3. 幂等性标识符:在某些处理场景中,可以在消息中包含幂等性标识符。消费者在处理消息之前检查这个标识符,以确保不会重复处理相同的消息。

  4. 事务性处理:Kafka 支持事务,消费者可使用事务性处理来确保消息的幂等性。在处理消息之前,消费者可以启动事务,并在成功处理后提交事务。这样确保消息只会被处理一次。

  5. 异常处理:消费者需正确处理异常情况。如果消息处理失败,消费者应能够重新处理消息而不引入额外的副作用。这可能涉及到将消息从未处理状态切换到已处理状态的机制。

  6. 幂等性测试:对于关键的消息处理逻辑,建议编写单元测试来验证其幂等性。这些测试可以模拟重复消息处理,以确保不会引入重复数据。

以下是一个简单的 Java 示例,展示了如何实现 Kafka 消费端的幂等性:

import org.apache.kafka.clients.consumer.ConsumerRecord;  
import org.apache.kafka.clients.consumer.ConsumerRecords;  
import org.apache.kafka.clients.consumer.KafkaConsumer;  
import org.apache.kafka.common.serialization.StringDeserializer;
import java.time.Duration;  
import java.util.Collections;  
import java.util.Properties;
public class KafkaConsumerIdempotenceDemo {  
    public static void main(String[] args) {  
        // 配置消费者参数  
        Properties props = new Properties();  
        props.put("bootstrap.servers", "localhost:9092");  
        props.put("group.id", "test-group");  
        props.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");  
        props.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeserializer");  
        props.put("auto.offset.reset", "earliest");
        // 创建消费者实例  
        KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
        // 订阅 topic  
        consumer.subscribe(Collections.singletonList("test-topic"));
        // 消费消息  
        while (true) {  
            ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100));  
            for (ConsumerRecord<String, String> record : records) {  
                // 处理消息  
                processMessage(record.value());
                // 提交偏移量  
                consumer.commit(record.offset());  
            }  
        }
        // 关闭消费者  
        consumer.close();  
    }
    private static void processMessage(String message) {  
        // 实现幂等处理逻辑,例如使用数据库事务或悲观锁  
        // 这里仅作为示例,模拟数据库操作  
        System.out.println("Processing message: " + message);  
    }  
}

在这个示例中,我们创建了一个 Kafka 消费者,订阅了名为 test-topic 的 topic。在 poll 方法中获取消息,并对每条消息进行幂等处理。处理完成后,使用 commit 方法提交消费偏移量。

需要注意的是,这个示例仅实现了消费端的幂等性,并未涉及生产端的消息幂等。

在实际应用中,为了确保消息的幂等性,需要在生产端和消费端都进行相应的处理。此外,Kafka 的幂等性仅保证了单个分区内的消息不重复,不同分区之间仍有可能出现重复消息。如需保证多个分区的幂等性,可以考虑使用 Kafka 的事务功能。

12、说说kafka消费者群组?

Kafka 消费者群组是 Kafka 中用于协同消费主题中消息的机制。它允许多个消费者协同工作,以从一个或多个主题中消费消息。

  1. 多个消费者:一个消费者群组可以包括多个消费者。这些消费者可以在不同的应用程序或服务器上运行。

  2. 主题分区:Kafka 主题通常被划分为多个分区,每个分区包含一部分消息。消费者群组可以同时消费多个分区中的消息。

  3. 负载均衡:Kafka 自动分配分区给消费者,以实现负载均衡。每个分区通常只分配给一个消费者来避免重复消费。

  4. 水平扩展:通过增加消费者,可以水平扩展消费者群组以处理更多的消息。

  5. 消费者位移管理:Kafka 为每个消费者群组中的消费者维护位移(offset),表示它们在每个分区中的消费位置。这确保了消费者能够从上次停止的位置继续消费。

  6. 消费者协作:在同一个消费者群组中,每个分区只能由一个消费者消费。这有助于避免重复消费。

  7. 消息处理并行性:每个消费者可以在独立的线程中处理消息,从而提高消息的处理并行性。

  8. 自动重平衡:如果有新的消费者加入或旧的消费者退出,Kafka 自动触发群组的重平衡,以重新分配分区,确保负载均衡。

  9. 消费者状态监控:Kafka 提供监控工具来跟踪消费者群组的状态,包括消费速度和位移。

13、说说kafka生产端分配,路由?

Kafka 生产端的主要职责是将消息发送至 Kafka 集群,并确保消息被正确路由至适宜的主题和分区。在这一过程中,Kafka 采用了分区策略来对消息进行分区,从而实现消息在各个分区间的均衡分布。

以下是一些相关概念:

  1. 主题:Kafka 中的主题作为消息的逻辑容器,生产者可以将消息发送至一个或多个主题。主题通常代表一类消息,如日志、事件或其他数据类型。

  2. 分区:Kafka 主题可以被划分为多个分区,每个分区是消息的物理存储单元。分区可以并行处理消息,并且每个分区都有独立的偏移量(offset)来跟踪已消费的消息。

  3. 分区策略:用于确定消息将被发送至哪个分区的规则。Kafka 提供了多种分区策略,包括轮询(Round-robin)、哈希(Hashing)和自定义分区策略。生产者可根据需求选择合适的分区策略。

  4. 轮询分区策略:轮询分区策略是最简单的策略,它按顺序将消息发送至不同分区。这确保了消息在不同分区间的均匀分布,适用于负载均衡场景。

  5. 哈希分区策略:哈希分区策略根据消息的键(Key)进行哈希计算,将相同键的消息路由至相同分区。这保证了具有相同键的消息始终进入同一分区。

  6. 自定义分区策略:开发人员可以编写自定义分区策略,根据特定业务逻辑将消息路由至分区。这使得消息路由更具灵活性。

  7. Producer API:Kafka 为各种编程语言提供了生产者 API,方便开发人员将消息发送至 Kafka 集群,并可根据需求配置分区策略。

  8. 生产者确认:Kafka 生产者可以配置确认机制,以确保消息成功写入分区。这包括确认(acknowledgment)机制,生产者在等待分区确认后才会认为消息发送成功。

  9. 消息分布:消息将根据分区策略分布至不同分区,生产者可以向不同分区发送消息,以确保消息的分布和处理。

Kafka 生产端(Producer)在发送消息时,需要考虑消息的分发策略。Kafka 生产端通过路由来将消息发送到与主题(Topic)关联的分区(Partition),从而实现高效的消息处理和数据分布。以下是一个使用 Java 实现 Kafka 生产端的路由和分配的示例:

1. 添加 Maven 依赖

<dependency>  
    <groupId>org.apache.kafka</groupId>  
    <artifactId>kafka-clients</artifactId>  
    <version>2.8.0</version>  
</dependency>

2. 创建 Kafka 生产者并配置参数

import org.apache.kafka.clients.producer.KafkaProducer;  
import org.apache.kafka.clients.producer.Producer;  
import org.apache.kafka.clients.producer.ProducerConfig;  
import org.apache.kafka.common.serialization.StringSerializer;
import java.util.Properties;
public class KafkaProducerDemo {  
    public static void main(String[] args) {  
        // 配置生产者参数  
        Properties props = new Properties();  
        props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092");  
        props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());  
        props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
        // 创建 Kafka 生产者实例  
        Producer<String, String> producer = new KafkaProducer<>(props);
        // 发送消息  
        for (int i = 0; i < 10; i++) {  
            String message = "Hello, Kafka!" + i;  
            producer.send(new KeyValue<>(message, message));  
        }
        // 关闭生产者  
        producer.close();  
    }  
}

在上面的示例中,我们创建了一个 Kafka 生产者,并配置了 bootstrap.servers、key.serializer 和 value.serializer 等参数。生产者将消息发送到名为"test-topic"的主题,该主题有两个分区(Partition)。

3. 实现路由和分配

Kafka 生产端通过路由和分配策略来决定将消息发送到哪个分区。这可以通过实现自定义的 Partitioner 类来实现。以下是一个简单的示例,将消息发送到分区序号小于等于消息序号的分区:

import org.apache.kafka.clients.producer.Partitioner;  
import org.apache.kafka.common.requests.SendResult;
import java.util.List;
public class CustomPartitioner implements Partitioner {  
    @Override  
    public int partition(String topic, Object key, int numPartitions) {  
        int partition = (int) (key.hashCode() % numPartitions);  
        if (partition < 0) {  
            partition = 0;  
        }  
        return partition;  
    }
    @Override  
    public void send(SendResult sendResult, List<Object> records) {  
        // 实现自定义发送逻辑,例如记录发送结果  
    }  
}

4. 使用自定义分区器发送消息

在之前的生产者示例中,我们将 Partitioner 类替换为自定义的 CustomPartitioner。然后,将消息发送到与主题关联的分区:

// 创建自定义分区器  
CustomPartitioner partitioner = new CustomPartitioner();
// 发送消息  
for (int i = 0; i < 10; i++) {  
    String message = "Hello, Kafka!" + i;  
    SendResult sendResult = producer.send(new KeyValue<>(message, message), partitioner);  
    System.out.printf("Message %s sent to partition %d with offset %d%n", message, sendResult.getPartition(), sendResult.getOffset());  
}

14、说说MySQL索引,为什么B+树?

  1. 平衡性:B+树是一种自平衡数据结构,能保持树的高度相对较低。这意味着在最坏情况下,查找特定条目的时间复杂度为 O(log n),其中 n 是索引中的条目数量。这对于实现高效检索操作至关重要。

  2. 顺序访问性能:B+树的内部节点包含指向子节点的指针,使 B+树在范围查询中具有较高效率。例如,如需查询某一范围内的数据,B+树可沿叶子节点顺序遍历,从而提高顺序访问性能。

  3. 磁盘读写性能:B+树的节点大小通常与数据库页大小相同,有助于减少磁盘读写操作。较大节点可容纳更多键值对,降低磁盘 I/O 频率。

  4. 有序性:B+树的叶子节点形成有序链表,便于实现范围查询和排序。数据库能快速遍历有序数据。

  5. 范围查询优势:得益于 B+树的有序性,范围查询和排序效率较高。可快速找到指定范围内的数据,无需扫描整个表。

  6. 支持多列索引:B+树索引支持多列组合索引,有助于应对复杂查询条件。

  7. 高扇出性能:B+树具有高扇出性能,即每个内部节点拥有多个子节点。降低树高度,减少磁盘读取次数。

  8. 支持并发操作:B+树索引支持并发插入和删除操作,适用于多用户、多线程的数据库环境。

15、说说主键索引,二级索引?

1. 主键索引

a. 这是一种用于唯一标识每条记录的索引。每个表格仅能设立一个主键索引。

b. 主键索引通常用于加速检索特定记录或进行数据修改操作。

c. 主键索引要求索引列的值唯一且非空。

d. 主键索引作为表的聚集索引,意味着数据按照主键索引的顺序进行物理存储。

e. 主键索引通常具有较高的查找效能,因为它能快速定位到特定行。

2. 二级索引

a. 二级索引是除主键索引以外的一种索引,用于加速特定查询条件的查找。

b. 表可以有多个二级索引,用于加速不同类型的查询,如根据非主键列的条件检索数据。

c. 二级索引的值可以重复,不要求唯一性,因为它们是辅助索引,主要用于快速定位主键值。

d. 二级索引通常包含索引列的值和对应的主键值,以便在查找时可直接找到对应行。

e. 二级索引能提升查询性能,但也会占用额外的存储空间和增加更新操作的开销。

16、说说MySQL ACID?

原子性(Atomicity):

a. 原子性确保事务是不可分割的操作单元,要么全部执行,要么全部不执行。若事务的任何部分失败,整个事务将回滚,数据库状态恢复至初始状态。

b. 原子性旨在防止不完整或部分执行的事务,确保数据库一致性。

一致性(Consistency):

a. 一致性确保事务在执行前后保持数据库的一致性状态。即事务执行后,数据库应从一个一致状态转变为另一个一致状态。

b. 一致性要求事务操作遵循数据库的完整性约束和业务规则,维持数据合法性。

隔离性(Isolation):

a. 隔离性确保并发执行的事务不会相互干扰,每个事务都仿佛在无其他事务干扰的情况下执行。

b. 隔离性分为不同级别(如读未提交、读已提交、可重复读和串行化),以控制并发事务间的相互影响。

持久性(Durability):

a. 持久性确保事务成功提交后,其结果永久存储在数据库中,即使系统崩溃或断电也不会丢失。

b. 数据库系统通常采用日志文件实现持久性,以便在系统崩溃后恢复事务。

17、MySQL 事务隔离级别,RR解决幻读吗?什么场景下会幻读?

1. 什么是幻读?

幻读,又称不可重复读,是一种并发事务问题。它发生在多个事务之间,其中一个事务在某个范围内插入新行,而另一个事务在此范围内尝试查询数据。这可能导致查询事务看到新插入的行,即使在其开始查询之前这些行并不存在。幻读与脏读类似,但关注的是插入操作而非修改操作。

2. RR隔离级别如何解决幻读?

RR 隔离级别通过使用锁或多版本并发控制(MVCC)来解决幻读问题。在 RR 隔离级别下,事务会获取一个范围锁,确保在事务进行中查询的范围内的数据在事务结束前不会被其他事务修改或插入。

例如,如果事务 A 在 RR 隔离级别下查询某个范围的数据,另一个事务 B 想要在相同范围内插入新行,事务 B 将被阻塞,直到事务 A 完成。这样可以防止事务 A 看到事务 B 插入的新行,从而解决幻读问题。

可能出现幻读问题

假设一个在线购物系统,多个用户同时浏览某个商品的库存情况。如果一个用户正在查询库存时,另一个用户刚好购买了最后一件商品,事务A可能在查询时看到商品的数量是1,但在实际购买时,库存已经为0了,这就是幻读问题。

18、MVCC,事务版本号怎么生成,存在哪里?

MVCC(多版本并发控制)是一种并发控制机制,允许数据库系统在同一时间点存在多个版本的数据,以支持事务隔离和并发查询。每个数据行在 MVCC 中都有一个或多个版本号,用于标识数据的不同版本。版本号的生成和存储取决于数据库管理系统的具体实现。

通常,MVCC系统中的版本号是在数据行上生成的,并且通常包括以下信息:

  1. 事务ID(Transaction ID):版本号通常包含生成该版本的事务的唯一标识符或 ID。这使得数据库能够跟踪哪个事务生成了哪个版本的数据。

  2. 时间戳(Timestamp):版本号通常包括生成该版本的时间戳。时间戳可以是事务开始或提交的时间,以及其他时间单位,用于确定版本的时间顺序。

版本号的生成和存储方式因 DBMS 而异,但通常存储在数据行的元数据中,以便系统能够在查询时识别和访问不同版本的数据。数据库系统还维护一个版本控制的数据结构,通常称为版本链或版本表,以跟踪每个数据行的不同版本及其关系。

MVCC 的主要优点是它允许高度并发的读取操作,因为每个事务都可以看到一致性的数据快照,而不会阻塞其他事务的写入操作。不同数据库管理系统的 MVCC 实现方式可能有所不同,但它们都旨在提供高并发性和事务隔离。在查询时,数据库系统会根据当前事务的 ID 或时间戳选择适当版本的数据,以确保事务之间的隔离。

19、说说binlog,redolog,undolog?

1. Binlog(二进制日志):

a. binlog 是 MySQL 数据库中的二进制日志,用于记录数据库的变更操作,如插入、更新和删除。它以二进制形式记录了 SQL 语句或数据变动事件的日志,而不是实际的数据值。

b. 主要用途在于数据库的备份、主从复制和故障恢复。通过分析 binlog,可以还原数据库的历史状态。

2. Redo Log(重做日志):

a. redo log 是数据库管理系统中的一种日志,主要用于记录数据变动操作。它以物理方式记录了对数据库页的更改,而非 SQL 语句。

b. 主要用途是确保事务的持久性(Durability),在数据库系统发生崩溃或故障时,可以使用  redo log 来重放事务,以确保数据的一致性。

3. Undo Log(撤销日志):

a. undo log 也是数据库管理系统中的一种日志,用于记录事务的撤销操作。它包含了事务执行前的数据状态,以便在需要时回滚事务。

b. 主要用途是支持事务的回滚操作和多版本并发控制(MVCC)。在事务发生回滚时,可以使用 undo log 将数据还原到之前的状态。

简单记忆:

  • binlog 用于记录数据更改的逻辑日志,通常用于备份和复制。

  • redo log 用于记录物理数据页的更改,以确保持久性。

  • undo log 用于支持事务的回滚和多版本并发控制。

20、挂了用什么log,主从同步用什么log?

当数据库系统发生崩溃或非正常关闭时,崩溃恢复日志(通常是 redo log)用于重放未完成的事务,以确保数据的持久性。主从同步通常依赖于二进制日志(Binary log)来保持主数据库和从数据库之间的数据一致性,以支持数据库复制和高可用性方案。

数据库管理中存在多种不同类型的日志,各自用于不同的目的,主要包括:

1. Crash Recovery Log(崩溃恢复日志):

  • 用于在数据库系统发生崩溃或非正常关闭时,恢复数据至一致状态。通常,这包括数据库的 redo log,用于重放未完成的事务以确保数据的持久性。

2. Binary Log(二进制日志或Binlog):

  • 用于记录数据库的修改操作,如插入、更新和删除。它通常用于数据库备份、主从复制和数据库同步。

3. Error Log(错误日志):

  • 用于记录数据库系统的错误信息、警告和异常情况。这对于诊断和解决问题非常有帮助。

4. Transaction Log(事务日志):

  • 用于记录事务的操作,以支持数据库的事务性和回滚操作。这包括数据库的 undo log,它用于支持事务的回滚操作。

21、说说binlog的两阶段提交?

两阶段提交(Two-Phase Commit)是分布式系统中的一种事务协议,用于在多个节点上执行原子操作。

在两阶段提交中,事务分为两个阶段:预提交(Pre-Commit)和确认提交(Commit)。

以下是对两阶段提交的具体解释和 Java 实现。

1. 预提交阶段

在这个阶段,事务需要在所有参与者节点上执行,并将执行结果存储在事务日志(如 MySQL 的 binlog)中。此时,事务仍然可以被回滚(Rollback)。

2. 确认提交阶段

在这个阶段,事务已经完成在所有参与者节点上的执行,并且日志已经被持久化。此时,事务不能被回滚,只能向前推进(Commit)或回滚(Rollback)。

以下是 Java 实现的两阶段提交代码示例:

public class TwoPhaseCommit {  
    private final Logger logger = LoggerFactory.getLogger(TwoPhaseCommit.class);
    private final AtomicBoolean committed = new AtomicBoolean(false);
    public void preCommit() {  
        logger.info("Entering pre-commit stage");
        // 在这里执行事务操作,如更新、插入等  
        // ...
        logger.info("Finished pre-commit stage");  
    }
    public void commit() {  
        if (committed.getAndUpdate(true, x -> true)) {  
            logger.info("Entering commit stage");
            // 在这里执行提交操作,如持久化日志、发送确认消息等  
            // ...
            logger.info("Finished commit stage");  
        } else {  
            logger.warn("Commit failed");  
        }  
    }
    public void rollback() {  
        if (committed.getAndUpdate(false, x -> false)) {  
            logger.info("Entering rollback stage");
            // 在这里执行回滚操作,如撤销更新、删除日志等  
            // ...
            logger.info("Finished rollback stage");  
        } else {  
            logger.warn("Rollback failed");  
        }  
    }  
}

在这个示例中,我们使用了一个原子布尔变量 committed 来标记事务是否已经进入确认提交阶段。

在预提交阶段,事务执行操作并将结果记录在日志中。

然后,事务进入确认提交阶段,执行持久化操作并发送确认消息。如果事务已经进入确认提交阶段,那么不能再回滚事务。

使用这个两阶段提交框架,可以确保事务在所有节点上的一致性和原子性。在实际应用中,还需要考虑如何处理事务日志、异常处理、并发控制等问题。

22、算法题:买卖股票

描述

给定一个数组 prices ,它的第 i 个元素 prices[i] 表示一支给定股票第 i 天的价格。

你只能选择某一天买入这只股票,并在未来的某一天卖出。

设计一个算法来计算你所能获得的最大利润。

返回你从这笔交易中能获得的最大利润。如果无法获得任何利润,返回 0 。

思路
  1. 首先,我们需要找到买入和卖出的最佳时机,因此我们需要计算每个元素的价格差。

  2. 接下来,我们需要找到最大的价格差,即最大利润。

  3. 为了找到最大利润,我们可以使用双指针法,一个指针从左边开始,另一个指针从右边开始,比较左右两边的价格差,更新最大利润。

实现:

以下是 Java 代码实现:

public class Main {  
    public static void main(String[] args) {  
        int[] prices = {7, 1, 5, 3, 6, 4};  
        System.out.println(maxProfit(prices));  
    }
    public static int maxProfit(int[] prices) {  
        int buy = Integer.MIN_VALUE, sell = Integer.MIN_VALUE;  
        int maxProfit = 0;
        for (int i = 0; i < prices.length; i++) {  
            int temp = Math.max(buy, prices[i] - sell);  
            buy = Math.max(buy, temp);  
            sell = Math.min(sell, prices[i] - temp);  
            maxProfit = Math.max(maxProfit, sell - buy);  
        }
        return maxProfit;  
    }  
}

当输入 [7, 1, 5, 3, 6, 4] 时,输出结果为 5,表示从这笔交易中能获得的最大利润为 5。

爱奇艺推荐中台探索与实践

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

图片

Uber 是怎么解决2200个微服务爆炸的?

微服务架构是为了让系统变得更容易拓展、更富有弹性。所以,因此各大公司纷纷转向微服务架构,但是在实际的微服务拆分过程中也会遇到不少的问题。甚至会导致 微服务爆炸。

曾经见过,一个单体应用被拆分成40个微服务, 带来非常大的部署、运维成本。拆成微服务之后,部署一套这样的系统,需要三天甚至一周的时间,痛苦不堪。这就是所谓的微服爆炸。

如何合理的进行微服务拆分,减少微服务爆炸?

DDD 中的领域模型构建以及边界上下文的划分,天然的和微服务划分有着异曲同工之妙,因此结合 DDD 领域驱动设计来进行微服务拆分是一种比较好的微服务拆分方案。

为什么要进行微服务拆分?

在进行微服务拆分之前,我们首先应该搞清楚为什么要进行微服务拆分?微服务拆分后会带来怎样的业务价值?在后期维护上面会不会比以前的维护成本更低?

先看看单体应用在业务不断发展的过程中会遇到怎样的问题。

单体应用维护难

随着业务的不断发展,单体应用的功能越来越多,需求不断变化,修改不断进行,单个应用多团队维护就会出现各种团队协作问题,不知不觉中降低了产品的研发效能。而且由于各个业务模块杂糅在一起,一个需求过来后,到底改哪个团队来做,经常在开会的时候吵得脸红脖子粗,增加了时间以及沟通成本。

单体应用更新难

进行需求迭代的时候,也许只修改了某个模块的功能,但是每次发布都是一整个大的包进行发布,其中构建、发包的时间成本会随着应用的迭代逐渐增加,导致整个需求上线过程变更显得十分笨重,不利于进行团队规模的敏捷开发。

单体应用稳定难

由于单体应用故障隔离范围只是线程级别的,单体应用可能会由于某个模块的功能有问题而导致整个服务平台的不可用,因此平台稳定性方面显然不能满足经常变化的业务发展的需要。

鉴于上述问题,我们需要对大泥球似的单体大应用进行合理拆分,以便于适用业务的快速发展。微服务架构拆分之后,团队成员不用都围绕一个大泥球应用转了,根据拆分的不同的业务域,各自负责自己的业务域,维护起来相对来说更加方便。

同事如果有需求迭代,没有功能修改的业务域可以不用发生变更,不需要进行重新部署,大大降低了修改变更导致的平台稳定性性问题。另外由于是微服务分布式架构,不再是单点应用,不再存在单点问题,性能方面也会有所提升。

微服务到底该怎么拆?

微服务到底应该怎么拆呢?应该按照怎样的标准来进行拆分呢?

DDD 的理论中提供了我们进行领域驱动设计的指导方针,对于我们进行微服务的拆分具备天然的指导意义。

DDD 指导我们首先要对当前系统平台的业务进行全面的分析,可以通过用例分析法、事件风暴法以及四色建模法来进行业务分析,使用统一的业务语言进行业务领域建模以及边界上下文的划定,后面我们就可以根据边界上下文来进行具体的微服务的划分了。

采用 DDD 来进行业务建模和服务拆分时,可以参考下面几个阶段:

  1. 使用 DDD(领域驱动建模) 进行业务建模,从业务中获取抽象的模型(例如订单、用户),根据模型的关系进行划分限界上下文。

  2. 检验模型是否得到合适的的抽象,并能反映系统设计和响应业务变化。

  3. 从 DDD 的限界上下文往微服务转化,并得到系统架构、API列表、集成方式等产出。

  4. 沉淀出通用的技术服务、通用的业务服务,形成技术中台+业务中台,实现企业级的通用能力复用

业务服务的建模

所谓业务能力就是平台的具体实现的业务功能是什么,比如:在电商业务中物流域我们按照业务可以划分为仓储、运输、配送、计费等业务领域。大的领域划分出来之后,我们可以用真实的业务流程来串联这些业务领域。

当业务流程经过这些业务领域的时候,必定会触发一些领域事件,经历一些业务流程,那么在这个过程中我们就可以梳理出对应的实体、值对象以及聚合根,我们将具有紧密业务逻辑关系的实体以及值对象收敛在聚合根的周围,从而形成聚合。

例如在仓储领域中就会涉及到入库、库内操作以及出库这三大流程,其中入库主要包括质检、收货以及上架。

这其中涉及到的实体主要用入库单、货品、操作员等,其中入库单就是聚合根,通过它可以将货品、操作员等实体以及值对象聚合起来,形成入库聚合

图片

如何找到上下文边界

DDD的方法论中,是如何找到上下文边界呢?

事件风暴法中,要求业务需求提出者和技术实施者协作完成领域建模。

把系统状态做出改变的事件作为关键点,从系统事件的角度出发,提取能反应系统运作的业务模型。再进一步识别模型之间的关系,划分出限界上下文,限界上下文可以看做逻辑上的微服务。

事件是系统数据流中的关键点,类似于电影制作中的关键帧。

在未建立模型之前,系统就像是一个黑盒,不断的刺探系统的状态的变化就可以识别出某种反应系统变化的实体。

例如:系统管理员可以登录、创建商品、上架商品,对应的系统状态的改变是用户已登录、商品已创建、商品已经上架;

相应的,顾客可以登录、创建订单、支付,对应的系统状态改变是用户已登录、订单已创建、订单已支付。

于是可以通过收集上面的事件看出:

  • 商品相关事件是对系统中商品状态做出的改变,商品可以表达系统中某一部分,商品可以作为模型”。

  • 订单相关事件是对系统中订单状态做出的改变,订单可以表达系统中某一部分,订单可以作为模型”。

在得到模型之后,通过分析模型之间的关系得出限界上下文。

例如商品属性和商品相对于用户、用户组关系更为密切,通过这些关系作出限界上下文拆分的基本线索。

其次是识别模型中的二义性,进一步让限界上下文更为准确。

在电商领域,另外一个不恰当设计的例子是:

把订单的订单项当做和商品同样的概念划分到了商品服务,当订单需要修改订单下的商品信息时,需要访问商品服务,这势必造成了订单和商品服务的耦合。

合理的设计应该是:

商品服务提供商品的信息给订单服务,但是订单服务没有理由修改商品信息,而是访问作为商品快照的订单项。订单项应该作为一个独立的概念被划分到订单服务中,而不是和商品使用同一个概念,甚至共享同一张数据库表。

典型具有”二义性“陷阱的场景,如下:

”地址“和”商品“在不同的系统中实际上表达不同的含义,这就是术语”上下文“的由来。

一组关系密切的模型形成了上下文(context),二义性的识别能帮我们找到上下文的边界(bounded)。

验证和评审领域模型

前面我们说到限界上下文可以作为逻辑上的微服务,并不意味着我们可以直接把限界上下文变成微服务。

在这之前很重要的一件事情是对模型进行验证,如果我们得到的限界上下文被抽象的不良好,在微服务实施后并不能得到良好的拓展性和重用。

限界上下文被设计出来后,验证它的方法可以从我们采用微服务的两个目的出发:降低耦合、容易扩展,可以作为限界上下文评审原则:

  1. 原则1:设计出来的限界上下文之间的互相依赖应该越少越好,依赖的上游不应该知道下游的信息。(被依赖者,例如订单依赖商品,商品不需要知道订单的信息)。

  2. 原则2:使用潜在业务进行适配,如果能在一定程度上响应业务变化,则证明用它指导出来的微服务可以在相当一段时间内足以支撑应用开发。

图片

一般抽象程度的领域模型

上图是一个电信运营商的领域模型的局部,这部分展示了电信号码资源以及群组、用户、宽带业务、电话业务这几个限界上下文。主要业务逻辑是,系统提供了号码资源,用户在创建时会和号码资源进行绑定写卡操作,最后再开通电话或宽带业务。在开通电话这个业务流程中,号码资源并不需要知道调用者的信息。

但是理想的领域模型往往抽象程度、成本、复用性这几个因素中获取平衡,软件设计往往没有理想的领域模型,大多数情况下都是平衡各种因素的苟且,因此评审领域模型时也要考虑现实的制约。

“抽象”的成本

用一个简单的图来表达话,我们的领域模型设计往往在复用性和成本取得平衡的中间区域才有实用价值。

前面电信业务同样的场景,业务专家和架构师表示,我们需要更为高度的抽象来满足未来更多业务的接入,因此对于两个业务来说,我们需要进一步抽象出产品和订单的概念。

但是同时需要注意到,我们最终落地时的微服务会变得更多,也变得更为复杂,当然优势也是很明显的 —— 更多的业务可以接入订单服务,同时订单服务不需要知道接入的具体业务。对于用户的感知来说,可以一次办理多个业务并统一支付了,这正是某电信当前的痛点之一。

高度抽象的领域模型

通用服务的拆分

这里的通用服务其实包含两个意思

  • 对于微服务本身来说,通用服务就是将各个微服务都涉及到的通用能力进行抽象形成单独的微服务。

  • 但是对于整个业务平台来说,通用能力实际就是技术中台+业务中台。

1、通用技术服务(基础技术服务)

所谓通用服务就是在各个微服务之间都会碰到的问题,比如说接口的鉴权、日志的监控和管理、服务状态的监控和管理以及服务幂等等分布式系统问题。

因此,我们需要将这些微服务的通用服务进行统一的抽象,形成通用的基础服务,这样微服务本身只需要关注自身的业务,这些微服务通用的能力由单独的基础服务来进行实现。

2、通用业务服务(业务中台服务)

我们还是拿大家最熟悉的电商业务来举个栗子吧,电商的业务形态有很多种,就阿里巴巴来说,有淘宝、天猫、主打生鲜的盒马、天猫超市等等。

不管上层的业务形态有怎样的变化,实际上他们都是有比较核心的业务域是通用的,比如用户、支付、仓储、物流等等。

那么实际上这些通用的业务对于整个电商平台来说实际就是通用能力,因此我们需要将这些通用的公共的能力进行下沉,形成业务中台,实现企业级的通用业务能力复用。

微服务拆分基本原则

在进行微服务拆分的过程中,有几条笔者总结的原则大家可以参考下,在实操的时候如果没有原则来遵循,

实际我们自己也没办法去评判微服务拆分的效果到底有没有达到我们的预期。

大致的原则如下:

1.功能性原则:微服务尽可能的按照业务逻辑上的功能去拆分,保证每个模块只包含一种主要的业务能力。

2.低耦合原则:微服务拆分的最终目的是要使系统中的各个模块之间兼容性最大化,稳定性最高,避免出现耦合性太强,维护和开发困难的情况。

在进行微服务拆分之前,应该对平台进行完整的领域划分,建立合适的领域模型,确定好边界上下文,并以此作为微服务拆分的指导。

隔离变与不变:将领域模型的稳定与不断变化的外部需求进行隔离,保证核心领域模型的稳定,避免领域模型之间的强依赖。从而达到实现微服务高内聚低耦合的目的。

3.可复用原则:微服务拆分的时候,在可复用的地方要做到复用,但复用并不会改变各单元功能的相对稳定性和可拆分性。

4.扩展性原则:微服务拆分的时候,要做到不同模块之间的功能关系简单清晰,尽可能保证不同单元的拓展性。

5.服务拆分要把握度, 避免过度拆分,导致微服务爆炸

如果在微服务拆分过程中发生过度拆分,就会导致微服务爆炸的情况。

微服务爆炸不可避免的增加软件系统的维护成本,同时由于拆分也会导致业务流程变长.

原本一两个服务就完成的业务,拆分后需要在五六个甚至更多的微服务才能完成,增加了平台出现 Bug 的概率,不知不觉中降低了平台的稳定性。

微服务爆炸意味着需要更多的服务器资源,从而在无形中增加了业务成本。因此我们可以借助于 DDD 划分的边界上下文,防止微服务过度拆分情况的发生。

Uber 微服务爆炸带来的问题

Uber 2018 年微服务架构依赖关系图

图片

随着Uber业务的发展,微服务个数达到了近 2200 个,微服务爆炸式的增长以及依赖关系的复杂性,为Uber带来了不少问题。

使用微服务架构,单体变成了很多个黑盒,黑盒的功能随时都可能发生变化,很容易导致意外行为发生

1)例如,为了找到一个问题的根源,工程师们需要跨 12 个不同的团队,检查大约 50 个服务。

2)理解服务之间的依赖关系变得相当困难,因为服务之间的调用层次可能会非常深; 一个依赖项的延迟会导致上游出现级联问题。如果没有对的工具,是不可能了解系统中发生了什么事情的,调试也变得非常困难。

3)为了构建一个简单的特性,工程师通常要跨多个服务,而这些服务可能属于不同的个人和团队。这需要在会议、设计和代码审查方面花费大量时间进行协作

4)当团队在彼此的服务中修改代码,修改彼此的数据模型,甚至代替服务所有者进行部署时,原本清晰的服务所有权边界就被破坏了。于是就形成了分布式单体,为了部署一个变更,原本看似独立的服务必须一起部署。

Uber如何解决2000多个微服务带来的复杂性问题?

微服务变多之后带来了问题,总结一下 :

调用依赖多导致的开发协作变多、链路变长理解以及排查困难、部署依赖导致需要同时发布。

那么,Uber是怎么解决这些问题的呢?

答案是:面向领域微服务架构  DOMA。

“面向领域微服务架构”(Domain-Oriented Microservice Architecture,DOMA)借鉴了已有的组织代码的方法,如领域驱动设计、Clean Architecture、面向服务架构,以及面向对象和面向接口的设计模式

我们认为 DOMA 的创新之处在于,它是在大型组织的大型分布式系统中利用已有的设计原则建立起来的一种相对新颖的方式。

与 DOMA 相关的核心原则和术语如下:

  1. 领域:

    我们不是围绕单个微服务,而是围绕相关的微服务集合。我们把这个叫作领域。

  2. 分层设计:

    我们进一步创建领域集合,称之为层。

    领域所属的层建立了领域微服务可以拥有的依赖关系。我们把这个叫作分层设计。

  3. 网关:

    我们为集合的单一入口点提供了干净的接口,称之为网关。

  4. 扩展:每个领域应该与其他领域无关

    也就是说,一个领域不应该在其代码库或数据模型中硬编码与另一个领域相关的逻辑。

    由于团队经常需要在另一个团队的领域里包含逻辑 (例如,自定义验证逻辑或数据模型的元上下文),我们提供了一个扩展架构来支持领域的扩展点。

换句话说,通过提供系统性架构、领域网关和预定义扩展点,DOMA 让微服务架构变得更好理解:一组灵活、可重用和分层的结构化组件

1.关于领域划分:

Uber 领域表示一个或多个微服务的集合,这些微服务与功能的逻辑分组相关联。

在设计领域时的一个常见问题是“一个领域应该多大?”

我们在这里不提供任何建议。有些领域可以包含数十个服务,有些领域只能包含一个服务。

关键是要仔细思考每个集合的逻辑角色

例如,我们的地图搜索服务构成一个领域,收费服务是一个领域,匹配平台 (匹配乘客和司机) 是一个领域。

这些也并不总是遵循公司的组织结构。

Uber Maps 本身被分为三个领域,在 3 个不同的网关后面部署了 80 个微服务。

2.分层设计

分层设计回答了在优步的微服务架构中“什么服务可以调用其他服务?”。

因此,我们可以将分层设计看作是“规模化的关注点分离”。

或者,我们可以把分层设计看作是“规模化的依赖管理”。

分层设计描述了一种机制,用于考虑Uber服务依赖关系中的故障影响范围和产品专用性。

随着domain从底层迁移到顶层,它们在停机时影响的服务更少,并代表更具体的产品用例。

相反,底层的功能具有更多的依赖关系,因此往往具有更大的故障影响范围,并代表一组更通用的业务功能。

下图说明了这个概念。

我们可以将顶层看作是特定的用户体验(如移动功能),而底层则是通用的业务功能(如帐户管理)。

特定层只依赖于它们下面的层,这为我们思考故障影响范围和domain集成等问题提供了有益的启发。

值得注意的是,功能通常会从这个图表的特定部分“向下”移动到更一般的部分。

可以想象,随着需求的发展,一个简单的特性最终会越来越像一个平台。

事实上,这种向下迁移是可预见的,优步的许多核心业务平台一开始是乘客或司机特定的功能,随着我们发展更多的业务线,它们变得更加一般化,并具有更多的依赖性(如Uber送餐或货运)。

在Uber内部,我们建立了以下五个层。

  1. 基础设施层:提供任何团队都可以使用的功能。这是优步对存储或网络等重大工程问题的答案。

  2. 业务层:提供优步作为一个组织可以使用的功能,但不只针对特定的产品类别或业务线(LOB),如乘车、餐饮或货运。

  3. 产品层:提供与特定产品类别或业务线相关的功能,但与移动应用程序无关,如“请求乘车”逻辑。

  4. 表示层:提供面向消费者的应用服务(移动/web)

  5. 边缘层:将优步服务安全地对外开放。这一层也是移动应用程序感知的。

正如您所看到的,每个后续层都代表了一个日益特定的功能分组,并且具有越来越小的故障影响范围(换句话说,依赖于该层功能的组件越来越少)

3.关于网关

“网关”在微服务架构中已经是一个很广泛的概念。

我们的定义与已有的定义差别不大,只是我们倾向于将网关看作是底层服务集合 (我们称之为领域) 的单个入口点。

Uber的网关视图示例,这是网关的一个高级视图。

  • 网关隐藏了领域的内部细节:微服务、数据表、ETL 管道等

  • 网关只有接口被公开给其他领域:RPC API、消息传递事件和查询。

由于上游只需要调用网关,而不是依赖某个领域中可能存在的多个下游服务,所以,网关在未来迁移、可发现性和降低系统复杂性方面提供了许多好处。

如果我们从面向对象设计的角度来,网关就是接口定义,让我们能够基于底层“实现”(在这里就是底层微服务集合) 做任何我们想做的事情。

我们可以看到,从逻辑上划分【业务领域】,从物理上使用【网关】聚合领域内的微服务对外接口封装,从而减少调用方对内部多个微服务细节的了解,体现了抽象和封装的价值

4.扩展

扩展表示一种扩展域的机制。扩展的基本定义是,它提供了一种扩展基础服务功能的机制,而无需更改该服务的实际实现,也不会影响其整体可靠性。在Uber,我们提供了两种不同的扩展模型:逻辑扩展和数据扩展。扩展的概念使我们能够将架构扩展到能够独立工作的多个团队。

逻辑扩展

逻辑扩展提供了一种扩展服务的底层逻辑机制。

对于逻辑扩展,我们使用提供程序或插件模式的变体,其接口是以服务为基础定义的。

这样一来,  使得扩展团队可以在不修改底层平台核心代码的情况下,以接口驱动的方式实现扩展逻辑。

如,一个司机上线到系统的api接口,通常,我们会进行各种检查,来确保司机可以运营(安全检查、合规等)。每一个检查都属于一个单独的团队。

实现这一api接口的一种方法,是让每个团队在同一个接口编写各自的处理逻辑,但是,这可能会引入复杂性。并且,每个检查都需要定制的、完全不相关的逻辑。

怎么做逻辑扩展?

或者说,怎么把上线接口将变成一个可以逻辑扩展的接口。

一般情况是,通过插件式的机制,进行检查的扩展,每个扩展都符合预定义的请求类型和响应。每个团队将注册一个负责执行此逻辑的扩展。在这种情况下,它们可能只是取一些关于驱动程序的上下文,然后返回一个bool,说明司机是否可以上线。api接口将简单地遍历这些响应,并确定其中是否有错误。

逻辑扩展的架构的优势:将核心代码与每个扩展解耦,并提供了扩展之间的隔离,它不知道其他逻辑在执行什么。围绕这一点,就能很容易建立更多的功能,比如可观察性等。

数据扩展

数据扩展提供了一种将任意数据附加到接口的机制,来避免核心平台数据模型中的臃肿。

对于数据扩展,我们利用Protobuf的Any功能,这样团队可以将任意数据添加到请求中。

服务通常会存储这些数据或将其传递给逻辑扩展,这样核心平台就永远不会负责反序列化(从而 "知道")这个任意上下文。

Any 定义的变量就是一个基础类,就像Java中的Object.class。

类似于声明变量,然后可以转成自己需要的任意类

在java中封包和拆包的参考代码:

 Foo foo = ...;
 Any any = Any.pack(foo);
 ...
 if (any.is(Foo.class)) {
   foo = any.unpack(Foo.class);
 }
自定义扩展

在逻辑和数据扩展之外,Uber的很多团队都推出了自己适合自己领域的扩展模式。

例如,与我们的展示架构绑定的很多集成都使用了基于DAG的任务执行逻辑。

DOMA的收益

DOMA 是 Uber 产品和平台团队一致努力的结果。平台支持成本通常下降一个数量级。产品团队从护栏和加速开发中获益。

例如,我们的扩展体系结构的早期平台消费者能够通过采用一种扩展体系结构,将划分优先级和集成新功能的时间从三天缩短到三小时,从而缩短了消费者的代码审查、规划和学习曲线时间。

以前,产品团队需要调用许多下游服务来利用一个域;使用DOMA架构之后,现在他们只需要调用一个。

通过减少接触点数量,平台能够将登录时间减少 25-50%。此外,我们能够将 2200 个微服务分为 70 个域。其中大约有 50%已经实施,而且大多数都有一些未来采用的计划。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值