Seata原理学习与入门实例

事务

事务(Transaction)指一个操作,由多个步骤组成,要么全部成功,要么全部失败。

比如我们常用的转账功能,假设A账户向B账号转账,那么涉及两个操作:

(1)从A账户扣钱;

(2)往B账户加入等量的钱。

因为是独立的两个操作,所以可能有一个成功,一个失败的情况。但是因为在这种场景下,必须要保证事务,即要么同时成功,要么同时失败(一个失败需要回滚),不能存在从A账户扣钱成功,往B账户加入等量钱失败这种情况。

数据库事务

[!NOTE]

传统应用中,事务主要靠数据库实现,理解数据库事务有利于理解分布式事务,同时,部分分布式事务模式也依赖数据库事务。

原文参考:深入学习MySQL事务:ACID特性的实现原理

事务是数据库的核心机制之一,是访问和更新数据库的程序执行单元;事务中可能包含一个或多个sql语句,这些语句要么都执行,要么都不执行。

接下来以MySQL为例,理解数据库事务实现和ACID原理。

逻辑架构和存储引擎

DeWatermark.ai_1739327317104

如上图所示,MySQL服务器逻辑架构从上往下可以分为三层:

(1)第一层:处理客户端连接、授权认证等。

(2)第二层:服务器层,负责查询语句的解析、优化、缓存以及内置函数的实现、存储过程等。

(3)第三层:存储引擎,负责MySQL中数据的存储和提取。MySQL中服务器层不管理事务,事务是由存储引擎实现的。MySQL支持事务的存储引擎有InnoDB、NDB Cluster等,其中InnoDB的使用最为广泛;其他存储引擎不支持事务,如MyIsam、Memory等。

事务提交和回滚

事务

MySQL事务操作过程:

-- 开始事务
START TRANSACTION;
#或 BEGIN;

-- 执行一些SQL语句
UPDATE accounts SET balance = balance - 100 WHERE user_id = 1;
UPDATE accounts SET balance = balance + 100 WHERE user_id = 2;

-- 判断是否要提交还是回滚
IF (条件) THEN
    COMMIT; -- 提交事务
ELSE
    ROLLBACK; -- 回滚事务
END IF;

ACID特性

ACID是衡量事务的四个特性:

  • 原子性(Atomicity,或称不可分割性)
  • 一致性(Consistency)
  • 隔离性(Isolation)
  • 持久性(Durability)

按照严格的标准,只有同时满足ACID特性才是事务;但是在各大数据库厂商的实现中,真正满足ACID的事务少之又少。例如MySQL的NDB Cluster事务不满足持久性和隔离性;InnoDB默认事务隔离级别是可重复读,不满足隔离性;Oracle默认的事务隔离级别为READ COMMITTED,不满足隔离性……因此与其说ACID是事务必须满足的条件,不如说它们是衡量事务的四个维度。

原子性

原子性是事务的本质要求,即一个事务是一个不可分割的工作单位,其中的操作要么都做,要么都不做;如果事务中一个sql语句执行失败,则已执行的语句也必须回滚,数据库退回到事务前的状态。

原子性通过MySQL的undo log实现:当事务对数据库进行修改时,InnoDB会生成对应的undo log;如果事务执行失败或调用了rollback,导致事务需要回滚,便可以利用undo log中的信息将数据回滚到修改之前的样子。

undo log属于逻辑日志,它记录的是sql执行相关的信息。当发生回滚时,InnoDB会根据undo log的内容做与之前相反的工作:对于每个insert,回滚时会执行delete;对于每个delete,回滚时会执行insert;对于每个update,回滚时会执行一个相反的update,把数据改回去。

以Update为例,首先记录原始数据到undo log,然后修改数据;如果此时读数据,读的是undo buffer。

undo log

如上图:

1、事务A执行update操作,此时事务还没提交,会将数据进行备份到对应的undo buffer,然后由undo buffer持久化到磁盘中的undo log文件中,此时undo log保存了未提交之前的操作日志,接着将操作的数据,也就是Teacher表的数据持久保存到InnoDB的数据文件IBD。

2、此时事务B进行查询操作,直接从undo buffer缓存中进行读取,这时事务A还没提交事务,如果要回滚(rollback)事务,是不读磁盘的,先直接从undo buffer缓存读取。

持久性

