TiDB 事务与锁-程序员宅基地

技术标签: tidb  

作者: 苏州刘三枪 原文来源: https://tidb.net/blog/865b670e

笔记有点乱,整理一下,以备查阅,如有错误请指出。

一、事务概览

TiDB 支持分布式事务,提供乐观事务与悲观事务。TiDB 3.0.8 及以后版本,TiDB 默认采用悲观事务模式。支持的隔离级别是 SI (Snapshot Isolation),参数 transaction_isolation 只是为了兼容 MySQL,在 TiDB 无实际意义

首次提交时就使用悲观事务,需要满足下面2个条件:

  • tidb_txn_mode='pessimistic'

  • 显示开启事务 [ BEGIN | START TRANSACTION ] / COMMIT

或者关闭自动提交 autocommit=0,默认使用悲观事务模式。

经过测试,当 tidb_txn_mode='pessimistic',且使用自动提交事务。首次commit时,仍然使用的是悲观事务。

另外显示事务、自动提交事务遇到写冲突都会自动重试,直到重试次数达到限制 pessimistic-txn.max-retry-count

1.1 MVCC

TiKV 支持多版本并发控制 (Multi-Version Concurrency Control, MVCC)。假设有这样一种场景:某客户端 A 在写一个 Key,另一个客户端 B 同时在对这个 Key 进行读操作。如果没有数据的多版本控制机制,那么这里的读写操作必然互斥。在分布式场景下,这种情况可能会导致性能问题和死锁问题。有了 MVCC,只要客户端 B 执行的读操作的逻辑时间早于客户端 A,那么客户端 B 就可以在客户端 A 写入的同时正确地读原有的值。即使该 Key 被多个写操作修改过多次,客户端 B 也可以按照其逻辑时间读到旧的值。

TiKV 的 MVCC 是通过在 Key 后面添加版本号来实现的。没有 MVCC 时,可以把 TiKV 看作如下的 Key-Value 对:

Key1 -> Value Key2 -> Value …… KeyN -> Value 有了 MVCC 之后,TiKV 的 Key-Value 排列如下: Key1_Version2 -> Value Key1_Version1 -> Value ……

Key2_Version3 -> Value Key2_Version2 -> Value Key2_Version1 -> Value …… KeyN_Version2 -> Value KeyN_Version1 -> Value …… 注意,对于同一个 Key 的多个版本,版本号较大的会被放在前面,版本号小的会被放在后面(见 Key-Value 一节,Key 是有序的排列),这样当用户通过一个 Key + Version 来获取 Value 的时候,可以通过 Key 和 Version 构造出 MVCC 的 Key,也就是 Key_Version。然后可以直接通过 RocksDB 的 SeekPrefix(Key_Version) API,定位到第一个大于等于这个 Key_Version 的位置。

示例:

原始数据:no=001

txn1 start_ts 为 8点,更新 no=002,未提交

txn2 start_ts 为 9点,读取不会阻塞,只能读取到 no=001

txn1 在 9点10分 提交

txn2 在 9点20分 继续读取,读取不会阻塞,也只能读取到 no=001

只能读取 commit_ts 早于 start_ts 的事务

二、相关参数与限制

2.2 悲观事务参数

performance.max-txn-ttl

默认值:1小时,当悲观锁的事务执行时间超过 TTL 时,会出现下述报错:

TTL manager has timed out, pessimistic locks may expire, please commit or rollback this transaction

pessimistic-txn.max-retry-count

默认值:256,悲观事务中单个语句最大重试次数。超过则会报错:pessimistic lock retry limit reached;

可以通过 grep -i Exec_retry_count tidb_slow_query.log 查看 SQL 的重试次数

innodb_lock_wait_timeout

默认值:50秒,在悲观锁模式下,事务最大等锁时间。超过则会报错:Lock wait timeout exceeded;try restarting transaction;

2.3 乐观事务参数

tidb_disable_txn_auto_retry

默认值:false,不禁用显式的乐观事务自动重试。

tidb_retry_limit

默认值:10,乐观事务的自动重试次数。

performance.stmt-count-limit

默认值:5000,单个事务包含的 sql 语句不超过 5000 条,该限制只在可重试的乐观事务中生效,如果使用悲观事务或者关闭了事务重试,事务中的语句数将不受此限制)

