记录些Spring+题集(45)

25Wqps高吞吐写MySQL,100W数据4秒写完,如何实现?

什么是JDBC的批量处理语句?

当需要成批插入或者更新记录时,可以采用Java的批量更新机制,这一机制允许多条语句一次性提交给数据库批量处理。通常情况下,批量处理 比单独提交处理更有效率

JDBC的批量处理语句包括下面三个方法:

  • addBatch(String):添加需要批量处理的SQL语句或是参数;

  • executeBatch():执行批量处理语句;

  • clearBatch():清空缓存的数据

通常我们会遇到两种批量执行SQL语句的情况:

  • 多条SQL语句的批量处理;

  • 一个SQL语句的批量传参;

方式一:普通插入

看看普通插入10000W条记录的 性能数据

/**
 * 方式一
 * 普通批量插入,直接将插入语句执行多次即可
 */
@Test
public void bulkSubmissionTest1() {
    long start = System.currentTimeMillis();//开始计时【单位:毫秒】
    Connection conn = jdbcUtils.getConnection();//获取数据库连接
    String sql = "insert into a(id, name) VALUES (?,null)";
    PreparedStatement ps = null;
    try {
        ps = conn.prepareStatement(sql);
        for (int i = 1; i <= 1000000; i++) {
            ps.setObject(1, i);//填充sql语句种得占位符
            ps.execute();//执行sql语句
        }
    } catch (SQLException e) {
        e.printStackTrace();
    } finally {
        jdbcUtils.close(conn, ps, null);
    }
    //打印耗时【单位:毫秒】
    System.out.println("百万条数据插入用时:" + (System.currentTimeMillis() - start)+"【单位:毫秒】");
}

用时

图片

折算:3736/60= 62分钟多

方式二:使用批处理插入

使用PreparedStatement

JDBC的批量处理语句包括下面三个方法:

  • addBatch(String): 将sql语句打包到一个Batch容器中,  添加需要批量处理的SQL语句或是参数;

  • executeBatch():将Batch容器中的sql语句提交,  执行批量处理语句;

  • clearBatch():清空Batch容器,为下一次打包做准备

注意 这三个方法实现sql语句打包,累计到一定数量一次提交

@Test
public void bulkSubmissionTest2() {
    long start = System.currentTimeMillis();
    Connection conn = jdbcUtils.getConnection();//获取数据库连接
    String sql = "insert into a(id, name) VALUES (?,null)";
    PreparedStatement ps = null;
    try {
        ps = conn.prepareStatement(sql);
        for (int i = 1; i <= 1000000; i++) {
            ps.setObject(1, i);
            ps.addBatch();//将sql语句打包到一个容器中
            if (i % 500 == 0) {
                ps.executeBatch();//将容器中的sql语句提交
                ps.clearBatch();//清空容器,为下一次打包做准备
            }
        }
        //为防止有sql语句漏提交【如i结束时%500!=0的情况】,需再次提交sql语句
        ps.executeBatch();//将容器中的sql语句提交
        ps.clearBatch();//清空容器
    } catch (SQLException e) {
        e.printStackTrace();
    } finally {
        jdbcUtils.close(conn, ps, null);
    }
    System.out.println("百万条数据插入用时:" + (System.currentTimeMillis() - start)+"【单位:毫秒】");
}

用时

图片

折算:3685/60= 61分钟多

方式一、二总结:到此可以看出其实批处理程序是没有起作用的

方式三:设置数据源的批处理重写标志

通过连接配置url设置&rewriteBatchedStatements=true,打开驱动的rewriteBatchedStatements 开关

在方式二的基础上, 允许jdbc驱动重写批量提交语句,在数据源的url需加上 &rewriteBatchedStatements=true ,表示(重写批处理语句=是)

驱动的url 设置参考如下:

spring.datasource.url = jdbc:mysql://localhost:3306/seckill?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=GMT%2B8&zeroDateTimeBehavior=convertToNull&allowMultiQueries=true&rewriteBatchedStatements=true
spring.datasource.username = root
spring.datasource.password = 123456

再执行 jdbc 的批处理

/**
 * 方式三
 */
@Test
public void bulkSubmissionTest3() {
    long start = System.currentTimeMillis();
    Connection conn = jdbcUtils.getConnection();//获取数据库连接
    String sql = "insert into a(id, name) VALUES (?,null)";
    PreparedStatement ps = null;
    try {
        ps = conn.prepareStatement(sql);
        for (int i = 1; i <= 1000000; i++) {
            ps.setObject(1, i);
            ps.addBatch();
            if (i % 500 == 0) {
                ps.executeBatch();
                ps.clearBatch();
            }
        }
        ps.executeBatch();
        ps.clearBatch();
    } catch (SQLException e) {
        e.printStackTrace();
    } finally {
        jdbcUtils.close(conn, ps, null);
    }
    System.out.println("百万条数据插入用时:" + (System.currentTimeMillis() - start)+"【单位:毫秒】");
}

用时:

图片

折算:10s

从60分钟到10s, 提升了多少倍?

360倍

到此批处理语句才正是生效

注意

数据库连接的url设置了【&rewriteBatchedStatements=true】时,java代码种的sql语句不能有分号【;】号,

