MySQL · 特性分析 · LOGICAL_CLOCK 并行复制原理及实现分析

本文涉及的产品
RDS MySQL Serverless 基础系列,0.5-2RCU 50GB
云数据库 RDS MySQL,集群系列 2核4GB
推荐场景:
搭建个人博客
云数据库 RDS PostgreSQL,集群系列 2核4GB
简介:

在MySQL5.7 引入基于Logical clock的并行复制方案前,MySQL使用基于Schema的并行复制,使不同db下的DML操作可以在备库并发回放。在优化后,可以做到不同表table下并发。但是如果业务在Master端高并发写入一个库(或者优化后的表),那么slave端就会出现较大的延迟。基于schema的并行复制,Slave作为只读实例提供读取功能时候可以保证同schema下事务的因果序(Causal Consistency,本文讨论Consistency的时候均假设Slave端为只读),而无法保证不同schema间的。例如当业务关注事务执行先后顺序时候,在Master端db1写入T1,收到T1返回后,才在db2执行T2。但在Slave端可能先读取到T2的数据,才读取到T1的数据。

MySQL 5.7的LOGICAL CLOCK并行复制,解除了schema的限制,使得在主库对一个db或一张表并发执行的事务到slave端也可以并行执行。Logical Clock并行复制的实现,最初是Commit-Parent-Based方式,同一个commit parent的事务可以并发执行。但这种方式会存在可以保证没有冲突的事务不可以并发,事务一定要等到前一个commit parent group的事务全部回放完才能执行。后面优化为Lock-Based方式,做到只要事务和当前执行事务的Lock Interval都存在重叠,即保证了Master端没有锁冲突,就可以在Slave端并发执行。LOGICAL CLOCK可以保证非并发执行事务,即当一个事务T1执行完后另一个事务T2再开始执行场景下的Causal Consistency。

LOGICAL_CLOCK Commit-Parent-Based 模式
由于在MySQL中写入是基于锁的并发控制,所以所有在Master端同时处于prepare阶段且未提交的事务就不会存在锁冲突,在Slave端执行时都可以并行执行。因此可以在所有的事务进入prepare阶段的时候标记上一个logical timestamp(实现中使用上一个提交事务的sequence_number),在Slave端同样timestamp的事务就可以并发执行。

Master端
在SQL层实现一个全局的logical clock: commit_clock。

当事务进入prepare阶段的时候,从commit_clock获取timestamp并存储在事务中。

在transaction在引擎层提交之前,推高commit_clock。这里如果在引擎层提交之后,即释放锁后操作commit_clock,就可能出现冲突的事务拥有相同的commit-parent,所以一定要在引擎层提交前操作。

Slave端
如果事务拥有相同的commit-parent就可以并行执行,不同commit-parent的事务,需要等前面的事务执行完毕才可以执行。