持久性是指事务一旦提交,它对数据库的改变就应该是永久性的。接下来的其他操作或故障不应该对其有任何影响。

原子性通过MySQL的redo log实现。

InnoDB作为MySQL的存储引擎,数据是存放在磁盘中的,但如果每次读写数据都需要磁盘IO,效率会很低。为此,InnoDB提供了缓存(Buffer Pool),Buffer Pool中包含了磁盘中部分数据页的映射,作为访问数据库的缓冲:当从数据库读取数据时,会首先从Buffer Pool中读取,如果Buffer Pool中没有,则从磁盘读取后放入Buffer Pool;当向数据库写入数据时,会首先写入Buffer Pool,Buffer Pool中修改的数据会定期刷新到磁盘中(这一过程称为刷脏)。

Buffer Pool的使用大大提高了读写数据的效率,但是也带了新的问题:如果MySQL宕机,而此时Buffer Pool中修改的数据还没有刷新到磁盘,就会导致数据的丢失,事务的持久性无法保证。

于是,redo log被引入来解决这个问题:当数据修改时,除了修改Buffer Pool中的数据,还会在redo log记录这次操作;当事务提交时,会调用fsync接口对redo log进行刷盘。如果MySQL宕机,重启时可以读取redo log中的数据,对数据库进行恢复。redo log采用的是WAL(Write-ahead logging,预写式日志),所有修改先写入日志,再更新到Buffer Pool,保证了数据不会因MySQL宕机而丢失,从而满足了持久性要求。

既然redo log也需要在事务提交时将日志写入磁盘,为什么它比直接将Buffer Pool中修改的数据写入磁盘(即刷脏)要快呢?主要有以下两方面的原因:

(1)刷脏是随机IO,因为每次修改的数据位置随机,但写redo log是追加操作,属于顺序IO。

(2)刷脏是以数据页(Page)为单位的,MySQL默认页大小是16KB,一个Page上一个小修改都要整页写入;而redo log中只包含真正需要写入的部分,无效IO大大减少。

隔离性

与原子性、持久性侧重于研究事务本身不同,隔离性研究的是不同事务之间的相互影响。隔离性是指,事务内部的操作与其他事务是隔离的,并发执行的各个事务之间不能互相干扰。

隔离性追求的是并发情形下事务之间互不干扰。简单起见,我们主要考虑最简单的读操作和写操作那么隔离性的探讨,主要可以分为两个方面:

  • (一个事务)写操作对(另一个事务)写操作的影响:锁机制保证隔离性
  • (一个事务)写操作对(另一个事务)读操作的影响:MVCC保证隔离性
锁机制

隔离性要求同一时刻只能有一个事务对数据进行写操作,InnoDB通过锁机制来保证这一点。

锁机制的基本原理可以概括为:事务在修改数据之前,需要先获得相应的锁;获得锁之后,事务便可以修改数据;该事务操作期间,这部分数据是锁定的,其他事务如果需要修改数据,需要等待当前事务提交或回滚后释放锁。

按照粒度,锁可以分为表锁、行锁以及其他位于二者之间的锁。

表锁在操作数据时会锁定整张表,并发性能较差;行锁则只锁定需要操作的数据,并发性能好。但是由于加锁本身需要消耗资源(获得锁、检查锁、释放锁等都需要消耗资源),因此在锁定数据较多情况下使用表锁可以节省大量资源。

MySQL中不同的存储引擎支持的锁是不一样的,例如MyIsam只支持表锁,而InnoDB同时支持表锁和行锁,且出于性能考虑,绝大多数情况下使用的都是行锁。

脏读,不可重复读,幻读

(1)脏读:当前事务(A)中可以读到其他事务(B)未提交的数据(脏数据),这种现象是脏读。

脏读

(2)不可重复读:在事务A中先后两次读取同一个数据,两次读取的结果不一样,这种现象称为不可重复读。脏读与不可重复读的区别在于:前者读到的是其他事务未提交的数据,后者读到的是其他事务已提交的数据。

不可重复读

(3)幻读:在事务A中按照某个条件先后两次查询数据库,两次查询结果的条数不同,这种现象称为幻读。不可重复读与幻读的区别可以通俗的理解为:前者是数据变了,后者是数据的行数变了。

幻读

事务隔离级别

