十亿级用户下用户名唯一性校验:分布式架构全解析 做后台开发这些年我越来越觉得越是看起来人畜无害的小功能越藏着整个系统最扎心的设计。你随手在注册页输入一个用户名点击确认啪一行红字“用户名已被占用”。用户骂一句走了。但这行红字背后是分布式系统里最经典、最刁钻的一道题在十亿级用户规模下如何快速、准确、并发安全地判断一个字符串是否被占用。我参与过一个用户量过十亿的海外社交产品注册流程里就有这个模块。当时我们花了整整三个迭代去打磨“用户名唯一性校验”从最开始数据库直接查到后来引入分片、缓存、Bloom Filter、预占用状态机中间踩了不知道多少坑。这篇文章我就把整个设计思路、关键代码、压测数据、以及那些文档里永远不会写的问题排查经验一次性讲清楚。如果你正好在做账号体系、注册登录、或者任何涉及全局唯一约束的后端系统这篇内容应该能帮你少走不少弯路。1. 业务画像与隐藏难点为什么“一个用户名”能逼疯架构师1.1 需求听起来很简单产品经理提的需求永远只有一句话用户注册时填写的用户名在全平台内不能重复。老用户改名时新用户名同样不能重复而且老用户名要被释放允许其他人使用。这句话翻译成技术指标其实就是两个点第一写入时保证全局唯一第二查询时快速返回“是否可用”。听起来不就是给字段加个唯一索引的事吗任何一个刚工作两年的开发都能写出这条SQLCREATE TABLE users ( id BIGINT PRIMARY KEY, username VARCHAR(32) NOT NULL UNIQUE, ... );单机数据库里这个唯一索引确实能解决一切。但问题是我们的用户量不是一万不是一百万而是十亿。日注册峰值可以冲到五十万平时每秒也有几百个新用户这还没算改名请求和各类脚本刷量。这个量级下你不可能把所有数据放在一台数据库上必然要分库分表、上分布式存储。而一旦数据分布到成百上千个节点上“全局唯一约束”就再也不是数据库自带的能力了它成了应用层必须亲自下场解决的分布式一致性问题。1.2 数据规模先算一笔账十亿用户不意味着十亿个用户名。因为用户名一旦释放可以被别人重新占用所以用户名记录要远比用户账号多。我当时粗略估算了一下平台累计注册用户数约12亿历史用户名占用记录含释放过的约15亿条上下活跃用户名当前被占用的约7亿注册接口峰值QPS约5000用户名查重/预占用请求峰值QPS约20000因为前端会在用户输入过程中实时查重单条记录按50字节算15亿条就是75GB以上的原始数据。这个体量任何单库都撑不住必须拆分。而且注意查重请求的QPS是注册接口的4倍因为用户边输入边查。存储层要承受的读压力远大于写压力如果每次都穿透到数据库数据库早就被打挂了。1.3 三个真正的技术难点难点一唯一性校验必须“读后写”原子化。如果先查一遍数据库发现名字空闲然后插入记录这中间存在时间窗口。两个请求同时查到“空闲”同时插入必然冲突。解决思路只有一条依赖存储层的唯一索引或等价机制来做最终裁决而不是靠应用层查重。难点二热点数据集中。虽然用户名理论上可以千奇百怪但总有一些“爆款”名字比如“jack”、“lucy”、“2024”之类会在一瞬间被大量用户同时争夺。这些就是典型的热key问题。如果架构里没有热点隔离设计某几个分片会被打穿其他分片闲着整个集群的吞吐被拖垮。难点三释放与重占的时序。用户改名后老用户名不能立刻开放注册得有一个保护期否则别人马上注册与你几乎相同的名字会产生大量仿冒账号问题。这就意味着“用户名被占用”不只是布尔值而是带状态的占用中、保护期、可释放。状态流转一旦涉及延迟和幂等复杂度立刻上一个台阶。2. 整体架构选型从单索引到“三层防线”2.1 存储分片按用户名哈希还是按用户ID哈希先说结论用户名记录表必须以用户名为分片键。原因非常简单我们所有核心操作都是通过用户名来寻址的比如“用户名是否被占用”“插入一条用户名记录”“释放一条用户名记录”。如果把分片键设成用户ID那么一次查重请求就得广播到所有分片这才是真正的灾难。实际方案是把这个表拆成1024个分片分片规则用用户名做CRC32后再取模。为了方便后续扩容我没有直接用user_name % 1024而是引入了一层虚拟分片def pick_shard(user_name: str) - int: # 虚拟分片先算物理分片再映射到真实节点 hash_val zlib.crc32(user_name.encode(utf-8)) % 1024 return hash_val为什么要加虚拟分片因为如果直接按机器数量取模以后扩机器整个映射关系全乱数据大迁移非常痛苦。引入固定1024个虚拟分片后扩容时只需要把某些虚拟分片从旧机器迁到新机器路由表局部更新即可。2.2 唯一约束存储层必须承担最终裁决不管前面加了几层缓存最后一道防线一定是存储层的唯一索引。我们的分片数据库使用MySQL每张分片表结构如下CREATE TABLE user_name_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_name VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0:预占用 1:激活占用 2:保护期 3:已释放, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, expire_at DATETIME NOT NULL COMMENT 预占用或保护期的过期时间, UNIQUE KEY uk_user_name (user_name) ) ENGINEInnoDB;这里最关键的是UNIQUE KEY uk_user_name。所有并发请求最终都会落到这一条唯一索引上只有一条能插入成功其他全部报DuplicateKey错误。这就保证了即使在缓存全部失效、分布式锁全部失效的最坏情况下数据库依然能挡住重复数据。我见过不少团队把唯一性寄托在Redis的SETNX上结果Redis一抖动线上全是重复用户名这属于设计事故。2.3 缓存层布隆过滤器挡掉99%的穿透唯一索引虽然可靠但扛不住每秒几万次的数据库写入。以我们为例大多数查重请求最终会“占用已有名字”也就是说这些请求杀到数据库唯一索引得到的是一个错误码。如果所有查重都打到存储层分片数据库压力直接爆炸。所以必须在存储层前面加一道高密度、低成本的预判机制。这里就要请出布隆过滤器了。布隆过滤器的特点是判断一个元素“一定不存在”是准确的判断“可能存在”则可能误判。这正好契合我们的场景——大部分用户名查重在布隆过滤器这一层就会被判定为“肯定存在”直接返回不可用根本不需要触碰数据库。只有布隆过滤器说“可能存在”实际上可能刚好没占用才会继续走后面的缓存和数据库。我们的布隆过滤器配置预估元素数量10亿误判率0.1%计算得到位数组大小约18亿bit约2.2GB需要哈希函数个数7个2.2GB内存完全放得下而且为了降低单点风险按虚拟分片拆成1024个小布隆过滤器每个分片负责自己那部分的用户名集合。不过要注意布隆过滤器不支持删除。用户释放用户名后布隆过滤器里这个位置不会被清掉这就导致已释放的名字可能被误判为“可能存在”。没关系处理方案是让它继续走缓存和数据库做精确判定最多就是多一次查询不会出错。牺牲一点点误判率换来的是极高的空间效率和查询性能。2.4 预占用机制把唯一性校验和注册事务解耦注册流程不是只写一条用户名记录就完事的还要创建用户资料、初始化关系链、写入推荐索引等等。如果等整个注册事务提交后才占用用户名那么在事务期间这个名字实际上没有任何保护会被别人抢先注册。我们设计的方案是引入“预占用”。注册请求一到立刻插入一条status0的用户名记录这个状态表示该用户名已被当前请求预留。如果后续的用户创建流程失败则释放这条预占用如果成功则更新状态为激活占用。预占用记录不是永久的有一个过期时间一般设置为15分钟。如果用户填写信息到一半关掉了浏览器15分钟后系统后台任务会自动把这些孤儿预占用记录清理掉名字重新释放出来。这样设计后用户名唯一性的校验就不需要和用户创建事务绑定在一起了数据库唯一索引承担了“防重”职责服务端流程编排承担了“保活”职责两个关注点彻底分离。3. 核心流程实操注册、改名、并发冲突破解3.1 注册查重的完整流程我把一次用户名校验的完整调用链画在脑子里大致是四级前端实时查重请求到API网关带上user_name参数应用层先查本地Caffeine缓存再查分布式Redis缓存如果缓存都没有查询对应的布隆过滤器分片判断是否“肯定不存在”如果布隆过滤器认为可能存在再路由到具体MySQL分片发起精确查询四个层级依次挡住请求只有最后一层才允许访问数据库。我统计过线上数据缓存加布隆过滤器可以拦截掉约90%的查重流量真正打到数据库的每分钟只有几千次数据库负载完全在可控范围内。核心的预占用接口伪代码如下def reserve_username(user_name: str, user_id: int) - bool: shard pick_shard(user_name) try: with db_client(shard).transaction(): now datetime.utcnow() expire_at now timedelta(minutes15) db_client(shard).execute( INSERT INTO user_name_record (user_name, user_id, status, expire_at) VALUES (:user_name, :user_id, 0, :expire_at) , {user_name: user_name, user_id: user_id, expire_at: expire_at} ) return True except IntegrityError as e: if DuplicateKey in str(e): return False raise这里一定要记住不能先SELECT再INSERT必须直接INSERT依赖数据库唯一索引去重。先查再插的模式在并发场景下必然产生竞态条件。我们曾经把“预占用”改成先查后插压测的时候到了每秒2000并发立刻出现3%的重复占用后来老老实实改回了直接插。3.2 老用户改名与释放策略改名逻辑比注册复杂得多因为它涉及两条记录新用户名要被占用老用户名要进入释放流程。这两步不能简单地在一个数据库事务里完成因为新老用户名可能路由到不同的分片。跨分片事务太重通常不推荐。我们的处理方案是“两阶段提交”的简化版第一阶段把老用户名标记为status2保护期并设置过期时间比如90天。第二阶段为新用户名执行预占用操作插入状态为1的激活记录。如果新用户名预占用失败说明新名字被别人抢走了事务回滚老用户名恢复为激活占用状态。新用户名预占用成功后才真正更新用户资料里的主用户名。看起来有点繁琐但好处是每一步都是单分片操作没有跨库事务任何一步失败都能单独补偿。这里有个容易踩的坑老用户名进入保护期后是“不可用”状态任何查重接口都不能返回空闲。只有到了90天保护期结束后台任务才会把它改成已释放名字才能被重新注册。为什么必须留保护期我见过有人为了省事用户名释放后立刻可注册结果大V改个名马上被人注册了山寨账号去骗粉丝。这已经不是技术问题了这是安全事故。3.3 并发冲突与自动重试即使有了唯一索引高并发下依然会遇到大量插入冲突。尤其是热门用户名同时在抢的人非常多。这些请求如果不做任何处理直接失败体验非常差。我们的策略是让预占用失败方自动退避重试。比如A用户正在预占用“jack”的过程中B用户也来抢。B的插入失败后不会立刻返回“已被占用”而是等待几十毫秒后再试一次因为A的整个注册流程可能马上因为创建资料失败而释放预占用。这个重试机制有效降低了热门名字的抢注失败率。但重试必须设置上限我们最多重试3次每次退避时间是10ms、50ms、100ms。超过上限就返回“已被占用”引导用户换个名字。另外重试本身要带幂等键否则B用户在网络超时后重试可能上一次请求其实已经插入成功了再次插入会误判为失败。我们用request_id作为用户维度的幂等标识同一请求的多次重试插入时先检查是否为同一request_id保证不二次扣减用户的可注册次数。4. 一致性与异常设计缓存、锁、幂等怎么配合4.1 为什么Redis里的状态不能当最终依据很多团队会把用户名存在Redis里用SETNX来判重再用DEL来释放。这个方案在小规模没问题但到了十亿级有几个致命隐患Redis不是强一致的多副本主从切换时可能丢数据Redis数据无法全量保存必然要定期淘汰一旦key被淘汰查重就会放行。所以我们从来没把Redis当作“最终裁决者”Redis只是“加速层”。具体做法是查询时先走Redis若命中则返回若未命中则查数据库并将结果回填Redis。写入时预占用成功后同步更新Redis同时让布隆过滤器保持原样。Redis里的key设置过期时间为24小时避免冷门用户名长期占据内存。4.2 分布式锁的粒度只需要锁“同一分片内同名”用户名唯一性既然最终由数据库唯一索引保证理论上可以不要锁。但为了减少数据库无谓的写冲突我们仍然加入了分布式锁只是粒度控制得很小心锁的粒度是用户名本身而不是用户或全局锁。Redis的Redisson实现的RLock锁的key就是lock_user_name锁的持有时间默认2秒。在预占用前先尝试获取锁获得锁后才发起数据库插入。由于锁只针对同一用户名不同用户名的注册完全并行不会形成全局瓶颈。不过要特别注意分布式锁不能成为正确性依赖只能作为性能优化。因为锁可能因为网络分区失效如果某个进程获取锁后GC暂停超过锁超时时间另一个进程也会拿到锁。所以我们始终把唯一索引当作兜底锁只是降低冲突概率保证常规流量下数据库写压力平稳。4.3 幂等设计与请求重放防护注册防重还有一个容易忽略的问题用户在客户端点了一次注册按钮但请求超时了前端自动重试可能会提交两次同样的注册请求。如果后端不做幂等同一个用户会出现两条不同用户名的预占用记录名字全被占住。解决方案是引入注册请求幂等表CREATE TABLE register_request ( request_id VARCHAR(64) PRIMARY KEY, user_name VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, status TINYINT NOT NULL, created_at DATETIME NOT NULL )同一request_id的重复请求后端只处理第一次后续请求直接返回第一次的处理结果。这张幂等表的数据量不大按天分表保留30天即可。加了幂等表之后注册接口的“重复扣减用户名”问题就彻底没了。5. 常见问题与排查实录那些压测和线上才会暴露的坑5.1 缓存与数据库不一致导致“明明没有却提示占用”这是我们上线初期遇到过最多的问题。场景是这样的用户注册时预占用成功但后续用户资料创建失败释放流程把数据库记录删了却忘了删除Redis里的key。之后其他用户来查这个名字Redis返回“占用”数据库明明是空闲的。排查思路很简单对比Redis命中结果和数据库精确结果。我们写了个离线对账脚本每天随机抽一定比例的热门用户名比对Redis与数据库状态发现不一致就触发缓存删除。但更根本的解法是释放操作只更新数据库等到缓存过期后自然回源不支持主动删除。这个方案牺牲了一点时效性但彻底避免了对账不及时的问题。后来又改为释放时先删Redis再更新数据库用最后一致性兜底线上不一致率基本消失。5.2 热点用户名秒杀某个分片某天我们突然收到监控报警某个MySQL分片的CPU持续90%以上其他分片却很闲。原因是当天有某个热点事件大量用户同时尝试注册 “支持者” 相关风格的热门昵称。因为分片规则是用户名CRC32取模所有类似拼写的用户名大概率落在少数几个分片上导致热点分片被打爆。常规限流只能保护数据库不挂但会让正常请求也失败。我们的解法分两层。第一层在应用层对热点用户名做本地限流每100ms最多允许一定数量的预占用请求打到同一个分片。第二层对“已占用”且是超热门的名词直接复制一份“只读拒绝名单”到本地内存查重时无需网络请求即可拒绝。比如某明星的名字一旦被占用把它加入本地拒绝名单任何对该名字的查重请求直接返回“占用”流量根本到不了存储层。5.3 扩容与Rehash虚拟分片的威力用户量继续增长后原来单分片能承载的容量不够了必须扩容。如果分片规则是物理机取模扩容等于重写所有数据。但因为我们用了1024个虚拟分片迁移就变成了路由表的局部更新把一些虚拟分片从旧集群搬到新集群搬完只修改配置中心的映射关系。具体操作时要注意迁移过程必须遵循“先复制再切换后删除”的原则。先把虚拟分片的数据全量复制到目标节点然后持续同步增量等到两边数据一致后把路由表中该虚拟分片的映射切换到新节点最后删除旧节点的数据。这套流程我们跑了多次单次迁移的最大停机时间控制在秒级对用户完全无感。有个细节特别提醒扩容前先梳理一次布隆过滤器的分片范围因为虚拟分片迁移后布隆过滤器也必须跟着迁移否则新节点上查不到对应的过滤器流量会全部穿透到数据库。我们第一扩容就漏了这个迁移完成后数据库负载瞬间翻了三倍吓得我冷汗直流。5.4 孤儿预占用与保护期释放的调度预占用记录15分钟会过期保护期记录90天会过期这些都需要后台定时任务清理。最开始的实现是每分钟全表扫描一次到后期数据量上来一次全表扫描要跑20分钟严重拖垮数据库。后来改成分段扫描每次只扫描一个虚拟分片上的过期记录用expire_at now作为过滤条件加上LIMIT 2000完成一轮再取下一轮。更高效的做法是为过期时间建二级索引扫描只读索引覆盖的字段避免全表扫描。实测之后单分片的清理任务从20分钟降到了1分钟以内。还有一个容易忽略的问题清理任务和预占用请求并发时可能“刚清理了预占记录新用户立刻占了这个名字”而旧用户正好在提交注册资料。这时旧用户再次确认注册就会失败。策略是在用户提交注册资料的接口里判断预占用记录如果已经不存在就自动重新发起一次预占用仍然成功则继续后续流程失败则返回“名字被抢”。这个自动补偿逻辑也算是给用户多了一重保障。写在最后关于唯一性校验的几条经验总结这套架构从设计到上线再到后来支撑十亿级用户量我个人最强烈的感受是在分布式环境下永远不要把“唯一性”寄托在应用层的业务检查上。幂等、缓存、布隆过滤器、分布式锁这些都是优化手段唯一能让系统在极端并发下不出错的只有存储层那把静态的唯一索引。另一个经验是关于“快”和“准”的平衡。布隆过滤器说“不存在”可以100%信说“可能存在”则必须继续查库缓存说“没有被占用”也要小心因为缓存过期后可能已经有人占用了。我们最终的信条是一切缓存和加速层都可以出错但数据库的结果必须是准的所有读路径上的不确定态最终都要收口到数据库做确认。如果你现在也在设计类似的全局唯一关系系统我的建议很简单先想清楚存储层怎么兜底再去谈缓存优化和性能指标。先写一个基于唯一索引的最小可用版本再逐层叠加布隆过滤器、缓存、锁。不要一上来就上一个花里胡哨的分布式锁方案底子没打牢多花的功能全都会变成日后的维护负担。用户名占用只是业务里一个极小的功能点但把它做扎实了你就能触类旁通地理解分布式系统里“唯一性”这个永恒命题。希望这篇记录能给你带来一些实操层面的启发。