2.4 其他重要参数

storage.scheduler-concurrency

默认值:524288,scheduler 内置一个内存锁机制,防止同时对一个 key 进行操作。每个 key hash 到不同的槽;

performance.committer-concurrency

默认值:16,在单个事务的提交阶段,用于执行提交操作相关请求的 goroutine 数量;

enable-batch-dml

默认值:false,官方建议禁用此参数;

2.5 事务的限制

  • 每个键值对不超过 6 MB (4.0.10起支持参数调整:raft-entry-max-size、txn-entry-size-limit)

  • 键值对的总数不超过 300000 (计算方式为 300000 / (1+二级索引数量) 代码写死无法修改)

  • 键值对的总大小默认为 100 MB (performance.txn-total-size-limit 参数不超过10GB。DELETE语句不受此限制)

注意:

  1. 开启大事务建议最大不超过2G,事务大小只受 txn-total-size-limit 控制,开启后30W键值对的总数失效。

  2. 事务对内存的占用可能会有 3-4 倍的放大,10GB 大的事务可能会占用 30-40GB 的内存。如果需要执行特别大的事务,需要提前做好内存的规划,避免对业务产生影响。

三、乐观事务

TiDB 中事务使用两阶段提交协议,和 MySQL 中的 2PC 不一样,参考图片:

image.png

乐观事务简图:

image.png

3.1 事务执行完整流程

  1. 客户端开始一个事务

  2. TiDB 从 PD 获取 start_ts

  3. TiDB 校验写入数据是否符合约束(如数据类型是否正确、是否符合非空约束等)。校验通过的数据将存放在 TiDB 中该事务的私有内存里

  4. 客户端发起 commit

  5. TiDB 开始两阶段提交,见 3.2 节

  6. TiDB 向客户端返回事务提交成功

  7. TiDB 异步清理本次事务遗留的锁信息

3.2 两阶段提交2PC

RocksDB Column Family 简称:

  • D 列:rocksdb.defaultcf

  • L 列:rocksdb.lockcf

  • W 列:rocksdb.writecf

在 prewrite 之前会在 TiDB 缓存所有数据。生产有遇到过同时并发写入上百MB数据,导致 TiDB OOM。

1、TiDB 选择一个 Key 作为当前事务的 Primary Key,剩下的为 Secondary Key

2、从 PD 获取所有数据的写入路由信息,并将所有的 Key 按照路由进行分类(即数据具体存在哪个 TiKV 节点上)

3、TiDB 发起 prewrite 请求,将 Primary Key与数据写入到 TiKV,并进行加锁【加锁前会检查写入冲突,参考本节最后】, ****加锁成功后执行下面操作:

(1) 锁信息写入L列,示例:<1,(W,pk,1,100 ... )>

(2) 行数据写入D列,示例:put<1_100,'相亲相爱一家人'>

未提交的数据未写入W列,因此客户端查询不到此数据。因为需要通过W列来查找Key的版本信息

4、然后 Secondary Key 并发地向所有涉及的 TiKV 发起 prewrite 请求,流程同 Primary Key 类似区别是锁信息指向了 Primary Key :

(1) 锁信息写入L列,示例:<2,(W, @1 ,2,100 ... )>

5、TiDB 收到所有 prewrite 都成功

6、TiDB 向 PD 获取 commit_ts

7、TiDB 向 Primary Key 所在 TiKV 发起 2PC 的 commit

(1) 写入 W 列,示例:put<1_110,100>

此时数据可见

(2) 写入 L 列,表示删除锁信息,示例:<1,(D,pk,1,100 ... )>

(3) 最后清理锁信息

8、Primary Commit 提交成功后,Secondary 可以进行异步提交

9、TiDB 收到两阶段提交成功

【加锁前检查写入冲突】

  • 检查 L 列,是否已经有别的客户端已经上锁 (Locking)

  • 检查 W 列,在本次事务开始时间之后,是否有更新 [startTs, +Inf) 的写操作已经提交 (Conflict)

Prewrite 出现冲突,当前事务回滚。

Primary Commit 出现冲突,全事务回滚。

3.2 ResolveLocks

