无需 Redis 的并发设备注册

Lobsters Hottest 工具

摘要

一篇关于解决并发设备注册竞态条件的技术实践,无需添加 Redis 等新基础设施,而是利用数据库级锁和巧妙的 schema 处理。

<p><a href="https://lobste.rs/s/hzvmru/concurrent_device_registration_without">评论</a></p>
查看原文
查看缓存全文

缓存时间: 2026/06/01 02:26

# 无 Redis 的并发设备注册 — seg6 来源:http://seg6.space/posts/concurrent-registration/ 用户在新机器上安装桌面应用,登录后,后端需要判断:他们是否有空闲席位,还是已经达到设备限制?是发放一个密钥,还是直接拒绝? 这个约束听起来很简单。对于任何拥有最大设备数 `L` 和当前活跃数 `A` 的用户,确保 `A <= L` 成立。就这些。这就是整个功能。 然后你发布了功能,用户的两台机器在同一毫秒内点击了“注册”,你的不变性就崩了。 这是一个我如何在不动用新基础设施的前提下,把它找回来的故事。 ## 约束条件 这里有一半的有趣决策来自于我*不能*做的事情。 MySQL 数据库几乎和我一样老。那些很久以前为单一目的设计的表,多年来逐渐增加了额外的列,现在正承担着它们从未被设计承担的关键任务。仍然服务生产流量的旧后端以一种未完全文档化且未充分测试的方式读取它们。重写是逐步进行的,两个后端将并行运行数月。 所以: - **不能对现有表进行结构重写**。审计所有旧查询不在考虑范围内。 - **不能引入新基础设施**。没有 Redis,没有 etcd。多一个需要运维和监控的服务,可能不值得。 - **不能加全局锁**。无论我引入什么,都不能阻塞不相关的查询。数据库必须继续做好本职工作。 - **负载均衡器后面有多个后端实例**。任何依赖共享进程状态的东西都行不通。 最后一条扼杀了大多数显而易见的方法。 下面是一张涉及到的表的快速说明: - `users`:主用户表。**MyISAM**,从未迁移到 InnoDB。先记住这一点,它很重要。 - `features`:每个用户的计划信息,包括 `devices`(席位限制 `L`)。`user_id` 上没有唯一约束。一个用户可能有零行、恰好一行,或者由于某些原因有多行。 - `registrations`:每个已注册设备对应一行。 ## 迭代 0:朴素处理器 ```go // POST /registration { username, password, device_name } func createRegistration() { // 实际上我们有一个中间件来做这个,不用担心。 user := GetUserByCredentials(username, password) if user == nil { return http.StatusUnauthorized } seatLimit := GetSeatLimit(user) activeSeats := GetActiveSeatCount(user) if activeSeats >= seatLimit { return http.StatusConflict } return CreateSeatRegistration(user, deviceName) } ``` 读、比较、插入。看起来没问题。 但它不是。 ``` 时间 请求 1 请求 2 数据库状态 ---- --------------------- --------------------- --------- t0 开始 A=1, L=2 认证通过 t1 获取限制 -> 2 开始 A=1, L=2 获取计数 -> 1 认证通过 t2 检查 (1 < 2): 通过 获取限制 -> 2 A=1, L=2 获取计数 -> 1 t3 检查 (1 < 2): 通过 A=1, L=2 创建 t4 创建 A=2, L=2 t5 完成 A=3, L=2 *已破坏* ``` 针对同一个用户的两个请求都读到了 `A=1`。两个都判断还有空间。两个都执行插入。用户现在拥有三个已注册设备,但限制是两个。 典型的检查时间到使用时间冲突。检查(“还有空间吗?”)和使用(“创建注册记录”)不是原子的,所以在这两者之间发生的任何变化(比如另一个请求插入它的行)都会使检查失效。解决办法是某种同步机制。有趣的问题是*同步机制放在哪里*。 ## 迭代 1:全局互斥锁 最愚蠢的同步原语是在处理器顶部放一个 `sync.Mutex`: ```go var registrationMu sync.Mutex func createRegistration() { registrationMu.Lock() defer registrationMu.Unlock() // ... } ``` 这在技术意义上是可行的。但这意味着两个完全不相关的用户(不同账户、不同计划、不同大洲)不能同时注册设备。其中一个必须等待。毫无理由地等待。 我们不能那样发布。一旦流量出现,吞吐量就会崩溃。这里提出的锁粒度太粗了。我们只需要对*同一个*用户的请求进行序列化。 ## 迭代 2:每个用户的互斥锁 一个 `sync.Map`,存放以用户 ID 为键的互斥锁。每个请求获取它*自己用户*的互斥锁。 ```go var userLocks sync.Map func lockFor(userID int) *sync.Mutex { mu, _ := userLocks.LoadOrStore(userID, &sync.Mutex{}) return mu.(*sync.Mutex) } ``` 不同用户并行注册,同一用户的请求序列化。吞吐量没问题,正确性没问题,一切良好。在一个单进程世界里,这可能是答案。 但我们并不生活在单进程世界里。 ```mermaid flowchart LR lb[负载均衡器] subgraph 实例[后端实例] i1[实例 1\nuserLocks 映射] i2[实例 2\nuserLocks 映射] i3[实例 3\nuserLocks 映射] end db[(MySQL)] r1[请求 A\n用户 42] --> lb r2[请求 B\n用户 42] --> lb lb --> i1 lb --> i3 i1 --> db i3 --> db ``` 后端作为多个副本运行。每个副本有自己进程的内存,自己的 `userLocks` 映射,自己的一份“用户 42 互斥锁”。两个针对用户 `42` 的请求命中两个不同的实例,每个实例愉快地锁定自己的本地互斥锁,谁也不等谁,两者并行地竞争数据库。我们又回到了起点,只不过现在自我感觉良好! 我们真正需要的是一个所有实例都能看到的锁。通常的菜单(Redis、etcd,如果你胆大的话还可以用 Redlock 实现)都违反了“不能引入新基础设施”的约束。 但关键是:每个实例已经共享了一个持久、网络可访问的状态。它们都连接到同一个 MySQL 数据库。数据库本质上就是分布式协调服务,只是附加了极其强大的持久性保证。锁定正是它们明确设计来做的事情。 所以我让数据库为我锁定。 ## 迭代 3:让数据库为我们锁定 InnoDB 支持行级锁定。在一个事务内部,`SELECT ... FOR UPDATE` 会对每个匹配的行加独占锁。其他尝试锁定或修改该行的事务会阻塞,直到持有者提交或回滚。不同的行?它们之间完全不冲突! 这正是我们要的粒度。同一用户的请求在同一行上序列化,不同用户甚至不知道彼此的存在。 结构如下: 1. 开始一个事务 2. 对这个用户关联的行执行 `SELECT ... FOR UPDATE` 3. 读取计数和限制 4. 如果还有空间则插入 5. 提交(或回滚) 两个针对同一用户的请求竞争锁。一个获胜,执行检查、插入、提交,直到此时锁才释放。另一个请求,整个第 2 步一直被阻塞,现在解除阻塞,针对世界的新状态运行*它的*检查,然后退出。两个针对*不同*用户的请求完全不冲突。 那么我们需要锁定哪一行呢? 这就是我花费了比我想承认的更多时间的地方,因为自然的答案结果都是错误的。 **`users` 表?** 这是最诱人的,因为每个用户恰好有一行。但 `users` 是一个 MyISAM 表。MyISAM 没有行级锁定。没有事务。这个方法依赖的所有原语都没有。对 MyISAM 表执行 `SELECT ... FOR UPDATE` 是一个静默空操作:语法解析通过,查询运行,但未获取任何锁。你不会得到错误,也不会得到警告。你只会得到一个面带自信微笑的竞态条件! **`features` 表?** 那里存放着席位限制,所以感觉挺对的。但直到我意识到 `user_id` 上没有唯一约束,并且有些用户可能在 `features` 表中根本没有行时,才发现不对。`SELECT ... FOR UPDATE` 只锁定*存在*的行。没有行,就没有锁,就没有保护。补丁(“如果缺失,插入一个默认行然后锁定它”)有它自己的竞态:两个请求都观察到没有行,都插入,现在同一个用户有了两行 `features`。我们用一个竞态条件换来了另一个竞态条件,糟糕的交易。 所以现有的行都不行。我真正想要的是一个行,它的唯一目的是*作为一个稳定的锁定目标*,具有适当的唯一性,并且系统中没有其他东西会触碰它。 ## 一个只为了被锁定而存在的表 ```sql CREATE TABLE registration_lock ( user_id INT NOT NULL, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (user_id) ) ENGINE=InnoDB; ``` 这就是整个表。没有业务数据。`created_at` 在那里是因为空表感觉奇怪,没有人会读它。重点是 `user_id` 上的主键,这给了我唯一性保证和一个用于 `SELECT ... FOR UPDATE` 的稳定目标。 流程变成: 1. `INSERT ... ON DUPLICATE KEY UPDATE`:原子地保证行存在 2. `SELECT ... FOR UPDATE`:获取锁 3. 读取计数和限制 4. 如果有空间则插入 5. 提交 第 1 步是关键。`INSERT ... ON DUPLICATE KEY UPDATE` 是一个单个原子语句:它要么插入一个新行,要么对现有行运行 `UPDATE` 子句。两个并发调用不可能都决定“这一行不存在,我来创建它”。 ```go func (s *Store) AcquireRegistrationLock(ctx context.Context, userID int) error { _, err := s.db(ctx).ExecContext(ctx, ` INSERT INTO registration_lock (user_id) VALUES (?) ON DUPLICATE KEY UPDATE created_at = created_at`, userID, ) if err != nil { return fmt.Errorf("确保锁定行: %w", err) } var exists int return s.db(ctx).GetContext(ctx, &exists, ` SELECT 1 FROM registration_lock WHERE user_id = ? FOR UPDATE`, userID, ) } func (s *RegistrationService) CreateRegistration( ctx context.Context, username, password, deviceName string, ) (*Registration, error) { user, err := s.store.GetUserByCredentials(ctx, username, password) if err != nil { return nil, err } return s.store.WithTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}, func(ctx context.Context) (*Registration, error) { if err := s.store.AcquireRegistrationLock(ctx, user.ID); err != nil { return nil, err } if exceeded := s.checkRegistrationLimit(ctx, user.ID); exceeded { return nil, ErrNoSeatsAvailable } return s.store.CreateRegistration(ctx, user.ID, deviceName) }, ) } ``` 整个操作在一个事务中运行。这一点很重要:InnoDB 在事务结束时释放行锁。如果锁和插入不在同一个事务中,那么当 `AcquireRegistrationLock` 返回时锁就会释放,之后的限制检查将完全不受保护。 而事务选项中的那个 `sql.LevelReadCommitted`?这可不是装饰品!这一点在整个项目中给我的打击比什么都大。 ## 第二个波折:隔离级别 第一次写这个的时候,我把隔离级别保持为 MySQL 的默认值 `REPEATABLE READ`,也就是网上每个例子都用的那个,因为我没有特别的理由去改它。单元测试通过了。我自我感觉良好。我写了一个压力测试,对一个只有一个席位的用户发起十个并发注册。结果有两个成功了。 锁是起作用的。我检查了慢查询日志:R2 确实在等待 R1 提交。所以请求被正确地序列化了。但 R2*仍然*返回说有空间。 这就是我不得不真正理解隔离级别作用的地方。 InnoDB 使用 MVCC:多版本并发控制。引擎不是让写者阻塞读者,而是保持每行的多个版本,并给每个事务显示数据库的一个“视图”。该视图的规则取决于隔离级别。 ## 可重复读 MySQL 的默认值。在事务的第一次读取时,InnoDB 会获取一个快照,记录当时已提交的行版本。该事务中随后的每个非锁定读取都从这个同一个快照提供,不管在你工作期间其他事务提交了什么。 这对于报表来说很好,因为你不想在查询过程中总数发生变化。但这在这里是错误的工具。我等待锁的*全部原因*就是另一个事务正在改变数据,如果之后我读取的是那些改变之前拍摄的快照,那么等待就成了一场无意义的戏剧。 这里有一个狡猾的小点:`SELECT ... FOR UPDATE` 是一个*锁定读取*,锁定读取即使在 `REPEATABLE READ` 下也能看到最新的已提交数据。但 `SELECT COUNT(*) FROM registrations` 是一个普通读取,它会愉快地使用过期的快照。所以在同一个事务中,两个查询可以向你展示两个不同版本的事实。这是那种只有被它坑过你才知道的事情。 ## 可序列化 每个事务都像它是世界上唯一一个那样运行。正确,但它基本上就是把全局互斥锁推到了数据库中。我们已经拒绝那个解决方案了;没有理由现在换个帽子又回来重新考虑。 ## 读已提交 每个语句在它运行的那一刻看到最新的已提交状态。没有长期存在的快照。当事务从等待锁中醒来后,下一次读取看到的是*刚刚发生*的事情。这就是我们想要的。锁序列化了临界区,`READ COMMITTED` 确保我们在其中看到新鲜的数据。 ## 两者对比 同一个场景,两个隔离级别。在 `READ COMMITTED` 下: ``` 时间 R1 R2 数据库状态 ---- ------------------------------ ------------------------------ --------- t0 开始 开始 A=1, L=2 t1 SELECT FOR UPDATE 等待锁 A=1, L=2 锁获取成功 t2 读取计数 -> 1 (仍在等待) A=1, L=2 读取限制 -> 2 检查 (1 < 2): 通过 t3 INSERT registration (仍在等待) A=2, L=2 t4 提交 锁获取成功 A=2, L=2 t5 读取计数 -> 2 A=2, L=2 读取限制 -> 2 检查 (2 >= 2): 已满 t6 回滚 A=2, L=2 ``` R2 在 R1 提交后醒来,读取新的计数,看到限制已满,放弃。我们的不变性成立了! 但在 `REPEATABLE READ` 下: ``` 时间 R1 R2 数据库状态 ---- ------------------------------ ------------------------------ --------- t0 开始 开始 A=1, L=2 快照已拍 快照已拍 t1 SELECT FOR UPDATE 等待锁 A=1, L=2 锁获取成功 t2 读取计数 -> 1 (仍在等待) A=1, L=2 读取限制 -> 2 检查 (1 < 2): 通过 t3 INSERT registration (仍在等待) A=2, L=2 t4 提交 锁获取成功 A=2, L=2 t5 读取计数 -> 1 (!) A=2, L=2 读取限制 -> 2 检查 (1 < 2): 通过 (!) t6 INSERT registration A=3, L=2 *已破坏* ```

相似文章

自由线程Python:过去、现在与未来

Lobsters Hottest

Thomas Wouters 在 PyCon US 2026 上发表了关于自由线程 Python 的过去、现在与未来的演讲,该 Python 版本移除了全局解释器锁 (GIL),允许并行线程执行。