在单体应用时代,并发控制是一个相对封闭且易于管理的问题。无论你使用何种编程语言,标准库中通常都会提供互斥锁、读写锁、信号量等并发原语。只要我们熟悉操作系统的线程模型与内存屏障,就能写出线程安全的代码。然而,当我们为了应对业务增长而将单体系统拆分为微服务,将单机部署演进为分布式集群时,曾经完美运行的代码就会在某个高并发的深夜暴露出致命的漏洞。
在分布式架构中,程序的运行环境跨越了网络、跨越了物理机房,甚至跨越了不同的时区。网络延迟、节点宕机、垃圾回收停顿(GC Pause)以及时钟漂移,构成了分布式系统中不确定的四大暗礁。在这种环境下,控制多个进程对共享资源的并发访问,就成了一项极具挑战性的工程难题。
今天,我想和你深入探讨一下分布式系统中的并发控制问题。我们不仅会分析基于数据库、Redis 以及 ZooKeeper 等主流基础设施的分布式锁实现原理,还会深入探讨它们背后的分布式共识理论、一致性模型以及在真实业务场景中的技术权衡。希望这篇文章能帮助你在面对复杂的并发场景时,不再只是盲目地复制粘贴网上的代码片段,而是能够从底层原理出发,做出最符合业务诉求的架构决策。
1 并发控制的本质与系统演进
要理解分布式锁,我们首先需要回归到并发控制的本质。无论是单机还是分布式环境,并发控制的核心目的都只有一个:保证在多个执行主体同时尝试修改同一个共享资源时,系统状态的正确性与一致性。
1.1 从单机锁到分布式锁的跨越
在单机环境中,我们依赖操作系统内核提供的原语来实现锁。当一个线程尝试获取已经被占用的锁时,操作系统会将其挂起,放入等待队列中;当锁被释放时,操作系统再唤醒等待的线程。这一切都发生在同一个操作系统的内存空间中,通信极其快速且可靠。
但是,当我们将应用部署到多个节点上时,这些节点拥有各自独立的内存空间。节点 A 上的锁对于节点 B 来说是完全不可见的。这就好比有两个位于不同城市的团队在编辑同一份共享文档,如果没有一个中央协调者,两边的修改注定会发生冲突。因此,我们需要引入一个所有应用节点都能访问到的外部系统(如数据库、缓存服务器或分布式协调组件)来充当这个中央协调者,这就是分布式锁的由来。
1.2 分布式系统带来的三大错觉
很多开发者在设计分布式锁时,往往会带着单机思维,认为只要向远端发送一个加锁请求,收到成功响应,资源就被安全地锁定了。然而,分布式系统充满了不确定性。著名的分布式计算的谬误告诉我们,网络绝不是可靠的。在评估任何分布式并发控制方案时,你必须随时警惕以下三种错觉:
第一是认为网络通信是瞬时且可靠的。实际情况是,你的加锁请求可能在网络中游荡了很久才到达目标服务器,也可能服务器已经处理完毕,但响应包在返回途中丢失了。
第二是认为各个节点的时间是绝对同步的。现代服务器通常通过 NTP 协议与时间服务器同步,但时钟漂移是物理客观存在的。如果你的分布式锁强依赖于各个节点的绝对时间,那么在发生时钟跳跃时,锁的安全性就会土崩瓦解。
第三是认为进程会一直平滑运行。在 Java 或 Go 等具备垃圾回收机制的语言中,一次长达数秒的 Stop The World 停顿(STW)足以让原本应该在持有锁期间完成的业务逻辑被无限期推迟,进而导致锁超时失效,最终引发并发冲突。
2 基于数据库的轻量级并发控制
在引入复杂的分布式锁中间件之前,我们往往可以依靠手头最成熟的存储组件 ------ 关系型数据库来解决相当一部分并发问题。数据库本身就具备强大的事务处理能力和锁机制。
2.1 悲观锁:FOR UPDATE 的适用与局限
悲观锁顾名思义,它对并发冲突持悲观态度,认为冲突一定会发生。在 MySQL 中,我们可以利用 InnoDB 存储引擎的行级锁来实现分布式排他锁。具体做法是在执行查询时显式加上 FOR UPDATE 子句。
当一个事务执行了包含 FOR UPDATE 的 SQL 语句后,数据库就会对命中结果集的行加上排他锁(X锁)。在当前事务提交或回滚之前,其他任何试图修改这些行或同样尝试加上排他锁的事务都会被阻塞。
这种方案的优点在于非常简单,利用了数据库原生的事务 ACID 特性,不需要引入任何额外的中间件。但它的局限性也非常明显:首先,加锁会使得数据库并发读写性能大幅下降,尤其是在长事务场景下,极易引发连接池耗尽。其次,如果查询条件没有命中索引,InnoDB 可能会退化为表锁,这在生产环境中是一场灾难。最后,如果应用节点在持有锁的过程中突然宕机,虽然数据库最终会因为连接断开而回滚事务释放锁,但这期间的阻塞仍然不可避免。
2.2 乐观锁:版本号与 CAS 机制的优雅实践
相比于悲观锁的重度阻塞,乐观锁是一种更轻量、更适合高并发读多写少场景的方案。乐观锁并不会在数据库层面真正加锁,而是通过在数据表中引入一个 Version 字段或 Timestamp 字段来实现。
这其实是 CAS(Compare And Swap)思想在数据库领域的应用。在更新数据时,业务系统会首先查询出当前记录的数据以及它的 Version 值。经过业务逻辑计算后,在执行 UPDATE 语句时,将刚才查到的 Version 值作为 WHERE 条件的一部分,并且在 SET 子句中将 Version 字段加一。
如果在此期间有其他节点成功更新了这条记录,数据库中的 Version 值就会改变。此时我们执行的 UPDATE 语句会因为找不到匹配 Version 的记录而导致更新影响的行数为 0。业务代码根据受影响行数就能判断出并发冲突,进而选择重试或向前端返回错误提示。
乐观锁避免了长时间的数据库连接占用,也没有死锁风险。但你需要自己处理冲突重试逻辑。如果并发竞争极其激烈,大量的重试请求不仅会消耗应用服务器的 CPU 资源,也会给数据库带来无谓的查询压力。此时,我们就需要一种更专业的、独立于业务数据存储之外的分布式协调机制。
3 缓存时代的基石:Redis 分布式锁的演进
由于关系型数据库的磁盘 I/O 瓶颈,在绝大多数互联网架构中,Redis 成为了实现分布式锁的最热门选择。Redis 基于内存的极高吞吐量以及单线程处理网络请求的特性,使其天生适合作为并发协调者。然而,基于 Redis 构建一个完全可靠的分布式锁,远比许多人想象的要复杂得多。
3.1 原始时代的 SETNX 与死锁危机
最初,开发者们发现 Redis 提供了一个非常讨巧的命令:SETNX(Set if Not eXists)。这个命令的语义是,如果键不存在,则设置该键并返回 1;如果键已经存在,则什么都不做并返回 0。利用这个原子性操作,我们可以轻松实现加锁。
但问题随之而来:如果节点 A 成功执行了 SETNX 获得了锁,然后在执行业务逻辑时发生了 OOM(内存溢出)被操作系统 Kill 掉,或者它所在的物理机直接断电重启,这个锁就会永远留在 Redis 中。其他所有的节点都会因为键已存在而陷入无尽的等待,这就是典型的死锁。
为了解决死锁,我们必须给锁加上一个过期时间(TTL)。在早期的 Redis 版本中,SETNX 和 EXPIRE 是两个独立的命令。如果在这两个命令执行的间隙发生了网络中断或进程崩溃,依然会导致死锁。
3.2 保证原子性:SET 命令的增强与 Lua 脚本
从 Redis 2.6.12 版本开始,官方对 SET 命令进行了极大的增强,允许我们在一条命令中同时指定 NX(不存在才设置)和 EX(设置过期时间)参数。这彻底解决了加锁过程的原子性问题。如今的标准加锁命令形式如下:
SET lock_key unique_value EX 30 NX
这里有一个非常关键的设计:锁的值必须是一个具有全局唯一性的独特标识(例如 UUID 加上当前线程 ID)。为什么要这么设计呢?这涉及到锁的释放逻辑。
假设我们把锁的过期时间设置为 10 秒,节点 A 获取了锁并开始执行业务,但由于网络请求超时或严重的 GC 停顿,节点 A 耗费了 15 秒才执行完毕。此时,Redis 中的锁早已在第 10 秒时自动过期释放了。在第 11 秒时,节点 B 顺利获取了同一个键的锁。当节点 A 在第 15 秒尝试去删除锁时,如果它不校验锁的拥有者,直接执行 DEL 命令,就会把节点 B 刚刚获取到的锁错误地删除了,导致节点 C 又能趁虚而入获取锁。
为了避免这种锁的误删,我们在释放锁时必须先获取键对应的值,判断其是否与自己加锁时生成的 unique_value 相同,如果相同才执行删除操作。然而,获取与判断再删除,这又涉及到了多个操作,无法保证原子性。如果在获取到值之后、执行删除之前,锁刚好过期并被别的节点抢占,依然会发生误删。
因此,业界标准的做法是使用 Lua 脚本来执行解锁操作。Redis 会将整个 Lua 脚本作为一个整体原子的执行,执行期间不会穿插其他客户端的命令。
3.3 核心代码示例与关键点剖析
下面是一段使用 Go 语言及 go-redis 库实现的标准化 Redis 分布式锁的核心代码逻辑。请仔细体会其中的设计意图。
Go
package distributed_lock
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"time"
"[github.com/go-redis/redis/v8](https://github.com/go-redis/redis/v8)"
)
// RedisLock 定义了基于 Redis 的分布式锁结构体
type RedisLock struct {
client *redis.Client
key string
token string
expiration time.Duration
}
// 释放锁的 Lua 脚本
// 逻辑:如果获取到的键值等于预期的 token,才执行删除;否则返回 0
const unlockScript = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`
// NewRedisLock 实例化一个分布式锁对象
func NewRedisLock(client *redis.Client, key string, expiration time.Duration) *RedisLock {
// 生成一个高随机性的 16 字节 UUID 作为 token
b := make([]byte, 16)
_, _ = rand.Read(b)
token := hex.EncodeToString(b)
return &RedisLock{
client: client,
key: key,
token: token,
expiration: expiration,
}
}
// TryLock 尝试获取锁,非阻塞模式
func (l *RedisLock) TryLock(ctx context.Context) (bool, error) {
// 使用 SetNX 原语,原子性地设置键值并设定过期时间
// 只有当 key 不存在时才设置成功
success, err := l.client.SetNX(ctx, l.key, l.token, l.expiration).Result()
if err != nil {
return false, err
}
return success, nil
}
// Unlock 释放锁,基于 Lua 脚本确保原子性判断与删除
func (l *RedisLock) Unlock(ctx context.Context) error {
// 执行 Lua 脚本,传入锁的 key 和当前线程/实例对应的 token
res, err := l.client.Eval(ctx, unlockScript, []string{l.key}, l.token).Result()
if err != nil {
return err
}
// 判断 Lua 脚本的返回值
if status, ok := res.(int64); ok && status == 1 {
return nil
}
// 如果返回值为 0,说明锁已过期被他人占用,或者由于某种原因自己不持有该锁
return errors.New("failed to unlock: lock may have expired or belongs to someone else")
}
关键点解释:
- **防范误删保护机制:**代码中在实例化锁的时候,通过 crypto/rand 生成了一个不可预测的十六进制字符串作为 token。在执行 Lua 脚本解锁时,必须验证 Redis 中存储的值与自己持有的 token 相等才能执行 DEL。这就彻底排除了长任务导致超时释放后,客户端错误删除他人锁的风险。
- **Lua 脚本的原子语义:**Redis 在执行传入的 Lua 脚本时具有排他性。当脚本中的 get 命令判断通过后,可以绝对保证紧接着执行的 del 命令期间,没有任何其他命令可以介入修改这个键。
- **超时机制的兜底:**SetNX 方法结合了 expiration 参数,这就意味着无论当前获取锁的 Go 进程遭遇了 Panic、死锁还是物理机宕机,Redis 都会在设定的超时时间后自动回收该锁,避免了系统的永久性拥塞。
3.4 锁超时与自动续期:Watchdog 机制深度解析
在上述实现中,我们仍然面临一个棘手的难题:如何预估超时时间?如果超时时间设置得太短,业务逻辑可能还没执行完锁就被释放了,导致并发冲突;如果设置得太长,一旦客户端宕机,其他节点必须等待漫长的时间才能重新获取锁,严重影响系统可用性。
为了解决这个矛盾,Java 生态中著名的 Redisson 客户端引入了 Watchdog(看门狗)机制,这是一种极其优雅的设计模式,后来也被各个语言的开源库所借鉴。
Watchdog 的核心思想是将锁的初始超时时间设置得相对较短(例如 30 秒)。在成功获取锁之后,客户端会在后台启动一个定时调度线程(看门狗)。这个线程会每隔一段时间(例如超时时间的三分之一,即 10 秒)向 Redis 发起一个指令,重置锁的超时时间。
只要持有锁的业务进程依然存活并且没有显式地释放锁,看门狗就会不断地帮它 "续命"。一旦业务逻辑执行完毕调用了释放锁的方法,看门狗线程也会随之销毁。而如果客户端发生宕机,看门狗线程自然也就停止运行了。当达到剩余的超时时间后,Redis 就会自动释放该锁。
这种机制完美地平衡了业务执行不确定性与异常容灾恢复速度之间的矛盾。但在实际应用中你需要特别注意一点:看门狗并不是万能的。如果你的应用程序因为发生了长时间的 Full GC 而导致所有线程(包括看门狗线程)暂停运行,那么续期请求就无法按时发送,锁依然会过期失效。因此,对于极其敏感的核心金融交易,仅仅依靠 Watchdog 仍然不够安全。
4 高可用架构下的 Redlock 算法与世纪争议
到目前为止,我们讨论的基于 Redis 的分布式锁都是建立在单机 Redis 或者主从复制架构上的。这引出了另一个高可用层面的痛点:主从切换导致的数据丢失。
4.1 主从架构下的锁丢失问题
Redis 的主从同步是异步执行的。假设节点 A 在 Redis 主库上成功获取了锁,主库随之向客户端返回了成功的响应。但在主库还没来得及将这条数据同步给从库时,主库因为硬件故障宕机了。此时,Redis Sentinel 或集群管理工具会将从库提升为新的主库。问题随之产生:新的主库上根本没有这个锁的记录。节点 B 此时发起加锁请求,就会成功获取到一把实质上属于节点 A 的锁。系统的互斥保证被彻底打破。
4.2 Redlock 算法的核心逻辑
为了解决这种单点故障引发的一致性问题,Redis 的作者 Salvatore Sanfilippo(网名 antirez)提出了一种名为 Redlock 的分布式算法。
Redlock 的核心思想是抛弃主从复制,转而采用多个完全独立的 Redis 主节点(官方建议 5 个)。当客户端需要加锁时,它不仅向一台机器请求,而是并发地向这 5 个节点发送加锁请求。
客户端在发起请求前记录一个初始时间 T1。如果客户端能够在半数以上(即至少 3 个)的节点上成功获取到锁,并且完成这些加锁操作所花费的总时间小于锁的过期时间,那么客户端才认为自己成功持有了分布式锁。锁的实际可用有效时间,等于初始设定的过期时间减去获取锁所消耗的时间。
如果由于网络原因或节点故障,客户端无法在大多数节点上成功加锁,或者加锁耗时太长,它就会立刻向所有节点发起解锁请求,以防止锁残留。
4.3 Martin Kleppmann 的致命质疑
Redlock 提出后在社区引起了极大的关注,但很快就遭到了分布式系统专家、《数据密集型应用系统设计》(DDIA)一书作者 Martin Kleppmann 的严厉批评。这场争论至今依然是分布式系统领域的经典案例。
Martin 指出,Redlock 建立在一个极其危险的系统模型之上,它强依赖于物理时钟的同步以及有界的网络延迟。
他举了一个典型的时钟跳跃反例:假设有 5 个节点 A、B、C、D、E。客户端 1 成功在 A、B、C 三个节点上获取了锁。此时,节点 C 的服务器因为 NTP 同步异常,时钟突然向前跳跃了。这导致 C 节点上该锁的过期时间提前到来,锁被自动清除了。就在这时,客户端 2 发起加锁请求,并在 C、D、E 三个节点上成功加锁。至此,两个客户端同时持有了应该互斥的锁,互斥性被摧毁。
此外,Martin 还强调了进程暂停(Process Pause)带来的危害。哪怕客户端获得了 Redlock 认可的法定多数锁,如果在它确认自己获得锁并且正准备去写数据库的时候,发生了长达数秒的 GC 暂停。当它苏醒过来去写数据库时,锁其实早就过期了,而其他节点可能已经拿到了新锁并修改了数据。
4.4 隔离令牌(Fencing Token)的理论补充
为了解决在 GC 暂停或极端网络延迟下锁失效导致的数据被错误覆盖的问题,Martin 提出:只有锁是不够的,底层存储系统必须配合使用隔离令牌(Fencing Token)。
隔离令牌是一个单调递增的整数序列。每次客户端向锁服务申请锁时,锁服务不仅返回成功标志,还会返回一个递增的 Token。客户端拿着这个 Token 去操作后端的数据库或存储服务。存储服务会记录下它所见过的最大 Token 值。如果此时来了一个携带着旧 Token(例如因为长 GC 刚才苏醒的僵尸进程)的写请求,由于存储服务发现这个 Token 小于它已经接受的最新 Token,就会直接拒绝这次写请求。
这种机制将系统正确性的保障从依赖难以预测的 "时间" 与 "网络延迟",转移到了依赖数据的因果顺序和版本控制上,这才是真正严谨的分布式架构思维。
5 基于 CP 模型的强一致性锁:ZooKeeper 与 etcd
既然 Redis 在面临极端网络分区与时钟问题时存在理论上的破绽,那么对于不允许出现哪怕万分之一并发冲突的金融级结算业务,我们应该如何选择?答案是转向 CP(一致性与分区容错性)架构的协调组件,如 ZooKeeper 或 etcd。
5.1 ZooKeeper 临时顺序节点机制
ZooKeeper 是 Hadoop 生态的基石,它通过 ZAB 协议实现了强一致性。使用 ZooKeeper 实现分布式锁,主要依赖它的两个核心特性:临时节点(Ephemeral Node)和事件监听(Watch)。
加锁的过程其实是在 ZooKeeper 的某个指定路径下,创建一个临时顺序节点。临时节点的特性是,它的生命周期与客户端的会话(Session)强绑定。如果客户端由于宕机导致网络连接断开,ZooKeeper 服务端会在 Session 超时后自动删除该节点,这就天然地解决了锁超时带来的死锁问题,完全不需要像 Redis 那样手动评估和设置过期时间。
为了实现排他性并避免惊群效应(Thundering Herd),ZooKeeper 采用了顺序节点的玩法。所有试图加锁的客户端都在同一父目录下创建顺序节点(例如 lock-00001、lock-00002)。创建完毕后,客户端会查询目录下所有的子节点,如果发现自己创建的节点序号是最小的,就认为自己获得了锁。
如果自己不是最小的,客户端并不会不断地轮询消耗资源,而是利用 Watch 机制,只监听排在自己前一个的节点。一旦前一个节点被删除(持有者释放锁或崩溃),ZooKeeper 会主动推送事件通知当前客户端,客户端再次确认自己是最小节点后,便顺利上位。这种排队机制极其公平,且对网络 I/O 非常友好。
5.2 etcd 的租约与 Revision 机制
随着云原生时代的到来,由 Go 语言编写、基于 Raft 协议构建的 etcd 逐渐取代了 ZooKeeper,成为了 Kubernetes 集群的大脑。etcd 同样是实现强一致性分布式锁的利器。
etcd 并没有树形目录的概念,而是通过前缀查询来实现类似的功能。etcd 的锁机制核心在于租约(Lease)和 Revision(修订版本号)。
客户端首先会向 etcd 申请一个租约,并为其设置一个存活时间。接着,客户端会在一个特定的前缀路径下(例如 /my_lock/)以自己的标识符写入一个键值对,并将这个键与刚才申请的租约绑定。只要客户端正常运行,它就会像 Redis 的看门狗一样,定期向 etcd 发送 KeepAlive 信号来续租。如果客户端死掉,租约到期,etcd 就会自动清理与之绑定的键。
与 ZooKeeper 的顺序节点对应,etcd 每发生一次写操作,都会产生一个全局递增的 Revision 号。客户端写入键后,通过比较自己键的 Revision 与前缀下所有键的 Revision,如果自己最小,则获取锁。否则,使用 Watch 机制监听比自己 Revision 刚好小一点的那个键的删除事件。
5.3 AP 与 CP 模型的选型对比
在 CAP 定理的框架下,Redis 集群本质上是一个偏向 AP(可用性与分区容错性)的系统,而 ZooKeeper 和 etcd 则是典型的 CP 系统。
对于 Redis 而言,即使部分节点宕机,只要还能响应请求,它就会继续提供服务,性能极高。但在脑裂(Network Partition)等极端情况下,可能会违背锁的排他性。
对于 ZooKeeper 和 etcd 而言,写入操作必须在集群多数节点之间达成共识后才能返回。这保证了极高的数据安全性,无论主节点如何切换,都不会丢失锁记录。但代价是较长的网络通信延迟和较低的并发吞吐量。如果在分布式锁频繁竞争的场景下大量使用 ZK,产生的事务日志与共识开销可能会拖垮整个协调集群。
6 业务视角下的技术权衡
了解了如此多底层实现后,作为架构师或核心开发者,我们面临的最重要的问题不再是 "怎么写代码",而是 "该如何选择"。工程从来不是追求完美理论的艺术,而是在性能、成本、一致性与开发复杂度之间寻找平衡点。
6.1 我们真的需要重量级分布式锁吗?
在引入 Redis 或 ZooKeeper 之前,请务必审视你的业务逻辑。许多所谓的并发冲突,其实可以通过更优雅的方式化解。
比如电商的库存扣减,如果你只关心库存不能为负数,那么只需在数据库层利用一句简单的 SQL 乐观锁即可:
UPDATE inventory SET count = count - {delta} WHERE product_id = {id} AND count >= {delta}
这远比在外部加一把分布式锁后再进行读取和修改要快得多,也稳定得多。
再比如面对重复提交的请求,如果你能将接口设计为具备幂等性,通过业务上的唯一流水号或状态机(例如只能从 "待支付" 变更为 "已支付",不允许从 "已支付" 变更为 "待支付")来进行数据库的唯一约束控制,那么分布式锁在此时只是起到了阻挡多余并发流量打垮数据库的"防波堤"作用,而不再是保证数据绝对正确性的最后一根救命稻草。
6.2 性能与一致性的融合考量
如果你的场景是为了提升系统效率,避免多个节点重复执行一些繁重但无副作用的工作(例如定时生成一份报表并推送到文件服务器),那么偶尔发生一次锁失效,导致两个节点重复生成了报表,虽然浪费了一点 CPU 资源,但对业务结果没有破坏性影响。这种情况下,基于单机或哨兵架构的 Redis 锁是绝对的首选,因为它响应极快且易于维护。
如果你面对的是金融资产的转移、账户余额的结算,一旦两个节点同时修改数据就会导致公司资金严重损失。那么你应该首选数据库层面的悲观锁机制,或者依赖 etcd/ZooKeeper 构建强一致性的分布式锁,并且在业务逻辑的最底层,务必加上 Fencing Token 或基于状态机的防线,绝不能将财务安全仅仅寄托在一个外部的锁组件上。
7 结语:抛开技术银弹的工程思维
技术的演进永远伴随着业务边界的拓展。从单机时代的 Mutex 互斥量,到数据库行锁的争夺,再到缓存时代 Redis 分布式锁的广泛普及,最后到需要运用共识算法保障强一致性的 etcd,人类在驾驭并发这头猛兽的道路上不断探索着新的边界。
在这个过程中我们发现,没有任何一种技术手段是可以被称为"银弹"的。Redis 锁追求极致的性能却妥协了极端情况下的安全性;ZooKeeper 确保了安全却牺牲了系统的吞吐上限。甚至连锁这种机制本身,也在遭受着无锁化设计、不可变数据结构、最终一致性理念的强力挑战。
作为技术人,我们应当对分布式系统的复杂性保持敬畏。当你下一次在代码中敲下 client.SetNX 的时候,我希望你的脑海中能够闪过网络分区的隐患、垃圾回收的停顿、时钟跳跃的诡谲,以及那个永远躲在暗处、随时可能因为宕机而永远无法执行的 Unlock。唯有深刻理解了这些不确定性并在架构上预留出防错的余地,我们才能在脆弱的分布式世界中,构建出真正坚若磐石的软件系统。