文章851
标签121
分类10

[踩坑] MySQL 8.0 小版本升级踩坑:max_connect_errors 默认值 100 引发的连接异常

背景

最近做了一次 MySQL 8.0 的小版本升级,升级完重启之后业务侧开始报连接异常,日志里出现了一些奇怪的 warning。表面看不致命,但实际有部分客户端被 MySQL 直接拉黑了连不上。最后 DBA 排查出来是 max_connect_errors 这个参数默认值太小导致的,改大之后问题消失。

记录一下整个过程,避免下次再踩。

现象

升级重启之后,错误日志里陆续出现这类 warning:

[2026-04-30 05:25:49 #20157.0]  WARNING del (ERRNO 800): failed to delete events[656], it has already been removed
[2026-04-30 05:38:49 $24029.0]  WARNING kill_event_workers(:595): waitpid(24423) failed, Error: No child processes[10]
[2026-04-30 05:38:55 @26321.0]  WARNING set_max_connection: max_connection is exceed the maximum value, it's reset to 1024

业务侧的表现是:部分应用服务器开始报 Host 'xxx.xxx.xxx.xxx' is blocked because of many connection errors,必须执行 FLUSH HOSTS 才能恢复,但过一会儿又会被拉黑。

排查思路

最开始怀疑是 max_connections 不够,但实际看了一下连接数远没到上限。后来注意到关键报错信息 blocked because of many connection errors,这是 MySQL 主机黑名单机制的典型提示,方向就明确了——是 max_connect_errors 这个参数在作怪。

根因:max_connect_errors 默认值 100

MySQL 对每一个客户端 IP(host)都会维护一个错误计数器,存放在 performance_schema.host_cache 里。当某个 host 的连接错误累积超过 max_connect_errors 时,MySQL 会直接把这个 host 拉黑,拒绝所有后续连接,直到计数器被清零或者重启 MySQL。

max_connect_errors 的默认值是 100,在生产环境这是一个非常容易触发的值:

  • 升级重启瞬间,大量客户端同时发起重连
  • 网络抖动、TCP 三次握手失败、鉴权重试等都会累积错误次数
  • 一台应用服务器只要瞬间出现 100 次失败,这台 host 就被永久封禁

升级期间几乎一定会触发。

解决方案

把参数改大就行,业内常见的做法是改成 10 万甚至 100 万:

# my.cnf
[mysqld]
max_connect_errors = 100000

也可以在线动态修改,不需要重启:

SET GLOBAL max_connect_errors = 100000;

改完之后告警立刻消失,业务恢复正常。

配套命令

排查和恢复过程中常用的几条命令记录一下。

查看当前值:

SHOW VARIABLES LIKE 'max_connect_errors';

查看 host_cache,看哪些 IP 累积了错误:

SELECT * FROM performance_schema.host_cache
WHERE sum_connect_errors > 0;

手动清空黑名单(被拉黑的 host 立即恢复):

FLUSH HOSTS;
-- MySQL 8.0.23 之后官方推荐改用:
TRUNCATE TABLE performance_schema.host_cache;

几个容易混淆的参数

排查过程中很容易把下面几个参数搞混,顺手对比一下:

参数含义默认值
max_connections同时在线连接数上限151
max_connect_errors单个 host 允许的累积错误次数,超过即拉黑100
max_user_connections单个用户允许的最大连接数0(不限制)
connect_timeout连接握手阶段超时时间(秒)10

这次踩的坑是第二个,但日志里 set_max_connection ... reset to 1024 这条 warning 一开始很容易把人误导到第一个上去。

经验总结

  1. max_connect_errors 默认值 100 在生产环境基本等同于地雷,新部署 MySQL 时务必改大,建议至少 10 万起步。
  2. 任何 MySQL 重启动作前,先确认这个参数已经调过,否则瞬时重连风暴大概率会触发拉黑。
  3. 看到 Host 'xxx' is blocked because of many connection errors 不要只想着 FLUSH HOSTS 救火,要去改根因参数。
  4. 监控里加上 performance_schema.host_cache 的巡检,能提前发现哪些客户端在异常重连。

参考

[踩坑] Swoole 启动报错 `FactoryProcess_manager_start failed` 排查记录:System V 消息队列耗尽

一、问题现象

某天重启一个基于 Swoole 的 PHP 服务,直接挂了。日志里报:

[27-Apr-2026 09:37:03] PHP Fatal error:  Swoole\Server::start(): 
failed to start server. Error: start: FactoryProcess_manager_start failed 
in /opt/webserver/binance-uper/vendor/ZScript/Server/Server.php on line 124

第一反应是端口被占了,但 netstat / ss 一看,端口干干净净,没人占用。

二、初步排查方向(都不是)

排查 FactoryProcess_manager_start failed 这个错时,常见怀疑对象有这几个:

  1. 残留的 master / manager 进程没退干净 —— ps aux | grep php 看一下,没有。
  2. pid_file / unix sock 残留文件 —— /tmp 下没有相关残留。
  3. ulimit -u(nproc)太低,fork 不出新进程 —— 当前用户进程数离上限还远。
  4. ulimit -n(nofile)不够 —— 102400,绰绰有余。
  5. 配置项冲突(比如开了 task_worker 但没注册 onTask)—— 配置没动过,排除。

全都不是。

三、打开 Swoole debug 日志,真相浮现

把 Swoole 日志级别开到 debug:

$server->set([
    'log_file'  => '/tmp/swoole.log',
    'log_level' => SWOOLE_LOG_DEBUG,
]);

再启动一次,日志里多出来几行关键信息:

WARNING  MsgQueue(:52): msgget() failed, Error: No space left on device[28]
WARNING  create_task_workers: [Master] create task_workers failed
WARNING  start: FactoryProcess_manager_start failed

核心错误是 msgget() failed, No space left on device

这跟磁盘空间一点关系没有,翻译过来是:System V 消息队列(msg queue)的 ID 已经分配完了,内核拒绝再分配新的

四、原理:Swoole 为什么会用消息队列

Swoole 的 task_worker 进程间通信(IPC)有几种模式,由 task_ipc_mode 控制:

取值含义
1(默认)unix socket
2System V 消息队列
3消息队列 + 抢占式分配

我们这台机器之前为了某些场景设成了 2,所以 task_worker 启动时会调用 msgget() 申请一块消息队列。

而 Linux 系统对消息队列总数有上限:

cat /proc/sys/kernel/msgmni

默认值因发行版不同从 32 到几千不等。问题是:Swoole 进程异常退出(kill -9、OOM、崩溃)时,这些消息队列不会自动回收,会一直挂在系统里。

服务跑久了、重启多了,残留的队列越堆越多,最终把 msgmni 占满,新进程的 msgget() 直接失败,manager 进程起不来,服务就挂了。

可以用 ipcs -q 查看当前系统所有消息队列:

ipcs -q

正常情况下应该只有几个。如果列出来几十上百条,而且 OWNER 都是你的服务用户,基本可以锤实是这个原因。

五、解决方案

5.1 紧急恢复:清理残留的消息队列

# 只删自己用户的(更安全)
ipcs -q | awk -v u=$(whoami) 'NR>3 && $3==u {print $2}' | xargs -r -n1 ipcrm -q

⚠️ 警告:如果机器上还有别的服务也在用 SysV IPC,不要无脑全删。一定要按 OWNER 过滤,只删自己服务用户拥有的那些。

清完之后再启动服务,立刻恢复正常。

5.2 长期方案:从根上避免

推荐做法:把 task IPC 改成 unix socket

$server->set([...]) 里加一行:

$server->set([
    // ... 其他配置
    'task_ipc_mode' => 1,  // 1 = unix socket,绕开 SysV 消息队列
]);

这样根本不会用到消息队列,这个坑就彻底没了。大部分业务场景下,unix socket 的性能跟消息队列没差别,甚至更好。

备选:调高系统消息队列上限

如果确实有理由必须用消息队列模式,可以把 msgmni 调大:

# 临时生效
sudo sysctl -w kernel.msgmni=1024

# 永久生效
echo "kernel.msgmni = 1024" | sudo tee -a /etc/sysctl.conf
sudo sysctl -p

但治标不治本——只要还有进程异常退出残留队列,迟早还会撞上来,只是周期长一点。

六、复盘:这类报错的排查思路

FactoryProcess_manager_start failed 本身是个挺笼统的错误,Swoole 把好几种 fork / IPC 失败都归到了这一条上。光看这一行根本无法定位。

正确的排查顺序应该是:

  1. 第一步永远是开 debug 日志,把 log_level 调到 SWOOLE_LOG_DEBUG,看 master/manager 退出前的真实错误。
  2. 看到具体的子错误后,再去对应的方向排查:

    • msgget() failed → 消息队列耗尽,本文场景。
    • fork() failed → nproc 不够,或者内存爆了。
    • bind() failed → 端口或 unix sock 被占。
    • chown/chmod failed → 配置里 user/group 跟实际权限不匹配。

不要凭"端口没占用"或者"昨天还好好的"就开始猜,系统级资源(IPC、fd、进程数)的耗尽问题,只看进程列表是看不出来的。

七、一句话总结

看到 Swoole 的 FactoryProcess_manager_start failed,先别盯着端口和进程,
打开 debug 日志看真实错误。
如果是 msgget() No space left on device,
ipcs -q + ipcrm -q 清理残留队列,
然后把 task_ipc_mode 改成 1,一劳永逸。

CEX 业务数据库选型对比

CEX 业务特点鲜明:高并发、低延迟、强一致、海量历史数据、实时风控与分析并存。单一数据库很难全覆盖,通常是组合方案。

五款数据库核心定位

数据库类型核心优势主要短板
MySQL单机 OLTP(可分库分表)成熟稳定、生态完善、事务强单机容量和并发有瓶颈,分库分表运维复杂
PostgreSQL单机 OLTP(功能最强)SQL 标准好、JSON/GIS/窗口函数强、扩展性强分布式方案不如 MySQL 成熟,高并发写入弱于 MySQL
TiDB分布式 HTAP(偏 TP)水平扩展、MySQL 协议、强一致事务延迟比单机 MySQL 高、资源消耗大
DorisMPP OLAP实时分析、导入快、查询秒级不适合事务、高频点更新弱
ClickHouse列存 OLAP超大数据量聚合极快、压缩率高JOIN 弱、更新删除代价高、并发查询能力差

CEX 核心业务模块与数据库匹配

1. 撮合引擎 / 订单簿

  • 通常不落数据库,在内存中用自研引擎(Disruptor、LMAX 模式)跑
  • 数据库只做持久化归档,任何传统 DB 都扛不住撮合级别的延迟要求(微秒级)

2. 账户 / 资金 / 钱包(核心交易账本)

  • 首选 MySQLPostgreSQL(单机 + 主从 + 分库分表)
  • 数据量超大、需要弹性扩展时选 TiDB
  • PostgreSQL 的 NUMERIC 类型处理金额更严谨,MySQL 生态和 DBA 人才更多
  • 关键要求:强一致、ACID、高可用,绝对不用 Doris/ClickHouse

3. 订单 / 成交记录(高写入 + 高查询)

  • 热数据(近 1~3 个月):MySQL 分库分表TiDB
  • 冷数据 / 历史查询:归档到 DorisClickHouse
  • 用户个人订单查询走 TP 库,全局统计走 AP 库

4. K 线 / 行情数据

  • 实时 K 线:Redis + 消息队列
  • 历史 K 线、深度分析:ClickHouse(时序聚合极强)或 Doris
  • ClickHouse 在超大数据量 Tick 级数据上几乎是业界标配

5. 实时风控 / 反洗钱 / 大户监控

  • Doris 更合适:实时导入 + 多表 JOIN + 高并发查询
  • ClickHouse 也能做,但 JOIN 弱、并发弱,适合单表分析

6. BI 报表 / 运营分析 / 对账

  • DorisClickHouse 都行
  • 要频繁 JOIN、并发查询多(分析师同时查) → Doris
  • 单表超大、追求极致聚合速度 → ClickHouse

7. 日志 / 审计 / 操作流水

  • ClickHouse 最佳,压缩率高、写入快、成本低
  • 量级不大时 Doris 也可以

典型CEX 架构组合

方案 A:中小型CEX (成本优先)

MySQL(核心交易 + 订单)→ Binlog/CDC → ClickHouse(分析 + 日志)
                                    → Redis(行情缓存)

方案 B:中大型CEX (主流方案)

MySQL 分库分表(账户 + 订单热数据)
    ↓ CDC (Canal/Flink CDC)
    ├→ Doris(实时风控 + 运营分析 + 订单历史)
    └→ ClickHouse(K线 + 行情 + 日志)
Redis(撮合缓存 / 行情推送)
Kafka(事件总线)

方案 C:超大型 / 扩展性优先

TiDB(账户 + 订单,替代 MySQL 分库分表)
    ↓ TiCDC
    ├→ Doris(实时分析 + 风控)
    └→ ClickHouse(海量历史 + Tick 数据)

选型建议(按场景)

场景首选备选
用户账户、资金流水MySQL / PostgreSQLTiDB
订单、成交(在线)MySQL 分库分表TiDB
订单、成交(归档)DorisClickHouse
K线、Tick、深度ClickHouseDoris
实时风控DorisFlink + Doris
运营报表 / BIDorisClickHouse
日志 / 审计ClickHouseDoris
合规对账PostgreSQL / MySQLTiDB

几个实战经验

  • 金额字段千万别用 Float/Double,MySQL 用 DECIMAL,PostgreSQL 用 NUMERIC
  • 撮合引擎不要指望任何数据库,内存 + 异步落盘是唯一解
  • MySQL 分库分表到一定规模后运维成本会超过 TiDB,拐点大约在单表百亿 / 集群几十个分片
  • ClickHouse 不适合做用户维度的高并发查询(比如用户查自己订单),它擅长的是"扫全表做聚合"
  • Doris 和 ClickHouse 并不是二选一,很多CEX 两个都用,各司其职
  • PostgreSQL 在券商 / 传统金融更常见,加密货币CEX MySQL 更主流(主要是生态和人才惯性)

[踩坑] 后端性能优化:一天排查的 4 个问题

记录今日遇到的四个典型后端问题,涵盖数据库迁移、浏览器并发限制、权限缓存、Nginx 代理配置。

1. MySQL 聚合过慢 → 迁移 ClickHouse

问题

接口中存在大量 foreach 循环聚合查询,随着数据量增长,MySQL 在多条件聚合场景下性能瓶颈明显,接口响应逐渐变慢。

解决

迁移至 ClickHouse,利用其列式存储和向量化执行引擎提升聚合性能。

迁移注意点

场景MySQLClickHouse
去重计数COUNT(DISTINCT col)uniq(col)
分组聚合GROUP_CONCATgroupArray()
分页LIMIT offset, countLIMIT count OFFSET offset
保留字一般无需处理userdateindex 等需加反引号

2. 接口耗时 5s,页面显示 12s → HTTP/1.1 并发限制

问题

页面同时发起十几个接口请求。HTTP/1.1 每个域名最多维持 6 个并行连接,其余请求在队列中等待。某个连接完成后,队列里的下一个才能发出。接口本身耗时 5s,但因后续接口需等待前面的连接 slot 释放,页面整体加载耗时达到 12s。

HTTP/1.1 连接模型:

请求队列:[ 1 ][ 2 ][ 3 ][ 4 ][ 5 ][ 6 ] | [ 7 ][ 8 ][ 9 ]...
                 ↑ 同时发出(最多 6 个)        ↑ 等待 slot 释放

解决

Nginx 开启 HTTP/2。HTTP/2 通过多路复用在单个 TCP 连接上并发处理所有请求,不再受 6 个并行上限限制,先处理完的先返回,互不阻塞,页面加载时间降至接近单个接口耗时。

server {
    listen 443 ssl http2;
    ...
}

3. 运营接口全部比技术慢 → 权限节点未缓存

问题

技术账号是超级管理员,跳过权限节点检测;运营账号权限节点数量多,每次请求都做完整的节点验证,导致运营侧所有接口响应明显慢于技术侧。

解决

对权限节点检测结果增加缓存:

  • 缓存时长:1 分钟
  • 节点有效期:10 分钟
  • 缓存 key:需包含用户 ID,避免不同账号共享权限缓存
  • 缓存失效:更新权限后主动删除对应缓存;账号被禁用时同样需要清除
请求 → 查缓存(hit)→ 直接通过,耗时极低
请求 → 查缓存(miss)→ 查库验证 → 写入缓存
更新权限 → 主动删除缓存

4. 批量开启 HTTP/2 后鉴权服务全部失效

问题

批量为 Nginx 开启 HTTP/2 后,某鉴权服务全环境不可用。

排查链路:

  1. 数据库无异常
  2. 定位到 Nginx 配置变更
  3. 发现鉴权服务以 IP:port 方式部署,未配置 SSL
  4. Nginx 与该上游的通信被升级为 HTTP/2,HTTP/2 要求上游支持 TLS,鉴权服务无法完成握手,连接失败
需要区分两段链路:浏览器 → Nginx 走 HTTP/2(需要 TLS);Nginx → 上游服务 默认走 HTTP/1.1,若显式或批量配置为 HTTP/2,则上游同样需要支持 TLS。内网 IP:port 部署的服务往往没有证书,批量升级时容易踩这个坑。

解决

对该上游服务的 proxy 单独指定 HTTP/1.1,与其他服务隔离:

location /auth {
    proxy_pass http://auth-service;
    proxy_http_version 1.1;
}

小结

#现象根因解决方向
1聚合接口慢MySQL foreach 聚合性能瓶颈迁移 ClickHouse,注意语法差异
2页面加载 12sHTTP/1.1 并发连接上限 6 个Nginx 开启 HTTP/2 多路复用
3运营接口全慢权限节点每次请求均查库节点检测结果加 1 分钟缓存
4鉴权服务失效上游无 SSL,HTTP/2 要求 TLS单独配置 proxy_http_version 1.1

事务隔离级别深度解析:PostgreSQL vs MySQL

事务隔离级别是数据库并发控制的核心概念,直接影响系统的数据一致性并发性能。SQL 标准(SQL-92)定义了四种隔离级别,但 PostgreSQL 和 MySQL(InnoDB)在实现细节上存在显著差异,理解这些差异是写出正确、高效 SQL 的关键。


一、并发问题:为什么需要隔离级别?

在多事务并发执行时,可能出现以下四类问题:

1. 脏读(Dirty Read)

事务 A 读取了事务 B 尚未提交的数据。若 B 回滚,A 读到的就是"幻觉数据"。

-- 事务 B(未提交)
UPDATE accounts SET balance = 9999 WHERE id = 1;

-- 事务 A(脏读)
SELECT balance FROM accounts WHERE id = 1;  -- 读到 9999(B 未提交)

2. 不可重复读(Non-Repeatable Read)

事务 A 两次读取同一行,期间事务 B 修改并提交了该行,导致两次结果不同。

-- 事务 A 第一次读
SELECT balance FROM accounts WHERE id = 1;  -- 1000

-- 事务 B 修改并提交
UPDATE accounts SET balance = 500 WHERE id = 1;
COMMIT;

-- 事务 A 第二次读(同一事务内)
SELECT balance FROM accounts WHERE id = 1;  -- 500(结果变了!)

3. 幻读(Phantom Read)

事务 A 两次执行范围查询,期间事务 B 插入了符合条件的新行,导致两次结果行数不同。

-- 事务 A 第一次查
SELECT * FROM orders WHERE amount > 1000;  -- 返回 5 行

-- 事务 B 插入并提交
INSERT INTO orders (amount) VALUES (2000);
COMMIT;

-- 事务 A 第二次查(同一事务内)
SELECT * FROM orders WHERE amount > 1000;  -- 返回 6 行(多了一行"幻行")

4. 序列化异常(Serialization Anomaly)

多个事务并发执行的结果,无法等价于这些事务按某种顺序串行执行的结果。这是最高级别的并发问题,只有 SERIALIZABLE 能完全防止。


二、SQL 标准定义的四种隔离级别

隔离级别脏读不可重复读幻读序列化异常
READ UNCOMMITTED✅ 可能✅ 可能✅ 可能✅ 可能
READ COMMITTED❌ 防止✅ 可能✅ 可能✅ 可能
REPEATABLE READ❌ 防止❌ 防止✅ 可能✅ 可能
SERIALIZABLE❌ 防止❌ 防止❌ 防止❌ 防止
✅ = 该问题可能发生    ❌ = 该问题被防止

三、PostgreSQL 的隔离级别实现

PostgreSQL 基于 MVCC(多版本并发控制) 实现隔离,每个事务看到的是数据的一个一致性快照,读操作不阻塞写操作,写操作不阻塞读操作。

默认隔离级别:READ COMMITTED

-- 查看当前隔离级别
SHOW transaction_isolation;      -- 会话级
SHOW default_transaction_isolation;  -- 数据库默认

PostgreSQL 的四个级别行为如下:

隔离级别脏读不可重复读幻读序列化异常
READ UNCOMMITTED❌ 防止¹✅ 可能✅ 可能✅ 可能
READ COMMITTED(默认)❌ 防止✅ 可能✅ 可能✅ 可能
REPEATABLE READ❌ 防止❌ 防止❌ 防止²✅ 可能
SERIALIZABLE❌ 防止❌ 防止❌ 防止❌ 防止
¹ PostgreSQL 不真正支持 READ UNCOMMITTED,设置后实际按 READ COMMITTED 处理。
² PostgreSQL 的 REPEATABLE READ 额外防止了幻读,这比 SQL 标准要求更严格。

各级别详解

READ COMMITTED

每条 SQL 语句看到的是语句开始时已提交的数据快照。

BEGIN;  -- 默认 READ COMMITTED
SELECT balance FROM accounts WHERE id = 1;  -- 读到 1000

-- 此时另一事务提交了修改,balance 变为 800

SELECT balance FROM accounts WHERE id = 1;  -- 读到 800(不可重复读!)
COMMIT;

REPEATABLE READ

整个事务看到的是事务开始时的数据快照,且 PostgreSQL 实现中额外防止了幻读。

BEGIN ISOLATION LEVEL REPEATABLE READ;

SELECT balance FROM accounts WHERE id = 1;  -- 读到 1000

-- 另一事务修改 balance 为 800 并提交

SELECT balance FROM accounts WHERE id = 1;  -- 仍然读到 1000(快照固定)

-- 幻读也被防止
SELECT * FROM orders WHERE amount > 500;  -- 结果行数不会因其他事务插入而改变
COMMIT;

⚠️ 注意:在 REPEATABLE READ 下,若尝试更新另一事务已修改的行,PostgreSQL 会检测到写-写冲突并报错:

ERROR: could not serialize access due to concurrent update

SERIALIZABLE

基于 SSI(Serializable Snapshot Isolation) 算法,检测序列化异常并在检测到危险模式时中止事务,抛出错误码 40001

BEGIN ISOLATION LEVEL SERIALIZABLE;

-- 执行业务逻辑...

COMMIT;
-- 若存在序列化冲突,COMMIT 时会报:
-- ERROR: could not serialize access due to read/write dependencies among transactions
-- SQLSTATE: 40001

应用层必须实现重试逻辑

function withSerializable(PDO $pdo, callable $fn): mixed
{
    for ($attempt = 0; $attempt < 3; $attempt++) {
        try {
            $pdo->exec('BEGIN ISOLATION LEVEL SERIALIZABLE');
            $result = $fn($pdo);
            $pdo->exec('COMMIT');
            return $result;
        } catch (PDOException $e) {
            $pdo->exec('ROLLBACK');
            if ($e->getCode() !== '40001' || $attempt === 2) {
                throw $e;
            }
            usleep((2 ** $attempt) * 50_000);  // 指数退避:50ms, 100ms, 200ms
        }
    }
}

四、MySQL(InnoDB)的隔离级别实现

MySQL InnoDB 同样使用 MVCC,但在锁机制上有所不同,尤其在 REPEATABLE READ 级别下依赖间隙锁(Gap Lock) 防止幻读。

默认隔离级别:REPEATABLE READ

-- 查看当前隔离级别
SELECT @@transaction_isolation;           -- MySQL 8.0+
SELECT @@tx_isolation;                    -- MySQL 5.7 及以下
SHOW VARIABLES LIKE 'transaction_isolation';

MySQL 四个级别行为如下:

隔离级别脏读不可重复读幻读备注
READ UNCOMMITTED✅ 可能✅ 可能✅ 可能几乎不使用
READ COMMITTED❌ 防止✅ 可能✅ 可能常用于高并发写入
REPEATABLE READ(默认)❌ 防止❌ 防止⚠️ 部分防止³InnoDB 默认
SERIALIZABLE❌ 防止❌ 防止❌ 防止所有 SELECT 加共享锁
³ MySQL REPEATABLE READ 下,普通 SELECT(快照读)不会产生幻读,但当前读SELECT ... FOR UPDATE / SELECT ... LOCK IN SHARE MODE)需要间隙锁才能防止幻读。