否则批处理语句打包就会出现错误,导致后面的sql语句提交出现【BatchUpdateException】异常

图片

批量更新异常:BatchUpdateException

图片

rewriteBatchedStatements底层原理

从 Java 1.2 开始,该Statement接口一直提供addBatch我们可以用来批处理多个语句的接口,以便在调用executeBatch方法时通过单个请求发送它们,如下面的示例所示:

String INSERT = "insert into post (id, title) values (%1$d, 'Post no. %1$d')";

try(Statement statement = connection.createStatement()) {
    for (long id = 1; id <= 10; id++) {
        statement.addBatch(
            String.format(INSERT, id)
        );
    }
    statement.executeBatch();
}

通过分析源码,会发现以下代码块:

if (this.rewriteBatchedStatements.getValue() && nbrCommands > 4) {
    return executeBatchUsingMultiQueries(
        multiQueriesEnabled,
        nbrCommands,
        individualStatementTimeout
    );
}

updateCounts = new long[nbrCommands];

for (int i = 0; i < nbrCommands; i++) {
    updateCounts[i] = -3;
}

int commandIndex = 0;

for (commandIndex = 0; commandIndex < nbrCommands; commandIndex++) {
    try {
        String sql = (String) batchedArgs.get(commandIndex);
        updateCounts[commandIndex] = executeUpdateInternal(sql, true, true);
         
        ...
    } catch (SQLException ex) {
        updateCounts[commandIndex] = EXECUTE_FAILED;

        ...
    }
}

因为rewriteBatchedStatements 标志is  flase ,每个 INSERT 语句还是单独执行 executeUpdateInternal, 并没有走 executeBatchUsingMultiQueries 批处理逻辑。

因此,即使我们使用addBatch ,默认情况下,MySQL 在使用普通 JDBC对象executeBatch时仍会单独执行 INSERT 语句。

但是,如果我们启用rewriteBatchedStatementsJDBC 配置属性

方式一:在springboot应用中,调整dataSource的 url 参数

spring.datasource.url = jdbc:mysql://localhost:3306/seckill?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=GMT%2B8&zeroDateTimeBehavior=convertToNull&allowMultiQueries=true&rewriteBatchedStatements=true

方式二:通过dataSource的方法设置

MysqlDataSource dataSource = new MysqlDataSource();

String url = "jdbc:mysql://localhost/high_performance_java_persistence?useSSL=false";

dataSource.setURL(url);
dataSource.setUser(username());
dataSource.setPassword(password());

dataSource.setRewriteBatchedStatements(true);

设置完了之后,调试executeBatch方法执行,你会看到:

这一次,executeBatchUsingMultiQueries被调用了

if (this.rewriteBatchedStatements.getValue() && nbrCommands > 4) {
    return executeBatchUsingMultiQueries(
        multiQueriesEnabled,
        nbrCommands,
        individualStatementTimeout
    );
}

并且该executeBatchUsingMultiQueries方法会将各个 INSERT 语句拼接到 queryBuf (一个StringBuilder),

拼接后,运行单个execute调用:

StringBuilder queryBuf = new StringBuilder();

batchStmt = locallyScopedConn.createStatement();
JdbcStatement jdbcBatchedStmt = (JdbcStatement) batchStmt;

...

int argumentSetsInBatchSoFar = 0;

for (commandIndex = 0; commandIndex < nbrCommands; commandIndex++) {
    String nextQuery = (String) this.query.getBatchedArgs().get(commandIndex);

    ...

    queryBuf.append(nextQuery);
    queryBuf.append(";");
    argumentSetsInBatchSoFar++;
}

if (queryBuf.length() > 0) {
    try {
        batchStmt.execute(queryBuf.toString(), java.sql.Statement.RETURN_GENERATED_KEYS);
    } catch (SQLException ex) {
        sqlEx = handleExceptionForBatch(
            commandIndex - 1, argumentSetsInBatchSoFar, updateCounts, ex
        );
    }

    ...
}

因此,对于普通的 JDBCStatement批处理,MySQLrewriteBatchedStatements配置属性将附加当前批处理的语句并在单个数据库往返中执行它们。

将 rewriteBatchedStatements 与 PreparedStatement 一起使用

使用 JPA 和 Hibernate 时, SQL 语句都将使用 JDBC 执行PreparedStatement,而不是Statement,这是有充分理由的:

  • PreparedStatement准备好的语句允许你增加语句缓存的可能性

  • PreparedStatement准备好的语句允许你避免 SQL 注入攻击,因为你绑定参数值而不是像我们在之前的String.format调用中那样注入它们。

但是,由于 Hibernate 默认不启用 JDBC 批处理,我们需要提供以下配置属性来激活自动批处理机制:

spring.jpa.properties.hibernate.jdbc.batch_size=10
spring.jpa.properties.hibernate.order_inserts=true
spring.jpa.properties.hibernate.order_updates=true

因此,当持久化 10 个Post实体时:

for (long i = 1; i <= 10; i++) {
    entityManager.persist(
        new Post()
            .setId(i)
            .setTitle(String.format("Post no. %d", i))
    );
}

