在SaaS系统中,一个非常常见的数据事故是“后保存的人把先保存的人修改内容覆盖掉了”。例如管理员A打开一条客户资料,管理员B随后也打开同一条资料。A修改电话号码并保存,B修改联系人姓名后再点击保存。如果系统直接执行UPDATE,那么B提交的数据很可能把A刚刚修改的电话号码一起覆盖。两个用户都没有报错,数据库里的数据也完全合法,但A的修改已经悄悄消失了。
这种问题通常不是数据库“出错”,而是应用没有正确处理并发更新。解决这类问题最常用的机制之一就是乐观并发控制(Optimistic Concurrency Control),而其中最简单、最可靠的实现,就是在业务表增加一个整数版本号字段,例如version。
最重要的一点是,版本号通常不是用来保证整张表里的唯一性。假设一张documents表有100万条记录,那么每条记录完全可以拥有自己的version。文档A可能是版本12,文档B也可能是版本12,因为它们是不同的资源。真正需要唯一识别的是资源本身,例如tenant_id + document_id,而不是要求整个数据库里的version字段都不能重复。因此,单纯给version建立UNIQUE索引,通常不仅没有必要,还可能直接破坏正常的数据模型。
最典型的设计是:
CREATE TABLE documents (
id BIGINT PRIMARY KEY,
tenant_id BIGINT NOT NULL,
title VARCHAR(255) NOT NULL,
content TEXT NOT NULL,
version BIGINT NOT NULL DEFAULT 1,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
这里的version代表的是“这一行数据目前处于第几个修改版本”。第一次创建记录时可以从1开始,每次成功修改后增加1。
真正防止覆盖的关键,并不是SELECT时读取一次version,而是UPDATE时再次检查version。例如客户端读取文档时得到:
id = 1001
version = 7
用户修改内容以后,服务器不能直接执行:
UPDATE documents
SET content = ?, version = version + 1
WHERE id = 1001;
因为这条SQL没有确认用户手里的数据是不是最新版本。
正确的思路是把旧版本号放进WHERE条件:
UPDATE documents
SET
content = ?,
version = version + 1,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
AND tenant_id = ?
AND version = ?;
假设用户提交的是版本7,如果数据库里的记录仍然是版本7,UPDATE会成功,同时版本变成8。如果另外一个用户已经先完成修改,数据库中的版本已经变成8,那么这条UPDATE的匹配行数就是0。服务器由此知道:客户端拿到的内容已经过期,不能继续覆盖当前数据。
这一步才是整个机制的核心。
也就是说,版本号真正承担的职责不是“给数据编号”,而是告诉数据库:“我这次修改是基于哪个版本进行的。”数据库确认这个版本仍然存在以后,才允许写入。这个操作最好在一次原子UPDATE中完成,而不是先SELECT判断版本,再执行另一个UPDATE。后者在高并发情况下仍然可能产生竞争窗口。
在SaaS系统中还必须把租户ID纳入数据访问条件。对于典型的多租户表,应该形成类似:
WHERE tenant_id = ?
AND id = ?
AND version = ?
这样既完成租户隔离,也完成乐观锁校验。需要注意的是,不是因为两个用户属于不同租户就一定不会发生版本冲突。如果每个租户拥有完全独立的数据,自然不会互相修改同一行;但某些SaaS系统存在跨租户共享资源、平台级配置或者组织继承配置,这时候仍然需要明确资源的并发控制边界。
时间戳可以记录数据什么时候修改,但通常不应该直接拿updated_at代替整数版本号。原因很现实:时间并不天然等于版本。在高并发环境中,两次更新可能发生在非常接近的时间内,数据库时间精度、应用服务器时钟以及不同数据库的时间处理方式都会影响判断。
更重要的是,版本号表达的是“第几次成功修改”,而时间戳表达的是“什么时候修改”。这是两个不同的信息。
因此,比较实用的表结构通常是:
version → 并发控制
updated_at → 时间记录
两者可以同时存在,而不是强迫一个字段承担两个职责。
原稿中所谓“10位时间戳采用YYYYMMDDHHMMSS”的说法也需要纠正。YYYYMMDDHHMMSS实际上是14位数字,而且无论采用何种格式,时间戳都不能自动解决并发更新问题。两个请求可能拥有足够接近的时间值,因此不应该把时间字符串当成可靠的乐观锁。
至于UUID和Snowflake,它们同样不是版本号的天然替代品。UUID主要解决唯一标识问题,Snowflake主要解决分布式环境中的ID生成问题。一个对象可以拥有UUID作为id,同时拥有整数version作为并发控制字段,两者承担不同职责。把Snowflake ID直接当版本号,往往会让模型变得更加复杂,却没有解决真正的并发覆盖问题。
对于普通MySQL和PostgreSQL业务表,一个简单的BIGINT版本号已经足够。它占用空间小、比较速度快,而且可以长期递增。一般没有必要设计所谓20260317143000-001这样的复合版本号。真正需要保存时间和修改顺序时,让version和updated_at分别承担职责,系统反而更清楚。
版本冲突发生以后,应用层应该明确告诉客户端发生了什么。对于HTTP API,可以使用409 Conflict表达资源当前状态与客户端提交条件发生冲突。服务器还可以返回当前版本或者要求客户端重新获取资源,然后根据业务决定是让用户手工合并、重新编辑,还是直接放弃这次修改。
对于普通后台管理系统,最简单的处理方式通常是提示:“数据已经被其他用户修改,请刷新后重新编辑。”对于在线文档、代码编辑器或者多人协作系统,仅仅提示冲突就不够了,这时候才需要进一步设计字段级合并、操作日志或者更加复杂的协作协议。
还有一个重要问题是版本号应该什么时候增加。正确逻辑通常是“成功修改一次,版本加一”。如果一次数据库事务执行了多项修改,那么这些修改应该作为一个事务整体提交,并让最终状态对应一个明确的版本。事务最终回滚时,数据库中的版本号也应该随着事务一起回滚,不需要另外编写所谓“版本号同步回滚程序”。只要版本号更新本身就在同一个数据库事务里,这就是数据库事务机制本身应该解决的问题。
例如:
START TRANSACTION;
UPDATE documents
SET
title = ?,
content = ?,
version = version + 1,
updated_at = CURRENT_TIMESTAMP
WHERE tenant_id = ?
AND id = ?
AND version = ?;
COMMIT;
应用程序随后检查UPDATE影响的行数。如果是1,说明这次更新成功;如果是0,则说明版本已经变化,应该进入冲突处理流程。如果事务因为其他原因回滚,版本号也不会留下一个孤立的“假版本”。
数据库索引方面也不需要为了版本号本身建立全局唯一索引。真正需要根据查询模式设计索引。例如多租户系统经常使用:
CREATE INDEX idx_documents_tenant_id_id
ON documents (tenant_id, id);
如果查询本身通过主键找到记录,再检查version,甚至可能根本不需要单独建立version索引。版本号的作用是并发条件,而不是查询入口。是否需要把它纳入复合索引,应根据实际SQL、数据规模和执行计划决定,而不是看到version字段就建立一个索引。
缓存同样不能成为版本控制的最终依据。Redis可以保存热点数据或者降低读取压力,但“数据库中的version到底是多少”最终应该由真正负责持久化数据的数据库决定。如果客户端拿着Redis中的旧版本号去执行写操作,系统仍然必须依靠数据库条件更新来判断是否允许提交。否则缓存失效、延迟或者并发更新都可能重新制造覆盖问题。
如果系统进一步采用事件驱动架构,还可以把版本号写入事件。例如某条订单从版本17变成版本18,同时产生一个对应的领域事件。这样可以让审计系统、消息系统和下游服务知道自己处理的是哪个数据版本。不过事件版本和数据库行版本仍然需要明确各自的语义,不能因为都有一个version字段就认为它们是同一个概念。
对于需要完整历史记录的系统,还可以建立独立的审计表:
audit_log
id
tenant_id
resource_id
version
operation
operator_id
changed_at
change_data
这样主表里的version负责并发控制,审计表负责保存修改历史。两者配合起来,就能够回答两个不同的问题:当前数据有没有被别人抢先修改,以及过去到底发生过什么变化。
真正需要特别注意的是,版本号只能解决一部分并发问题。它能够防止“旧数据覆盖新数据”,但不能自动解决业务层面的冲突。例如两个人同时修改同一份合同,一个修改付款金额,一个修改付款期限,系统即使成功检测到版本冲突,仍然需要业务逻辑决定两个修改能不能合并。数据库负责告诉你“有人已经改过了”,至于“这两个修改能不能同时存在”,属于应用层的业务规则。
对于绝大多数SaaS CRUD系统,一个简单的BIGINT version配合UPDATE ... WHERE id = ? AND version = ?已经足以解决大量并发覆盖问题。MySQL、PostgreSQL等数据库都可以采用这种模式。UUID、Snowflake适合解决分布式ID问题,updated_at适合记录修改时间,审计表负责保存历史,而真正的乐观锁则由版本号和原子条件更新共同完成。
很多数据覆盖事故并不是因为系统缺少一个多么先进的分布式算法,而是因为开发人员执行UPDATE时忘记问数据库一句最重要的话:“这还是我刚才看到的那一版吗?”如果答案是肯定的,就提交并把版本从7推进到8;如果已经变成8,就停止这次写入。把这条规则落实到数据库的原子更新中,比设计一个复杂的“智能版本号系统”更加可靠,也更容易长期维护。
SaaS 数据更新出现覆盖问题,版本号字段应该怎样设计
图片说明:示意图 图片来源:Public Domain(公有领域)
喜欢这篇报道?
使用下面的功能,方便以后继续阅读和分享 MNewsTV
关于文章收藏
收藏不需要注册帐号,收藏信息仅保存在当前浏览器中。
删除收藏请进入「我的收藏」进行管理。
捐助(Paypal): https://www.paypal.me/observeccp 订阅中国观察电报 Telegram : https://t.me/s/ObserveCCP