MySQL 的锁机制补充

MySQL 在 REPEATABLE READ 下通过三种锁防止幻读:

-- 1. 记录锁(Record Lock):锁定具体行
SELECT * FROM orders WHERE id = 1 FOR UPDATE;

-- 2. 间隙锁(Gap Lock):锁定索引间隙,防止插入
-- 若 id 存在 1, 5, 10,查询 id BETWEEN 3 AND 7
-- 会锁定 (1,5] 和 (5,10] 的间隙,防止其他事务插入 id=3,4,6,7 的行
SELECT * FROM orders WHERE id BETWEEN 3 AND 7 FOR UPDATE;

-- 3. 临键锁(Next-Key Lock)= 记录锁 + 间隙锁
-- 这是 InnoDB 默认的行锁策略

⚠️ 间隙锁在高并发写入场景容易造成死锁,可通过将隔离级别改为 READ COMMITTED 来禁用间隙锁:

-- 会话级别修改(适合高并发写入场景)
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

五、PostgreSQL vs MySQL 核心差异对比

差异总览

对比维度PostgreSQLMySQL (InnoDB)
默认隔离级别READ COMMITTEDREPEATABLE READ
REPEATABLE READ 防幻读✅ 是(MVCC 快照)⚠️ 部分(需加锁)
READ UNCOMMITTED实际按 READ COMMITTED 处理真正允许脏读
SERIALIZABLE 实现SSI(乐观,可能失败重试)悲观锁(SELECT 加共享锁)
幻读防止机制MVCC 快照间隙锁(Gap Lock)
写-写冲突处理后写者等待或失败后写者等待(行锁)
锁粒度行级 + 表级行级(记录锁/间隙锁/临键锁)