Hibernate 将执行单个 JDBC INSERT,如datasource-proxy (https://vladmihalcea.com/log-sql-spring-boot/)日志条目所示:

Type:Prepared, Batch:True, QuerySize:1, BatchSize:10,
Query:["
    insert into post (title, id) values (?, ?)
"],
Params:[
    (Post no. 1, 1), (Post no. 2, 2), (Post no. 3, 3),
    (Post no. 4, 4), (Post no. 5, 5), (Post no. 6, 6),
    (Post no. 7, 7), (Post no. 8, 8), (Post no. 9, 9),
    (Post no. 10, 10)
]

注意:如果使用IDENTITY实体标识符策略,Hibernate 将无法自动批处理插入语句。看看这篇文章(https://vladmihalcea.com/9-high-performance-tips-when-using-mysql-with-jpa-and-hibernate/)。

因此,使用默认的 MySQL JDBC 驱动程序设置,一条语句被发送到 MySQL 数据库服务器。

但是,如果你检查数据库服务器日志,我们可以看到语句到达后,MySQL 执行每个语句,就好像它们在 for 循环中运行一样:

Query    insert into post (title, id) values ('Post no. 1', 1)
Query    insert into post (title, id) values ('Post no. 2', 2)
Query    insert into post (title, id) values ('Post no. 3', 3)
Query    insert into post (title, id) values ('Post no. 4', 4)
Query    insert into post (title, id) values ('Post no. 5', 5)
Query    insert into post (title, id) values ('Post no. 6', 6)
Query    insert into post (title, id) values ('Post no. 7', 7)
Query    insert into post (title, id) values ('Post no. 8', 8)
Query    insert into post (title, id) values ('Post no. 9', 9)
Query    insert into post (title, id) values ('Post no. 10', 10)
Query    commit

因此,启用rewriteBatchedStatementsMySQL JDBC Driver 设置后:

dataSource.setRewriteBatchedStatements(true);

当我们重新运行之前插入 10 个实体的测试用例时Post,我们可以看到在数据库端执行了以下 INSERT 语句:

Query   insert into post (title, id)
        values ('Post no. 1', 1),('Post no. 2', 2),('Post no. 3', 3),
               ('Post no. 4', 4),('Post no. 5', 5),('Post no. 6', 6),
               ('Post no. 7', 7),('Post no. 8', 8),('Post no. 9', 9),
               ('Post no. 10', 10)
Query   commit

语句更改的原因是 MySQL JDBC 驱动程序现在调用将executeBatchWithMultiValuesClause批处理的 INSERT 语句重写为单个多值 INSERT 的方法。

if (!this.batchHasPlainStatements &&
    this.rewriteBatchedStatements.getValue()) {

    if (getQueryInfo().isRewritableWithMultiValuesClause()) {
        return executeBatchWithMultiValuesClause(batchTimeout);
    }

    ...
}

方式四:通过数据库连接取消自动提交,手动提交数据

在方式三的基础上,取消自动提交sql语句,当sql语句都提交了才手动提交sql语句

需将Connection conn;连接的【conn.setAutoCommit(false)】(设置自动提交=否)

/**
 * 方式四
 *
 */
@Test
public void bulkSubmissionTest4() {
    long start = System.currentTimeMillis();
    Connection conn = jdbcUtils.getConnection();//获取数据库连接
    String sql = "insert into a(id, name) VALUES (?,null)";
    PreparedStatement ps = null;
    try {
        ps = conn.prepareStatement(sql);
        conn.setAutoCommit(false);//取消自动提交
        for (int i = 1; i <= 1000000; i++) {
            ps.setObject(1, i);
            ps.addBatch();
            if (i % 500 == 0) {
                ps.executeBatch();
                ps.clearBatch();
            }
        }
        ps.executeBatch();
        ps.clearBatch();
        conn.commit();//所有语句都执行完毕后才手动提交sql语句
    } catch (SQLException e) {
        e.printStackTrace();
    } finally {
        jdbcUtils.close(conn, ps, null);
    }
    System.out.println("百万条数据插入用时:" + (System.currentTimeMillis() - start)+"【单位:毫秒】");
}

用时:【4秒左右】

图片

汇总一下,批处理操作的详细步骤

上述示例代码中,我们通过 JDBC 连接 MySQL 数据库,并执行批处理操作插入数据。

具体实现步骤如下:

  1. 获取数据库连接。

  2. 创建 Statement 对象。

  3. 定义 SQL 语句,使用 PreparedStatement 对象预编译 SQL 语句并设置参数。

  4. 取消自动提交

  5. 将sql语句打包到一个Batch容器中,  添加需要批量处理的SQL语句或是参数

  6. 执行批处理操作。

  7. 清空Batch容器,为下一次打包做准备

  8. 不断迭代第5-7步,直到数据处理完成。

  9. 关闭 Statement 和 Connection 对象。

使用setAutoCommit(false) 来禁止自动提交事务,然后在每次批量插入之后手动提交事务。

每次插入数据时都新建一个 PreparedStatement 对象以避免状态不一致问题。

在插入数据的循环中,每累计到一定量的数据 如 10000 条数据就执行一次 executeBatch() 插入数据。

另外注意:

  1. 使用批量提交数据,url一定要设置允许重写批量提交rewriteBatchedStatements=true,

  2. sql语句一定不能有分号,否则有BatchUpdateException异常,

  3. 在循环插入时带有适当的等待时间 和批处理大小 ,从而避免内存占用过高等问题:

  • 设置适当的批处理大小:批处理大小指在一次插入操作中插入多少行数据。如果批处理大小太小,插入操作的频率将很高,而如果批处理大小太大,可能会导致内存占用过高。通常,建议将批处理大小设置为1000-5000行,这将减少插入操作的频率并降低内存占用。

  • 采用适当的等待时间:等待时间指在批处理操作之间等待的时间量。等待时间过短可能会导致内存占用过高,而等待时间过长则可能会延迟插入操作的速度。通常,建议将等待时间设置为几秒钟到几十秒钟之间,这将使操作变得平滑且避免出现内存占用过高等问题。

  • 可以考虑使用一些内存优化的技巧,例如使用内存数据库或使用游标方式插入数据,以减少内存占用。

  • 总的来说,选择适当的批处理大小和等待时间可以帮助您平稳地进行插入操作,避免出现内存占用过高等问题。

  1. 索引: 在大量数据插入前暂时去掉索引,最后再打上,这样可以大大减少写入时候的更新索引的时间。

  2. 数据库连接池:使用数据库连接池可以减少数据库连接建立和关闭的开销,提高性能。在没有使用数据库连接池的情况,记得在finally中关闭相关连接。

  3. 数据库参数调整:增加MySQL数据库缓冲区大小、配置高性能的磁盘和I/O等。

  4. 需要根据实际情况优化连接池和数据库的相关配置,以防止连接超时等问题。

事务补偿和事务重试,关系是什么?

一、为什么要考虑补偿机制?

我们都知道,在分布式环境中运行的应用程序在通信时可能会遇到一个主要问题,即一个业务流程通常需要整合多个服务,而仅一次通信就可能涉及 DNS 服务、网卡、交换机、路由器、负载均衡等设备。

以电商的购物场景为例,这种调用链非常普遍:客户端->购物车微服务->订单微服务->支付微服务。

那么为什么需要考虑补偿机制呢?

正如前面所说,一次跨机器的通信可能会经过DNS 服务,网卡、交换机、路由器、负载均衡等设备,这些设备都不一定是一直稳定的,在数据传输的整个过程中,只要任意一个环节出错,都会导致问题的产生。

而在分布式场景中,一个完整的业务又是由多次跨机器通信组成的,所以产生问题的概率成倍数增加。这些服务和设备并不总是稳定可靠的。在数据传输过程中,只要任何一个环节出现问题,都可能引发故障。在微服务环境中,这种情况更加突出,因为业务需要在一致性上得到保障。

也就是说,如果一个步骤出现失败,要么需要持续重试以确保所有步骤都顺利完成,要么将服务调用回滚到之前的状态。因此,我们可以这样理解业务补偿:当某个操作出现异常时,通过内部机制消除由该异常引发的「不一致」状态。

大家经常看到:「补偿」和「事务补偿」或者「重试」,它们之间的关系是什么?

二、如何进行补偿?

业务补偿设计的实现方式主要可分为两种:回滚(事务补偿)和重试

  • 回滚(事务补偿),这是一种逆向操作,通过回滚业务流程来解决问题,这意味着当前的操作已经失败;

图片

  • 重试,这是一种正向操作,通过不断地尝试来完成业务流程,代表着仍有成功的可能性。

图片

通常情况下,业务事务补偿需要一个工作流引擎的支持。这个事务工作流引擎将各种服务连接在一起,并在工作流上进行业务补偿,以达到最终一致性。

因为「补偿」已经是一个额外的流程,既然能够走额外的流程,说明时效性并不是第一考虑的因素,所以做补偿的核心要点是:宁可慢,不可错。

因此,不能草率地确定补偿方案,需要谨慎评估。虽然错误无法完全避免,但我们应以尽量减少错误为目标。

1、回滚

回滚分为两种形式:

  • 显式回滚(逆向调用接口):通过调用逆向接口,执行上一次操作的反操作,或者取消上一次未完成的操作(需要锁定资源);

  • 隐式回滚(无需逆向调用接口):意味着这个回滚动作无需额外处理,通常由下游提供失败处理机制。

显式回滚

最常见的显示回滚就是做两件事:

  • 首先,确定失败的操作和状态,从而确定回滚范围。一个业务流程在设计之初就已经规划好,因此确定回滚范围相对容易。但需要注意的是,如果在一个业务处理过程中涉及到的服务并非都提供了「回滚接口」,那么在服务编排时应将提供「回滚接口」的服务放在前面,以便在后续服务出错时还有机会进行「回滚」。

    简而言之,要确保回滚接口有机会被调用。最优的选择是将其放在第一个。

  • 其次要提供进行「回滚」操作所需的业务数据。提供的回滚数据越多,越有利于程序的健壮性。因为程序在接收到「回滚」操作时可以进行业务检查,例如检查账户是否相等,金额是否一致等。

在这个过程中,数据结构和大小并不确定。因此,最好将相关数据序列化为 JSON,并存储在 NoSQL 数据库中。

隐式回滚

隐式回滚的使用场景相对较少。它意味着回滚操作无需额外处理,下游服务内部具有类似"预占"和"超时失效"的机制。

例如:在电商场景中,会将订单中的商品预占库存,等待用户在规定时间内支付。如果用户未在规定时间内支付,则释放库存。

回滚的实现方式

对于跨库的事务,常见的解决方案有:两阶段提交、三阶段提交(ACID)但是这 2 种方式,在高可用的架构中一般都不可取,因为跨库锁表会消耗很大的性能。

在高可用架构中,通常不要求强一致性,而是追求最终一致性。可以考虑使用事务表、消息队列、补偿机制、TCC 模式(占位/确认或取消)和 Sagas 模式(拆分事务 + 补偿机制)来实现最终一致性。

2、重试

“重试”的含义是我们认为故障是暂时的,而不是永久的,所以,我们会去重试。这种方法的最大优势在于无需提供额外的逆向接口,这对于代码维护和长期开发的成本有优势,同时考虑到业务的变化,逆向接口也需要随之变化。因此,在许多情况下,可以考虑使用重试。

使用场景

然而,相较于回滚操作,重试的使用场景较少。

  • 当下游系统返回请求超时,或受到限流等临时状态影响时,我们可以考虑采用重试。

  • 如果返回的结果是余额不足,无权限等明确的业务错误,就不需要重试。

  • 对于一些中间件或 RPC 框架,如果返回的是 503,404 等无法预期恢复时间的错误,也不需要重试。

重试策略

为了实施重试,我们需要制定一个重试策略,主流的重试策略主要包括以下几种:

1.立即重试:如果故障是暂时性的,可能是由于网络数据包冲突或硬件组件高峰流量等事件引起的,这种情况下,适合立即重试。但是,立即重试的次数不应超过一次,如果立即重试失败,应改用其他策略。

2.固定间隔: 这个很容易理解,比如每隔 5 分钟重试一次。需要注意的是,策略 1 和策略 2 通常用于前端系统的交互操作。

3.增量间隔:  这个也很简单,比如间隔 15 分钟重试一次。

return (retryCount - 1) * incrementInterval;

其主要目的是让重试失败的任务优先级靠后,让新的重试任务进入队列。

4.指数间隔: 与增量间隔类似,只是增长的幅度更大。

return 2 ^ retryCount;

5.全抖动: 在递增的基础上,增加随机性,适用于在某一时刻有大量请求需要分散压力的场景。

return random(0 , 2 ^ retryCount);

6.等抖动: 在指数间隔和全抖动之间找到一个平衡点,降低随机性的使用。

int baseNum = 2 ^ retryCount;
return baseNum + random(0 , baseNum);

3、4、5、6 策略的表现大致如下所示。(x 轴为重试次数)

图片

为什么说重试有坑呢?

正如之前所提到的,出于对开发成本的考虑,如果重试涉及到接口调用,就需要考虑 幂等性 的问题。

幂等性起源于数学概念,后来被引入到程序设计中。它意味着一个操作可以被多次执行,而不会产生错误。

因此,一旦某个功能支持重试,整个链路上的解耦都需要考虑幂等性的问题,以确保多次调用不会导致业务数据的变化。

实现幂等性的方法是将其过滤掉:

  1. 为每个请求分配一个唯一的标识符。

  2. 在重试过程中,判断该请求是否已经执行过或正在执行。如果是,就丢弃该请求。

对于第一点,可以使用全局 ID 生成器、ID 生成服务,或者简单地使用 Guid、UUID 为每个请求赋值。

对于第二点,可以使用 AOP 在业务代码前后进行校验。

图片

//【方法执行前】
if(isExistLog(requestId)){  //1。判断请求是否已被接收过。对应序号3
    var lastResult = getLastResult();  //2。获取用于判断之前的请求是否已经处理完成。对应序号4
    if(lastResult == null){ 
        var result = waitResult();  //挂起等待处理完成
        return result;
    }
    else{
        return lastResult;
    } 
}
else{
    log(requestId);  //3。记录该请求已接收
}
//do something。。【方法执行后】
logResult(requestId, result);  //4。将结果也更新一下。

如果 「补偿」 这个过程是通过消息队列(MQ)进行的,那么可以在 MQ 封装的 SDK 中直接实现。在生产端为请求分配全局唯一标识符,在消费端通过唯一标识进行去重。

重试的最佳实践

重试特别适合在高负载情况下进行降级。同时,它也应受到限流和熔断机制的影响。当重试与限流熔断结合使用时,才能达到最佳效果。

在增加补偿机制时,需要权衡投入与产出。对于一些不太重要的问题,应该选择 「快速失败」 而不是 「重试」 。

过度积极的重试策略(例如间隔太短或重试次数过多)可能会对下游服务产生负面影响,这一点需要特别注意。

一定要为 「重试」 设定一个终止策略。当回滚过程困难或代价较大时,可以接受较长的间隔和较多的重试次数。实际上,DDD 中经常提到的「saga」模式也是基于这种思路。但前提是不会因为保留或锁定稀缺资源而阻止其他操作(例如,1、2、3、4、5 个串行操作,由于 2 操作一直未完成,导致 3、4、5 无法继续进行)。

三、业务补偿机制的注意事项

1、ACID 还是 BASE

在分布式系统中,ACID 和 BASE 代表了两种不同层次的一致性理论。

在分布式系统里,ACID 还是 BASE的区别:

  • ACID 的一致性较强,但可扩展性较差,仅在必要时使用;

  • 而 BASE 的一致性相对较弱,但具有很好的可扩展性,并支持异步批量处理,适用于大多数分布式事务。

在重试或回滚的情境下,我们通常不需要强一致性,只需确保最终一致性即可。

2、业务补偿设计的注意事项

业务补偿设计的注意事项如下:

  • 为了完成一个业务流程,需要涉及到的服务支持幂性,并且上游需要有重试机制;

  • 我们需要仔细维护和监控整个过程的状态,所以最好不要将这些状态分布在不同的组件中,最好是由一个业务流程的控制方来负责,也就是一个工作流引擎。因此,这个工作流引擎需要具有高可用性和稳定性;

  • 补偿的业务逻辑和流程不一定要是严格的反向操作。有时可以并行执行,有时可能会更简单;

    总的来说,在设计业务正向流程时,也需要考虑业务的反向补偿流程;

  • 我们需要明确,业务补偿的业务逻辑与具体业务紧密相关,很难做到通用;

  • 下层的业务方最好提供短期的资源预留机制。例如在电商中,可以将商品库存预留以便等待用户在 15 分钟内支付。如果没有收到用户的支付,就释放库存,然后回滚到之前的下单操作,等待用户重新下单。

参考文献:

https://zhuanlan.zhihu.com/p/258741780

单节点2000Wtps,Kafka怎么做的?

一、生产端 Producer

图片

先来回顾下 Producer 生产者发送消息的流程:

Kafka的源码最核心的是由client模块和core模块构成,用一幅图大致介绍一下生产者发送消息的流程。

图片

生产者发送消息的流程

生产者发送消息流程

  1. 将消息封装成ProducerRecord对象

  2. Serializer对消息的key和value做序列化

  3. 根据Partitioner将消息分发到不同的分区,需要先获取集群的元数据

  4. RecordAccumulator封装很多分区的消息队列,每个队列代表一个分区,每个分区里面有很多的批次,每个批次里面由多条消息组成

  5. Sender会从RecordAccumulator拉取消息,封装成批次,发送请求

  6. 通过网络将请求发送到kafka集群

前置知识:队列缓存+批量写入架构

kafka的生产者,也用了这个架构。设计了一个核心组件RecordAccumulator

  1. RecordAccumulator:每一个是生产上都会维护一个固定大小的内存空间,主要用于合并单条消息,进行批量发送,提高吞吐量,减少带宽消耗。

  2. RecordAccumulator的大小是可配置的,可以配置buffer.memory来修改缓冲区大小,默认值为:33554432(32M)

  3. RecordAccumulator内存结构分为两部分

  • 第一部分为已经使用的内存,这一部分主要存放了很多的队列。每一个主题的每一个分区都会创建一个队列,来存放当前分区下待发送的消息集合。

  • 第二部分为未使用的内存,这一部分分为已经池化后的内存和未池化的整个剩余内存(nonPooledAvailableMemory)。池化的内存的会根据batch.size(默认值为16K)的配置进行池化多个ByteBuffer,放入一个队列中。所有的剩余空间会形成一个未池化的剩余空间。

生产者发送消息流程源码

1. 将消息封装成ProducerRecord对象

生产者生成某个消息后,ProducerRecord首先会经过一个或多个组成的拦截器链。

2. Serializer对消息的key和value做序列化

当消息通过所有的拦截器之后,会进行序列化,会根据key和value的序列化配置进行序列化消息内容,生产者和消费者必须使用相同的key-value序列化方式。

// 消息key序列化
properties.setProperty(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
// 消息value序列化
properties.setProperty(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
3. 经过序列化后,会根据自定义的分区器或者Kafka默认的分区器进行获取消息的所属的分区

自定义分区器可以参考下面。

Kafka默认的分区器规则:

  • 1)当消息的key存在时,首先获取当前topic下的所有分区数,然后对key进行求hash值,根据hash值和分区总数进行取余,获取所属的的分区。

  • 2)如果key不存在时,会根据topic获取一个递增的数值,然后通过和分区数进行取余,获取所属的分区。

Kafka默认分区器源码:

public class DefaultPartitioner implements Partitioner {

    private final ConcurrentMap<String, AtomicInteger> topicCounterMap = new ConcurrentHashMap<>();

    public void configure(Map<String, ?> configs) {}

    public int partition(String topic, Object key, byte[] keyBytes, Object value, byte[] valueBytes, Cluster cluster) {
        List<PartitionInfo> partitions = cluster.partitionsForTopic(topic);
        int numPartitions = partitions.size();
        if (keyBytes == null) {
            int nextValue = nextValue(topic);
            List<PartitionInfo> availablePartitions = cluster.availablePartitionsForTopic(topic);
            if (availablePartitions.size() > 0) {
                int part = Utils.toPositive(nextValue) % availablePartitions.size();
                return availablePartitions.get(part).partition();
            } else {
                // no partitions are available, give a non-available partition
                return Utils.toPositive(nextValue) % numPartitions;
            }
        } else {
            // hash the keyBytes to choose a partition
            return Utils.toPositive(Utils.murmur2(keyBytes)) % numPartitions;
        }
    }

    private int nextValue(String topic) {
        AtomicInteger counter = topicCounterMap.get(topic);
        if (null == counter) {
            counter = new AtomicInteger(ThreadLocalRandom.current().nextInt());
            AtomicInteger currentCounter = topicCounterMap.putIfAbsent(topic, counter);
            if (currentCounter != null) {
                counter = currentCounter;
            }
        }
        return counter.getAndIncrement();
    }

    public void close() {}

}

自定义分区器:

public class CustomerPartitions implements Partitioner{
    @Override
    public int partition(String topic, Object key, byte[] keyBytes, Object value, byte[] valueBytes, Cluster cluster) {
        int partition = 0;
        if(key == null) {
        } else {
            String keyStr = key.toString();
            if(keyStr.contains("Test")) {
                partition = 1;
            } else {
                partition = 2;
            }
        }
        return partition;
    }

    @Override
    public void close() {

    }

    @Override
    public void configure(Map<String, ?> configs) {

    }
}
4. 获取到消息所属的分区后,消息会被存放到消息缓冲区中(RecordAccumulator)中

根据topic和分区可以确定一个双端队列(Deque)中,这个队列中每个节点为多个消息的合集(ProducerBatch),新的消息会被放到队列的最后一个节点上,存放会存在多种情况。

场景一:消息大小不足16K。

首先会根据topic和分区获取所属队列的最后一个ProducerBatch

  • 如果最后一个ProducerBatch+当前消息 <= 16K时,会把当前消息存入这个ProducerBatch中,等待发送。

  • 如果最后一个ProducerBatch+当前消息 > 16K时,此时消息不会放入这个ProducerBatch中,而是会向池化的队列中获取一个ByteBuffer,把这个ByteBuffer放到队列的尾部,然后把消息放入这个新增的ProducerBatch中。

  • 如果最后一个ProducerBatch+当前消息 > 16K时,并且池化的队列中没有可用的ByteBuffer时,池化队列会向剩余的未使用的内存空间(nonPooledAvailableMemory)申请一个大小为16K的内存空间,添加到池化队列尾部。然后把这个新增的ByteBuffer添加到分区下的队列尾部,存储新的消息。

场景二:消息大小超过16K
  • 当消息超过16K时,任何一个ProducerBatch都无法存储这个消息。此时会直接向剩余的空间(nonPooledAvailableMemory)的进行分配和当前的消息大小一样的内存空间,加到队列的尾部,然后存储消息,等待发送。

  • 当剩余的空间(nonPooledAvailableMemory) < 消息大小时,nonPooledAvailableMemory会向池化队列获取空间,每次获取一个ByteBuffer(16K),直到nonPooledAvailableMemory的空间大于或等于消息大小时。获取的ByteBuffer会经过jvm的GC垃圾回收。过程比较慢。当nonPooledAvailableMemory空间大于获取等于消息大小时,会把分配消息大小的空间放入分区队列的尾部,把消息存入这个ProducerBatch内。

5. 生产者会有一个send线程,用于不断的获取消息和发送消息

sender线程会不断的扫描RecordAccumulator中所有的ProducerBatch,如果ProducerBatch达到batch.size(默认16K)大小或者最早的一个消息已经等待超过linger.ms(默认为0)时,这个ProducerBatch会被sender线程收集到。由于不同的topic和分区会被分到不同的Broker节点上,sender线程会把发送到相同Broker姐节点的ProducerBatch合并在一个Request请求中,一个Request请求不会超过max.request.size(默认1048576B = 1M)

6. 每个请求都会缓存在一个inFlightRequest缓冲区内,里面为每一个Broker分配了一个队列

新的请求会放在队列尾部,每个队列最多能够容纳max.in.flight.requests.per.connection(默认值为5)个Request,队列满了不会产生新的Request。

7. selector获取到Request会发往相对应的Broker节点。Broker节点收到Request后会进行ACK确认这个Request

acks 有三个配置值:[-1 , 0 , 1]

acks = -1 表示不需要收到leader节点的ACK回复就会发送下一个Request。高吞吐,低一致性

acks = 0 表示只需要接收到leader节点的ACK后就可以发送下一个Request。

acks = 1 表示 需要接收到leaer节点和ISR节点的ACK后才会发送下一个Request。一致性较高

8. 当收到Broker对某个Request的ACK后,会删除inFlightRequest队列中这个Request。然后调用clear方法清除对应的ProducerBatch

RecordAccumulator Clear清理场景:针对2.4.1.1,2.4.1.2,2.4.1.3,ProducerBatch都会标记为删除,然后放入池化队列中,不会进行GC。2.4.1.3中从nonPooledAvailableMemory获取的内存也不会归还給nonPooledAvailableMemory,任然存放在池化队列中。

针对2.4.2.1,2.4.2.2,超过16K的消息内存空间会被GC进行回收,然后作为nonPooledAvailableMemory的一部分

9. 如果发送过程中产生了异常,消息发送会存在重试机制。条件为重试次数小于指定值&&异常为RetriableException
private boolean canRetry(ProducerBatch batch, ProduceResponse.PartitionResponse response) {
    return batch.attempts() < this.retries &&
        ((response.error.exception() instanceof RetriableException) ||
         (transactionManager != null && transactionManager.canRetry(response, batch)));
}

图片

生产端的高并发核心架构设计

在消息发送时候,可发现这两个亮点:批量消息自定义协议格式。

  • 批量发送:减少了与服务端 Broker 处理请求的次数,从而提升总体的处理能力。

调用 send() 方法时,不会立刻把消息发送出去,而是缓存起来,选择恰当时机把缓存里的消息划分成一批数据,按批次发送给服务端 Broker。

  • 自定义协议格式:序列化方式和压缩格式都能减少数据体积,从而节省网络资源消耗。

各种压缩算法对比:

  • 吞吐量方面:LZ4 > Snappy > zstd 和 GZIP

  • 压缩比方面:zstd > LZ4 > GZIP > Snappy

Compressor nameRatioCompressionDecompress.
zstd 1.3.4-12.877470 MB/s1380 MB/s
zlib 1.2.11-12.743110 MB/s400 MB/s
brotli 1.0.2-02.701410 MB/s430 MB/s
quicklz 1.5.0-12.238550 MB/s710 MB/s
lzo1x2.09-12.108650 MB/s830 MB/s
lz4 1.8.12.101750 MB/s3700 MB/s
snappy 1.1.42.091530 MB/s1800 MB/s
lzf 3.6-12.077400 MB/s860 MB/s

二、服务端 Broker

Broker 的高性能主要从这 3 个方面体现:

  • PageCache 缓存

  • Kafka 的文件布局 以及 磁盘文件顺序写入

  • 零拷贝 sendfile:加速消费流程

下面展开讲讲。

1、PageCache 加速消息读写

使用 PageCache 主要能带来如下好处:

  • 写入文件的时候:操作系统会先把数据写入到内存中的 PageCache,然后再一批一批地写到磁盘上,从而减少磁盘 IO 开销。

图片

  • 读取文件的时候:也是从 PageCache 中来读取数据。

如果消息刚刚写入到服务端就会被消费,按照 LRU 的“优先清除最近最少使用的页”这种策略,读取的时候,对于这种刚刚写入的 PageCache,命中的几率会非常高。

2、Kafka 的文件布局 以及 磁盘文件顺序写入

文件布局如下图所示:

主要特征是:文件的组织方式是“topic + 分区”,每一个 topic 可以创建多个分区,每一个分区包含单独的文件夹。

Kafka 在分区级别实现文件顺序写:即多个文件同时写入,更能发挥磁盘 IO 的性能。

  • 相对比 RocketMQ:RocketMQ 在消息写入时追求极致的顺序写,所有的消息不分主题一律顺序写入 commitlog 文件, topic 和 分区数量的增加不会影响写入顺序。

  • 弊端: Kafka 在消息写入时的 IO 性能,会随着 topic 、分区数量的增长先上升,后下降。

所以使用 Kafka 时,要警惕 Topic 和 分区数量。

3、零拷贝 sendfile:加速消费流程

当不使用零拷贝技术读取数据时:

图片

流程如下:

1)消费端 Consumer:向 Kafka Broker 请求拉取消息

2)Kafka Broker 从 OS Cache 读取消息到 应用程序的内存空间:

  • 若 OS Cache 中有消息,则直接读取;

  • 若 OS Cache 中无消息,则从磁盘里读取。

3)再通过网卡,socket 将数据发送给 消费端Consumer