当在事务中读数据或者 prewrite keys 时,如果 key 上已经有 Lock 了,这时就需要进行 ResolveLock 。为什么会出现这种情况?有以下几种情况:

  1. 事务 txn_1 在完成 prewrite key_1,key_2 后就异常退出了,那么此时事务 txn_2 再去读 key_1, key_2 时,就会发现有 txn_1 在 prewrite 时写的 Lock。
  2. 事务 txn_1 在 prewrite key_1完成,但在 key_2 因为冲突而失败时,txn_1 会终止并异步清理 key_1 上的锁,如果异步清理锁还没完成,此时 txn_2 去读 key_1 ,也会遇到 Lock
  3. 事务 txn_1 在 commit primary key 成功后,是用异步 commit second keys,在异步 commit 还没完成时,txn_2 去读 second keys 时也会遇到 Lock。

那么如何 ResolveLocks 呢? prewrite keys 时会同时带上一个 LockTTL ResolveLock 的流程首先是检查所有 Lock 的 TTL,记下最久的 expire Time ,并发现如果有 keys 上的 locks 的 ttl 已经过期后,就会发起对这些过期 keys 的 Locks 进行 resolveLock ,如果还有 keys 的 locks 没有 resolve ,就根据最久的 expire time 进行 back off 后重试。

3.3 乐观事务优缺点

优点:事务在二阶段提交的 Prewrite 时才会检测冲突。在事务提交的过程中锁检测的代价是比较大的,所以乐观事务在一些场景有较好的写入提升。比如基于id自增主键的写入情景,或者有唯一索引但是很少或者不会出现多个并发同时对同一个行的DML操作的情景。

缺点:事务冲突不可避免,乐观模式采用了内部重试功能。

重试的好处:写冲突的情况避免直接报错给client。

重试的缺点:每次重试时间间隔会逐渐变长,写冲突高的情况下,一条SQL可能需要较长时间才能写入成功,另外TiDB 默认不进行事务重试,因为重试事务可能会导致更新丢失,从而破坏可重复读的隔离级别。

四、悲观事务

悲观事务模型下的 Percolator

TiDB 在乐观事务模型的基础上支持了悲观事务模型,将上锁的时机提前到进行DML时。TiDB 的悲观锁实现的原理确实如此,在一个事务执行 DML (UPDATE/DELETE) 的过程中,TiDB 不仅会将需要修改的行在本地缓存,同时还会对这些行直接上悲观锁,这里的悲观锁的格式和乐观事务中的锁几乎一致,但是锁的内容是空的,只是一个占位符,待到 Commit 的时候,直接将这些悲观锁改写成标准的 Percolator 模型的锁,后续流程跟乐观模型一样。

悲观事务会将需要修改的 key 在执行完 DML 后就上锁。

4.1 悲观事务模式的行为

悲观事务的行为和 MySQL 基本一致:

  • UPDATE DELETE INSERT 语句都会读取已提交的 最新 数据来执行,并对所修改的行加悲观锁。
  • SELECT FOR UPDATE 语句会对已提交的 最新 的数据而非所修改的行加上悲观锁。
  • 悲观锁会在事务提交或回滚时释放。其他尝试修改这一行的写事务会被阻塞,等待悲观锁的释放。其他尝试 读取 这一行的事务不会被阻塞,因为 TiDB 采用多版本并发控制机制 (MVCC)。
  • 如果多个事务尝试获取各自的锁,会出现死锁,并被检测器自动检测到。其中一个事务会被随机终止掉并返回兼容 MySQL 的错误码 1213
  • 如果多个事务同时等待同一个锁释放,会大致按照事务 start ts 顺序获取锁。
  • 乐观事务和悲观事务可以共存,事务可以任意指定使用乐观模式或悲观模式来执行。
  • 支持 FOR UPDATE NOWAIT 语法,遇到锁时不会阻塞等锁,而是返回兼容 MySQL 的错误码 3572
  • 如果 Point Get Batch Point Get 算子没有读到数据,依然会对给定的主键或者唯一键加锁,阻塞其他事务对相同主键唯一键加锁或者进行写入操作。

和 MySQL InnoDB 的差异

1、TiDB 不支持 gap locking(间隙锁)

BEGIN /*T! PESSIMISTIC */;
SELECT * FROM t1 WHERE id BETWEEN 1 AND 10 FOR UPDATE;
BEGIN /*T! PESSIMISTIC */;
INSERT INTO t1 (id) VALUES (6); -- 仅 MySQL 中出现阻塞。
UPDATE t1 SET pad1='new value' WHERE id = 5; -- MySQL 和 TiDB 处于等待阻塞状态。