关键差异详解

差异一:REPEATABLE READ 对幻读的处理

这是最容易踩坑的差异点。

-- ===== PostgreSQL REPEATABLE READ =====
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT COUNT(*) FROM orders WHERE amount > 500;  -- 返回 10

-- 另一事务 INSERT 了 amount=600 的行并提交

SELECT COUNT(*) FROM orders WHERE amount > 500;  -- 仍返回 10(快照隔离,幻读被防止)
COMMIT;

-- ===== MySQL REPEATABLE READ =====
START TRANSACTION;
SELECT COUNT(*) FROM orders WHERE amount > 500;  -- 返回 10(快照读,无幻读)

-- 另一事务 INSERT 了 amount=600 的行并提交

SELECT COUNT(*) FROM orders WHERE amount > 500;  -- 仍返回 10(快照读,无幻读)

-- 但!使用当前读:
SELECT COUNT(*) FROM orders WHERE amount > 500 FOR UPDATE;  -- 返回 11!(幻读出现)
COMMIT;

结论:MySQL 的 REPEATABLE READ当前读场景下不能防幻读,PostgreSQL 的 REPEATABLE READ 在所有场景都防幻读。

差异二:SERIALIZABLE 实现方式

-- PostgreSQL SERIALIZABLE:乐观策略,事务可以并发执行
-- 若检测到序列化冲突,在提交时报错(SQLSTATE 40001),需重试