LOGICAL_CLOCK Lock-Based模式原理及实现分析
Commit-Parent-Based 模式,用事务commit的点将clock分隔成了多个intervals。在同一个time interval中进入prepare状态的事务可以被并发。例如下面这个例子(引自WL#7165):

Trx1 ------------P----------C-------------------------------->
                            |
Trx2 ----------------P------+---C---------------------------->
                            |   |
Trx3 -------------------P---+---+-----C---------------------->
                            |   |     |
Trx4 -----------------------+-P-+-----+----C----------------->
                            |   |     |    |
Trx5 -----------------------+---+-P---+----+---C------------->
                            |   |     |    |   |
Trx6 -----------------------+---+---P-+----+---+---C---------->
                            |   |     |    |   |   |
Trx7 -----------------------+---+-----+----+---+-P-+--C------->
                            |   |     |    |   |   |  |

每一个水平线代表一个事务。时间从左到右。P表示prepare阶段读取commit-parent的时间点。C表示事务提交前增加全局counter的时间点。垂直线表示每个提交划分出的time interval。

从上图可以看到因为Trx5和Trx6的commit-parent都是Trx2提交点,所以可以并行执行。但是Commit-Parent-Based模式下Trx4和Trx5不可以并行执行,因为Trx4的commit-parent是Trx1的提交点。Trx6和Trx7也不可以并行执行,Trx7的commit-parent是Trx5的提交点。但Trx4和Trx5有一段时间同时持有各自的所有锁,Trx6和Trx7也是,即它们之间并不存在冲突,是可以并发执行的。

针对上面的情况,为了进一步增加复制性能,MySQL将LOGICAL_CLOCK优化为Lock-Based模式,使同时hold住各自所有锁的事务可以在slave端并发执行。

Master端

  • 添加全局的事务计数器产生事务timestamp和记录当前最大事务timestamp的clock。
class MYSQL_BIN_LOG: public TC_LOG
{
  ...
  public:
  /* Committed transactions timestamp */
  Logical_clock max_committed_transaction;
  /* "Prepared" transactions timestamp */
  Logical_clock transaction_counter;
  ...
}
  • 对每个事务定义其lock interval,并记录到binlog中。

在每个transaction中添加下面两个member。

class Transaction_ctx
{
  ...
  int64 last_committed;
  int64 sequence_number;
  ...
}

其中last_committed表示事务lock interval的起始点,是所有锁都获得时候的max-commited-timestamp。由于在一个事务执行过程中,数据库无法知道当前的锁是否为最后一个,在实际实现的时候,对每次DML操作都更新一次last_committed。

static int binlog_prepare(handlerton *hton, THD *thd, bool all)
{
  ...
  if (!all)//DML操作
    {
      Logical_clock& clock= mysql_bin_log.max_committed_transaction;
        thd->get_transaction()->
        store_commit_parent(clock.get_timestamp());//更新transaction中的last_committed
        sql_print_information("stmt prepare");
    }
  ...
}

class Transaction_ctx
{
  ...
  void store_commit_parent(int64 last_arg)
  {
    last_committed= last_arg;
  }
  ...
}

sequence_number为lock interval的结束点。从理论上在最后更新last_committed后,引擎层commit前的一个时刻即可,满足这一条件的情况下时间点越靠后越能获得更大lock interval,后面在Slave执行也就能获得更大并发度。由于我们需要把该信息记录到binlog中,所以实现中在flush binlog cache到binlog文件中的时候记录。而且当前的MySQL5.7已经disable掉了设置GTID_MODE为OFF的功能,会强制记录GTID_EVENT。这样事务的last_committed和sequence_number记录在事务开头的Gtid_log_event中。

int
binlog_cache_data::flush(THD *thd, my_off_t *bytes_written, bool *wrote_xid)
{
  ...
  if (flags.finalized)
  {
     trn_ctx->sequence_number= mysql_bin_log.transaction_counter.step();//获取sequence_number

  if (!error)
    if ((error= mysql_bin_log.write_gtid(thd, this, &writer)))//记录Gtid_log_event
  ...
}

bool MYSQL_BIN_LOG::write_gtid(THD *thd, binlog_cache_data *cache_data,
                               Binlog_event_writer *writer)
{
  ...
  Transaction_ctx *trn_ctx= thd->get_transaction();
  Logical_clock& clock= mysql_bin_log.max_committed_transaction;

  DBUG_ASSERT(trn_ctx->sequence_number > clock.get_offset());

  int64 relative_sequence_number= trn_ctx->sequence_number - clock.get_offset();                               
  int64 relative_last_committed=
    trn_ctx->last_committed <= clock.get_offset() ?
    SEQ_UNINIT : trn_ctx->last_committed - clock.get_offset();
  ...
  Gtid_log_event gtid_event(thd, cache_data->is_trx_cache(),
                        relative_last_committed, relative_sequence_number,//Gtid_log_event中记录relative_last_committed和relative_sequence_number
                        cache_data->may_have_sbr_stmts());
  ...
}

同时可以看到记录在Gtid_log_event(即binlog file中)的sequence_number和last_committed使用的是相对当前binlog文件的clock的值。即每个binlog file中事务的last_commited起始值为0,sequence_number为1。由于binlog切换后,需要等待上一个文件的事务执行完,所以这里记录相对值并不会引起冲突事务并发执行。这样做一个明显的好处是由于server在每次启动的时候都会生成新的binlog文件,max_committed_transaction和transaction_counter不需要持久化。

更新max_committed_transaction。

max_committed_transaction的更新一定要在引擎层commit(即锁释放)之前,如果之后更新,释放的锁被其他事务获取到并且获取到last_committed小于该事务的sequence_number,就会导致有锁冲突的事务lock interval却发生重叠。

void
MYSQL_BIN_LOG::process_commit_stage_queue(THD *thd, THD *first)
{
  ...
  if (head->get_transaction()->sequence_number != SEQ_UNINIT)
    update_max_committed(head);
  ...
  if (head->get_transaction()->m_flags.commit_low)
  {
    if (ha_commit_low(head, all, false))
      head->commit_error= THD::CE_COMMIT_ERROR;
  ...

}

Slave端
当事务的lock interval存在重叠,即代表他们的锁没有冲突,可以并发执行。下图中L代表lock interval的开始,C代表lock interval的结束。

- 可并发执行:
  Trx1 -----L---------C------------>
  Trx2 ----------L---------C------->

- 不可并发执行:
  Trx1 -----L----C----------------->
  Trx2 ---------------L----C------->

slave端在并行回放时候,worker的分发逻辑在函数Slave_worker Log_event::get_slave_worker(Relay_log_info rli)中,MySQL5.7中添加了schedule_next_event函数来决定是否分配下一个event到worker线程。对于DATABASE并行回放该函数实现为空。

bool schedule_next_event(Log_event* ev, Relay_log_info* rli)
{
  ...
  error= rli->current_mts_submode->schedule_next_event(rli, ev);
  ...
}

int
Mts_submode_database::schedule_next_event(Relay_log_info *rli, Log_event *ev)
{
  /*nothing to do here*/
  return 0;
}

Mts_submode_logical_clock的相关实现如下。

在Mts_submode_logical_clock中存储了回放事务中已经提交事务timestamp(sequence_number)的low-water-mark lwm。low-water-mark表示该事务已经提交,同时该事务之前的事务都已经提交。

class Mts_submode_logical_clock: public Mts_submode
{
  ...
  /* "instant" value of committed transactions low-water-mark */
  longlong last_lwm_timestamp;
  ...
  longlong last_committed;
  longlong sequence_number;

在Mts_submode_logical_clock的schedule_next_event函数实现中会检查当前事务是否和正在执行的事务冲突,如果当前事务的last_committed比last_lwm_timestamp大,同时该事务前面还有其他事务执行,coordinator就会等待,直到确认没有冲突事务或者前面的事务已经执行完,才返回。这里last_committed等于last_lwm_timestamp的时候,实际这两个值拥有事务的lock interval是没有重叠的,也可能有冲突。在前面lock-interval介绍中,这种情况是前面一个事务执行结束,后面一个事务获取到last_committed为前面一个的sequence_number的情况,他们的lock interval没有重叠。但由于last_lwm_timestamp更新表示事务已经提交,所以等于的时候,该事务也可以执行。

int
Mts_submode_logical_clock::schedule_next_event(Relay_log_info* rli,
                                               Log_event *ev)
{
  ...
  switch (ev->get_type_code())
  {
    case binary_log::GTID_LOG_EVENT:
    case binary_log::ANONYMOUS_GTID_LOG_EVENT:
    // TODO: control continuity
    ptr_group->sequence_number= sequence_number=
      static_cast<Gtid_log_event*>(ev)->sequence_number;
    ptr_group->last_committed= last_committed=
      static_cast<Gtid_log_event*>(ev)->last_committed;
      break;

      default:

        sequence_number= last_committed= SEQ_UNINIT;

        break;
  }
  ...
  if (!is_new_group)
  {
    longlong lwm_estimate= estimate_lwm_timestamp();
    if (!clock_leq(last_committed, lwm_estimate) && //如果last_committed > lwm_estimate
        rli->gaq->assigned_group_index != rli->gaq->entry) //当前事务前面还有执行的事务
    {
      ...
      if (wait_for_last_committed_trx(rli, last_committed, lwm_estimate))
      ...
    }
    ...
  }
}

@return   true  when a "<=" b,
          false otherwise
*/
static bool clock_leq(longlong a, longlong b)
{
if (a == SEQ_UNINIT)
  return true;
else if (b == SEQ_UNINIT)
  return false;
else
  return a <= b;
}

bool Mts_submode_logical_clock::
wait_for_last_committed_trx(Relay_log_info* rli,
                            longlong last_committed_arg,
                            longlong lwm_estimate_arg)
{
  ...
  my_atomic_store64(&min_waited_timestamp, last_committed_arg);//设置min_waited_timestamp
  ...
  if ((!rli->info_thd->killed && !is_error) &&
    !clock_leq(last_committed_arg, get_lwm_timestamp(rli, true)))//真实获取lwm并检查当前是否有冲突事务
  {

    //循环等待直到没有冲突事务
    do
    {
      mysql_cond_wait(&rli->logical_clock_cond, &rli->mts_gaq_LOCK);
    }
    while ((!rli->info_thd->killed && !is_error) &&
          !clock_leq(last_committed_arg, estimate_lwm_timestamp()));      
  ...                        
  }
}

上面循环等待的时候,会等待logical_clock_cond条件然后做检查。该条件的唤醒逻辑是:当回放事务结束,如果存在等待的事务,即检查min_waited_timestamp和当前curr_lwm(lwm同时会被更新),如果min_waited_timestamp小于等于curr_lwm,则唤醒等待的coordinator线程。

void Slave_worker::slave_worker_ends_group(Log_event* ev, int error)
{
  ...
  if (mts_submode->min_waited_timestamp != SEQ_UNINIT)
  {
    longlong curr_lwm= mts_submode->get_lwm_timestamp(c_rli, true);//获取并更新当前lwm。

    if (mts_submode->clock_leq(mts_submode->min_waited_timestamp, curr_lwm))
    {
      /*
        There's a transaction that depends on the current.
      */
      mysql_cond_signal(&c_rli->logical_clock_cond);//唤醒等待的coordinator线程
    }
  }
  ...
}

LOGICAL_CLOCK Consistency的分析
无论是Commit-Parent-Based还是Lock-Based,Master端一个事务T1和其commit后才开始的事务T2在Slave端都不会被并发回放,T2一定会等T1执行结束才开始回放。因此LOGICAL_CLOCK并发方式在Slave端只读时候的上述场景中能够保证Causal Consistency。但如果事务T2只是等待事务T1执行commit成功后再执行commit操作,那么事务T1和T2在Slave端的执行顺序就无法得到保证,用户在Slave端读取可能先读到T2再读到T1的提交。这种场景就无法满足Causal Consistency。

slave_preserve_commit_order的简要介绍
我们在前面的介绍中了解到,当slave_parallel_type为DATABASE和LOGICAL_CLOCK的时候,在Slave端的读取操作都存在场景无法满足Causal Consistency,都可能存在Slave端并行回放时候事务顺序发生变化。复制进行中时业务方可能会在某一时刻观察到Slave的GTID_EXECUTED有空洞。那如果业务需要完整的保证Causal Consistency呢,除了使用单线程复制,是否可以在并发回放的情况下满足这一需求?

MySQL提供了slave_preserve_commit_order,使LOGICAL_CLOCK的并发执行时候满足Causal Consistency,实际获得Sequential Consistency。这里Sequential Consistency除了满足之前分析的客户端事务T1、T2先后执行操作的场景外,还满足即使T1T2均并发执行的时候,第三个客户端在主库观察到T1先于T2发生,在备库也会观察到T1先于T2发生,即在备库获得和主库完全一致的执行顺序。

slave_preserve_commit_order实现的关键是添加了Commit_order_manager类,开启该参数会在获取worker时候向Commit_order_manager注册事务。

Slave_worker *
Mts_submode_logical_clock::get_least_occupied_worker(Relay_log_info *rli,
                                                     Slave_worker_array *ws,
                                                     Log_event * ev)
{
  ...
  if (rli->get_commit_order_manager() != NULL && worker != NULL)
    rli->get_commit_order_manager()->register_trx(worker);
  ...
}

void Commit_order_manager::register_trx(Slave_worker *worker)
{
  ...
  queue_push(worker->id);
  ...
}

在事务进入FLUSH_STAGE前, 会等待前面的事务都进入FLUSH_STAGE。

int MYSQL_BIN_LOG::ordered_commit(THD *thd, bool all, bool skip_commit)
{
  ...
  if (has_commit_order_manager(thd))
  {
    Slave_worker *worker= dynamic_cast<Slave_worker *>(thd->rli_slave);
    Commit_order_manager *mngr= worker->get_commit_order_manager();

    if (mngr->wait_for_its_turn(worker, all)) //等待前面的事务都进入FLUSH\_STAGE
    {
      thd->commit_error= THD::CE_COMMIT_ERROR;
      DBUG_RETURN(thd->commit_error);
    }

    if (change_stage(thd, Stage_manager::FLUSH_STAGE, thd, NULL, &LOCK_log))
      DBUG_RETURN(finish_commit(thd));
    }
  ...
}

bool Commit_order_manager::wait_for_its_turn(Slave_worker *worker,
                                                  bool all)
{
  ...
  mysql_cond_t *cond= &m_workers[worker->id].cond;
  ...
  while (queue_front() != worker->id)
  {
    ...
    mysql_cond_wait(cond, &m_mutex);//等待condition
  }
...                                                    
}

当该事务进入FLUSH_STAGE后,会通知下一个事务的worker可以进入FLUSH_STAGE。

bool
Stage_manager::enroll_for(StageID stage, THD *thd, mysql_mutex_t *stage_mutex)
{
    bool leader= m_queue[stage].append(thd);
    if (stage == FLUSH_STAGE && has_commit_order_manager(thd))
    {
      Slave_worker *worker= dynamic_cast<Slave_worker *>(thd->rli_slave);
      Commit_order_manager *mngr= worker->get_commit_order_manager();

      mngr->unregister_trx(worker);
    }
    ...
}

void Commit_order_manager::unregister_trx(Slave_worker *worker)
{
  ...
  queue_pop();//退出队列
  if (!queue_empty())
    mysql_cond_signal(&m_workers[queue_front()].cond);//唤醒下一个
  ...
}

在保证binlog flush的顺序后,通过binlog_order_commit即可获取同样的提交顺序。

浅谈LOGICAL_CLOCK依然存在的不足
LOGICAL_CLOCK为了准确性和实现的需要,其lock interval实际实现获得的区间比理论值窄,会导致原本一些可以并发执行的事务在Slave中没有并发执行。当使用级联复制的时候,这会后面层级的Slave并发度会越来越小。

实际很多业务中,虽然事务没有Lock Interval重叠,但这些事务操作的往往是不同的数据行,也不会有锁冲突,是可以并发执行,但LOGICAL_CLOCK的实现无法使这部分事务得到并发回放。

虽然有上述不足,LOGICAL_CLOCK的复制方式在有多客户端写入同样database的场景中相比DATABASE能够获得很大的复制性能提升,实际场景中很多业务的写入也都是在一个database下。

相关实践学习
如何在云端创建MySQL数据库
开始实验后,系统会自动创建一台自建MySQL的 源数据库 ECS 实例和一台 目标数据库 RDS。
全面了解阿里云能为你做什么
阿里云在全球各地部署高效节能的绿色数据中心,利用清洁计算为万物互联的新世界提供源源不断的能源动力,目前开服的区域包括中国(华北、华东、华南、香港)、新加坡、美国(美东、美西)、欧洲、中东、澳大利亚、日本。目前阿里云的产品涵盖弹性计算、数据库、存储与CDN、分析与搜索、云通信、网络、管理与监控、应用服务、互联网中间件、移动服务、视频服务等。通过本课程,来了解阿里云能够为你的业务带来哪些帮助 &nbsp; &nbsp; 相关的阿里云产品:云服务器ECS 云服务器 ECS(Elastic Compute Service)是一种弹性可伸缩的计算服务,助您降低 IT 成本,提升运维效率,使您更专注于核心业务创新。产品详情: https://www.aliyun.com/product/ecs
相关文章
|
15天前
|
存储 关系型数据库 MySQL
MySQL主从复制原理和使用
本文介绍了MySQL主从复制的基本概念、原理及其实现方法,详细讲解了一主两从的架构设计,以及三种常见的复制模式(全同步、异步、半同步)的特点与适用场景。此外,文章还提供了Spring Boot环境下配置主从复制的具体代码示例,包括数据源配置、上下文切换、路由实现及切面编程等内容,帮助读者理解如何在实际项目中实现数据库的读写分离。
MySQL主从复制原理和使用
|
1月前
|
缓存 算法 关系型数据库
Mysql(3)—数据库相关概念及工作原理
数据库是一个以某种有组织的方式存储的数据集合。它通常包括一个或多个不同的主题领域或用途的数据表。
46 5
Mysql(3)—数据库相关概念及工作原理
|
1月前
|
存储 缓存 关系型数据库
MySQL事务日志-Redo Log工作原理分析
事务的隔离性和原子性分别通过锁和事务日志实现,而持久性则依赖于事务日志中的`Redo Log`。在MySQL中,`Redo Log`确保已提交事务的数据能持久保存,即使系统崩溃也能通过重做日志恢复数据。其工作原理是记录数据在内存中的更改,待事务提交时写入磁盘。此外,`Redo Log`采用简单的物理日志格式和高效的顺序IO,确保快速提交。通过不同的落盘策略,可在性能和安全性之间做出权衡。
1611 14
|
14天前
|
SQL 分布式计算 Serverless
EMR Serverless Spark:一站式全托管湖仓分析利器
本文根据2024云栖大会阿里云 EMR 团队负责人李钰(绝顶) 演讲实录整理而成
87 2
|
17天前
|
存储 关系型数据库 MySQL
基于案例分析 MySQL 权限认证中的具体优先原则
【10月更文挑战第26天】本文通过具体案例分析了MySQL权限认证中的优先原则,包括全局权限、数据库级别权限和表级别权限的设置与优先级。全局权限优先于数据库级别权限,后者又优先于表级别权限。在权限冲突时,更严格的权限将被优先执行,确保数据库的安全性与资源合理分配。
|
15天前
|
SQL 关系型数据库 MySQL
Mysql中搭建主从复制原理和配置
主从复制在数据库管理中广泛应用,主要优点包括提高性能、实现高可用性、数据备份及灾难恢复。通过读写分离、从服务器接管、实时备份和地理分布等机制,有效增强系统的稳定性和数据安全性。主从复制涉及I/O线程和SQL线程,前者负责日志传输,后者负责日志应用,确保数据同步。配置过程中需开启二进制日志、设置唯一服务器ID,并创建复制用户,通过CHANGE MASTER TO命令配置从服务器连接主服务器,实现数据同步。实验部分展示了如何在两台CentOS 7服务器上配置MySQL 5.7主从复制,包括关闭防火墙、配置静态IP、设置域名解析、配置主从服务器、启动复制及验证同步效果。
Mysql中搭建主从复制原理和配置
|
23天前
|
SQL 关系型数据库 MySQL
阿里面试:MYSQL 事务ACID,底层原理是什么? 具体是如何实现的?
尼恩,一位40岁的资深架构师,通过其丰富的经验和深厚的技術功底,为众多读者提供了宝贵的面试指导和技术分享。在他的读者交流群中,许多小伙伴获得了来自一线互联网企业的面试机会,并成功应对了诸如事务ACID特性实现、MVCC等相关面试题。尼恩特别整理了这些常见面试题的系统化解答,形成了《MVCC 学习圣经:一次穿透MYSQL MVCC》PDF文档,旨在帮助大家在面试中展示出扎实的技术功底,提高面试成功率。此外,他还编写了《尼恩Java面试宝典》等资料,涵盖了大量面试题和答案,帮助读者全面提升技术面试的表现。这些资料不仅内容详实,而且持续更新,是求职者备战技术面试的宝贵资源。
阿里面试:MYSQL 事务ACID,底层原理是什么? 具体是如何实现的?
|
26天前
|
存储 SQL 分布式计算
湖仓一体架构深度解析:构建企业级数据管理与分析的新基石
【10月更文挑战第7天】湖仓一体架构深度解析:构建企业级数据管理与分析的新基石
41 1
|
13天前
|
数据采集 分布式计算 OLAP
最佳实践:AnalyticDB在企业级大数据分析中的应用案例
【10月更文挑战第22天】在数字化转型的大潮中,企业对数据的依赖程度越来越高。如何高效地处理和分析海量数据,从中提取有价值的洞察,成为企业竞争力的关键。作为阿里云推出的一款实时OLAP数据库服务,AnalyticDB(ADB)凭借其强大的数据处理能力和亚秒级的查询响应时间,已经在多个行业和业务场景中得到了广泛应用。本文将从个人的角度出发,分享多个成功案例,展示AnalyticDB如何助力企业在广告投放效果分析、用户行为追踪、财务报表生成等领域实现高效的数据处理与洞察发现。
37 0
|
1月前
|
人工智能 自然语言处理 关系型数据库
阿里云云原生数据仓库 AnalyticDB PostgreSQL 版已完成和开源LLMOps平台Dify官方集成
近日,阿里云云原生数据仓库 AnalyticDB PostgreSQL 版已完成和开源LLMOps平台Dify官方集成。