2、TiDB 不支持 SELECT LOCK IN SHARE MODE

3、DDL 可能会导致悲观事务提交失败。

MySQL 在执行 DDL 语句时,会被正在执行的事务阻塞住,而在 TiDB 中 DDL 操作会成功,造成悲观事务提交失败: ERROR 1105 (HY000): Information schema is changed. [try again later] 。TiDB 事务执行过程中并发执行 TRUNCATE TABLE 语句,可能会导致事务报错 table doesn't exist

4、 START TRANSACTION WITH CONSISTENT SNAPSHOT 之后,MySQL 仍然可以读取到之后在其他事务创建的表,而 TiDB 不能。

5、Autocommit 事务优先采用乐观事务提交。

6、对语句中 EMBEDDED SELECT 读到的相关数据不会加锁。

7、垃圾回收 (GC) 不会影响到正在执行的事务

4.2 异步提交事务

数据库的客户端会同步等待数据库系统通过两阶段 (2PC) 完成事务的提交,事务在第一阶段提交成功后就会返回结果给客户端,系统会在后台异步执行第二阶段提交操作,降低事务提交的延迟。如果事务的写入只涉及一个 Region,则第二阶段可以直接被省略,变成一阶段提交。

开启异步提交事务特性后,在硬件、配置完全相同的情况下,Sysbench 设置 64 线程测试 Update index 时,平均延迟由 12.04 ms 降低到 7.01ms ,降低了 41.7%。

tidb_enable_async_commit

注意

  • 对于新创建的集群,默认值为 ON。对于升级版本的集群,如果升级前是 v5.0 以下版本,升级后默认值为 OFF 。(你可以执行 set global tidb_enable_async_commit = ON; 和 set global tidb_enable_1pc = ON; 语句开启该功能。)
  • 启用 TiDB Binlog 后,开启该选项无法获得性能提升。要获得性能提升,建议使用 TiCDC 替代 TiDB Binlog。
  • 启用该参数仅意味着 Async Commit 成为可选的事务提交模式,实际由 TiDB 自行判断选择最合适的提交模式进行事务提交。

4.3 Pipelined 特性

加悲观锁需要向 TiKV 写入数据,要经过 Raft 提交并 apply 后才能返回,相比于乐观事务,不可避免的会增加部分延迟。为了降低加锁的开销,TiKV 实现了 pipelined 加锁流程:当数据满足加锁要求时,TiKV 立刻通知 TiDB 执行后面的请求,并异步写入悲观锁,从而降低大部分延迟,显著提升悲观事务的性能。但当 TiKV 出现网络隔离或者节点宕机时,悲观锁异步写入有可能失败,从而产生以下影响:

  • 无法阻塞修改相同数据的其他事务。如果业务逻辑依赖加锁或等锁机制,业务逻辑的正确性将受到影响。
  • 有较低概率导致事务提交失败,但不会影响事务正确性。

如果业务逻辑依赖加锁或等锁机制,或者即使在集群异常情况下也要尽可能保证事务提交的成功率,应关闭 pipelined 加锁功能。

image.png

该功能默认开启,可修改 TiKV 配置关闭:

[pessimistic-txn]
pipelined = false

若集群是 v4.0.9 及以上版本,支持动态关闭该功能:

set config tikv pessimistic-txn.pipelined='false';

4.4 内存悲观锁

TiKV 在 v6.0.0 中引入了内存悲观锁功能。开启内存悲观锁功能后,悲观锁通常只会被存储在 Region leader 的内存中,而不会将锁持久化到磁盘,也不会通过 Raft 协议将锁同步到其他副本,因此可以大大降低悲观事务加锁的开销,提升悲观事务的吞吐并降低延迟。

当内存悲观锁占用的内存达到 Region 或节点的阈值时,加悲观锁会回退为使用 pipelined 加锁流程 。当 Region 发生合并或 leader 迁移时,为避免悲观锁丢失,TiKV 会将内存悲观锁写入磁盘并同步到其他副本。