-- MySQL SERIALIZABLE:悲观策略,所有 SELECT 自动转为加共享锁
-- SELECT * FROM t WHERE id = 1
-- 等价于:SELECT * FROM t WHERE id = 1 LOCK IN SHARE MODE
-- 大量共享锁会严重降低并发性能

差异三:写-写冲突处理

-- 场景:事务 A 和事务 B 同时要修改同一行

-- PostgreSQL REPEATABLE READ
-- 先到的事务正常执行,后到的事务:
-- 若等待前者提交后发现该行已被修改,则报错:
-- ERROR: could not serialize access due to concurrent update
-- 应用层需重试

-- MySQL REPEATABLE READ(行锁机制)
-- 先到的事务加行锁,后到的事务等待锁释放(最长等 innodb_lock_wait_timeout 秒)
-- 默认等待 50 秒,超时则报错:ERROR 1205: Lock wait timeout exceeded

六、实战选择指南

PostgreSQL 隔离级别选择

-- ✅ 场景 1:普通查询、报表统计(无需高一致性)
-- 使用默认 READ COMMITTED,无需设置
SELECT SUM(amount) FROM orders WHERE DATE(created_at) = CURRENT_DATE;

-- ✅ 场景 2:先查后写、账户余额操作(防不可重复读)
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM wallets WHERE user_id = 42;
UPDATE wallets SET balance = balance - 100 WHERE user_id = 42 AND balance >= 100;
COMMIT;

