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