内存悲观锁实现了和 pipelined 加锁流程 类似的表现,即集群无异常时不影响加锁表现,但当 TiKV 出现网络隔离或者节点宕机时,事务加的悲观锁可能丢失。

如果业务逻辑依赖加锁或等锁机制,或者即使在集群异常情况下也要尽可能保证事务提交的成功率,应 关闭 内存悲观锁功能。

该功能默认开启。如要关闭,可修改 TiKV 配置:

[pessimistic-txn]
in-memory = false

也支持动态关闭该功能:

set config tikv pessimistic-txn.in-memory='false';

六、监控

6.1 查询阻塞SQL

SELECT START_TIME,
		USER,
		DB,
		STATE,
		SESSION_ID,
		SUBSTRING_INDEX(CURRENT_SQL_DIGEST_TEXT,
		' ',3) AS 'table_operator'
FROM information_schema.CLUSTER_TIDB_TRX
WHERE CURRENT_SQL_DIGEST_TEXT IS NOT NULL
		AND STATE='LockWaiting'
ORDER BY  DB,CURRENT_SQL_DIGEST_TEXT,START_TIME

6.1 监控图表

TiKV-Details -> Scheduler - commit

image.png

如果发现这个 wait duration 特别高,说明耗在等待锁的请求上比较久,如果不存在底层写入慢问题的话,基本上可以判断这段时间内冲突比较多。

TiDB -> KV Backoff OPS

image.png

txnlock 表示写写冲突

txnLockFast 表示读写冲突

七、QA

7.1 TxnLockNotFound

pingcap/tidb/blob/master/store/tikv/lock_resolver.go#L124
// IsCommitted returns true if the txn's final status is Commit.
func (s TxnStatus) IsCommitted() bool { return s.ttl == 0 && s.commitTS > 0 }

// CommitTS returns the txn's commitTS. It is valid iff `IsCommitted` is true.
func (s TxnStatus) CommitTS() uint64 { return uint64(s.commitTS) }

// By default, locks after 3000ms is considered unusual (the client created the
// lock might be dead). Other client may cleanup this kind of lock.
// For locks created recently, we will do backoff and retry.
var defaultLockTTL uint64 = 3000

// TODO: Consider if it's appropriate.
var maxLockTTL uint64 = 120000

// ttl = ttlFactor * sqrt(writeSizeInMiB)
var ttlFactor = 6000