-- ✅ 场景 3:跨表一致性操作、转账(需完全隔离)
BEGIN ISOLATION LEVEL SERIALIZABLE;
UPDATE wallets SET balance = balance - 100 WHERE user_id = 1;
UPDATE wallets SET balance = balance + 100 WHERE user_id = 2;
COMMIT;
-- 记得应用层捕获 SQLSTATE 40001 并重试!

MySQL 隔离级别选择

-- ✅ 场景 1:高并发写入、日志记录(避免间隙锁死锁)
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
INSERT INTO access_logs (user_id, action, created_at) VALUES (?, ?, NOW());

-- ✅ 场景 2:通用 OLTP 业务(默认 REPEATABLE READ + 显式加锁)
START TRANSACTION;
SELECT balance FROM wallets WHERE user_id = 42 FOR UPDATE;  -- 加行锁
UPDATE wallets SET balance = balance - 100 WHERE user_id = 42 AND balance >= 100;
COMMIT;

-- ✅ 场景 3:严格防幻读(使用 FOR UPDATE 触发间隙锁)
START TRANSACTION;
SELECT * FROM orders WHERE amount > 500 FOR UPDATE;  -- 加间隙锁防幻读
-- 执行后续逻辑...
COMMIT;

七、常见误区与注意事项

误区一:认为提高隔离级别总是更安全