SQL标准中定义了四种隔离级别,并规定了每种隔离级别下上述几个问题是否存在。一般来说,隔离级别越低,系统开销越低,可支持的并发越高,但隔离性也越差。隔离级别与读问题的关系如下:

事务隔离级别

在实际应用中,读未提交在并发时会导致很多问题,而性能相对于其他隔离级别提高却很有限,因此使用较少。可串行化强制事务串行,并发效率很低,只有当对数据一致性要求极高且可以接受没有并发时使用,因此使用也较少。因此在大多数数据库系统中,默认的隔离级别是读已提交(如Oracle)或可重复读(后文简称RR)。

InnoDB默认的隔离级别是RR,后文会重点介绍RR。需要注意的是,在SQL标准中,RR是无法避免幻读问题的;但是InnoDB实现的RR,在只读事务中可以避免幻读问题。

MVCC

RR解决脏读、不可重复读、幻读等问题,使用的是MVCC:MVCC全称Multi-Version Concurrency Control,即多版本的并发控制协议。

下面的例子很好的体现了MVCC的特点:在同一时刻,不同的事务读取到的数据可能是不同的(即多版本)——在T5时刻,事务A和事务C可以读取到不同版本的数据。

mvcc

MVCC最大的优点是读不加锁,因此读写不冲突,并发性能好。InnoDB实现MVCC,多个版本的数据可以共存,主要基于以下技术及数据结构:

1)隐藏列:InnoDB中每行数据都有隐藏列,隐藏列中包含了本行数据的事务id、指向undo log的指针等。

2)基于undo log的版本链:前面说到每行数据的隐藏列中包含了指向undo log的指针,而每条undo log也会指向更早版本的undo log,从而形成一条版本链。

3)ReadView:通过隐藏列和版本链,MySQL可以将数据恢复到指定版本;但是具体要恢复到哪个版本,则需要根据ReadView来确定。所谓ReadView,是指事务(记做事务A)在某一时刻给整个事务系统(trx_sys)打快照,之后再进行读操作时,会将读取到的数据中的事务id与trx_sys快照比较,从而判断数据对该ReadView是否可见,即对事务A是否可见。

[!NOTE]

RC读已提交与RR一样,都使用了MVCC,其主要区别在于:

RR是在事务开始后第一次执行select前创建ReadView,直到事务提交都不会再创建。根据前面的介绍,RR可以避免脏读、不可重复读和幻读。

RC每次执行select前都会重新建立一个新的ReadView,因此如果事务A第一次select之后,事务B对数据进行了修改并提交,那么事务A第二次select时会重新建立新的ReadView,因此事务B的修改对事务A是可见的。因此RC隔离级别可以避免脏读,但是无法避免不可重复读和幻读。

一致性

一致性是指事务执行结束后,数据库的完整性约束没有被破坏,事务执行的前后都是合法的数据状态。数据库的完整性约束包括但不限于:实体完整性(如行的主键存在且唯一)、列完整性(如字段的类型、大小、长度要符合要求)、外键约束、用户自定义完整性(如转账前后,两个账户余额的和应该不变)。

一致性是一个非常笼统的术语,它要求数据必须符合所有验证规则,如:

  • 保证原子性、持久性和隔离性,如果这些特性无法保证,事务的一致性也无法保证
  • 数据库本身提供保障,例如不允许向整形列插入字符串值、字符串长度不能超过列的限制等
  • 应用层面进行保障,例如如果转账操作只扣除转账者的余额,而没有增加接收者的余额,无论数据库实现的多么完美,也无法保证状态的一致

分布式事务

在分布式场景下,一个系统由多个子系统构成,每个子系统有独立的数据源。多个子系统之间通过互相调用来组合出更复杂的业务。在时下流行的微服务系统架构中,每一个子系统被称作一个微服务,同样每个微服务都维护自己的数据库,以保持独立性。

分布式事务要求在业务跨服务跨数据库的情况下,依旧维持事务性。

如在用户购物的业务场景中,同时设计订单系统和库存系统,两者有各自独立的数据库,购物事务发生时,两个业务按照ACID事务要求同时进行订单增加和库存减少操作,要么都成功,要么都失败回滚。

两阶段提交2PC

大部分分布式事务模式都是基于两阶段提交2PC(Two Phase Commitment )及其变种实现的,因此先理解2PC原理。

