从线程到单库:一致性问题是怎么一步步长出来的
从线程到单库:一致性问题是怎么一步步长出来的
很多分布式问题听起来很大:分布式事务、缓存一致性、消息可靠性、主从延迟、CAP。真正开始理解时,反而应该从小地方看起。
一致性问题的核心公式可以先写成一句话:
一致性问题 = 多个执行单元 + 共享或复制的状态 + 缺少可靠同步机制线程、进程、数据库、微服务、缓存、消息队列,本质上都绕不开这句话。区别只是执行单元越来越远,状态的共享方式越来越间接,失败模式越来越复杂。
本文先看前三层:
线程级别:多个线程共享同一块内存
进程级别:多个进程通过 IPC / 文件 / socket 协作
单库级别:多个应用实例访问同一个数据库这三层有一个共同特点:系统里通常还有一个相对明确的权威状态源。线程级别的权威状态是进程内存,进程级别可能是文件或 IPC 通道,单库级别则是数据库。
线程级别:多个线程共享同一块内存
在操作系统里,线程不是一句抽象概念。以 Linux 为例,进程和线程在内核里都可以看作 task_struct。区别在于:线程之间通常共享同一个 mm_struct,也就是共享同一套用户态虚拟地址空间。
可以简单理解为:
线程 A 的 task_struct -> 同一个 mm_struct -> JVM 堆
线程 B 的 task_struct -> 同一个 mm_struct -> JVM 堆所以 Java 里说“多个线程共享 JVM 堆”,不是一句纯理论描述。落实到操作系统层面,就是多个执行流看到同一套地址空间。它们各自有自己的执行现场和栈,但堆上的对象、静态变量、全局缓存这些东西可以被共同访问。
在 Java 后端里,最容易踩坑的是 Spring 默认单例 Bean。
@Service
public class OrderService {
private Long currentUserId;
public void createOrder(Long userId) {
this.currentUserId = userId;
// 后续逻辑继续使用 currentUserId
}
}这段代码的问题不是语法,而是对象生命周期。OrderService 默认是单例,多个请求线程会并发调用同一个对象。用户 A 的请求刚把 currentUserId 设置成 A,用户 B 的请求可能立刻把它覆盖成 B。结果就是串用户、串订单、权限判断错误。
面试里经常问“Spring Bean 是否线程安全”,本质就是在问:这个单例对象里有没有可变共享状态。
线程级别的典型场景
常见问题包括:
- Controller、Service、Mapper 这类单例对象里放了可变成员变量。
- 多个线程同时执行
count++,导致计数丢失。 - 多个线程共同读写
HashMap、ArrayList这类非线程安全容器。 - 定时任务刷新本地缓存,请求线程同时读取缓存,读到半成品。
ThreadLocal在线程池复用场景下没有清理,导致用户信息、租户信息、traceId 串到下一个请求。- 异步任务把结果共同写入同一个集合,出现丢数据或结构损坏。
这些问题的共同点是:多个线程访问同一份可变内存,并且至少有一个线程在写。
count++ 是最经典的例子。它看起来是一行代码,底层却不是一个不可分割的操作:
读取 count
执行 +1
写回 count两个线程同时进来,都读到 10,各自加 1,再都写回 11。最终执行了两次加法,结果只增加了一次。这就是丢更新。
线程级别的四类问题
线程安全问题可以拆成四类:
原子性:一个逻辑操作被拆成多步,中间被别的线程插入。
可见性:一个线程修改了变量,另一个线程看不到最新值。
有序性:编译器或 CPU 重排序,导致实际执行顺序和代码顺序不完全一致。
互斥性:多个线程同时进入临界区,破坏共享状态。对应的工具包括:
synchronized
Lock / ReentrantLock
volatile
AtomicInteger / AtomicLong / LongAdder
ConcurrentHashMap
BlockingQueue
ThreadLocal
不可变对象
无状态设计这里要注意一个工程判断:不是所有问题都应该上来就加锁。
优先级通常是:
能不共享,就不共享。
能用局部变量,就不用成员变量。
能用不可变对象,就不用可变对象。
必须共享时,再考虑并发容器、原子类、锁。比如配置缓存刷新,不要在原 Map 上边删边加,而是先构建一份新 Map,再一次性替换引用:
private volatile Map<String, Config> configSnapshot = Map.of();
public void refresh() {
Map<String, Config> next = loadAllConfig();
configSnapshot = Map.copyOf(next);
}这样请求线程要么看到旧快照,要么看到新快照,不会看到刷新到一半的数据。
怎么判断框架里有没有隐式线程
很多人写代码时没写 new Thread(),但代码早就运行在线程池里了。关键不是看你有没有手动创建线程,而是看这个方法是不是框架入口。
在 Spring MVC + Tomcat 里,典型链路是:
Tomcat Acceptor 接收连接
Tomcat Poller 监听 socket 事件
Tomcat worker 线程处理 HTTP 请求
Filter
DispatcherServlet
Controller
Service
Mapper所以一个 Controller 方法虽然看起来只是普通方法:
@GetMapping("/orders")
public Object listOrders() {
return orderService.list();
}但它不是你主动调用的,而是 Tomcat 的工作线程回调进来的。线程名常见是:
http-nio-8080-exec-1
http-nio-8080-exec-2Tomcat 底层可以使用 NIO,这不等于业务代码不在线程里执行。NIO 解决的是大量连接不必一连接一线程。进入 Spring MVC 的同步业务处理后,一个正在处理的请求通常仍然会占用一个 worker 线程,直到响应返回。
所以判断方式是:
Controller / Filter / Interceptor:请求线程
@Scheduled:调度线程
@KafkaListener / @RabbitListener:消费线程
@Async:异步线程池
ApplicationRunner:启动流程线程
普通 Service 方法:由调用它的那个线程继续执行
@Transactional:不会自动创建新线程,只是把事务绑定到当前线程面试里可以这样说:
我判断一段代码是否有隐式线程,先看它是不是框架回调入口。Controller 是 Tomcat worker 线程调用的,MQ Listener 是消费线程调用的,定时任务是调度线程调用的。如果中间没有异步、线程池或响应式调度器,Controller 到 Service 到 Mapper 这条同步调用链通常还是同一个请求线程。
进程级别:多个进程通过 IPC / 文件 / socket 协作
线程共享同一个地址空间,进程默认不共享。
在 Linux 里,不同进程通常有不同的 mm_struct。这意味着它们看不到对方堆里的对象。一个进程里的 HashMap,另一个进程不能直接访问。
但是进程并不是完全孤立的。它们可以通过外部介质协作:
管道
Unix Domain Socket
TCP Socket
共享内存
文件
mmap
消息队列
信号这时一致性问题从“共享内存并发”变成了“共享外部资源并发”。
比如两个进程同时写一个文件:
进程 A 写入一半
进程 B 插入写入
进程 A 继续写如果没有协议或锁,文件内容可能交错,日志可能损坏,状态文件可能写成半截。
再比如本机两个服务进程通过 socket 通信。socket 本身是字节流,不天然知道业务消息边界。你发两次,接收方不一定就按两次读到;你一次读到的可能是半包,也可能是多个包粘在一起。
这就是为什么网络编程里要处理:
拆包
粘包
消息长度
序列号
超时
重试
幂等进程级别的问题已经开始接近分布式问题,只是它可能还发生在同一台机器上。
进程级别的解决手段
常见手段包括:
文件锁:控制多个进程写同一个文件。
单写者模型:只有一个进程负责写状态,其它进程发请求。
IPC 协议:明确消息格式、长度、序列号和错误处理。
本机消息队列:用队列隔离生产者和消费者。
共享内存 + 信号量:高性能,但复杂度高。
幂等设计:重复请求不会造成重复副作用。这里可以得到一个重要结论:
不共享
mm_struct只能说明进程之间不能直接读写对方内存,不代表没有一致性问题。只要它们共享文件、socket、数据库、Redis 或任何外部状态,就仍然会有并发协作问题。
单库级别:多个应用实例访问同一个数据库
再往上走一步,来到后端工程最常见的场景:多个应用实例访问同一个数据库。
比如一个 Spring Boot 服务部署了 3 个实例:
app-1 -> MySQL
app-2 -> MySQL
app-3 -> MySQL这 3 个实例是 3 个 JVM 进程,拥有不同的 mm_struct,各自有自己的 JVM 堆。synchronized 只能锁住当前 JVM 内的线程,锁不住其它实例。
但是它们共享同一个 MySQL 表。于是竞争点从 JVM 堆转移到了数据库行。
典型场景包括:
多个实例同时扣库存
多个实例同时抢优惠券
多个实例同时处理同一笔订单回调
多个实例都执行定时任务
多个服务同时推进订单状态以扣库存为例,如果代码是:
select stock from product where id = 1;
-- 业务判断 stock > 0
update product set stock = stock - 1 where id = 1;两个请求同时读到 stock = 1,都判断可以扣,最后可能出现超卖。这里问题不在 Java 线程锁,而在数据库并发控制。
更好的写法是把判断和修改合并成一个原子更新:
update product
set stock = stock - 1
where id = 1 and stock > 0;然后根据影响行数判断是否扣减成功。
这时数据库帮我们做了并发控制。多个应用实例虽然不共享内存,但通过数据库的行锁、事务、索引约束来达成一致。
单库级别常用手段
单库场景下,可以依赖数据库提供的强能力:
事务:把多个 SQL 包在一个原子提交单元里。
行锁:防止多个事务同时修改同一行。
乐观锁:通过 version 字段防止丢更新。
唯一索引:防止重复插入、重复处理。
隔离级别:控制事务之间可以看到什么。
幂等表:记录业务请求是否已经处理过。
状态机:限制状态只能按合法路径流转。例如支付回调可能被第三方重复通知。最稳的做法不是相信“只会通知一次”,而是在数据库里建立幂等约束:
create unique index uk_pay_callback on pay_callback(third_trade_no);或者在订单表里用状态机控制:
update orders
set status = 'PAID'
where id = ? and status = 'WAIT_PAY';只有影响行数为 1,才说明这次状态推进成功。重复回调再次执行时,影响行数为 0,不会重复发货、重复加积分。
两个服务访问同一个数据库,算不算分布式事务
这个问题面试里很常见。
答案不是看“是不是同一个数据库”,而是看“一次业务是否跨了多个独立事务边界”。
如果一次业务只在一个服务里开启一个数据库事务完成:
服务 A 一个事务内:
写订单表
写订单明细表
扣库存表这通常还是本地事务,数据库可以统一提交或回滚。
但如果是:
服务 A 开事务写订单
服务 A 调服务 B
服务 B 开另一个事务扣库存哪怕 A 和 B 连接的是同一个 MySQL 实例,也已经出现分布式事务味道。因为 A 和 B 用的是不同连接、不同事务上下文,不能天然保证一起提交、一起回滚。
面试可以这样答:
两个服务访问同一个数据库,不一定就是分布式事务。如果一次业务操作只落在一个本地事务里,更多是数据库并发控制问题。但如果一次业务流程跨了两个服务,并且两个服务各自开启事务,即使底层是同一个数据库,也会出现事务边界分裂的问题。工程上通常优先把强一致写操作收敛到一个服务或一个本地事务里;收敛不了时,再考虑最终一致、补偿或分布式事务方案。
第一篇小结
从线程到单库,一致性问题是逐步长大的:
线程级别:
同一个 mm_struct,共享同一块内存。
问题是共享变量、共享对象、共享容器的并发读写。
进程级别:
不同 mm_struct,默认内存隔离。
问题转移到文件、IPC、socket、共享内存等外部介质。
单库级别:
多个 JVM 进程不共享堆,但共享同一个数据库。
问题转移到数据库行、事务、唯一约束和状态机。所以可以得到一条主线:
共享内存 -> 共享外部资源 -> 共享数据库状态到了这一层,系统还相对好处理,因为数据库通常还是权威状态源。真正困难的是下一层:一次业务跨多个服务、多个资源、多个副本。那时就不再只是“谁能锁住这行数据”,而是要面对网络失败、消息丢失、缓存延迟、主从复制和补偿事务。