ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

数据库如何保证数据不出错?事务、日志与并发控制全解析

数据库如何保证数据不出错?事务、日志与并发控制全解析 关系型数据库天天写但真正能把“数据为什么不出错”讲清楚的人不多。很多人会认为MySQL 里有事务、有主键、有唯一索引数据就不会乱。实际上关系型数据库能保证数据不出错靠的是事务、日志、锁、隔离级别、约束和恢复流程共同配合而不是某一条 SQL 或某一个机制单独生效。这篇文章适合正在用关系型数据库做业务的后端开发者、数据开发、数据库运维也适合想系统理解数据库原理的读者。看完之后你至少能回答三个问题一个事务执行一半断电了会怎样两个请求同时改同一行数据会怎样数据真出问题的时候应该按什么顺序排查1. 先搞清楚“数据不出错”到底指什么1.1 出错的原因并不只是“SQL 写错了”很多人一听到“数据出错”第一反应是 SQL 写得不对比如语法错误、类型不匹配、字段不存在。这类问题确实会让程序报错但数据库本身不会因此产生脏数据。真正需要关心的“数据出错”往往是下面几类一个操作只执行了一部分另一方已经收到结果。新增一条记录时没有约束拦截导致重复数据混进核心表。两个事务同时更新同一行后提交的把先提交的覆盖了。主库已经写入但从库还没有同步查询落在了旧数据上。数据库崩溃后重启发现已经提交的数据丢失了。这些问题的共同点是它们不会在 SQL 解析阶段立刻暴露而是在长时间运行中逐渐影响业务。等到用户反馈“订单显示不对”“金额对不上”的时候往往已经波及一批数据。关系型数据库设计了一套约定来应对这些风险。这套约定的核心叫事务事务之上又叠加了日志、锁和恢复机制。先理解事务后面的内容才好展开。1.2 事务才是“不出错”的基本单位事务通常被解释成 ACID四个字母分别对应原子性、一致性、隔离性、持久性。很多初学者会把 ACID 当成抽象概念背下来但在实际工程里它每个字母都对应明确机制原子性事务内多条语句要么全部成功要么全部回滚。一致性事务执行前后数据需要符合业务规则和约束条件。隔离性多个并发事务不能看到彼此未提交的中间状态。持久性事务提交后即使断电、重启数据也不能丢。这四个性质不是孤立的。原子性依赖于回滚日志持久性依赖于重做日志和写盘策略隔离性依赖于锁和版本控制。一致性则是所有机制共同作用的结果还需要业务代码主动配合。举个例子转账场景里从 A 扣 100 元向 B 加 100 元。如果不用事务扣款执行完但存款写入前系统崩溃总账就会少 100 元。数据库的原子性保证事务提交前发生崩溃所有影响都不生效持久性保证事务提交后发生崩溃修改结果不会丢失。1.3 “没报错”不等于“数据一定对”这里要想清楚一个边界关系型数据库保证的是“数据库本身始终能恢复到一致状态”而不是“你的业务判断永远正确”。程序里已经读到旧数据、发了重复消息、算错了单价只要这些结果被正常提交进数据库数据库并不认为它们有错。所以判断“数据有没有出错”要分两层。第一层是数据库是否能把已经提交的事务正确落盘并在崩溃重放后保持一致第二层是应用是否把真实业务状态正确翻译成了事务操作。很多时候系统查不出数据异常是因为异常发生在第一层和第二层之间比如根本没开启事务每条 SQL 自动提交。2. 崩溃恢复的底牌redo log、undo log 和检查点2.1 redo log 解决的是“提交后数据为什么还在”先想一个场景事务已经提交数据库返回给客户端“成功”但服务器此时突然断电。如果数据只存在于内存缓冲池里还没来得及写回磁盘电源一断内存清空这条提交就相当于丢了。关系型数据库不会等事务提交时把整个数据页立刻刷回磁盘因为随机写性能太差。它更常用的办法是 WAL也就是先写日志再把数据落盘。事务提交时把本次修改写入 redo log 并保证日志落盘这个事务就算提交成功。数据页可以继续留在内存里等后续合适时机再由后台线程刷盘。redo log 的核心价值是“重放”。崩溃恢复时数据库扫描日志把已经提交但没有落盘的数据变更重做一遍数据就不会丢。这块机制保证了事务的持久性。如果你在配置里看到 redo log 文件大小、刷盘参数、组提交等概念都是在调整持久性和性能之间的平衡。默认配置通常适合大多数业务不要只为了“性能更好”就把刷盘策略调弱。一旦日志丢失崩溃恢复时连重放的依据都没有。2.2 undo log 解决的是“执行到一半怎么撤销”redo log 负责“向前恢复”undo log 负责“向后回滚”。一个事务里可能有多条更新语句如果执行到第三步时发现约束冲突或者应用主动回滚数据库需要把第一二步已经改过的数据恢复原样。undo log 记录的是反向操作。插入的记录要被删除删除的记录要重新插入更新的字段要回到旧值。回滚时按照 undo log 里的版本链一路退回去就能恢复到事务开始前的样子。undo log 还有另一个重要作用就是给多版本并发控制提供数据版本。读操作需要看到一个更早的快照时必须通过 undo log 找到对应版本。这也是为什么不要随便清理 undo 历史长事务会持有很旧的版本如果版本被清理某些查询可能会报错或产生异常结果。2.3 崩溃恢复不是从头重放一遍全部日志如果数据库每次都从第一条日志重放到最后一条恢复时间会随着运行时间越来越长这对高可用系统不可接受。检查点 checkpoint 机制就是为了避免这种低效恢复。checkpoint 表示“这个位置之前的数据页已经落盘”。数据库在后台定期把脏页刷到磁盘并记录检查点位置。崩溃恢复时只需要从最近一个检查点后开始扫描日志把已提交事务对应数据页重做把未提交事务用 undo log 回滚。可以做一个小实验来理解恢复顺序库里有事务 A 已提交事务 B 未提交。崩溃恢复后A 的修改要能看到B 的修改要消失。恢复流程大致是扫描日志、定位检查点、重做已提交事务、回滚未提交事务、清空相关内存缓冲最后对外提供服务。整个过程不是瞬间完成的节点重启后如果流量直接打入可能会出现短暂的排队或不可用。2.4 主从复制里的日志一致性也要盯紧单机数据库用 redo log 做恢复主从集群还需要另一个层面的日志来保持一致。以 MySQL 为例主库执行事务后写 binlog从库拉取 binlog 并回放从库的数据才能追上主库。binlog 和 redo log 属于不同链路如果两者不一致崩溃恢复后可能出现主库有这条数据、从库没有的情况。为了避免日志不一致数据库内部通常采用类似两阶段提交的思路让事务的 redo log 与 binlog 同时落盘。这也是为什么观察一个“写多、读多、从库还经常延迟”的系统时不能只看慢 SQL还要看日志落盘策略和主从复制状态。PostgreSQL 等数据库虽然日志方案不同但核心思想近似先写预写日志再把数据页写入磁盘崩溃后基于日志做一致性恢复。换数据库时这些基础规则依然通用。3. 并发读写不出错隔离级别、锁和 MVCC 缺一不可3.1 并发事务到底会引发哪些“逻辑错误”单事务即使再安全面对并发请求也会出问题。两个事务同时扣减库存可能把 100 件库存卖成 200 件一个事务先读取用户金额另一个事务随后修改并提交前一个事务再拿着旧值去计算结果就会出错。经典并发问题有三个脏读读到了其他事务未提交的数据。不可重复读同一个事务里两次读取同一行结果不一致。幻读同一个事务里两次范围查询行数不一致中间插入了新行。针对这些问题数据库定义了事务隔离级别从低到高依次是读未提交、读已提交、可重复读、串行化。隔离级别越高并发保护越强但并发能力经常下降。下面这张表可以作为对照隔离级别脏读不可重复读幻读典型说明读未提交可能可能可能基本不用读已提交不会可能可能多数数据库默认级别之一可重复读不会不会InnoDB 下基本避免兼顾并发和一致性串行化不会不会不会并发最低数据最强MySQL 默认隔离级别是可重复读而一些其他数据库默认是读已提交。不要把某一款数据库的默认值当成所有数据库的通用事实。3.2 锁不是越多越好要分清行锁和间隙锁数据库实现隔离级别时需要依赖锁机制。行级锁只锁住正在操作的具体记录允许其他事务操作不同记录。表级锁会锁住整张表实现简单但并发很低。现代关系型数据库的写操作通常不是暴力锁整表复杂的是范围更新时还需要处理“间隙锁”。间隙锁锁住的是一个范围不只是某一行。目的是防止其他事务在范围内插入新记录避免幻读。例如查询条件是id 10 AND id 20如果只锁住已经存在且符合条件的行其他事务在 id15 处插入新记录当前事务再次查询就会多出来一个“幻影行”。间隙锁锁定这个范围后插入操作会被阻塞。锁多了自然会遇到死锁。典型死锁场景是事务 1 先更新订单表再更新用户表事务 2 先更新用户表再更新订单表两边各持有一把锁等对方释放。数据库会检测死锁并回滚其中一个事务但应用代码会收到一个死锁重试异常。代码层面减少死锁的办法是让所有事务按照相同顺序访问资源。比如先更新订单再更新用户统一顺序后相互等待的环就不会出现。这个规则不是数据库自动解决的是应用规范问题。3.3 MVCC 让“普通读”不用加锁也能一致如果每次 SELECT 都要加锁读多写少的业务并发能力会被严重限制。MVCC 解决的核心问题是让读操作不加锁仍然能读取一个一致快照。原理是数据库把每行记录保存多个版本每个版本关联事务的可见性信息。某个事务开始后它通过 Read View 判断哪些版本对它可见哪些版本不可见。可见时返回旧版本不可见时读取 undo log 中的历史版本。比如事务 T1 先查询用户余额事务 T2 随后把余额从 100 改成 200 并提交。T1 因为持有自己开始时刻的 Read View仍然可能看到 100。具体能看到哪个值取决于隔离级别和读取时机。可重复读下T1 整个事务周期内反复读取看到的都是第一次快照对应的值读已提交下每次读都会生成新快照就可能看到已提交的 200。这就是为什么工作中经常出现“另一个事务已经提交了我的查询怎么还是旧值”。不代表数据错误而是当前事务的快照机制决定的。如果你需要每次查询都读到最新已提交结果隔离级别或事务设计需要重新评估。4. 正确性不只靠数据库事务边界和幂等设计要跟上4.1 建表约束是数据库能主动拦截的第一道防线数据库能主动告诉你“这条数据不能写”依赖的是约束。字段类型、非空约束、默认值、唯一索引、主键、外键和 CHECK 约束都是第一层防御。主键的意义不只是查询快更是“同一行记录能否被唯一定位”的基础。没有主键或者主键语义不唯一重复插入和误更新很难控制。唯一索引则能在并发场景拦截重复订单号、重复流水号。如果你把唯一性判断全部放在应用代码里两个请求同时通过检查并插入就可能产生重复数据。外键在单机数据库里能维护表间关系的完整性但很多团队因为分库分表或者高频写入压力选择不用。不用外键也是一种设计选择不能因此忽略表间关系的数据校验。批量导入或数据修复时先跑一遍约束检查比写一堆复杂 SQL 更直接。4.2 应用层最容易把事务边界写错数据库提供事务能力不代表业务代码天然正确。最容易踩的问题是事务范围过大。一个事务里执行几十次更新中间还夹杂一次外部 HTTP 调用这个事务会长时间持有锁。外部接口响应慢事务迟迟不提交其他写请求都被堵在锁上数据库连接池也会被打满。还有一类问题是事务范围太小。明明需要更新订单状态、扣减库存、记录流水三个动作保持一致结果代码里把三个 SQL 放在三个方法每个方法各自开启事务。第一个成功第二个失败数据库不会自动撤销第一个方法的影响。常见解决方向包括能在一个事务里完成的数据变更就尽量放进同一个事务边界外部调用尽量放在事务提交之后再做如果外部调用失败需要配合补偿流程。不要把“调了一次接口并且返回 200”当成整个业务流程成功。4.3 分库分表之后跨库一致性需要新方案单库单表时关系型数据库的本地事务可以覆盖所有相关数据。一旦拆成多个库数据库本地事务无法再跨节点保证原子性单机 ACID 的收益就会被打破。这时候通常要引入分布式事务方案比如基于两阶段提交的强一致方案或者基于消息队列加本地消息表的最终一致性方案。两阶段提交能保证更强一致性但协调器复杂度高、性能开销大最终一致性方案更常用需要业务能容忍短时间的中间状态。要在设计阶段就判断业务能否接受“短暂不一致后重试成功”。如果这个场景是核心资金链路设计会更复杂如果是非核心通知类数据最终一致性通常够用。不要以为拆了库、上了消息队列原来在单库里没问题的事务就自动保持正确。5. 数据真不一致了按这个顺序排查和处理5.1 先判断问题属于哪一层遇到“数据不对”的反馈不要直接改数据或清缓存。先定位层级否则可能改错对象还掩盖了根因。如果程序报“锁等待超时”“死锁”优先排查数据库锁和事务范围。如果数据被旧值覆盖优先排查并发更新的版本号、SELECT FOR UPDATE 或事务隔离级别。如果主从数据不一致优先查主从延迟、复制中断和 binlog 应用情况。如果重启后部分已提交数据丢失优先查 redo log、undo log 落盘策略和备份恢复工具。如果重复插入同一业务单号优先查唯一索引和 insert 前是否依赖可重复读下的先查后插。判断层级之后再动手比上来就“跑一条 UPDATE 修复”稳得多。5.2 锁等待和死锁信息能从日志里看到数据库通常会输出锁等待和死锁相关信息。比如 MySQL 中可以用SHOW ENGINE INNODB STATUS查看最近一次死锁信息也可以查询系统表定位事务持有锁和等待锁的关系。注意不同版本下查看事务锁的语法有差异要结合当前版本手册来用。看到死锁信息后先别急着调大锁等待时间。锁等待超时调大只是“让程序等待更久”并不能减少死锁也不能降低锁冲突。更有效的排查顺序是先找到死锁涉及的表和索引确认多个事务是否按不同顺序更新同一组数据再检查应用里有没有长事务、隐式提交、跨表乱序更新。如果是数据库压力高导致的普通锁等待优先找慢 SQL 和长事务。通过连接列表定位运行时间最长的会话再查它正在执行的 SQL 和持有锁状态。确认是长事务后优先考虑在业务代码里拆分事务而不是在数据库配置层强行把等待时间调大。5.3 批量任务最容易出现的问题不是没跑完而是跑重了批量更新、数据订正、重算任务这类流程最怕“任务中断后重新跑”。如果没有幂等设计重跑一次可能把已经处理过的数据再处理一遍。设计批量任务时要关注三件事每批数据要能被唯一确定用业务 ID 或批次号做去重标识。处理流程要记录进度尽量支持断点续跑。数据订正前先备份至少保留受影响表的相关字段快照。业务代码调用接口同样要考虑幂等。同一个请求因为网络超时被客户端重发如果服务端没有去重标识就可能创建两条订单。数据库层面能用唯一索引兜底但应用层最好在入口就识别重复请求不要在数据处理到一半时才发现冲突。5.4 验证数据一致性不能只看总数对不对很多项目收尾时只验证“行数一致”“SUM 一致”但数据一致性还包括字段级语义、时间先后、状态流转是否合法。最简单也常用的一组验证思路对比主库和从库关键表的全量字段差异不只是总数。回放 binlog 或数据库日志确认某条业务数据确实经历了预期变更。查看异常时段的应用日志确认事务提交前是否收到过超时或重试信号。对关键金额字段做随机抽检看业务流水是否连续完整。如果修复过异常数据必须保留修复前后的快照和修复 SQL方便后续确认没有二次污染。6. 学完原理之后我建议你亲自动手测一遍6.1 先做一个回滚实验验证原子性启动一个数据库客户端手动开启事务后执行一条 UPDATE把某个字段改成新值然后立刻执行ROLLBACK。再用另一个客户端查询确认字段没有变化。这个实验能直观感受事务的边界。-- 客户端 A START TRANSACTION; UPDATE user SET balance balance - 100 WHERE id 1; ROLLBACK;如果之前没做过这个实验你可能会惊讶地发现事务不提交其他连接默认情况下看不到你的修改。这种“中间状态不可见”正是隔离性的基本表现。6.2 再做两个事务同时更新的实验打开两个数据库连接模拟两个事务同时对同一行做更新。如果有一方迟迟没有提交另一方执行相同行更新时通常会被阻塞直到超时或等到对方提交。这个现象可以帮你理解行锁和锁等待时间。实验步骤可以设计成客户端 A开启事务更新一条记录不提交。客户端 B开启事务尝试更新同一条记录观察是否卡住。在客户端 A 中提交再观察客户端 B 是否顺利完成。这个实验跑完后可以进一步思考如果两个事务不是更新同一行而是更新同一个范围会不会触发间隙锁把普通查询和SELECT ... FOR UPDATE的行为对比一下你就知道锁的作用范围和查询条件有关。6.3 在测试库里模拟一次崩溃恢复不要在生产环境做这种实验找一个测试库。开启事务并提交一部分数据然后模拟数据库异常退出再重启数据库观察数据是否仍然存在。也可以准备一个未提交事务重启后观察回滚结果。这一步的核心目的是理解“提交成功”和“写入磁盘”并不是同一时刻发生。看到崩溃恢复后的数据结果比背十遍 WAL 概念都有用。测试环境里可以放心验证生产系统里需要依赖完整备份、监控和恢复演练不能临时用极端手段验证。6.4 把数据库能力放进整体系统设计里关系型数据库的机制设计得再严谨也只能保证数据库自身动作不产生错误。只要应用不建唯一索引、不规划事务边界、不设计幂等键、不做备份恢复演练数据库照样会被错误用法拖垮。不管未来使用 MySQL、PostgreSQL还是转型去接触分布式存储事务、日志、并发控制这些思想都会延续。真正让你“懂数据库为什么不出错”的不是记住某个参数而是理解每一步写入如何在内存、日志、数据页之间流动以及它在崩溃和并发时分别扮演什么角色。把这套链路想通后遇到大部分数据异常就不会慌按日志、锁、事务边界、索引几个方向排查通常很快就能找到问题。
返回列表