目录
  1. 1. 一、用户系统的需求分析
  2. 2. 二、密码存储与验证
  3. 3. 三、Token认证与JWT
  4. 4. 四、数据库Schema设计
  5. 5. 五、会话管理与设备踢出
  6. 6. 六、缓存策略:Cache-Aside模式
  7. 7. 七、Read-Through与Write-Through模式
  8. 8. 八、缓存穿透、击穿与雪崩
  9. 9. 九、数据库与缓存的最终一致性
  10. 10. 十、面试常见追问
系统设计之从用户系统中理解数据库与缓存

一、用户系统的需求分析

用户系统是几乎所有互联网应用的基石——注册、登录、鉴权、用户信息管理构成了整个应用的身份基础设施。设计一个支持十亿级别用户的通用用户系统,需要覆盖以下核心功能。

功能需求包括:用户注册(手机号、邮箱或第三方账号)、用户登录(密码登录、短信验证码、OAuth2第三方登录)、Token鉴权(后续请求的身份验证)、用户信息管理(读/写个人信息、隐私设置)、会话管理(多设备登录、踢出设备)。非功能需求:注册/登录延迟低于500毫秒,Token验证延迟低于5毫秒(因为每次API请求都需要验证),密码等敏感信息永远不能以明文存储,系统可用性达到99.99%。

容量估算:假设总注册用户数为十亿,DAU为一亿。每天新增注册用户约一百万。每个用户平均有10个属性(昵称、头像、简介等),每个属性约100字节,用户元数据约1KB每人,总计约1TB。这个数据量对于一个关系型数据库分片集群来说完全可以承载。更大的挑战在于高并发的Token验证和热点用户的缓存设计。

二、密码存储与验证

密码是用户系统中最敏感的数据,绝对不可以明文存储。即使数据库被拖库,攻击者也不应该能还原出用户的明文密码。

当前工业标准是使用bcryptscrypt算法对密码进行哈希。与简单的SHA-256不同,bcrypt和scrypt专门设计为抵抗暴力破解。bcrypt的核心特性:内置盐值(Salt),每个密码自动生成一个随机盐值并嵌入哈希结果中,避免了彩虹表攻击;计算成本可配置(cost factor参数),使哈希计算故意变慢——在2024年的硬件上,cost factor=12时约需0.3秒计算一次哈希。对用户来说这0.3秒几乎无感知(只在登录时计算一次),但对于攻击者来说,尝试十亿个密码就需要消耗巨大的计算资源。

bcrypt的工作流程:

1. 用户注册,提供密码 "MySecureP@ss123"
2. 服务端生成随机盐值 (16字节)
3. 使用bcrypt(cost=12)计算哈希: bcrypt("MySecureP@ss123", salt, 12)
结果: $2b$12$LJ3m4ys3GZ3jRUzVfTqFYeXXXXYYYYZZZZ...
其中 $2b$ 是算法版本, 12 是cost, 后面是盐值和哈希值的Base64编码
4. 将整个结果字符串存入数据库的 password_hash 列
5. 用户登录时,从数据库取出已存的哈希,从中提取盐值和cost
6. 用相同的盐值和cost对输入的密码做bcrypt
7. 比对两个哈希值是否相等(恒定时间比对,防止时序攻击)

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库)
String jwt = Jwts.builder()
.setSubject(String.valueOf(userId)) // 用户ID
.claim("role", "user") // 自定义声明
.setIssuedAt(new Date()) // 签发时间
.setExpiration(new Date(System.currentTimeMillis() + 7200000)) // 2小时过期
.signWith(SignatureAlgorithm.HS256, secretKey)
.compact();

// 验证JWT
Claims claims = Jwts.parser()
.setSigningKey(secretKey)
.parseClaimsJws(jwt)
.getBody();
Long userId = Long.parseLong(claims.getSubject());

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设计

用户系统的核心表设计:

-- 用户主表
CREATE TABLE users (
user_id BIGINT PRIMARY KEY,
phone VARCHAR(20),
email VARCHAR(255),
password_hash VARCHAR(255),
status TINYINT DEFAULT 1, -- 1:正常 2:冻结 3:注销
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
UNIQUE INDEX idx_phone (phone),
UNIQUE INDEX idx_email (email)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 用户资料表 (分离大字段和很少变更的信息)
CREATE TABLE user_profiles (
user_id BIGINT PRIMARY KEY,
nickname VARCHAR(50),
avatar_url VARCHAR(500),
bio VARCHAR(500),
gender TINYINT DEFAULT 0,
birthday DATE,
location VARCHAR(100),
updated_at DATETIME NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

-- 用户登录记录表 (按时间分表)
CREATE TABLE login_history_202406 (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
login_type TINYINT NOT NULL, -- 1:密码 2:短信 3:微信
ip VARCHAR(45),
device_info VARCHAR(255),
login_at DATETIME NOT NULL,
INDEX idx_user_time (user_id, login_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

将用户核心鉴权信息(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) {
String cacheKey = "user:profile:" + userId;
String lockKey = "lock:user:profile:" + userId;

UserProfile profile = redis.get(cacheKey);
if (profile != null) {
return profile;
}

// 尝试获取锁,超时时间5秒
boolean locked = redis.setnx(lockKey, "1", 5);
if (locked) {
try {
profile = db.queryUserProfile(userId);
redis.set(cacheKey, profile, 1800); // TTL 30分钟
return profile;
} finally {
redis.del(lockKey);
}
} else {
// 未获取到锁,短暂休眠后重试读缓存
Thread.sleep(50);
return getUserProfile(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,然后同时写入映射索引和用户数据分片。

文章作者: Leo·Cheung
文章链接: http://tufusi.com/2024/04/05/%E7%B3%BB%E7%BB%9F%E8%AE%BE%E8%AE%A1%E4%B9%8B%E4%BB%8E%E7%94%A8%E6%88%B7%E7%B3%BB%E7%BB%9F%E4%B8%AD%E7%90%86%E8%A7%A3%E6%95%B0%E6%8D%AE%E5%BA%93%E4%B8%8E%E7%BC%93%E5%AD%98/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 ONE·PIECE
打赏
  • 微信
  • 支付宝

评论