更高的隔离级别会增加锁竞争(MySQL)或事务失败重试率(PostgreSQL),在高并发系统中可能反而导致性能骤降。应按实际需求选择最低满足要求的隔离级别。

误区二:PostgreSQL 和 MySQL 的 REPEATABLE READ 等价

这是最常见的迁移陷阱! 如上文所述,MySQL 的 REPEATABLE READ 在当前读下不防幻读,PostgreSQL 则完全防幻读。从 MySQL 迁移到 PostgreSQL 时需要重新评估隔离级别需求。

误区三:忽略序列化失败的重试

PostgreSQL SERIALIZABLE 隔离级别必须配合应用层重试逻辑,否则序列化冲突会直接返回错误给用户,导致业务失败。

误区四:在 MySQL 高并发写入时使用 REPEATABLE READ

MySQL REPEATABLE READ 下的间隙锁在并发 INSERT 时极易产生死锁,对于日志表、消息队列等高写入场景,推荐降级为 READ COMMITTED

快速排查命令

-- ===== PostgreSQL =====
-- 查看当前隔离级别
SHOW transaction_isolation;

-- 查看所有正在等待锁的事务
SELECT pid, query, state, wait_event_type, wait_event
FROM pg_stat_activity
WHERE wait_event_type = 'Lock';

