
1. 一个“已被占用”背后到底压着什么需求1.1 看似一行if判断其实是三类系统约束的叠加“用户名已被占用”这六个字是Instagram上被触发频率最高的UI文案之一。新用户注册时要查老用户改名时要查第三方的批量注册工具在跑字典攻击时更是每秒产生成千上万次探测。这个提示在客户端看起来就是一个简简单单的页面反馈但在服务端它牵动的是全局唯一性、实时一致性、高并发抗量三件事的交叉。先说全局唯一性。Instagram的用户名不是普通的昵称它的语义更像是网站主键——instagram.com/[username]直接形成为个人主页的URL消息里人靠它路由搜索框里输名字靠它检索。所有用户加在一起接近二十亿任何两个人都不能拥有同一个名字这个约束一旦被突破主页会串、消息会错、搜索会乱。唯一性这张网从注册的第一秒开始就要兜住全量历史数据。但难点在于这个“全量”不是停在一个静态大表里而是散落在一大堆数据库分片中的。单机数据库里加一个UNIQUE索引就能解决的问题一旦数据被拆到几十个物理节点上局部唯一不等于全局唯一分片A里没人叫“alex”不代表分片B里没有。你必须有一套跨分片的机制让每一次校验都能看到全局的答案。再说实时一致性。用户注册的时候前脚查了一次“这个名字能用”后脚就要确保真的能用中间不能有毫秒级的窗口被另一个人抢走。如果两个请求同时落进来都通过了校验都尝试写入最终只能有一个赢。这个“抢”的过程听起来简单但在分布式环境里它要求校验服务和写入链路不能跨过一道会漏风的门。最后是高并发。普通用户注册也就注册一次但“用户名探测”是个极端场景。短用户名、热门单词、情侣名、公司名这些字符串会被无数人反复试。攻击者会用几百万条字典词表循环探测每次探测都是一次读请求每次都能命中同一个热点。你的缓存一旦没有扛住这个压力数据库就会被拖垮。所以这个功能的架构本质上是在处理一种“看起来平凡实际上很极限”的读多写少流量。1.2 为什么“唯一”这件事在分布式系统里最棘手我做过多年的后端系统见过太多项目在单库阶段把用户名唯一的实现写成一条SQL。你可能也写过这样的代码INSERT INTO users (username, password) VALUES (alex, ...); -- 如果违反唯一约束捕获 duplicate key 错误即可这条SQL在单库环境下没任何问题数据库的行锁和唯一索引天然保证了原子性。但Instagram的问题在于它几千亿行的数据永远不可能塞进一个单体数据库。PostgreSQL单机单表撑死也就几亿行的舒适区而Instagram的体量决定了必须分片。于是你遇到了一个经典矛盾分片是为了容量和吞吐但唯一性偏偏是全局性的没法跟着分片走。举个例子。假设用户表按user_id的哈希值分成64个分片注册新用户时user_id是由发号器生成的注册流程先拿到一个新ID再根据ID决定写入哪个分片。这个时候校验“用户名是否被占用”的逻辑就变成了在64个分片里同时查询这个用户名是否存在。这显然不现实——每次注册都会变成一次跨全库的广播查询延迟高不说分片一多数据库根本扛不住。还有一个更麻烦的维度热点行。单库方案里一条“用户名唯一索引”背后其实是一个B树条目。高并发下大家都去抢同一条记录比如“love”这个用户名数据库的行锁会让所有写请求串行排队性能雪崩。分片之后这种热点请求依然存在只不过热点会集中在某一个分片上拖着整个分片的其他业务一起遭殃。所以Instagram最终的选择并不是放弃PostgreSQL而是在PostgreSQL之上再加一层全局的“姓名坐标系统”让校验请求不要再暴力扫全库。这个坐标系统要满足三个特质响应要快到可以撑住每秒几十万次查询数据要完整到覆盖全部历史用户名容忍度上要能接受“极短时间的最终一致”。这个架构拆开看就是Redis PostgreSQL的经典组合。但如果你以为它只是把用户名全塞进Redis缓存那就太小看这套设计了。2. Instagram选型PostgreSQL分片 Redis热路径2.1 为什么核心数据仍然放在PostgreSQL而不是NoSQL很多人一听到Instagram这种体量第一反应是“肯定上全分布式NoSQL了”。实际上Instagram对用户元数据、关系链、Feed这种核心业务长期依赖的还是PostgreSQL。我到今天都记得 Instagram 工程师在公开分享里提过的一个观点他们选型是先看业务约束再看技术趋势而不是反过来。拿用户名这件事来说你需要的既不是文档型数据库的灵活性也不是纯KV存储的极端读性能而是一个对事务和约束支持完善的关系存储。用户在注册时不仅仅写入一个用户名还要同时创建用户资料、初始化会话、生成默认设置这些操作必须在一个事务里完成要么全成功要么全回滚。PostgreSQL的事务完整性、外键约束、成熟的主从复制方案在这个场景下是实打实的优势。另外PostgreSQL的生态和运维工具链非常成熟。Instagram的工程团队可以维护几千个PostgreSQL实例但如果是某个年轻的NoSQL数据库可能连可靠的工具链都凑不齐。分片、备份、监控、故障恢复每一个环节都需要踩过无数坑的沉淀。技术选型有时候不取决于“谁更先进”而是取决于“谁的问题你更了解”。这里有一个我自己的体会很多团队过度崇拜NoSQL的扩展性把核心数据仓促迁移到文档型数据库结果复杂查询和事务一致性成了新的瓶颈反而不如老老实实分库分表。Instagram用PostgreSQL做底座的思路在今天依然有参考价值——分布式存储是手段事务与正确性才是底线。2.2 分片解决容量问题却制造了全局唯一性的难题Instagram的PostgreSQL分片方案用的并不是业内常见的那种按ID范围切分而是按user_id的哈希值划分逻辑分片再映射到物理分片。逻辑分片的好处是未来物理机器加几台只需要调整映射关系不需要重洗全部数据。每个物理分片上有若干个逻辑分片user_id分配后通过哈希函数快速定位。但用户表分片了用户名索引却无法跟着分。你可以在每个分片的users表上建username的UNIQUE索引但那个索引只能在当前分片内保证唯一性。两个不同的user_id哈希后落到了不同的分片一个叫“john”一个叫“john”各自分片内都通过了唯一性检查但全局已经重复了。所以Instagram必须有一个“全局登记处”任何用户名在被最终写入PostgreSQL之前先到登记处占个位置。这个登记处只回答一个问题这个字符串现在合法吗被谁占着它不需要存用户的全部资料只需要维护“用户名 - user_id”的映射关系以及适当的TTL策略。这个登记处选了Redis集群。原因很直白校验请求是极高QPS的读操作而Redis的读性能、扩展性、TTL机制都是现成的。每天有几亿次校验如果全量打到PostgreSQL任何分片都会被拖死。用Redis做热路径把99%的查询拦截在数据库之前PostgreSQL只承担真实的注册写入和极低比例的冷路径查询。2.3 Redis在这里不只是缓存更像“全局唯一性坐标”我见过不少团队把Redis缓存和Redis存储混淆Instagram这套设计里Redis的角色更微妙。它不只是给PostgreSQL挡读流量它是用户名这个业务实体在分布式环境下的“权威坐标”。为什么敢这么说因为用户名的“存活状态”本身就是一个高时效性的数据。新用户注册名字立刻要可见用户改名旧名字要进入冷却期用户删除账号名字要经历一段冻结时间才能释放。这些状态变化的频率远高于用户档案的修改频率非常适合放在Redis这种高速存储里。Redis里存的value结构也不只是一个user_id数字。通常你会看到类似这样的设计key是username:{str}value是拥有者的user_id加上状态标记。为什么需要状态标记因为逻辑删除和冷却期是两套不同的要求。一个用户注销后他的用户名会进入一段大约几周甚至几个月的冻结期防止名字刚释放就被恶意抢注商贩盯上。冻结状态下Redis里的key依然存在但value的status字段是frozen而不是active。这个状态标记直接影响校验服务的判断逻辑。而且这个坐标系统还能配合Redis自身的淘汰策略。绝大多数用户名被校验过一次之后很长时间都不会再被查询这时候你可以允许它被淘汰出内存因为消息队列里还有一份全量快照PostgreSQL里也有持久化的最终数据。Redis是“热坐标”PostgreSQL才是“冷真相”。Redis丢了缓存从PostgreSQL重建即可PostgreSQL丢了数据那可就是事故了。这个分层思想我建议所有做高并发校验类功能的团队都抄作业未来的热点可能有千万个key但真正持续热的最多也就几万个你要做的是保证热key进内存冷key按需加载。3. 用户名校验服务的实操链路3.1 注册与校验的两条路径快路径与全量兜底用户名校验服务如果从API入口拆会分成两条路径。第一是注册前的预校验路径第二是用户名变更时的写路径。这两条路径的流量特征不一样不能共用同一套策略。先看预校验路径。用户填完用户名点提交请求到达服务端后先拼Redis key执行一次GET username:{str}。如果返回存在直接返回“已被占用”。如果不存在还不能急着放行——因为Redis可能有淘汰或过期需要再往PostgreSQL做一次兜底查询。这个兜底查询没法全库查Instagram的实现方式是把“用户名哈希后定位到某个分片”作为路由策略将用户名做一个独立哈希映射到某个特定的PostgreSQL分片集合专门查询那个分片下的username_global表。这张表不是用户的业务分片表而是一张精简的、专门记录用户名分配状态的薄表几千万行分摊到几十个分片上检索成本可控。你可能会问如果Redis和PostgreSQL都没有但两个请求同时并发进来怎么办这里就要靠一个原子性的抢占动作。Instagram的做法是使用Redis的SETNX命令把“正在注册中的用户名”也作为key原子占位。两个并发请求都通过了预校验但SETNX只有一个能成功。失败的请求要么重试要么返回“被人抢先一步”这就做到了几乎不会出现重复用户名。写路径的逻辑也类似。用户修改用户名服务端先把新名字的SETNX抢下来然后写PostgreSQL写成功后把Redis里的key状态更新为active同时把旧用户名的key改成frozen并设置TTL。这里有个细节写PostgreSQL和更新Redis不是同步事务所以Instagram在两者之间加了一个消息队列把用户名变更事件串行化。3.2 防狙击热门用户名的并发写入怎么扛热门用户名有一个很特殊的现象就是“狙击”。我做过一个社区产品注册开放当天“apple”、“google”、“facebook”这些词在几秒钟内就被抢光了。这不是巧合是有监控脚本在跑。针对Instagram这个体量热门用户名面临的并发写入是每秒几千次甚至上万次而且全都落在同一个字符串上。如果你只是在服务端简单地走一遍“查Redis — 写PostgreSQL”流程热点会非常明显。所有请求都打同一个Redis keyRedis单实例单线程特性会让GET本身没问题但杀人的是后续的写请求和锁等待。不同请求之间还会有竞态两个请求都查询了不存在两个都尝试SETNX虽然原子性保证了只有一个赢但输掉的一方如果立刻重试又会引发连锁效应。Instagram解决这个问题的思路是把对同一个用户名的操作串行化。具体实现是在服务端对用户名做一致性哈希把所有对同一个用户名的操作路由到同一个队列分区或同一个处理线程。这样同一时刻只有一个线程在处理“love”这个用户名的注册其他请求在这个线程外排队等待。排队时间可能只有几百毫秒但数据库的压力被彻底削平了。这个思路类似分布式锁但粒度更细不会锁住整个用户表。防狙击还有一个加分项服务端对短用户名、全数字用户名、字典词表命中率高的字符串做了更严格的策略。比如长度小于4的用户名必须额外通过一次人机验证连字符和数字组合的高相似度名字会延迟放行。这些策略不是架构的核心但在业务体验上能挡住一大半恶意探测。3.3 用户名回收与防滥用策略用户名回收是被很多团队忽略的角落。Instagram的用户名不只是注册时被占用户随时随地可能改名字、注销账号。如果旧名字被立即释放会催生一批“改名商人”他们把热门用户名抢注后挂高价出售或者批量改名的机器人会卡在你释放的下一秒把名字抢走。合理的策略是用户主动改名的旧名字至少在24小时之内不能被再次注册长一点的窗口是7天被官方判定违规封禁的用户名则进入更长的冻结期甚至永久不可释放。这条策略的实现也很简单就是在username:{str}这个key里保留一条状态为recently_released的记录附带TTL。校验服务看到这个状态直接向用户返回“不可用”不管对应的user_id是否还存在。这里还有一个小坑冻结期的key如果被Redis淘汰了怎么办那就会导致一个“实际上不可用”的旧名字被重新查询为空闲状态。所以Instagram在将用户名释放为可注册状态之前会做一次PostgreSQL的二次校验确保冷路径上也不会绕过冻结逻辑。我的经验是涉及用户名的所有校验逻辑最终都必须能“在Redis全空的情况下”依然正确否则就别上线。4. 缓存一致性、降级处理与故障切换4.1 Redis与PostgreSQL最终一致性怎么保证Redis和PostgreSQL之间没有分布式事务这是所有这套架构的团队都要面对的事实。Instagram解决这个问题的核心是消息队列加本地事件表。每次用户名状态变更服务端在写PostgreSQL的业务事务里同时向本地消息表插入一条事件记录。事务提交后一个独立的异步进程会扫这张表把事件投递到消息队列然后由消费端去更新Redis里的用户名状态。这样设计的好处是如果Redis更新失败消息会不断重试不会出现“数据库已经改完了Redis还留着旧值”的长期不一致。如果Redis真的完全不可用消息队列里会积压事件等到Redis恢复后自动补写最终收敛。那查询路径上如果Redis刚写入PostgreSQL还没提交怎么办Instagram的做法是校验服务不会同时依赖两边的“有”和“无”。Redis里显示被占用的名字通常会延迟一段时间才反映到PostgreSQL。这个时间窗口里如果有人用这个名字去注册注册请求会先写入PostgreSQL但PostgreSQL的username_global表上还有唯一约束写入会失败于是注册也会失败。也就是说Redis的插入优先级高于释放优先级这是故意设计的——宁可短暂地“误杀”一个可用名字也不能放行一个已经被占用的名字。我这种做业务后端的看法是用户名相关的缓存一致性很难做到严格的强一致但只要你把失败方向收敛到“误杀”而不是“误放”用户体验就不会有感知。用户看到“已被占用”顶多换个名字“误放”的账号串号和错人就是大事故了。4.2 多级缓存与热Key防护的实测坑高并发校验还有一个让所有架构师头疼的问题热key。无论Redis集群做得多大热点永远会集中在少数几个短用户名上。比如“sunny”这种单词可能一天内被探测几十万次Redis单分片根本扛不住这么大的读QPS。我看到的解法是分两层。第一层是进程内缓存在应用服务器本地维护一个“已知被占用的热门用户名”列表来自Redis的统计或离线预计算。如果请求的key命中这个黑名单本地直接拦截根本不需要打到Redis。第二层是针对冷热分离的把用户名按长度、频率分成热区、冷区热区用户名的缓存TTL设得很短但永远不淘汰冷区则允许LRU淘汰过期后回源数据库。还有个更细的实操点Redis单线程处理高QPS时如果每个查询都附带更大的value比如存了很长的JSON profile性能会明显下降。Instagram选择在Redis里只存user_id status不存任何扩展属性。如果你需要展示用户名对应的头像、昵称之类信息单独再查用户服务不要塞进这个关键时刻的缓存项里。我自己实测过一个value从40字节涨到2KBRedis读QPS能下降近一半。4.3 分片扩容、迁移过程里怎么保唯一性最后聊一个很少有人认真讲的场景扩容。你的用户量涨了原来32个分片的PostgreSQL不够了要扩到64个。此时用户名映射关系总不能跟着重走吧如果重走那就意味着所有既有用户名全部失效不重走新分片和旧分片的username_global表怎么统一Instagram的运维思路是让“用户名到分片的映射”完全独立于“数据分片”。也就是说用户名通过另一个一致性哈希函数固定映射到一个“用户名索引分片”这个映射关系一旦确定不再随用户数据分片的扩容而改变。数据分片扩容时你只需要把一部分用户数据的副本迁到新分片但用户名的索引归属不变Redis全局坐标也不变。这套机制保证了唯一性验证路径在扩容时是稳定的。唯一要做的额外工作是在扩容期间把Redis的更新操作加上双写一边写原索引分片对应的缓存一边写新分片对应的缓存避免迁移期间的窗口期出现不一致。这件事听起来简单实际做的时候经常因为迁移工具和业务更新并发执行而出乱子所以我建议任何团队都要给“用户名状态迁移”专门留出演练时间不要指望线上一次成功。5. 常见问题排查实录5.1 Redis缓存穿透与缓存击穿现象某个原本不存在的用户名被字典攻击脚本反复查询Redis和PostgreSQL都没有每次都穿透到数据库。你说攻击者会挑什么词恰恰是那种“看起来像有效但还没人注册”的词。排查思路先看Redis的GET hit ratio如果某个key的miss率异常高就需要在服务端加布隆过滤器。布隆过滤器的作用是快速判断一个用户名“一定不存在”如果判断不存在则直接返回不再查库。Instagram体量下一个占用内存几十MB的布隆过滤器就能容纳上亿个用户名。别把布隆过滤器当成什么高深技术它的本质就是用少量hash函数加一个位图换一次数据库查询值。还有一个容易踩的坑布隆过滤器对“删除”不好处理。用户名状态有active、frozen、released释放后再注册的新用户名在布隆过滤器里依然是“不存在”吗所以要定期重建过滤器或者把“释放后的用户名”单独放进一个短TTL的Redis集合里让布隆过滤器和真实状态对账。5.2 数据库与缓存不一致的案例名字被“抢劫”有一次我们把一个用户的名字改成新名字后旧名字明明进入了冻结期但用户的个人主页几分钟后又能访问旧链接。查了很久发现是消息队列消费延迟导致Redis中的旧值状态没及时更新成frozen。这类问题的根子在于读多写少的业务里大家默认Redis的写入一定快但你得给消息队列留够余量。实际排查时我建议直接监控每个用户名变更事件从提交到Redis更新的平均耗时。如果超过200毫秒就要检查消费端是不是单线程卡住了。另外消费逻辑必须幂等同一个事件重复投递不能把状态从frozen错误地改回active。5.3 校验服务抖动导致的注册中断再分享一个比较隐蔽的坑。某个大促活动期间注册用户量暴增校验服务的Redis客户端连接池被打满注册接口大面积超时。你说Redis本身抗住了吗扛住了但客户端连接池和GC把请求憋死了。这里给所有做高并发项目的同学一个建议任何外部中间件客户端线程模型一定要压测。Instagram这种体量下连接池的大小、超时时间、熔断阈值都必须单独配置。不要依赖默认参数。我见过太多项目在测试环境一点问题没有上线一打流量就立刻连接池耗尽。解决方案也直接校验服务做成无状态水平扩展每个实例的连接池上限设小一点但实例数拉大再加上快速失败的熔断开关。一旦某个Redis节点超过熔断阈值直接切换到PostgreSQL冷路径宁可慢一点也不要把请求全部卡死在等待上。慢请求比失败请求更伤系统这是后端铁律。6. 这套设计对其他团队的可迁移性6.1 什么规模才需要照搬这套架构说了这么多你可能会觉得这套架构太复杂我们项目根本用不上。我的判断标准很简单如果你数据库里的用户量还在千万级以下单库PostgreSQL加上一个唯一索引就足够了不需要Redis热路径不需要消息队列不需要分片。别给自己加戏。什么时候要考虑Instagram这套思路当你的用户名查询QPS已经让数据库的读CPU持续超过50%或者你的用户数据已经分片但你又想在分片上支持全局唯一约束时这套架构才值得落地。我见过一个百万级用户的产品花了一个月时间搞Redis缓存用户名校验结果数据库压力本来就不大倒是引入了缓存和数据库两套系统的一致性问题。没有热点的功能不需要热路径的缓存。6.2 简化落地方案从单库到两段式校验如果你已经走到了需要缓存热路径这一步我给一个简化版的落地路线图。第一步保持PostgreSQL为主存储在users表上保留username唯一索引。第二步为所有用户名建一个Redis集合legit_usernames在注册成功时向这个集合写入用户名。第三步校验服务先查Redis命中即拒绝未命中则查PostgreSQL唯一索引仍然未命中才放行写入。写入成功后异步回填Redis。这个方案比Instagram的完整版简单很多但能支撑住日均百万级的注册和改名量。缺点只有一个如果Redis和PostgreSQL双写之间出了故障用户名可能短暂重复注册失败或放行但这个概率极低业务上可接受。等你的规模到了“正常注册流程都无法走完”的级别再去啃分片全局坐标系统的硬骨头。6.3 个人经验先做对账再谈优化我在实际运维中总结过一条纪律任何用户名状态变更都要有对账脚本。每天凌晨扫描一次PostgreSQL的username_global表与Redis的key集合找出两边不一致的记录自动修复或告警。这个对账脚本不复杂但对于防止脏数据长期积累至关重要。Instagram的工程团队一定也在做类似的事只不过他们的体量下对账的粒度需要做到更细。在碰这种功能之前我建议你先把GitHub上的开源实现翻一翻比如某些社区系统的用户名注册模块理解它们的全链路设计。但最后一定要回归自己的业务你的用户名到底是不是全局URL的一部分如果是那必须严格如果只是个人资料里的一个展示字段可以走宽松约束不要在非核心功能上过度架构。我这些年踩过最大的坑就是“过早把简单问题复杂化”。用户名校验的架构设计本质上是跟着你的用户规模和流量特征走的。规模小就老老实实单库唯一索引规模大了再逐步加上Redis热路径、分片索引、消息队列。Instagram的这套方案是参考答案不是标准答案照着抄作业前先量一量自己的腿长。