顾名思义,两阶段提交分为以下两个阶段:

  • 准备阶段(Prepare Phase)
  • 提交阶段(Commit Phase)

在两阶段提交协议中,系统一般包含两类角色:

  • 协调者(Coordinator),协调所有分布式原子事务,决定提交或回滚;通常一个系统中只有一个;
  • 参与者(Participant),即参与分布式的每一个子业务,一般包含多个。

整体流程:

第一阶段为准备阶段,若所有事务参与者都预留资源成功,则第二阶段进行提交,否则事务协调者回滚全部资源

准备阶段

由事务协调者询问通知各个事务参与者,是否准备好了执行事务:

  1. 协调者向所有参与者发送事务内容,询问是否可以提交事务,并等待答复;
  2. 各参与者执行本地事务操作,将 undo 和 redo 信息记入事务日志中,但不提交;
  3. 如参与者执行成功,给协调者反馈同意,否则反馈中止,表示事务不可以执行;

2pc-1

提交/回滚阶段

协调者收到各个参与者的准备消息后,根据反馈情况通知各个参与者提交或回滚。
提交事务

当第一阶段所有参与者都反馈同意时,协调者发起正式提交事务的请求,当所有参与者都回复同意时,则意味着完成事务,具体流程如下:

  1. 协调者节点向所有参与者节点发出正式提交的 commit 请求。
  2. 收到协调者的 commit 请求后,参与者正式执行事务提交操作,并释放在整个事务期间内占用的资源。
  3. 参与者完成事务提交后,向协调者节点发送 ACK 消息。
  4. 协调者节点收到所有参与者节点反馈的 ACK 消息后,完成事务。

回滚事务

如果任意一个参与者节点在第一阶段返回的消息为中止,或者协调者节点在第一阶段的询问超时之前无法获取所有参与者节点的响应消息时,那么这个事务将会被回滚,具体流程如下:

  1. 协调者向所有参与者发出 rollback 回滚操作的请求
  2. 参与者利用阶段一写入的 undo 信息执行回滚,并释放在整个事务期间内占用的资源
  3. 参与者在完成事务回滚之后,向协调者发送回滚完成的 ACK 消息
  4. 协调者收到所有参与者反馈的 ACK 消息后,取消事务

2pc-2

[!NOTE]

2PC缺点:

2PC是一个强一致性的同步阻塞协议,事务执⾏过程中需要将所需资源全部锁定,也就是俗称的刚性事务。所以它比较适⽤于执⾏时间确定的短事务,整体性能比较差

一旦事务协调者宕机或者发生网络抖动,会让参与者一直处于锁定资源的状态或者只有一部分参与者提交成功,导致数据的不一致

Seata

Seata 是一款开源的分布式事务解决方案,致力于提供高性能和简单易用的分布式事务服务。Seata 将为用户提供了XA、AT、TCC 和 SAGA 事务模式,为用户打造一站式的分布式解决方案。

Seata中的角色,相比2PC,参与者分为事务管理者和资源管理者:

  • TC,Transaction Coordinator,事务协调者,用来协调全局和各个分支事务(不同服务)的状态, 驱动它们的回滚或提交。
  • TM,Transaction Manager,事务管理者,业务层中用来开启/提交/回滚一个整体事务(在调用服务的方法中用注解开启事务)。
  • RM,Resource Manager,资源管理者,管理分支事务,与 TC 进行协调注册分支事务,并且汇报分支事务的状态,驱动分支事务的提交或回滚。

简单流程图:

简单流程图

XA模式

Seata的XA模式完全按照2PC事务设计。

第一阶段:

  1. RM 注册分支事务到 TC;
  2. RM 执行分支业务的 SQL 但不提交
  3. RM 报告执行状态到 TC;

第二阶段:

  1. TC 检测检测各分支事务状态,判断整体事务提交或回滚;
  2. RM 接受 TC 的指令,进行统一的提交或回滚操作。

image-20250214102255406

XA优点:

  1. 事务强一致性,满足 ACID 原则;
  2. 实现简单,无代码入侵。

XA缺点:

  1. 一阶段锁定资源,占用数据库连接,二阶段结束才释放,性能较差;
  2. 依赖关系型数据库实现事务;

AT模式