// Lock represents a lock from tikv server.
type Lock struct {
	Key      []byte

References

[1] Percolator 和 TiDB 事务算法

[2] TiDB 新特性漫谈:悲观事务

[3] 线性一致性和 Raft

[4] TiKV 源码解析系列文章(十二)分布式事务

[5] Transaction in TiDB

[6] Large-scale Incremental Processing Using Distributed Transactions and Notifications

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接:https://blog.csdn.net/TiDBer/article/details/137879765

智能推荐

c# 调用c++ lib静态库_c#调用lib-程序员宅基地

文章浏览阅读2w次,点赞7次,收藏51次。四个步骤1.创建C++ Win32项目动态库dll 2.在Win32项目动态库中添加 外部依赖项 lib头文件和lib库3.导出C接口4.c#调用c++动态库开始你的表演...①创建一个空白的解决方案,在解决方案中添加 Visual C++ , Win32 项目空白解决方案的创建:添加Visual C++ , Win32 项目这......_c#调用lib

deepin/ubuntu安装苹方字体-程序员宅基地

文章浏览阅读4.6k次。苹方字体是苹果系统上的黑体,挺好看的。注重颜值的网站都会使用,例如知乎:font-family: -apple-system, BlinkMacSystemFont, Helvetica Neue, PingFang SC, Microsoft YaHei, Source Han Sans SC, Noto Sans CJK SC, W..._ubuntu pingfang

html表单常见操作汇总_html表单的处理程序有那些-程序员宅基地

文章浏览阅读159次。表单表单概述表单标签表单域按钮控件demo表单标签表单标签基本语法结构<form action="处理数据程序的url地址“ method=”get|post“ name="表单名称”></form><!--action,当提交表单时,向何处发送表单中的数据,地址可以是相对地址也可以是绝对地址--><!--method将表单中的数据传送给服务器处理,get方式直接显示在url地址中,数据可以被缓存,且长度有限制;而post方式数据隐藏传输,_html表单的处理程序有那些

PHP设置谷歌验证器(Google Authenticator)实现操作二步验证_php otp 验证器-程序员宅基地

文章浏览阅读1.2k次。使用说明:开启Google的登陆二步验证(即Google Authenticator服务)后用户登陆时需要输入额外由手机客户端生成的一次性密码。实现Google Authenticator功能需要服务器端和客户端的支持。服务器端负责密钥的生成、验证一次性密码是否正确。客户端记录密钥后生成一次性密码。下载谷歌验证类库文件放到项目合适位置(我这边放在项目Vender下面)https://github.com/PHPGangsta/GoogleAuthenticatorPHP代码示例://引入谷_php otp 验证器

【Python】matplotlib.plot画图横坐标混乱及间隔处理_matplotlib更改横轴间距-程序员宅基地

文章浏览阅读4.3k次,点赞5次,收藏11次。matplotlib.plot画图横坐标混乱及间隔处理_matplotlib更改横轴间距

docker — 容器存储_docker 保存容器-程序员宅基地

文章浏览阅读2.2k次。①Storage driver 处理各镜像层及容器层的处理细节,实现了多层数据的堆叠,为用户 提供了多层数据合并后的统一视图②所有 Storage driver 都使用可堆叠图像层和写时复制(CoW)策略③docker info 命令可查看当系统上的 storage driver主要用于测试目的,不建议用于生成环境。_docker 保存容器

随便推点

网络拓扑结构_网络拓扑csdn-程序员宅基地

文章浏览阅读834次,点赞27次,收藏13次。网络拓扑结构是指计算机网络中各组件(如计算机、服务器、打印机、路由器、交换机等设备)及其连接线路在物理布局或逻辑构型上的排列形式。这种布局不仅描述了设备间的实际物理连接方式,也决定了数据在网络中流动的路径和方式。不同的网络拓扑结构影响着网络的性能、可靠性、可扩展性及管理维护的难易程度。_网络拓扑csdn

JS重写Date函数,兼容IOS系统_date.prototype 将所有 ios-程序员宅基地

文章浏览阅读1.8k次,点赞5次,收藏8次。IOS系统Date的坑要创建一个指定时间的new Date对象时,通常的做法是:new Date("2020-09-21 11:11:00")这行代码在 PC 端和安卓端都是正常的,而在 iOS 端则会提示 Invalid Date 无效日期。在IOS年月日中间的横岗许换成斜杠,也就是new Date("2020/09/21 11:11:00")通常为了兼容IOS的这个坑,需要做一些额外的特殊处理,笔者在开发的时候经常会忘了兼容IOS系统。所以就想试着重写Date函数,一劳永逸,避免每次ne_date.prototype 将所有 ios

如何将EXCEL表导入plsql数据库中-程序员宅基地

文章浏览阅读5.3k次。方法一:用PLSQL Developer工具。 1 在PLSQL Developer的sql window里输入select * from test for update; 2 按F8执行 3 打开锁, 再按一下加号. 鼠标点到第一列的列头,使全列成选中状态,然后粘贴,最后commit提交即可。(前提..._excel导入pl/sql

Git常用命令速查手册-程序员宅基地

文章浏览阅读83次。Git常用命令速查手册1、初始化仓库git init2、将文件添加到仓库git add 文件名 # 将工作区的某个文件添加到暂存区 git add -u # 添加所有被tracked文件中被修改或删除的文件信息到暂存区,不处理untracked的文件git add -A # 添加所有被tracked文件中被修改或删除的文件信息到暂存区,包括untracked的文件...

分享119个ASP.NET源码总有一个是你想要的_千博二手车源码v2023 build 1120-程序员宅基地

文章浏览阅读202次。分享119个ASP.NET源码总有一个是你想要的_千博二手车源码v2023 build 1120

【C++缺省函数】 空类默认产生的6个类成员函数_空类默认产生哪些类成员函数-程序员宅基地

文章浏览阅读1.8k次。版权声明:转载请注明出处 http://blog.csdn.net/irean_lau。目录(?)[+]1、缺省构造函数。2、缺省拷贝构造函数。3、 缺省析构函数。4、缺省赋值运算符。5、缺省取址运算符。6、 缺省取址运算符 const。[cpp] view plain copy_空类默认产生哪些类成员函数

推荐文章

热门文章

相关标签