-- 查看锁详情
SELECT * FROM pg_locks WHERE NOT granted;

-- ===== MySQL =====
-- 查看当前隔离级别
SELECT @@transaction_isolation;

-- 查看正在等待的事务
SELECT * FROM information_schema.INNODB_TRX WHERE trx_state = 'LOCK WAIT';

-- 查看锁等待详情(MySQL 8.0+)
SELECT * FROM performance_schema.data_lock_waits;

八、总结

PostgreSQLMySQL
默认级别READ COMMITTEDREPEATABLE READ
推荐日常使用READ COMMITTEDREPEATABLE READ
高并发写入READ COMMITTEDREAD COMMITTED
账务/余额操作REPEATABLE READREPEATABLE READ + FOR UPDATE
严格金融场景SERIALIZABLE(需重试)SERIALIZABLE(性能差)
迁移注意点RR 防幻读更彻底当前读需额外加锁防幻读
💡 一句话总结:PostgreSQL 的 MVCC 快照在 REPEATABLE READ 就能防幻读,而 MySQL 需要 FOR UPDATE 间隙锁配合;PostgreSQL 的 SERIALIZABLE 是乐观的(可能失败重试),MySQL 是悲观的(加共享锁)。理解这两点,就能避免 90% 的隔离级别踩坑。

参考资料:PostgreSQL 官方文档 - 事务隔离 | MySQL 官方文档 - InnoDB 事务模型

">