AT(Auto Transaction)是Seata的默认模式,基于XA和2PC模式演进而来。AT模式依旧依赖数据库事务,但有效解决了XA长期资源锁定导致的性能问题。

AT模式是一种无侵入的分布式事务解决方案,该模式下,用户只需关注自己的业务 SQL,Seata 框架会在第一阶段拦截并解析SQL,生成 undo log,并自动生成事务二阶段的提交和回滚操作。

AT 模式下,是利用快照实现数据回滚,属于弱一致。

流程

第一阶段:

  1. RM 注册分支事务到 TC;
  2. 记录 undo log(数据快照);
  3. RM 执行分支业务的 SQL 并提交
  4. RM 报告执行状态到 TC;

第二阶段:

  1. TC 检测检测各分支事务状态,判断整体事务提交或回滚;
  2. RM 接受 TC 的指令,进行统一的提交或回滚操作。
    • 提交时,异步删除相应分支的 undo log;
    • 回滚时,根据 undo log 生成补偿回滚的 SQL,执行分支回滚并返回结果给 TC;

image-20250214110111520

全局锁解决脏写

因为AT模式下第一阶段数据会提交,如果第二阶段要回滚前,有另一个事务也修改了这条数据,那么回滚就导致另一个事务修改的数据被覆盖,导致数据丢失。

AT脏写

AT模式中通过全局锁来解决脏写问题,只有持有全局锁的事务才有执行SQL的权限。

AT全局锁

流程如下:

  1. 一阶段本地事务提交前,需要确保先拿到全局锁 ;
  2. 拿不到全局锁 ,不能提交本地事务。拿不到全局锁会重试,次数有限,超出限制将放弃,并回滚本地事务,释放本地锁。
  3. 二阶段提交或回滚完全部数据后,释放全局锁。
数据快照解决非Seata事务脏写

全局锁可以阻止Seata其他事务对本事务的数据修改,但无法阻止非Seata事务的修改。

为了防止非Seata事务脏写,RM 在第一阶段将分支事务注册到 TC 时,会在 undo log 保存两个数据快照,分别是:

  • before-image:数据修改前的快照
  • after-image:数据修改后的快照

当发生异常时,before-image用来做数据回滚,after-image来判断修改后数据于当前数据是否相同,相同则通过before-image做数据回滚,不同则说明被其他非 Seata 事务修改过,记录异常,人工介入

非Seata脏写

脏读问题

AT模式的脏读是指,在全局事务未提交前,被其它业务读到已提交的分支事务的数据,即读未提交。

Seata AT默认全局隔离级别就是读未提交,即容忍可这种脏读行为。

如果应用在特定场景下,必需要求全局的 读已提交 ,目前 Seata 的方式是通过 SELECT FOR UPDATE 语句的代理:

  1. 业务查询时要使用@GlobalTransactional@GlobalLock来修饰查询方法的调用;
  2. 查询语句须使用select for update语句。

SELECT FOR UPDATE 语句的执行会申请 全局锁 ,如果 全局锁 被其他事务持有,则释放本地锁(回滚 SELECT FOR UPDATE 语句的本地执行)并重试。这个过程中,查询是被 block 住的,直到 全局锁 拿到,即读取的相关数据是 已提交 的,才返回。

显然,AT 事务模式下读已提交的成本很高,对于非必要场景还是要尽量避免使用。

优缺点

优点:

  1. 一阶段直接完成事务提交,释放数据库资源,性能比较好;
  2. 利用全局锁实现读写隔离;
  3. 没有代码入侵,框架自动完成回滚或提交。

缺点:

  1. 两阶段之间属于软状态,属于最终一致;
  2. 数据快照会影响性能,但比 XA 模式要好很多;

TCC模式

TCC模式也可以看做2PC的一个变种,与AT模式一样在每一阶段都直接进行事务提交,不同的是,TCC模式的每一步都需要业务代码实现,对业务是强侵入性的。

image-20250217153608508

以账户余额扣除为例,讲解TCC业务代码流程:

  • Try:资源的检测和预留;业务上,检查余额是否充足,如果充足则冻结金额增加30元,可用余额扣除30
  • Confirm:完成资源操作业务,要求Try成功,Confirm一定能成功;业务上,冻结金额减30,业务结束。
  • Cancel:预留资源释放,可以理解为Try的反向操作。业务上,冻结金额扣减30,可用余额增加30,业务结束。