当使用零拷贝技术读取数据:

Kafka 使用零拷贝技术可以把这个复制次数减少一次,直接从 PageCache 中把数据复制到 Socket 缓冲区中。

  • 这样不用将数据复制到用户内存空间。

  • DMA 控制器直接完成数据复制,不需要 CPU 参与,速度更快。

三、消费端 Consumer

消费者只从 Leader分区批量拉取消息。

为了提高消费速度,多个消费者并行消费比不可少。Kafka 允许创建消费组(唯一标识 group.id),在同一个消费组的消费者共同消费数据。

举个例子:

  • 有两个 Kafka Broker,即有 2个机子

  • 有一个主题:TOPICA,有 3 个分区(0, 1, 2)

图片

如上图,举例 4 中情况:

  • group.id = 1,有一个消费者:这个消费者要处理所有数据,即 3 个分区的数据。

  • group.id = 2,有两个消费者:consumer 1消费者需处理 2个分区的数据,consumer2 消费者需处理 1个分区的数据。

  • group.id = 3,有三个消费者:消费者数量与分区数量相等,刚好每个消费者处理一个分区。

  • group.id = 4,有四个消费者:消费者数量 > 分区数量,第四个消费者则会处于空闲状态。

参考文献

https://juejin.cn/post/7134463012563320868

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值