31-JDBC(3)
1. 事务管理#
1.1 数据库事务介绍#
- 事务:一组逻辑操作单元,使数据从一种状态变换到另一种状态。
- 事务处理(事务操作):保证所有事务都作为一个工作单元来执行,即使出现了故障,都不能改变这种执行方式。当在一个事务中执行多个操作时,要么所有的事务都被提交(commit),那么这些修改就永久地保存下来;要么数据库管理系统将放弃所作的所有修改,整个事务回滚(rollback)到最初状态。
- 为确保数据库中数据的一致性,数据的操纵应当是离散的成组的逻辑单元:当它全部完成时,数据的一致性可以保持,而当这个单元中的一部分操作失败,整个事务应全部视为错误,所有从起始点以后的操作应全部回退到开始状态。
1.2 JDBC 事务处理#
- 数据一旦提交,就不可回滚。
- 数据什么时候意味着提交?
- 当一个连接对象被创建时,默认情况下是自动提交事务:每次执行一个 SQL 语句时,如果执行成功,就会向数据库自动提交,而不能回滚。
- DML 默认情况下,一旦执行自动提交,但可以通过
set autocommit = false
取消 DML 操作的自动提交 - DDL 操作一旦执行,就会自动提交。设置
autocommit
也不好使
- DML 默认情况下,一旦执行自动提交,但可以通过
- 关闭数据库连接,数据就会自动的提交。如果多个操作,每个操作使用的是自己单独的连接,则无法保证事务。即同一个事务的多个操作必须在同一个连接下。
- 当一个连接对象被创建时,默认情况下是自动提交事务:每次执行一个 SQL 语句时,如果执行成功,就会向数据库自动提交,而不能回滚。
- JDBC 程序中为了让多个 SQL 语句作为一个事务执行:
- 同一个事务的多个操作必须在同一个 Connection 下
- 调用 Connection 对象的
setAutoCommit(false);
以取消自动提交事务 - 在所有的 SQL 语句都成功执行后,调用
commit();
提交事务 - 在出现 Exception 时,调用
rollback();
回滚事务
若此时 Connection 没有被关闭,还可能被重复使用,则需要恢复其自动提交状态
setAutoCommit(true)
。尤其是在使用数据库连接池技术时,执行close()
前,建议恢复自动提交状态。
【案例:用户 AA 向用户 BB 转账 100】
@Test
public void testUpdateWithTrans() {
Connection conn = null;
try {
conn = JDBCUtils.getConnection();
conn.setAutoCommit(false);
String sql1 = "update user_table set balance = balance-100 where user = ?";
update2(conn, sql1, "AA"); // 同一个数据库连接
// 模拟网络异常
System.out.println(1101/0);
String sql2 = "update user_table set balance = balance+100 where user = ?";
update2(conn, sql2, "BB"); // 同一个数据库连接
conn.commit(); // 提交数据
} catch (Exception e) {
e.printStackTrace();
try {
conn.rollback(); // 一旦事务执行过程中出现异常,立即回滚
} catch (SQLException e1) {
e1.printStackTrace();
}
} finally {
/*
针对连接池,还回去之前要先把连接的属性恢复成默认值
try {
conn.setAutoCommit(true);
} catch (SQLException e) {
e.printStackTrace();
}
*/
JDBCUtils.close(conn, null, null);
}
}
/**
* 通用的增删改操作(考虑事务)
* @param conn 数据库连接(同一个事务的多个操作必须在同一个 Connection 下)
* @param sql 要执行的SQL语句
* @param args SQL语句中的参数
* @return SQL 影响的记录条数
*/
public int update2(Connection conn, String sql, Object... args) {
PreparedStatement stat = null;
int line = 0;
try {
stat = conn.prepareStatement(sql);
int count = args.length;
for(int i = 0; i < count; i++)
stat.setObject(i+1, args[i]);
line = stat.executeUpdate();
} catch (Exception e) {
e.printStackTrace();
} finally {
// 连接不要关
JDBCUtils.close(null, stat, null);
}
return line;
}
1.3 事务的 ACID 属性#
- 原子性(Atomicity)
原子性是指事务是一个不可分割的工作单位,事务中的操作要么都发生,要么都不发生。 - 一致性(Consistency)
事务必须使数据库从一个一致性状态变换到另外一个一致性状态。 - 隔离性(Isolation)
事务的隔离性是指一个事务的执行不能被其他事务干扰,即一个事务内部的操作及使用的数据对并发的其他事务是隔离的,并发执行的各个事务之间不能互相干扰。 - 持久性(Durability)
持久性是指一个事务一旦被提交,它对数据库中数据的改变就是永久性的,数据将持久化到本地,除非其他事务对其进行修改。
1.3.1 数据库的并发问题#
- 事务并发问题如何发生?
- 当多个事务同时操作同一个数据库的相同数据时
- 事务的并发问题有哪些?
- 【脏读】对于两个事务 T1、T2,T1 读取了已经被 T2 更新但还没有被提交的字段。之后,若 T2 回滚,T1 读取的内容就是临时且无效的
- 【不可重复读】对于两个事务T1、T2,T1 读取了一个字段,然后 T2 更新了该字段。之后, T1再次读取同一个字段,值就不同了
- 【幻读】 对于两个事务T1、T2,T1 从一个表中读取了一个字段,然后 T2 在该表中插入了一些新的行。之后, 如果 T1 再次读取同一个表,就会多出几行
- 如何避免事务的并发问题?
- 通过设置事务的隔离级别, 一个事务与其他事务隔离的程度称为隔离级别。
- 数据库系统必须具有隔离并发运行各个事务的能力,使它们不会相互影响,避免各种并发问题。数据库规定了多种事务隔离级别,不同隔离级别对应不同的干扰程度,隔离级别越高,数据一致性就越好,但并发性越弱。
1.3.2 四种隔离级别#
为解决上述并发问题而设置的不同的隔离级别:
- Oracle 支持的 2 种事务隔离级别:READ COMMITED,SERIALIZABLE。Oracle 默认的事务隔离级别为:READ COMMITED
- MySQL 支持 4 种事务隔离级别。Mysql 默认的事务隔离级别为:REPEATABLE READ
下面来演示一下:
1.3.3 在 MySQL 中设置隔离级别#
-
每启动一个 MySQL 程序,就会获得一个单独的数据库连接。每个数据库连接都有一个全局变量
@@tx_isolation
,表示当前的事务隔离级别。 -
查看当前的隔离级别
SELECT @@tx_isolation;
-
设置当前 MySQL 连接的隔离级别
set transaction isolation level [read committed | ...];
-
设置数据库系统的全局的隔离级别
set global transaction isolation level [read committed | ...];
-
补充操作
- 创建 MySQL 数据库用户
create user ljq identified by 'ljq';
- 授予权限
# 授予通过网络方式登录的tom用户,对所有库所有表的全部权限,密码设为ljq grant all privileges on *.* to ljq@'%' identified by 'ljq'; # 给 ljq 用户使用本地命令行方式,授予 test 这个库下的所有表的插删改查的权限。 grant select,insert,update,delete on test.* to ljq@localhost identified by 'ljq';
- 创建 MySQL 数据库用户
1.3.4 在 JDBC 中设置隔离级别#
Connection API:
int TRANSACTION_READ_UNCOMMITTED = 1;
int TRANSACTION_READ_COMMITTED = 2;
int TRANSACTION_REPEATABLE_READ = 4;
int TRANSACTION_SERIALIZABLE = 8;
getTransactionIsolation() // 获取当前事务的隔离级别
setTransactionIsolation(level) // 设置当前事务的隔离级别
2. DAO 及其实现类#
2.1 BaseDAO#
public abstract class BaseDAO<T> {
private Class<T> clazz = null;
// 写子类里的话,每个实现类都要写一遍,麻烦
{
// this 是谁?子类对象!
Type genericSuperclass = this.getClass().getGenericSuperclass();
ParameterizedType paramType = (ParameterizedType) genericSuperclass;
Type[] actualTypeArguments = paramType.getActualTypeArguments();
clazz = (Class<T>) actualTypeArguments[0];
// 获取当前 BaseDAO 的子类继承父类时指定的父类泛型的具体类型
}
public <E> E getValue(Connection conn, String sql, Object... args) {
PreparedStatement ps = null;
ResultSet rs = null;
try {
ps = conn.prepareStatement(sql);
for(int i = 0; i < args.length; i++)
ps.setObject(i+1, args[i]);
rs = ps.executeQuery();
if(rs.next())
return (E) rs.getObject(1);
} catch (SQLException e) {
e.printStackTrace();
} finally {
JDBCUtils.close(null, ps, rs);
}
return null;
}
public int update(Connection conn, String sql, Object... args) {
PreparedStatement stat = null;
int line = 0;
try {
stat = conn.prepareStatement(sql);
int count = args.length;
for(int i = 0; i < count; i++)
stat.setObject(i+1, args[i]);
line = stat.executeUpdate();
} catch (Exception e) {
e.printStackTrace();
} finally {
// 连接不要关
JDBCUtils.close(null, stat, null);
}
return line;
}
public List<T> queryList(Connection conn, String sql, Object... args) {
PreparedStatement stat = null;
ResultSet rs = null;
List<T> list = null;
try {
stat = conn.prepareStatement(sql);
for (int i = 0; i < args.length; i++)
stat.setObject(i+1, args[i]);
rs = stat.executeQuery();
ResultSetMetaData rsmd = rs.getMetaData();
int columnCount = rsmd.getColumnCount();
list = new ArrayList<>();
T instance = null;
while (rs.next()) {
instance = clazz.newInstance();
for (int i = 0; i < columnCount; i++) {
// 获取第i列的列值
Object columnValue = rs.getObject(i+1);
// 获取第i列的别名
String columnLable = rsmd.getColumnLabel(i+1);
Field field = clazz.getDeclaredField(columnLable);
field.setAccessible(true);
field.set(instance, columnValue);
}
list.add(instance);
}
} catch (Exception e) {
e.printStackTrace();
} finally {
JDBCUtils.close(null, stat, rs);
}
return list;
}
public T query(Connection conn, String sql, Object... args) {
PreparedStatement stat = null;
ResultSet rs = null;
T instance = null;
try {
stat = conn.prepareStatement(sql);
for (int i = 0; i < args.length; i++)
stat.setObject(i+1, args[i]);
rs = stat.executeQuery();
ResultSetMetaData rsmd = rs.getMetaData();
int columnCount = rsmd.getColumnCount();
if (rs.next()) {
instance = clazz.newInstance();
for (int i = 0; i < columnCount; i++) {
// 获取第i列的列值
Object columnValue = rs.getObject(i+1);
// 获取第i列的别名
String columnLable = rsmd.getColumnLabel(i+1);
Field field = clazz.getDeclaredField(columnLable);
field.setAccessible(true);
field.set(instance, columnValue);
}
}
} catch (Exception e) {
e.printStackTrace();
} finally {
JDBCUtils.close(null, stat, rs);
}
return instance;
}
}
2.2 CustomerDAO#
/**
* 此接口用于规范 针对于Customer表的常用操作
*
*/
public interface CustomerDAO {
void insertCustomer(Connection conn, Customer cust);
void deleteById(Connection conn, int id);
void updateById(Connection conn, Customer cust);
Customer getCustomerById(Connection conn, int id);
List<Customer> getAllCustomer(Connection conn);
Long getCount(Connection conn);
Date getMaxBirth(Connection conn);
}
2.3 CustomerDAOImpl#
public class CustomerDAOImpl extends BaseDAO<Customer> implements CustomerDAO {
@Override
public void insertCustomer(Connection conn, Customer cust) {
String sql = "insert into customers(name, email, birth) values(?, ?, ?)";
update(conn, sql, cust.getName(), cust.getEmail(), cust.getBirth());
}
@Override
public void deleteById(Connection conn, int id) {
String sql = "delete from customers where id = ?";
update(conn, sql, id);
}
@Override
public void updateById(Connection conn, Customer cust) {
String sql = "update customers set name = ?, email = ?, birth = ? where id = ?";
update(conn, sql, cust.getName(), cust.getEmail(), cust.getBirth(), cust.getId());
}
@Override
public Customer getCustomerById(Connection conn, int id) {
String sql = "select id, name, email, birth from customers where id = ?";
Customer customer = query(conn, sql, id);
return customer;
}
@Override
public List<Customer> getAllCustomer(Connection conn) {
String sql = "select id, name, email, birth from customers";
List<Customer> list = queryList(conn, sql);
return list;
}
@Override
public Long getCount(Connection conn) {
String sql = "select count(*) from customers";
return getValue(conn, sql);
}
@Override
public Date getMaxBirth(Connection conn) {
String sql = "select max(birth) from customers";
return getValue(conn, sql);
}
}
3. 数据库连接池#
3.1 JDBC 数据库连接池的必要性#
- 在使用开发基于数据库的 web 程序时,传统的模式基本是按以下步骤:
- 在主程序(如 servlet、beans)中建立数据库连接
- 进行 SQL 操作
- 断开数据库连接
- 这种模式开发存在的问题:
- 普通的 JDBC 数据库连接使用 DriverManager 来获取,每次向数据库建立连接的时候都要将 Connection 加载到内存中,再验证用户名和密码(得花费 0.05s~1s 的时间)。需要数据库连接的时候,就向数据库要求一个,执行完成后再断开连接。这样的方式将会消耗大量的资源和时间。数据库的连接资源并没有得到很好的重复利用。若同时有几百人甚至几千人在线,频繁的进行数据库连接操作将占用很多的系统资源,严重的甚至会造成服务器的崩溃。
- 对于每一次数据库连接,使用完后都得断开。否则,如果程序出现异常而未能关闭,将会导致数据库系统中的内存泄漏,最终将导致重启数据库。(回忆:何为 Java 的内存泄漏?)
- 这种开发不能控制被创建的连接对象数,系统资源会被毫无顾及的分配出去,如连接过多,也可能导致内存泄漏,服务器崩溃。
3.2 数据库连接池技术#
- 为解决传统开发中的数据库连接问题,可以采用数据库连接池技术。
- 数据库连接池的基本思想:就是为数据库连接建立一个“缓冲池”。预先在缓冲池中放入一定数量的连接,当需要建立数据库连接时,只需从“缓冲池”中取出一个,使用完毕之后再放回去。
- 数据库连接池负责分配、管理和释放数据库连接,它允许应用程序重复使用一个现有的数据库连接,而不是重新建立一个。
- 数据库连接池在初始化时将创建一定数量的数据库连接放到连接池中,这些数据库连接的数量是由 [最小数据库连接数] 来设定的。无论这些数据库连接是否被使用,连接池都将一直保证至少拥有这么多的连接数量。连接池的 [最大数据库连接数量] 限定了这个连接池能占有的最大连接数,当应用程序向连接池请求的连接数超过最大连接数量时,这些请求将被加入到等待队列中。
- 工作原理
- 数据库连接池技术的优点
- 资源重用:由于数据库连接得以重用,避免了频繁创建,释放连接引起的大量性能开销。在减少系统消耗的基础上,另一方面也增加了系统运行环境的平稳性。
- 更快的系统反应速度:数据库连接池在初始化过程中,往往已经创建了若干数据库连接置于连接池中备用。此时连接的初始化工作均已完成。对于业务请求处理而言,直接利用现有可用连接,避免了数据库连接初始化和释放过程的时间开销,从而减少了系统的响应时间。
- 新的资源分配手段:对于多应用共享同一数据库的系统而言,可在应用层通过数据库连接池的配置,实现某一应用最大可用数据库连接数的限制,避免某一应用独占所有的数据库资源。
- 统一的连接管理,避免数据库连接泄漏:在较为完善的数据库连接池实现中,可根据预先的占用超时设定,强制回收被占用连接,从而避免了常规数据库连接操作中可能出现的资源泄露。
3.3 多种开源的数据库连接池#
- JDBC 的数据库连接池使用 javax.sql.DataSource 来表示,DataSource 只是一个接口,该接口通常由服务器(Weblogic, WebSphere, Tomcat) 提供实现,也有一些开源组织提供实现:
- DBCP 是 Apache 提供的数据库连接池。tomcat 服务器自带 dbcp数据库连接池。速度相对c3p0较快,但因自身存在BUG,Hibernate3 已不再提供支持。
- C3P0 是一个开源组织提供的一个数据库连接池,速度相对较慢,稳定性还可以。hibernate官方推荐使用。
- Proxool 是 sourceforge 下的一个开源项目数据库连接池,有监控连接池状态的功能,稳定性较c3p0差一点。
- BoneCP 是一个开源组织提供的数据库连接池,速度快。
- Druid 是阿里提供的数据库连接池,据说是集DBCP 、C3P0 、Proxool 优点于一身的数据库连接池,但是速度不确定是否有 BoneCP 快。
- DataSource 通常被称为 [数据源],它包含 {连接池} 和 {连接池管理} 两个部分,习惯上也经常把 DataSource 称为
- DataSource 用来取代 DriverManager 来获取 Connection,获取速度快,同时可以大幅度提高数据库访问速度。
- 特别注意:
- 数据源和数据库连接不同,数据源无需创建多个,它是产生数据库连接的工厂,因此整个应用只需要一个数据源即可。
- 当数据库访问结束后,程序还是像以前一样关闭数据库连接:
conn.close();
,但conn.close()
并没有关闭数据库的物理连接,它仅仅把数据库连接释放,归还给了数据库连接池。
3.3.1 C3P0 数据库连接池#
// 方式1
public void test1() throws Exception {
ComboPooledDataSource cpds = new ComboPooledDataSource();
cpds.setDriverClass("com.mysql.jdbc.Driver");
cpds.setJdbcUrl("jdbc:mysql://localhost:3306/test");
cpds.setUser("root");
cpds.setPassword("root");
// ----- 设置相关参数,对数据库连接池进行管理 -----
cpds.setInitialPoolSize(10); // 设置初始时数据库连接池中的连接数
// ...
Connection conn = cpds.getConnection();
}
// 方式2
@Test
public void test2() throws Exception {
DataSource source = new ComboPooledDataSource("HelloC3P0");
Connection conn = source.getConnection();
}
c3p0-config.xml
<?xml version="1.0" encoding="UTF-8" ?>
<c3p0-config>
<named-config name="HelloC3P0">
<property name="driverClass">com.mysql.jdbc.Driver</property>
<property name="jdbcUrl">jdbc:mysql:///test</property>
<property name="user">root</property>
<property name="password">root</property>
<!-- 对数据库连接池进行管理 -->
<!-- 连接池中连接不够用时,C3P0一次性向数据库服务器申请的连接数 -->
<property name="acquireIncrement">5</property>
<!-- 连接池中初始化的连接数 -->
<property name="initialPoolSize">10</property>
<!-- 连接池中维护的最少连接数 -->
<property name="minPoolSize">10</property>
<!-- 连接池中维护的最多连接数 -->
<property name="maxPoolSize">50</property>
<!-- 连接池中最多维护的Statement个数 -->
<property name="maxStatements">0</property>
<!-- 每个连接中可以最多可以使用的Statement个数 -->
<property name="maxStatementsPerConnection">2</property>
</named-config>
</c3p0-config>
3.3.2 DBCP 数据库连接池#
@Test
public void test1() throws SQLException {
// 创建 DBCP 连接池
BasicDataSource source = new BasicDataSource();
// 设置基本信息
source.setDriverClassName("com.mysql.jdbc.Driver");
source.setUrl("jdbc:mysql:\\\test");
source.setUsername("root");
source.setPassword("root");
// 设置管理信息
source.setInitialSize(10);
source.setMaxActive(10);
// 获取连接池中的连接
Connection conn = source.getConnection();
}
public void test2() throws Exception {
Properties prop = new Properties();
// 相对于src; FileInputStream 是相对于当前project
prop.load(ClassLoader.getSystemClassLoader().getResourceAsStream("dbcp.properties"));
// FileInputStream is = new FileInputStream(new File("src/dbpc.properties"));
DataSource bdsf = BasicDataSourceFactory.createDataSource(prop);
Connection connection = bdsf.getConnection();
}
dbcp.properties
driverClassName=com.mysql.jdbc.Driver
url=jdbc:mysql:///test
username=root
password=root
3.3.3 Druid 数据库连接池#
@Test
public void getConnection() throws Exception {
// DruidDataSource source = new DruidDataSource();
// source.setUrl(jdbcUrl);
Properties prop = new Properties();
prop.load(ClassLoader.getSystemClassLoader().getResourceAsStream("druid.properties"));
DataSource source = new DruidDataSourceFactory().createDataSource(prop);
Connection conn = source.getConnection();
System.out.println(conn);
}
druid.properties
driverClassName=com.mysql.jdbc.Driver
url=jdbc:mysql:///test
username=root
password=root
initialSize=10
maxActive=10
4. Apache-DBUtils#
4.1 简介#
- commons-dbutils 是 Apache 组织提供的一个开源 JDBC工具类库,它是对 JDBC 的简单封装,学习成本极低,并且使用 dbutils 能极大简化jdbc编码的工作量,同时也不会影响程序的性能。
- API 介绍:
- org.apache.commons.dbutils.QueryRunner
- org.apache.commons.dbutils.ResultSetHandler
- org.apache.commons.dbutils.DbUtils
4.2 QueryRunner#
- 常用方法
- 需要 [控制事务] 时,使用这组方法
int update(Connection conn, String sql)
int update(Connection conn, String sql, Object param)
int update(Connection conn, String sql, Object... params)
<T> T query(Connection conn, String sql, ResultSetHandler<T> rsh)
<T> T query(Connection conn, String sql, ResultSetHandler<T> rsh, Object... params)
- 无需 [控制事务] 时,使用这组方法
int update(String sql)
int update(String sql, Object param)
int update(String sql, Object... params)
<T> T query(String sql, ResultSetHandler<T> rsh)
<T> T query(String sql, ResultSetHandler<T> rsh, Object... params)
- 需要 [控制事务] 时,使用这组方法
- 方法原理
4.3 ResultSetHandler#
public interface ResultSetHandler<T> {
public T handle(ResultSet rs) throws SQLException;
}
- 该接口用于处理
java.sql.ResultSet
,将数据按要求转换为另一种形式 - 接口的主要实现类
- ArrayHandler:把结果集中的第一行数据转成对象数组。
- ArrayListHandler:把结果集中的每一行数据都转成一个数组,再存放到 List 中。
- BeanHandler:将结果集中的第一行数据封装到一个对应的 JavaBean 实例中。
- BeanListHandler:将结果集中的每一行数据都封装到一个对应的 JavaBean 实例中,存放到List里。
- ColumnListHandler:将结果集中某一列的数据存放到List中。
- KeyedHandler:将结果集中的每一行数据都封装到一个 Map 里,再把这些 map 再存到一个 map 里,其 key 为指定的key。
- MapHandler:将结果集中的第一行数据封装到一个 Map 里,key 是列名,value 就是对应的值。
- MapListHandler:将结果集中的每一行数据都封装到一个 Map 里,然后再存放到 List
- ScalarHandler:查询单个值对象
4.4 DbUtils#
提供如关闭连接、装载JDBC驱动程序等常规工作的工具类,里面的所有方法都是静态的。主要方法如下:
public static boolean loadDriver(String driverClassName)
:这一方装载并注册 JDBC 驱动程序,如果成功就返回true。使用该方法,你不需要捕捉这个 ClassNotFoundExceptionpublic static void close(…) throws java.sql.SQLException
:DbUtils 类提供了 3 个重载的关闭方法。这些方法检查所提供的参数是不是NULL,如果不是的话,它们就关闭 Connection、Statement 和 ResultSetpublic static void closeQuietly(…)
:这一类方法不仅能在 Connection、Statement 和 ResultSet 为 NULL 情况下避免关闭,还能隐藏一些在程序中抛出的 SQLEeceptionpublic static void commitAndClose(Connection conn) throws SQLException
:用来提交连接的事务,然后关闭连接public static void commitAndCloseQuietly(Connection conn)
:用来提交连接,然后关闭连接,并且在关闭连接时不抛出 SQL 异常public static void rollback(Connection conn) throws SQLException
public static void rollbackAndClose(Connection conn) throws SQLException
public static void rollbackAndCloseQuietly(Connection)
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· AI与.NET技术实操系列(二):开始使用ML.NET
· 记一次.NET内存居高不下排查解决与启示
· 探究高空视频全景AR技术的实现原理
· 理解Rust引用及其生命周期标识(上)
· 浏览器原生「磁吸」效果!Anchor Positioning 锚点定位神器解析
· DeepSeek 开源周回顾「GitHub 热点速览」
· 物流快递公司核心技术能力-地址解析分单基础技术分享
· .NET 10首个预览版发布:重大改进与新特性概览!
· AI与.NET技术实操系列(二):开始使用ML.NET
· 单线程的Redis速度为什么快?