跳转到正文
zeno's blog
返回

微服务(三):多实例共享数据库为什么不需要额外同步

专题: 微服务

Table of contents

Open Table of contents

TL;DR

多个无状态服务实例访问同一个 PostgreSQL,不需要分布式锁或 Redis 互斥。数据库的 MVCC + 事务 + 约束已经处理了并发控制。你唯一需要操心的是业务层”先读后写”的竞态,而这个问题单实例多 goroutine 也一样存在,解决方案全在 SQL 层面(原子 SQL、乐观锁、SELECT FOR UPDATE、唯一约束)。


“连接数据库”不等于”有状态”

这是一个常见误解。有状态 vs 无状态的判断标准是:请求和特定实例之间有没有绑定关系。

有状态服务(如 IM Connection Service)无状态服务(如 API Service)
状态在哪进程内存里(连接表、会话数据)外部共享存储(PostgreSQL、Redis)
请求路由必须路由到持有该连接的特定实例任意实例都能处理,结果一样
实例挂了该实例上的用户断线,状态丢失无影响,请求自动打到其他实例
扩缩容需要连接迁移、优雅关闭加实例就行,kill 也不怕

API Service 的所有”状态”(用户数据、消息、好友关系)都在 PostgreSQL 和 Redis 里。这些是外部共享存储,所有实例看到同一份数据。任何实例挂了或新加一个实例,对客户端完全透明。

数据库已经替你做了并发控制

多实例访问同一个数据库和”多 goroutine 访问同一个 map”是完全不同的事情。后者需要你自己加锁,前者数据库已经处理了:

-- 实例 1
UPDATE users SET name = 'Alice' WHERE id = 1;
-- 实例 2(几乎同时)
UPDATE users SET name = 'Bob'   WHERE id = 1;

PostgreSQL 用 MVCC + 行级锁保证这两条语句串行执行,不会出现数据损坏。应用代码不需要做任何额外的事情。

真正需要处理的:业务层”先读后写”竞态

数据库保证单条 SQL 的原子性。但如果业务逻辑是”先读后写”(read-modify-write),多个并发请求之间会出竞态:

// 实例 1 和实例 2 同时执行
balance, _ := repo.GetBalance(userID)    // 都读到 100
newBalance := balance - 80
repo.UpdateBalance(userID, newBalance)   // 都写入 20
// 结果:扣了两次 80,但余额是 20 而不是 -60

这不是”多实例”独有的问题——单实例多 goroutine 也一样。 解决方案全在 SQL 层面:

方案 1:原子 SQL(首选)

把读和写合成一条 SQL,让数据库保证原子性:

UPDATE accounts SET balance = balance - 80
WHERE id = $1 AND balance >= 80;
-- 受影响行数 = 0 → 余额不足

适用:简单数值操作(扣款、计数、库存扣减)。

方案 2:乐观锁

给行加一个 version 字段,更新时校验版本号:

UPDATE orders SET status = 'paid', version = version + 1
WHERE id = $1 AND version = $2;
-- 受影响行数 = 0 → 有人先改了,重试

适用:冲突概率低的通用场景。冲突时重试即可。

方案 3:SELECT FOR UPDATE(悲观锁)

事务内锁住行,其他事务排队等待:

BEGIN;
SELECT * FROM accounts WHERE id = $1 FOR UPDATE;  -- 锁住这行
-- 业务计算...
UPDATE accounts SET balance = $2 WHERE id = $1;
COMMIT;

适用:冲突频繁、必须严格串行的场景。代价是阻塞等待,吞吐量下降。

方案 4:唯一约束

用数据库约束从根源防止重复:

-- 防止重复添加好友
ALTER TABLE friendships ADD CONSTRAINT uq_friendship UNIQUE (user_id, friend_id);

-- 应用层直接 INSERT,冲突时数据库报错
INSERT INTO friendships (user_id, friend_id) VALUES ($1, $2);
-- 重复插入 → unique violation → 应用层捕获处理

适用:防重复写入(好友关系、点赞、收藏)。

选型决策

场景推荐方案原因
数值加减(余额、库存、计数)原子 SQL最简单,零冲突开销
状态流转(订单 pending→paid)乐观锁冲突时重试,不阻塞
必须串行的复杂事务SELECT FOR UPDATE强一致,但牺牲吞吐
防重复写入唯一约束数据库层面兜底,最可靠

IM 项目中的实际情况

大部分操作根本不存在竞态问题:

操作有竞态?原因
用户注册/登录读用户、校验密码、返回 token
查消息历史纯读操作
消息持久化INSERT 新行,每条消息独立
添加好友无(用唯一约束)UNIQUE(user_id, friend_id) 兜底
群组人数上限先读 count 再 INSERT 有竞态

群组人数上限的正确处理:

-- 原子 SQL:检查和写入在一条语句中完成
INSERT INTO group_members (group_id, user_id)
SELECT $1, $2
WHERE (SELECT count(*) FROM group_members WHERE group_id = $1) < 100;
-- 受影响行数 = 0 → 群满了

扩缩容的真正瓶颈:数据库连接池

多实例无状态服务的瓶颈不在同步,在连接池。每个实例开一个连接池(比如 20 个连接),3 个实例 = 60 个连接。PostgreSQL 默认 max_connections = 100

当实例数增长到连接池不够用时,在数据库前面加 PgBouncer 做连接池代理:

实例 1 (20 conn) ─┐
实例 2 (20 conn) ─┼→ PgBouncer (连接复用) → PostgreSQL (100 conn)
实例 3 (20 conn) ─┘

但这是数据库层面的扩展问题,不影响 api-service 本身无状态的性质。

Pitfalls

  1. 引入分布式锁解决数据库并发 — Redis 分布式锁(Redlock)用于协调无共享存储的多节点。你的多实例共享 PostgreSQL,数据库自己的锁机制比 Redlock 更可靠、更简单。除非操作跨多个数据源(既写 DB 又写 Redis),否则不需要分布式锁
  2. 在应用层用 sync.Mutex 防并发 — Go 的 Mutex 只在单进程内有效。多实例部署后,实例 1 的锁管不了实例 2。并发控制必须下沉到数据库
  3. SELECT + INSERT 不加保护 — “先查有没有,再插入” 在并发下必然出问题。用 INSERT ... ON CONFLICT 或唯一约束替代
  4. 长事务中使用 SELECT FOR UPDATE — 锁持有时间 = 事务时间。长事务会阻塞其他请求,导致连接池耗尽。FOR UPDATE 的事务要尽量短

分享这篇文章:

上一篇
微服务(四):No-mock 趋势与测试策略
下一篇
Tokio:定时器、时间轮与精度