本文目录
上级:后端学习 from zero
不同于前端里程序通常运行在一对一的浏览器环境,后端的工作环境多对一是常态。多个请求可以同时作用于同一个业务对象,所以写入操作始终要考虑并发。相比前端异步处理,后端还需要同时关注数据正确性、异常兜底,以及加锁后的吞吐和延迟。
可以先问三个问题:
- 多个请求会不会同时操作同一个业务对象?
- 如果同时操作,结果是否必须唯一或有顺序?
- 如果重复执行或交叉执行,业务结果会不会错?
例子:座位预约
多个线程同时预约同一个座位,如果「检查座位是否可预约」和「设置占用者」之间存在时间差,可能两方都判断座位可用,并都返回预约成功。
朴素代码:
public class BookingService {
Map<String, Seat> seats;
public boolean bookSeat(String seatId, String visitorId) {
Seat seat = seats.get(seatId);
if (seat.isAvailable()) {
seat.setOccupant(visitorId);
return true;
}
return false;
}
}
check-then-act
check-then-act 是检查一个条件,然后基于这个条件采取行动。
问题在于检查和行动是两个独立步骤,中间其他线程可能介入。解决方式是把它们视为一个原子操作。
public class BookingService {
private final Map<String, Seat> seats;
public boolean bookSeat(String seatId, String visitorId) {
Seat seat = seats.get(seatId);
synchronized (seat) {
if (seat.isAvailable()) {
seat.setOccupant(visitorId);
return true;
}
return false;
}
}
}
read-modify-write
read-modify-write 是读取一个值,计算新值,再写回去。计数器是典型例子。
private int count = 0;
public void increment() {
count++;
}
count++ 实际上包含读取、加一、写回三个步骤。多个线程抢写时会丢更新。
简单计数可以用原子变量:
private AtomicInteger count = new AtomicInteger(10);
public void increment() {
count.incrementAndGet();
}
队列与协调
注册后发送邮件是耗时 IO。为了避免阻塞 API 线程,可以把任务交给后台工作线程。API 线程和工作线程之间需要协调,最自然的结构是队列。
队列的好处:
- 解耦生产者和消费者。
- FIFO 能提供基本顺序。
- 可以控制流量。
坏方法:
- 忙等待:线程一直循环检查队列,浪费 CPU。
- 睡眠轮询:节省 CPU,但会引入固定延迟。
更好的方法是阻塞队列:
BlockingQueue<Task> queue = new LinkedBlockingQueue<>();
public void handleSignup(User user) {
saveUser(user);
queue.put(new EmailTask(user));
}
while (true) {
Task task = queue.take();
process(task);
}
高并发时应该使用有界阻塞队列。队列满了就阻塞生产端,这种让生产端暂时「踩刹车」的机制叫背压。
忙等待会持续消耗 CPU,睡眠轮询虽然节省 CPU 却会引入固定延迟。阻塞队列在空时让消费者休眠,在有新任务时由生产者唤醒,兼顾资源和响应速度。
BlockingQueue<Task> queue = new LinkedBlockingQueue<>(1000);
public void handleSignup(User user) throws InterruptedException {
saveUser(user);
queue.put(new EmailTask(user)); // 队列满时形成背压
}
while (true) {
process(queue.take()); // 队列空时阻塞,不忙等
}
稀缺资源
如果第三方 API 最多允许 10 个并发请求,使用 Semaphore 表达“只有 10 张许可证”:
permits.acquire();
try {
doDownload();
} finally {
permits.release();
}
release() 必须在 finally 中,否则异常会造成许可证泄露,最终把所有后续线程永久卡住。若稀缺的是需要复用的具体对象,例如数据库连接,信号量不够;应使用阻塞队列实现对象池,取出连接后在 finally 中放回。
稀缺资源
如果第三方 API 最多只允许 10 个并发请求,可以用信号量。
permits.acquire();
try {
doDownload();
} finally {
permits.release();
}
release() 必须放进 finally,否则异常会导致许可证泄露。
如果稀缺的是「可复用对象」,比如数据库连接,信号量不够,因为它只能计数,不能给你一个具体对象。此时可以用阻塞队列实现对象池。
BlockingQueue<Connection> pool = new LinkedBlockingQueue<>(10);
public Result query(String sql) {
Connection conn = pool.take();
try {
return conn.execute(sql);
} finally {
pool.put(conn);
}
}
锁的类型
常见分类:
- 进程内锁:比如
synchronized、ReentrantLock。 - 悲观锁:先锁资源,再处理。
- 乐观锁:更新时检查是否被别人修改过。
- 自旋锁:等待锁时持续检查,不阻塞线程。
相关子页:
悲观锁
悲观锁假设并发冲突总会发生,因此在整个数据处理过程中持有锁。
数据库中典型实现:
select *
from orders
where id = ?
for update;
适合写多读少、高并发冲突、事务逻辑复杂的场景。缺点是并发性能差,锁持有时间长时容易阻塞甚至死锁。
乐观锁
乐观锁假设大多数情况下不会冲突,读取时不加锁,更新时检查版本。
update products
set stock = 9,
version = version + 1
where id = 1
and version = 1;
适合读多写少、并发冲突概率低的场景。
状态 CAS 也是一种常见中间做法:
update orders
set status = 1
where id = ?
and status = 0;
它关注的是当前是否处于允许执行的状态,而不是 version 是否变化。
NOWAIT
SELECT FOR UPDATE NOWAIT 是悲观锁的非阻塞版本。拿不到锁时不等待,而是立即返回错误。
适合高交互系统、避免请求长期阻塞、任务队列 worker 抢任务等场景。
状态 CAS
并发控制不一定要锁住整段业务,也可以让数据库用条件更新完成原子抢占:
update orders
set status = 1
where id = ?
and status = 0;
它和版本号乐观锁的关注点不同:版本号判断“数据是否被别人改过”,状态 CAS 判断“当前状态是否允许进入下一步”。必须明确合法状态迁移,并根据影响行数判断抢占是否成功。
选择顺序
先识别共享对象和正确性约束,再决定用原子变量、状态 CAS、队列、信号量、进程内锁还是数据库锁。不要为了一个可能不存在的冲突,直接引入更重的分布式锁或消息系统。