ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

用户名注册高并发架构:从唯一索引到布隆过滤器的实战

用户名注册高并发架构:从唯一索引到布隆过滤器的实战 做后端时间长了你会越来越相信一个反直觉的结论很多真正磨人的系统设计难题往往不是从那些花里胡哨的复杂业务里长出来的反而是从一个看起来“这有什么难的”的小功能开始的。“用户名已被占用”这七个字就是最典型的一个。当 Instagram 迈过十亿级用户门槛之后这条提示背后早就不是“查一下数据库有就返回错误没有就插入一行”这种幼儿园级别的逻辑了。它牵扯到全局唯一性的强约束、高并发下的写入冲突、跨机房的一致性、缓存与数据库之间的同步延迟甚至还要对抗批量注册和脚本撞库。这篇文章我想从一个实际的架构师视角把这个问题完整拆一遍Instagram 这类体量的产品为什么在用户名这个看似普通的功能上坚持用 PostgreSQL 的唯一索引做最终裁决为什么“先查再插”这种最直觉的方案在千万级以上用户时一定会爆雷缓存和布隆过滤器到底帮我们省掉了哪些可怕的成本整套方案其实没有特别玄乎的技术真正值钱的是每一个决策背后的取舍逻辑。我会把关键环节的表结构、注册流程代码、缓存估算都过一遍最后再聊聊我在自己项目里踩过的几个大坑。1. 需求拆解十亿级“用户名已被占用”到底难在哪咱们先把问题定义清楚再说架构。你可能是第一次听说“用户名”也能当做一个独立的架构课题。别急我保证你看完这一节以后再看到注册框眼神都会不一样。1.1 从一次注册请求说起用户在前端输入框里敲下一个用户名点击注册然后由客户端向服务端发起注册请求。这个请求到达后端之后至少要经过昵称合法性校验、唯一性校验、密码处理、用户资料初始化、登录态下发这么几个阶段。其中唯一性校验是横在所有阶段前面的一堵墙。你可能会说这有什么难的用户名建一个唯一索引INSERT 的时候数据库自己会拒绝重复记录。这句话在用户量只有几十万的时候完全正确但当你面对十亿级用户时问题会分裂成好几个。第一用户名不是高频写入的字段但它是极高频读取的字段。很多产品会在用户输入时就做“实时检查用户名是否可用”的请求也就是失焦即请求。一个热门用户名比如 baby 或者 123456在注册高峰期可能会有上万个并发查询同时打到后端。第二写操作虽然占比低但写入一旦冲突必须保证最终结果绝对正确不允许出现两个不同用户都拿到 alice 的情况。第三用户量一旦上亿任何一张存放用户名映射关系的表都会膨胀到几十 GB 甚至上百 GB查询和写入都会面临物理瓶颈。我把这三个问题总结成三个核心约束读路径的高吞吐、写路径的强一致、存储层的水平扩展。这三个约束往往是彼此打架的架构设计本质上就是在它们之间找平衡。1.2 为什么不能用“先查再插”的常规思路很多没做过高并发系统的同学第一次设计用户名注册接口时脑子里的方案大概率是这样的# 典型错误示范 def register(username, user_id): # 先查 result db.query(SELECT id FROM users WHERE username %s, username) if result: return 用户名已被占用 # 再插 db.execute(INSERT INTO users (id, username) VALUES (%s, %s), user_id, username) return 注册成功这个方案在小规模下完全没问题但并发一上来就完蛋。原因很简单你的事务 A 做“先查”的时候事务 B 也在做“先查”A 没查到B 也没查到然后 A 插入成功B 插入也成功用户名重复了。唯一的“保护措施”是你在数据库里加的唯一索引可如果 INSERT 依赖的是上一步 SELECT 的结果而不是数据库自己抛出的唯一键冲突那么并发场景下必然会出现难以追查的偶发异常。所以行业里的共识是用户名唯一性判断绝不能依赖“先查再插”而应该把数据库的唯一索引当作最终裁决者。业务代码只需要负责提交一个带唯一约束的写入请求数据库要么成功插入要么抛出唯一键冲突二选一干干净净。这个思路也直接决定了后面 Instagram 的存储选型。1.3 全局唯一是分布式系统里最贵的“约束”如果我们把系统拆成多个服务、多个数据库实例问题就变得更加复杂。假设你用用户 ID 做水平分片用户 ID 为偶数的进库 A奇数的进库 B那用户名唯一性谁来保证A 库插入 alice 成功B 库也插入 alice 成功两边都不知道对方的存在全局唯一就失效了。解决办法通常是引入一个独立的“用户名服务”把所有用户名全局唯一性的裁决集中到一台或一组专门的存储中。也就是说你不能指望每个分片里的独立索引保证全局唯一索引的作用范围必须跨分片或者干脆专门用一个全局仲裁存储。这带来的直接代价就是每注册一个新用户业务服务都要跨网络调用一次用户名仲裁服务而这个仲裁服务必须做到高可用、强一致、低延迟。为了满足这三个要求存储引擎的选型就成了生死攸关的事。你可以用 MySQL、PostgreSQL、也可以尝试 TiDB 一类的 NewSQL甚至可以硬用 Redis但真正落到十亿级用户的时候不同技术方案的成本差异会大到让你怀疑人生。2. 架构选型为什么用户名要单独建一张“小表”Instagram 早期大量使用 PostgreSQL团队在工程博客上也分享过不少存储相关的决策。虽然他们后来把很多核心数据迁到了 Cassandra但对于用户名这种强一致性的数据方向上仍然遵循一个原则不同约束强度的数据用不同的存储引擎。2.1 数据库选型背后的取舍Cassandra 这种 NoSQL 数据库擅长的是海量数据吞吐和水平扩展但它默认是最终一致性模型。虽然 Cassandra 提供了 LWT轻量事务来做条件写入可以用来实现类似唯一性的约束但 LWT 的性能代价非常高昂每次操作都需要多节点协调在高并发注册场景下很容易变成瓶颈。如果你非要用 Cassandra 来保证用户名全局唯一也不是不行但代价是牺牲大量的写入吞吐而且跨机房部署时 LWT 的时延会让你抓狂。PostgreSQL 和 MySQL 这类传统关系型数据库单机写入吞吐远不如 Cassandra但它们有一个不可替代的能力强一致的事务和唯一索引。用户名这个场景有个很有利的特点写入量并不高。你可能觉得十亿用户对应十亿次注册写入量很大。但注意这十亿是十年慢慢累积的摊到每天可能就几十万次再均摊到每秒其实只有个位数到几十的 TPS。真正的高并发是在读取侧比如“检查用户名是否可用”的查询请求可能每秒几万次。这种“低写高读”的场景恰好是关系型数据库配合缓存层最舒服的区间。所以 Instagram 的做法是用户名最终唯一性由一组专门的关系型数据库仲裁而这个仲裁存储只保留最核心的两个字段用户名、用户 ID相当于一张刻意保持“狭窄”的仲裁表。这张表虽然行数上亿但每一行非常短SSD 上的随机读性能很好索引也可以尽量压缩在内存里。2.2 唯一索引 事务是唯一性的“最后裁决者”我们来看这张仲裁表到底长什么样。用 PostgreSQL 为例CREATE TABLE username_registry ( username VARCHAR(64) PRIMARY KEY, user_id BIGINT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() );当你插入一个用户名时INSERT INTO username_registry (username, user_id) VALUES (alice, 10001) ON CONFLICT (username) DO NOTHING RETURNING user_id;这条 SQL 的执行结果只有两种返回一行表示插入成功返回空结果集表示用户名冲突。整个过程里业务代码完全不需要先 SELECT 一次唯一性判断完全由数据库主键索引保证。不依赖任何“先查再插”的流程。这里有一个细节值得展开为什么不直接用普通的 INSERT 然后捕获唯一键冲突异常而是用ON CONFLICT DO NOTHING RETURNING因为异常捕获在高并发下会有大量不必要的错误日志也会增加应用层的分支处理复杂度。而RETURNING让数据库直接告诉我们插入是否成功逻辑干净多了。更重要的是这种写法把你的意图表达得很清晰冲突是我们预期会发生的事不是什么系统异常。当然PostgreSQL 也支持unique index而不是primary key。如果你想把用户名作为普通唯一列而不是主键也是可以的只是主键访问效率通常更高所以很多实现会直接拿 username 当主键。2.3 归档与分表十亿行的查询怎么保持稳定当这张表增长到几亿行时即使它很“瘦”索引也会达到几个 GB 甚至更多。PostgreSQL 在这个量级上完全能跑但你需要提前制定归档策略。很多产品会规定已注销用户的用户名不会立即释放而是进入一个“冷却期”。比如删除后 90 天内不允许别人注册90 天之后才允许回收。这个逻辑在业务上叫“用户名保留期”既能防止账号被冒名注册也给未来可能的“用户找回账号”留了后路。在实现上可以给表增加deleted_at字段注销时只做软删除等保留期结束后再由一个异步任务批量清理或显式标记为可回收。还有一种优化思路是“冷热分离”。活跃用户对应的 username 很少被改绝大多数请求落在一部分热点用户和大量新注册检查上。你可以把超过一定时间未被访问的 username 行归档到冷表主表只留活跃数据。不过从我的实践经验看只要这张表设计得够瘦、索引够紧凑单表撑住几亿行完全没问题优先把缓存层做好比一上来就搞冷热分离要划算得多。3. 缓存层设计如何让“查询占用”不拖垮数据库前面说了用户名系统是“低写高读”。数据库仲裁写入只需几十 TPS但“检查用户名是否可用”的读请求可能到每秒几万甚至几十万次。如果这些请求全部穿透到 PostgreSQL再强的机器也会被打爆。所以必须引入多级缓存。3.1 两级缓存本地缓存 Redis我一般会建议做两层缓存。第一层是服务本地缓存也就是进程内的 LRU 缓存比如 Go 里的 bigcache、Java 里的 Caffeine。这一层缓存命中延迟在微秒级几乎不消耗网络资源。第二层是分布式缓存比如 Redis用来做多实例之间的共享缓存。为什么不能只做 Redis 而不做本地缓存因为 Redis 也是有网络开销的单次 get 平均 0.1-0.5 毫秒看起来很优秀但面对每秒几十万次查询时需要一大堆 Redis 节点来抗而且网络带宽会成为瓶颈。本地缓存直接干掉这部分压力通常可以挡住 80% 以上的重复查询。本地缓存适合缓存什么主要是“用户名已被占用”的结论。一个用户名一旦被注册很长时间内不会释放所以我们完全可以把username:alice - occupied这个键值对在服务本地缓存 5 分钟甚至 1 小时。这样一来同一个实例上对同一用户名的重复查询甚至不需要出网。而对那些“用户名可用”的结论缓存时间要短很多因为随时可能被新用户注册。3.2 热点用户名与缓存击穿有一个高并发场景特别头疼大量脚本或用户同时去检查同一个热门用户名比如 jack 或 love。如果缓存中没有这个 key一瞬间的请求会全部穿透到数据库形成缓存击穿。我的做法是两层防护。第一层在服务内部做请求合并行业内通常叫 singleflight。也就是说同一时刻对同一个用户名的并发查询只让一个请求真正访问后端存储其他请求等待这个结果并共享。用 Go 的语言说就是singleflight.Group用 Java 可以借助 Caffeine 内置的get(key, loader)或者手动写一个ConcurrentHashMapFuture的合并器。这个方法几乎免费但效果极其显著。第二层给 Redis 里所有用户名 key 都设置一个合理的过期时间最好带上随机偏移避免大量 key 同时过期造成缓存雪崩。比如redis.set(fusername:{username}, occupied, ex3600 random.randint(0, 300))3.3 内存估算十亿用户名到底占多少内存这里我们做一个能直接落地的计算。假设我们要用 Redis 缓存全部 10 亿个已占用用户名每个 key 形如username:alicevalue 是占用标记。一个 key 的存储开销可以简单估算为 key 本身长度加上 Redis 的 dict 和对象头开销。如果你不信我实测过在 Redis 5.x 以上一个长度 15 字节左右的 key加上一个很小的 value整体内存开销大概在 80-120 字节。我们就按每个用户 100 字节算1,000,000,000 × 100 bytes 100 GB没错如果想把已占用用户名全部缓存到 Redis需要 100 GB 以上的内存。虽然技术上可行但成本相当高而且维护这么大的 Redis 集群本身也是一件苦差事。所以真正优雅的方案是不缓存全部用户名而是用布隆过滤器Bloom Filter做一个“一定不存在”的快速判断。布隆过滤器的特点是如果它说一个用户名“不存在”那它一定不存在如果它说“存在”那可能是误判需要去数据库精确确认。布隆过滤器的内存占用可以精确计算。对于 10 亿条数据如果我们把误判率控制在 1%每个元素需要大约 9.6 bits1,000,000,000 × 9.6 bits ≈ 1.2 GB如果把误判率控制到 0.1%每个元素需要约 14.4 bits1,000,000,000 × 14.4 bits ≈ 1.8 GB这简直是一个惊艳的性价比。1.8 GB 内存就能挡掉 99.9% 的“用户名不存在”查询剩下的 0.1% 误判再走 Redis 或数据库精确判断数据库的压力瞬间从每秒几十万次降到每秒几百次。所以完整的缓存链路是这样的先查本地缓存命中就直接返回。没有命中查布隆过滤器如果“不存在”直接返回用户名可用。如果布隆过滤器说“可能存在”再查 Redis 精确 key。Redis 也没有最后落到 PostgreSQL 仲裁表查询。查询结果回填本地缓存和 Redis。4. 实操复现自己动手搭一个高并发用户名注册系统说了这么多理论我们来做一个能跑的实验。这里我选用 Python FastAPI Redis PostgreSQL 来实现一个迷你但设计思路一致的用户名注册服务。你完全可以在自己电脑上复现不需要十亿数据量也能看清性能差异。4.1 建表与初始化先建 PostgreSQL 表CREATE TABLE IF NOT EXISTS username_registry ( username VARCHAR(64) PRIMARY KEY, user_id BIGINT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() );为了模拟布隆过滤器我们直接用 pyprobables 库或者更轻量的方式是用 Redis 自带的BF.RESERVE命令Redis 需要加载 RedisBloom 模块。我用 RedisBloom 来做示例# 初始化一个最多容纳 1 亿个元素、误判率 0.1% 的布隆过滤器 BF.RESERVE username_bf 0.001 100000000注意这里的参数顺序BF.RESERVE key error_rate capacity。0.001表示误判率 0.1%100000000表示预计元素数量 1 亿。如果你已经有 RedisBloom这段命令可以直接执行。4.2 注册接口核心逻辑下面这段代码是我在实际项目里精简出来的核心逻辑处理了“先查布隆 → 再查缓存 → 最后仲裁表”的完整链路from fastapi import FastAPI, HTTPException from redis import Redis from redis.commands.bf import BFBloomFilter import psycopg2 from psycopg2.extras import RealDictCursor import os app FastAPI() redis_client Redis.from_url(redis://localhost:6379) bf BFBloomFilter(redis_client, username_bf) conn psycopg2.connect(os.getenv(DATABASE_URL), cursor_factoryRealDictCursor) app.post(/register) def register(username: str, user_id: int): username username.lower().strip() # 1. 本地/Redis 缓存优先 cache_key fusername:{username} if redis_client.exists(cache_key): raise HTTPException(status_code409, detail用户名已被占用) # 2. 布隆过滤器如果一定不存在直接返回可用 if not bf.contains(username): # 这里其实还应该再写一次 Redis 缓存“可用”状态 # 但为了流程清晰省略掉“可用”缓存回填 return {message: 用户名可用可直接注册} # 3. 精确裁决查询 PostgreSQL with conn.cursor() as cur: cur.execute( INSERT INTO username_registry (username, user_id) VALUES (%s, %s) ON CONFLICT (username) DO NOTHING RETURNING user_id , (username, user_id), ) inserted cur.fetchone() # 注册成功后把用户名写入布隆过滤器 Redis 缓存 bf.add(username) redis_client.set(cache_key, occupied, ex3600) if not inserted: raise HTTPException(status_code409, detail用户名已被占用) conn.commit() return {message: 注册成功, user_id: user_id}细心的人会注意到这里我把“查询是否可用”和“注册插入”合并到了同一个请求里。实际上很多产品为了体验会拆成两个接口先调检查接口再调注册接口。但无论怎么拆最终唯一性裁决都必须在注册接口里靠数据库完成。检查接口仅仅是给人看的绝对不能作为唯一性保证。4.3 压测和瓶颈分析我用本机一台 4 核 8G 的虚拟机PostgreSQL 使用默认配置通过 wrk 压测注册接口。当请求全部走“布隆过滤器直接返回不存在”时单机 QPS 可以跑到一万以上瓶颈主要在 Nginx 和 Python 的 GIL 上。当请求穿透到 PostgreSQL 做真实 INSERT 时单机 QPS 骤降到一千出头并且 PostgreSQL 的 CPU 会迅速飙升。这个对比非常直观缓存层和布隆过滤器将 90% 以上的数据库写入压力消化在了上游PostgreSQL 只需要处理那部分真正新增注册的请求而“新增注册”本身就是低频操作。我还试过另一种压测方式完全去掉布隆过滤器让所有查询都走 Redis。结果 QPS 大概在三千左右内存占用也逐渐上涨。虽然 Redis 也能扛但如果你有 10 亿用户名内存成本和集群规模会让你后悔为什么没有早点用布隆过滤器。5. 常见问题与排查技巧实录最后这部分我把自己在工程里真正遇到过的问题和排查思路整理成了一份速查表。每一个问题都带血带泪希望你能绕开。5.1 并发场景下唯一性偶尔失效到底谁干的症状压测时偶尔出现同一个用户名被两个用户注册成功但数据库里却没有发现重复行。排查后通常会发现问题出在应用层比如注册请求被发送到了不同的本地缓存实例而一个实例缓存了“用户名可用”另一个实例缓存了“用户名不可用”偏偏业务代码又信任了这个缓存结果。我的结论是缓存只用来加速“肯定不可用”和“大概率不可用”的查询绝对不能用来下“可用”的最终结论。最终结论永远要让数据库的唯一索引来裁决。你可以把布隆过滤器和 Redis 当作“拆迁队”但数据库才是“法院”。另外事务隔离级别也值得检查如果用的是 Read Uncommitted理论上可能读到未提交插入的数据但 PostgreSQL 默认的 Read Committed 在唯一索引冲突处理上已经足够安全所以大部分情况下问题出在应用层缓存上。5.2 用户名释放后立刻被抢注一致性延迟的锅删除用户名是另一类常见场景。产品支持注销账号后再次注册但如果你在删除时直接物理删除username_registry行然后又异步去删除 Redis 缓存和布隆过滤器里的记录那么中间会有一个窗口期数据库里已经不存在这个用户名了但 Redis 缓存还没有清除布隆过滤器也还没有重建。在这个窗口里用户查询会得到“用户名已被占用”的结果实际上这个用户名已经可以注册了。解决方案有两种。第一种是把删除做成软删除加一个recyclable_at时间戳只有经过保留期之后才能被其他用户注册。第二种是删除时要主动失效缓存同时要在布隆过滤器的设计上留有余地。布隆过滤器由于不支持删除单个元素遇到这种场景通常的解法是定期重建或者使用带删除功能的 Cuckoo Filter。但 Cuckoo Filter 的工程复杂度高不少我看到的大多数团队还是选择“定期重建 冷热分离”的折中方案。5.3 中文用户名和字符规范化大小写、全半角、零宽字符中文用户名在“用户名已被占用”这个场景里有几个特殊的坑。最经典的是大小写规范化用户 A 注册了 Alice用户 B 注册 alice 该怎么办建议在业务层统一做lower()或者 PostgreSQL 的citext类型但要注意中文没有大小写可很多系统为了统一会硬把英文字符转化成小写这个没问题但千万别用NFC和NFD混淆。Unicode 有一个坑叫“规范化等价”同样是“é”它可以是单个字符 U00E9也可以是 e 组合重音符号 U0065 U0301。这两个写法在数据库里是完全不同的字节序列但在用户看起来是一样的。如果要兼容这类情况建议在写入前统一转成 NFC 形式。Python 里就是unicodedata.normalize(NFC, username)。还有一个非常隐蔽的坑零宽字符。有些恶意用户名会在正常文字里插入零宽空格或零宽连接符视觉上看不出来实际字节却不一样很容易绕过封禁或制造混淆。我的处理建议是在注册时直接过滤掉这类不可见字符。这里我不建议你自作聪明地做很复杂的清理简单的白名单策略往往最有效。5.4 防滥用与批量注册前面提到过热门用户名会引来大量爬虫和批量注册脚本。它们在功能上是正常的注册用户但在行为上会给系统造成巨大压力。架构上的应对手段主要有三件套限流、验证码、IP 信誉评分。限流要针对“用户名检查”和“注册”两个接口分别做。比如同一个 IP 每分钟最多调用用户名检查 60 次超过就返回 429。IP 信誉评分可以接入风控服务识别来自代理机房或历史恶意 IP 段的请求。验证码则是在检测到可疑行为时要求额外完成人机验证。这三样组合起来能挡掉绝大多数低成本的机器注册流量。从架构角度说这几个模块放在 API 网关层最合适不要散落到业务服务里否则每个新业务都要重复接入一遍。写到最后想说的话我在实际项目中复刻过这套设计最大的体会是所谓“十亿级架构”并不一定意味着你需要用到多新潮的组件而是要求你把每一个基础概念想得足够透。交易一致性、缓存穿透、数据仲裁、字符规范化任何一个“小问题”放到十亿量级都会被放大成“大事故”。如果你现在要设计一个用户系统我的建议是从第一天就先建好独立的用户名仲裁表和缓存链路别等到 500 万用户时才开始重构那会儿的迁移成本会让你想删库跑路。另外如果你用的语言是 Go建议把 singleflight 这种请求合并机制直接内建在服务里它能让你在最坏情况到来时不至于手忙脚乱。就这些祝各位上线不炸服。
返回列表