一、用户系统的需求分析
用户系统是几乎所有互联网应用的基石——注册、登录、鉴权、用户信息管理构成了整个应用的身份基础设施。设计一个支持十亿级别用户的通用用户系统,需要覆盖以下核心功能。
功能需求包括:用户注册(手机号、邮箱或第三方账号)、用户登录(密码登录、短信验证码、OAuth2第三方登录)、Token鉴权(后续请求的身份验证)、用户信息管理(读/写个人信息、隐私设置)、会话管理(多设备登录、踢出设备)。非功能需求:注册/登录延迟低于500毫秒,Token验证延迟低于5毫秒(因为每次API请求都需要验证),密码等敏感信息永远不能以明文存储,系统可用性达到99.99%。
容量估算:假设总注册用户数为十亿,DAU为一亿。每天新增注册用户约一百万。每个用户平均有10个属性(昵称、头像、简介等),每个属性约100字节,用户元数据约1KB每人,总计约1TB。这个数据量对于一个关系型数据库分片集群来说完全可以承载。更大的挑战在于高并发的Token验证和热点用户的缓存设计。
二、密码存储与验证
密码是用户系统中最敏感的数据,绝对不可以明文存储。即使数据库被拖库,攻击者也不应该能还原出用户的明文密码。
当前工业标准是使用bcrypt或scrypt算法对密码进行哈希。与简单的SHA-256不同,bcrypt和scrypt专门设计为抵抗暴力破解。bcrypt的核心特性:内置盐值(Salt),每个密码自动生成一个随机盐值并嵌入哈希结果中,避免了彩虹表攻击;计算成本可配置(cost factor参数),使哈希计算故意变慢——在2024年的硬件上,cost factor=12时约需0.3秒计算一次哈希。对用户来说这0.3秒几乎无感知(只在登录时计算一次),但对于攻击者来说,尝试十亿个密码就需要消耗巨大的计算资源。
bcrypt的工作流程:
1. 用户注册,提供密码 "MySecureP@ss123" |
scrypt是bcrypt的改进版,除CPU计算成本外还增加了内存成本参数,使GPU/ASIC并行暴力破解的成本进一步增加。如果系统对安全性要求极高(如金融应用),可以考虑使用scrypt或更新的Argon2(2015年密码哈希竞赛的获胜者)。
除了密码本身的存储安全,还需要额外的防御措施:限制单个IP或单个账号的登录尝试频率(如5次/分钟),超过阈值后要求验证码或临时锁定;强制密码复杂度(至少8位,包含大小写字母和数字);支持双因素认证(2FA/TOTP)。
三、Token认证与JWT
HTTP是无状态协议,不能天然地维持用户登录状态。需要在每次请求中携带身份凭据。当前主流的方案是JWT(JSON Web Token)结合OAuth2框架。
JWT由三部分组成,用点号分隔:Header.Payload.Signature。Header指定签名算法(如HS256或RS256)和Token类型。Payload包含Claims(声明),包括用户ID、过期时间(exp)、签发时间(iat)、签发者(iss)等。Signature是对Header和Payload的签名,确保Token未被篡改。
JWT的生成和验证流程:
// 生成JWT (使用JJWT库) |
JWT的核心优势是无状态——只要拥有共享的密钥(对称加密)或公钥(非对称加密),任何服务都可以独立验证Token,不需要访问中心化的会话存储。这在微服务架构中非常重要——API网关在收到请求后,直接本地验证JWT,无需每次调用认证服务,延迟极低。
Access Token与Refresh Token的双Token模式:Access Token有效期较短(如15分钟到2小时),用于日常API请求的鉴权。Refresh Token有效期较长(如7天到30天),存储在客户端和Redis中,用于在Access Token过期后获取新的Access Token。双Token模式兼顾了安全性和用户体验——如果Access Token泄露,攻击者只能在其有效期内利用它;而Refresh Token长期有效,减少了用户重复登录的次数。
OAuth2的授权码流程(Authorization Code Flow)是第三方登录的标准方案:用户在客户端点击“微信登录”;客户端重定向到微信授权服务器;用户在微信端确认授权;微信重定向回客户端并附带授权码(Authorization Code);客户端将授权码发送给应用后端;应用后端用授权码向微信服务器换取Access Token;应用后端用Access Token获取用户信息(微信OpenID、头像、昵称等);应用后端生成自己的JWT返回给客户端。
四、数据库Schema设计
用户系统的核心表设计:
-- 用户主表 |
将用户核心鉴权信息(users表)与用户资料(user_profiles表)分离是一个明智的设计。用户在登录和Token验证时只需要users表的数据(密码哈希、状态等),资料表的大量字段不需要参与鉴权流程。这种分离还有利于缓存策略——鉴权信息需要强一致性,不适合长TTL缓存;资料信息可以容忍一定延迟,可以大胆使用缓存。
MySQL分片策略:以user_id为分片键。对于十亿用户,如果采用1024个分片,每个分片承载约100万用户。手机号和邮箱的登录场景下,需要先通过手机号或邮箱查到user_id,再到对应分片查询密码哈希。因此需要维护一个“手机号→user_id”和“邮箱→user_id”的映射关系。这个映射表可以单独部署或使用Redis存储(phone_to_uid:{phone} → user_id),因为在登录流程中,这个映射的查找频率很高,且数据量可控(10亿条,每条约50字节,共约50GB,可以全部放在Redis Cluster中)。
五、会话管理与设备踢出
使用JWT后,会话由客户端持有,服务端天然无状态。但这也带来了一个问题:用户想要踢出某个设备的登录状态时,无法直接使已签发的JWT失效(JWT在过期前一直有效)。
解决方案是维护一个Token黑名单。当用户执行“退出登录”或“踢出某个设备”操作时,将对应的JWT的jti(JWT ID,每个Token的唯一标识)或用户+设备组合加入Redis黑名单,黑名单的TTL设置为该Token的剩余有效时间。在API网关验证JWT时,除了验证签名和过期时间,额外检查Token是否在黑名单中。
更轻量级的方案是在JWT中嵌入一个版本号(Token Version),存储在Redis中(user:token_version:{user_id})。用户踢出所有设备时,递增版本号。API网关验证JWT时,比对Token中的版本号与Redis中的当前版本号是否一致。这个方法的好处是黑名单从“每个Token一个条目”简化为“每个用户一个数字”,Redis的内存开销和查询开销都极小。
另一种替代方案是完全放弃客户端存储Token,改为服务端会话管理:服务端将用户会话信息存储在Redis中(session:{session_id} → {user_id, device, login_time, ...}),设置TTL为会话过期时间。客户端只持有session_id(放在Cookie或自定义Header中),每次请求时服务端查询Redis验证会话。这种方案的优点是服务端完全控制会话,可以随时单点失效;缺点是每次请求多一次Redis查询(虽然通常只需要0.5ms左右),且Redis成为单点依赖。实践中,可以将JWT和Redis会话方案结合——JWT用于无状态鉴权(大多数请求),Redis用于存储可撤销信息(如版本号、黑名单)。
六、缓存策略:Cache-Aside模式
用户信息查询是典型的高频读场景——每次API请求都需要加载当前用户的基本信息(昵称、头像URL等)。如果不加缓存,每次请求都走数据库,数据库压力巨大。用户系统的缓存策略主要采用Cache-Aside模式(旁路缓存),别名Lazy-Loading。
Cache-Aside的读取流程:应用先查缓存,如果命中(Cache Hit)直接返回;如果未命中(Cache Miss),从数据库读取数据,将数据写入缓存(设置合理的TTL,如30分钟),然后返回给调用方。
Cache-Aside的写入流程:应用先更新数据库,然后删除(Invalidate)缓存中的对应数据。注意这里是“删除”而非“更新”缓存——因为在并发场景下,两个写操作可能以不同的顺序更新缓存,导致缓存中的值与数据库不一致。删除缓存后,下一次读取会自然地重新从数据库加载最新数据并写入缓存。
为什么Cache-Aside模式写入时是“先更新数据库,再删除缓存”?如果反过来(先删除缓存,再更新数据库),存在如下问题:A删除缓存后,B在A更新数据库之前读取数据,发现缓存未命中,从数据库读出旧数据,写入缓存。然后A再更新数据库。结果是缓存中永远存着旧数据,直到TTL过期。虽然“先更新数据库,再删除缓存”也不是百分百安全的(极小的概率窗口),但发生概率远低于前者,在实际工程中被广泛接受。
七、Read-Through与Write-Through模式
Read-Through模式:缓存层直接位于数据库前面,应用只与缓存交互。缓存未命中时,缓存层本身(而非应用)负责从数据库加载数据并填充缓存。应用代码不需要关心数据加载的细节。Write-Through模式:应用写入数据时,缓存层同时更新缓存和数据库。这两种模式通常需要专门的缓存中间件(如Redis的Lettuce/Redisson的缓存抽象,或Tair等阿里内部缓存服务)提供封装。
Cache-Aside与Read/Write-Through的核心区别在于责任方不同:Cache-Aside由应用显式控制缓存的读写;Read/Write-Through由缓存层(或缓存框架)透明地管理。Cache-Aside更灵活、应用感知缓存行为,适合复杂的业务缓存逻辑;Read/Write-Through实现更简洁,适合数据访问模式简单的场景。
八、缓存穿透、击穿与雪崩
这三个问题是缓存系统设计中的经典挑战。
缓存穿透(Cache Penetration):查询一个数据库中不存在的数据(且缓存中也不存在),每次请求都穿透缓存直达数据库。恶意攻击者可以构造大量不存在的用户ID进行请求,瞬间打垮数据库。解决方案:布隆过滤器——将所有合法用户ID预先加载到布隆过滤器中,查询前先经过布隆过滤器判断,如果过滤器返回“不存在”,直接返回,不查缓存也不查数据库。或者缓存空值——对不存在的查询结果也缓存一个空值(如null),设置较短的TTL(如1分钟),防止短时间内的重复穿透攻击。
缓存击穿(Cache Breakdown):某个热点数据的缓存刚好过期,此时大量并发请求同时打到数据库。由于数据库处理速度远低于缓存,瞬时大量请求可能导致数据库连接池耗尽、CPU飙升。解决方案:互斥锁(Mutex)——缓存未命中时,不是所有请求都去查数据库,而是只让一个请求(获取分布式锁的请求)去查数据库并回写缓存,其他请求等待或返回降级数据。Redis的SETNX命令可以实现分布式锁:
public UserProfile getUserProfile(Long userId) { |
缓存雪崩(Cache Avalanche):大量缓存数据在同一时间点过期,或者Redis集群整体故障,导致所有请求回源到数据库。解决方案:TTL随机化——为每个key的TTL添加一个随机偏移量(如在基准TTL 30分钟的基础上,随机增加0到5分钟),避免批量同时过期。多级缓存——本地缓存(Caffeine)+ 分布式缓存(Redis)+ 数据库,每一层都有独立的热点保护。降级熔断——当检测到Redis不可用时,限制对数据库的请求量(如通过Hystrix/Sentinel进行熔断),直接返回降级数据或默认值。
九、数据库与缓存的最终一致性
Cache-Aside模式在数据库写入成功后、缓存删除之前,总有极短的窗口期内缓存中的值是旧数据。对于用户系统来说,这种短暂的不一致是可以接受的——用户修改了昵称后,其他人可能在几百毫秒内看到的是旧昵称。但对于一些强一致性要求的场景(如余额、库存),需要使用更严格的方案。
延迟双删(Double Delete):在更新数据库后,不是只删除一次缓存,而是延迟几百毫秒后再删除一次,以覆盖并发读写的不一致窗口。这种做法降低了不一致的概率,但不能完全消除。
订阅数据库Binlog:使用Canal(阿里开源)或Debezium监听MySQL的Binlog变更事件,当数据库数据发生变化时,异步更新或删除缓存。这种方案将缓存的更新与业务代码解耦,且能捕获所有数据库变更(包括非正常写入路径,如数据订正脚本的更新)。
对于用户系统,Cache-Aside + TTL已经足够。设置合理的TTL(如30分钟)保证了数据最终会达到一致。对于令牌黑名单等强一致性要求的数据,直接在Redis中作为唯一真实来源(Source of Truth),不依赖数据库的异步同步。
十、面试常见追问
问题一:JWT vs 传统的Session方案怎么选?
JWT适合微服务架构,因为Token验证不需要访问集中式存储,任意服务都可以独立验证。缺点是Token签发后无法主动失效(需要借助黑名单或版本号)。Session方案适合单体架构或需要严格控制会话状态(如金融系统)的场景,服务端可以随时终止会话。在大型系统中,通常采用混合方案:JWT用于无状态鉴权,Redis存储少量可撤销信息。
问题二:为什么不用MD5或SHA-256存储密码?
MD5和SHA-256是通用哈希函数,设计目标是快速计算。这个“快速”特性在密码存储场景中是致命缺陷——攻击者可以用GPU每秒计算数十亿次SHA-256哈希,暴力破解极其高效。bcrypt/scrypt/Argon2故意设计得很慢,使暴力破解成本指数级上升。此外,普通哈希不加盐值,相同密码产生相同哈希,彩虹表攻击非常有效。
问题三:如何设计用户系统的数据库分片?
以user_id为分片键是最自然的选择,因为绝大多数用户相关的查询都包含user_id。挑战在于按手机号/邮箱登录时如何定位到正确的分片。解决方案是维护一个“手机号→user_id”的映射索引,存储在Redis中或一个独立的全局索引表中。这个映射索引的数据量可控(一个十亿用户的系统,映射索引约50GB),可以全部缓存在Redis Cluster中。用户注册时,先分配全局唯一的user_id,然后同时写入映射索引和用户数据分片。