TCC模式有空回滚或业务悬挂风险:

  • 空回滚:分支事务Try操作阻塞时,可能导致全局事务超时触发Cancel操作。在Try未执行时先执行了Cancel,这时的Cancel理论上不应该回滚,这时就需要空回滚
  • 业务悬挂:对于已经空回滚的业务,这时如果线程不再阻塞,继续执行Try,但不可能ConfirmCancel,这就是业务悬挂,需要避免空回滚后的Try操作。

通过记录当前事务 ID 和执行状态,解决空回滚和业务悬挂问题:

空回滚避免

TCC优点:

  1. 一阶段直接完成事务提交,释放数据库资源,性能比较好;
  2. 相比 AT,无需生成快照和使用全局锁,性能最好;
  3. 不依赖数据库事务,依赖补偿操作,可用于非事务型数据库。

TCC缺点:

  1. 代码入侵,每个阶段都需要编写对应的业务代码;
  2. 软状态,属于最终一致;
  3. 需要考虑ConfirmCancel的失败情况,做好幂等处理。

Saga模式

Saga其核心思想是将长事务拆分为多个本地短事务并依次正常提交,如果所有短事务均执行成功,那么分布式事务提交;如果出现某个参与者执行本地事务失败,则由 Saga 事务协调器协调根据相反顺序调用补偿操作,回滚已提交的参与者,使分布式事务回到最初始的状态。

与TCC事务补偿机制相比,TCC有一个预留(Try)动作,相当于先报存一个草稿,然后才提交。Saga事务没有预留动作,直接提交

  • 一阶段: 直接提交本地事务
  • 二阶段: 成功则什么都不做;失败则通过编写补偿业务来回滚

saga

Saga优点:

  1. 事务参与者可以基于事件驱动实现异步调用,吞吐高;
  2. 一阶段直接提交本地事务,无锁,性能好;
  3. 代码入侵较 TCC 低,实现简单。

Saga缺点:

  1. 软状态持续时间不确定,时效性差;
  2. 没有锁和事务隔离,可能会有脏写。

Seata AT实践

使用Seata官方示例理解Seata组件和业务部署。https://seata.apache.org/zh-cn/docs/user/quickstart

资源准备

  1. 已准备MySQL 5.7。本地运行Seata,file模式;本地idea运行代码。

  2. 应用部署,参考官方部署流程,主要是进行数据库表场景和代码内数据库、Seata连接信息修改

    [!NOTE]

    Seata因为使用云服务器中的,所以不需要本地运行,但需要修改所有应用中的Seata配置

  3. 架构分析

    Seata官方示例是一个简单的商城逻辑,以Dubbo为基础的微服务架构,包括:

    主服务:

    • business,作为TM发起事务,同时调用存储和订单服务。

    从服务:

    • 仓储服务:对给定的商品扣除仓储数量。
    • 订单服务:根据采购需求创建订单,同时调用账户服务扣除余额。
    • 帐户服务:从用户帐户中扣除余额。

服务示例

添加分布式事务,只需要在business业务方法上增加@GlobalTransactional注解即可。

image-20250217181102450

业务正常并提交

访问commit方法并传入参数,commit正常调用所有方案,无异常

http://127.0.0.1:9999/test/commit?userId=ACC_001&commodityCode=STOCK_001&orderCount=1

界面返回commit信息

image-20250218101506267

后台返回分布式事务相关信息

image-20250218101445167

查看数据库,出现order库记录订单信息,account库金额减少,stock库存减少

image-20250218112219731

业务异常并回滚

访问business的rollback方法,会调用purchaseRollback,在发起业务后,抛出异常,模拟业务异常。

image-20250218151818119

界面返回rollback信息

image-20250218152008443

后台返回分布式事务相关信息,显示业务被回滚

image-20250218152031930

此时数据库不会有任何变化,因为数据库的变更都被回滚了。

但如果再执行正常指令,会发现order库id有中断,即中间插入过数据但被回滚了。

image-20250218152651511

posted @   VitoChen  阅读(25)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· winform 绘制太阳,地球,月球 运作规律
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 超详细:普通电脑也行Windows部署deepseek R1训练数据并当服务器共享给他人
· 上周热点回顾(3.3-3.9)
levels of contents
点击右上角即可分享
微信分享提示