目录
  1. 1. 一、用户系统的需求分析
    1. 1.1. 用户系统的架构定位
    2. 1.2. 用户系统的可用性要求
  2. 2. 二、密码存储与验证
    1. 2.1. Argon2:密码哈希的新标准
    2. 2.2. 定时攻击(Timing Attack)与恒定时间比对
  3. 3. 三、Token认证与JWT
    1. 3.1. JWT的签名算法选择:HMAC vs RSA vs ECDSA
    2. 3.2. Refresh Token的安全存储
  4. 4. 四、数据库Schema设计
    1. 4.1. 全局唯一用户ID的生成
    2. 4.2. 数据归档与冷热分离
  5. 5. 五、会话管理与设备踢出
    1. 5.1. 多设备登录管理的实现
    2. 5.2. 单点登录(SSO)的实现
  6. 6. 六、缓存策略:Cache-Aside模式
    1. 6.1. Cache-Aside并发写问题的详细时间线分析
    2. 6.2. 缓存预热与缓存恢复
  7. 7. 七、Read-Through与Write-Through模式
    1. 7.1. Write-Behind模式
  8. 8. 八、缓存穿透、击穿与雪崩
    1. 8.1. 热点用户缓存击穿的优化方案
  9. 9. 九、数据库与缓存的最终一致性
    1. 9.1. Binlog订阅方案的架构
  10. 10. 十、OAuth2授权码流程的深入解析
    1. 10.1. 授权码流程的完整步骤与安全分析
    2. 10.2. PKCE(Proof Key for Code Exchange)
    3. 10.3. OpenID Connect(OIDC)
  11. 11. 十一、面试常见追问
系统设计之从用户系统中理解数据库与缓存

一、用户系统的需求分析

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

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

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

用户系统的架构定位

在微服务架构中,用户系统通常是整个系统的基座服务(Foundation Service)。其他所有服务都依赖它进行身份验证。用户系统的架构设计直接影响全局的可用性和延迟:

              ┌─────────────┐
│ API 网关 │ ← 每次请求都验证Token
└──────┬──────┘

┌──────────────┼──────────────┐
│ │ │
┌─────▼─────┐ ┌────▼─────┐ ┌────▼─────┐
│ 订单服务 │ │ 聊天服务 │ │ 支付服务 │
└───────────┘ └──────────┘ └──────────┘
│ │ │
└──────────────┼──────────────┘

┌──────▼──────┐
│ 用户服务 │ ← 所有服务共享的基础
│ (Auth+Profile)│
└──────┬──────┘

┌──────────────┼──────────────┐
│ │ │
┌─────▼─────┐ ┌────▼─────┐ ┌────▼────┐
│ MySQL │ │ Redis │ │ Kafka │
│ 用户数据 │ │Token/会话 │ │用户事件 │
└───────────┘ └──────────┘ └─────────┘

用户系统的可用性要求

用户系统是全局基础设施,其可用性要求高于一般业务服务。如果用户系统不可用,则所有需要鉴权的API都无法正常工作——即使是一个简单的商品列表页,也通常需要验证用户Token来确定用户的个性化状态。因此用户系统的可用性目标通常是99.99%或更高。这意味着需要多机房部署、自动故障切换、降级方案(如允许Token验证在Redis故障时使用本地缓存)。

二、密码存储与验证

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

当前工业标准是使用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)。

Argon2:密码哈希的新标准

Argon2在2015年的密码哈希竞赛(Password Hashing Competition)中获胜,成为新的推荐标准。Argon2有三个变体:

  • Argon2d:最大化抗GPU破解能力,访问模式依赖数据(Data-dependent),但有侧信道攻击风险
  • Argon2i:访问模式与数据无关(Data-independent),抵抗侧信道攻击,适合密码哈希
  • Argon2id:混合模式,前半段用Argon2i,后半段用Argon2d,兼顾两者优势

Argon2的可调参数包括:内存成本(Memory Cost,KB为单位)、时间成本(Time Cost,迭代次数)、并行度(Parallelism,线程数)。推荐的密码哈希使用Argon2id,参数设置为memory=64MB, time=3, parallelism=4。

定时攻击(Timing Attack)与恒定时间比对

定时攻击利用比对操作的执行时间差异来推断秘密信息。例如,如果密码比对使用strcmp(逐字符比对,第一个不匹配的字符处立即返回),攻击者可以通过测量每次比对的时间来逐字符猜测密码。防御措施是使用恒定时间比对(Constant-Time Comparison):

public static boolean constantTimeEquals(byte[] a, byte[] b) {
if (a.length != b.length) {
return false;
}
int diff = 0;
for (int i = 0; i < a.length; i++) {
diff |= a[i] ^ b[i]; // 异或:相同=>0,不同=>非0
}
return diff == 0;
}

这个实现遍历所有字节,无论差异出现在哪个位置,执行时间完全相同(由数组长度决定)。diff |= a[i] ^ b[i]累积所有差异,避免分支语句(if/return)导致的执行时间差异。

三、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返回给客户端。

JWT的签名算法选择:HMAC vs RSA vs ECDSA

  • HS256(HMAC-SHA256):对称密钥,密钥共享给所有需要验证Token的服务。优点是速度快(单次验证微秒级),缺点是需要安全分发共享密钥,密钥泄露影响全局
  • RS256(RSA-SHA256):非对称密钥,私钥签名(仅认证服务持有),公钥验证(所有服务持有)。优点是私钥不外泄,公钥可公开分发,缺点是验证速度慢(毫秒级),Token体积大(约200字节签名)
  • ES256(ECDSA-P256):椭圆曲线签名,私钥签名,公钥验证。签名为64字节(远小于RS256的256字节),验证速度快于RSA但慢于HMAC

在微服务架构中,推荐使用RS256或ES256——认证服务持有私钥签发Token,其他服务通过公钥验证。公钥可存储在配置中心或通过JWKS(JSON Web Key Set)端点对外暴露。即使公钥被公开也安全(公钥只能验证不能签名)。

Refresh Token的安全存储

Refresh Token的有效期长(如30天),一旦泄露危害更大。安全存储的最佳实践:

  • 服务端存储Refresh Token的哈希值(类似密码存储),原始Token仅在签发时一次性返回给客户端
  • Refresh Token绑定设备信息(在签发时记录device_fingerprint),刷新时校验设备匹配
  • 支持Refresh Token轮换(Rotation):每次使用Refresh Token获取新的Access Token时,同时签发新的Refresh Token并废弃旧的
  • 检测Refresh Token重用(Reuse Detection):如果检测到已废弃的Refresh Token仍被使用(可能是Token泄露),立即废弃该用户的所有Refresh Token,强制重新登录

四、数据库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中)。

全局唯一用户ID的生成

用户系统的ID生成器需要生成全局唯一且趋势递增的user_id。方案比较:

方案 优点 缺点 适用场景
MySQL自增ID 简单,天然递增 单点瓶颈,不可跨库 小型应用
Snowflake 高性能,本地生成 时钟回拨风险 中型应用
号段模式(美团Leaf) 高性能,容错强 依赖外部存储 大型应用
Redis INCR 简单可靠 Redis成为依赖 各种规模

推荐号段模式:user_id表存储当前的号段使用情况,每次申请一个号段(如1000个ID),在应用服务器本地内存中分配。一次数据库访问支持1000次用户注册,数据库QPS极低。

数据归档与冷热分离

用户系统中,老用户(如2年未登录)的数据不会频繁访问,但必须保留。可以将users表和user_profiles表的数据按活跃度分层:

  • 热数据(近30天有活跃):在高速SSD MySQL分片中
  • 温数据(30-90天前活跃):在HDD MySQL分片中(成本更低)
  • 冷数据(>90天未登录):归档到对象存储(Parquet格式),仅在用户重新激活时加载回MySQL

五、会话管理与设备踢出

使用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用于存储可撤销信息(如版本号、黑名单)。

多设备登录管理的实现

多设备登录需要追踪每个用户的活跃设备列表。数据结构:

Redis Hash:
user:devices:{user_id} -> {
"device_id_1": "{\"platform\":\"iOS\",\"login_time\":1620000000,\"last_active\":1620000100}",
"device_id_2": "{\"platform\":\"Android\",\"login_time\":1620001000,\"last_active\":1620001100}",
...
}

设备踢出流程:

  1. 用户选择踢出设备 device_id_2
  2. user:devices:{user_id}中删除 device_id_2
  3. 将 device_id_2 加入黑名单 device_blacklist:{device_id_2},TTL = 当前JWT最大有效时间
  4. 递增user:token_version:{user_id}
  5. 下发新的JWT给当前设备(带有新版本号)
  6. 下次被踢设备访问时,Token版本号不匹配,鉴权失败,客户端跳转到登录页

单点登录(SSO)的实现

在企业级用户系统中,单点登录(SSO)允许用户在一个应用中登录后,自动获得其他关联应用的访问权限。OAuth2 + OpenID Connect(OIDC)是当前标准的SSO协议。

核心流程:

  1. 用户访问应用A,应用A重定向到SSO认证中心(Authorization Server)
  2. SSO认证中心检查用户是否已有有效的登录会话(通过Browser Cookie)
  3. 如果没有,提示用户输入凭据;如果有,直接签发授权码
  4. 应用A用授权码换取ID Token(OIDC)和Access Token
  5. 用户再访问应用B时,同样重定向到SSO认证中心
  6. SSO认证中心检测到已有有效会话,直接返回授权码
  7. 应用B用授权码换取自己的Token

JWT的aud(audience)和iss(issuer)声明用于区分不同应用的Token,确保应用A的Token不能被应用B使用。

六、缓存策略: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过期。虽然”先更新数据库,再删除缓存”也不是百分百安全的(极小的概率窗口),但发生概率远低于前者,在实际工程中被广泛接受。

Cache-Aside并发写问题的详细时间线分析

场景:两个并发写操作(写1和写2)同时更新用户昵称

先更新DB再删除缓存的正确流程:

T1: 写1更新DB (昵称 → "Alice")
T2: 写2更新DB (昵称 → "Bob")
T3: 写1删除缓存
T4: 写2删除缓存
T5: 读1查询缓存 → 未命中 → 从DB读 → "Bob" → 写入缓存
结果: 缓存和DB都是"Bob",一致

唯一的风险窗口:写1删除缓存后,在写2删除缓存前,缓存中刚加载了旧数据。概率极低。

先删除缓存再更新DB的错误流程:

T1: 写1删除缓存
T2: 写2删除缓存
T3: 读1查询缓存 → 未命中 → 从DB读 → 旧值"Charlie" → 写入缓存
T4: 写1更新DB (→ "Alice")
T5: 写2更新DB (→ "Bob")
结果: DB是"Bob",缓存是"Charlie",不一致,直到TTL过期

缓存预热与缓存恢复

用户系统在服务重启或缓存集群故障恢复后,面临冷缓存问题——所有请求穿透到数据库。解决方案:

  1. 缓存预热:服务启动时,从数据库加载一批热点用户(如最近24小时有活跃的用户)到Redis,避免数据库被冷启动流量打死
  2. 渐进式加载:服务启动后,不是一次性加载所有缓存,而是在处理请求时逐渐填充(Cache-Aside模式天然支持),同时设置每秒最大数据库查询数(Rate Limiting),防止瞬时压力
  3. 缓存高可用:使用Redis Cluster或Redis Sentinel,保证缓存层自身的高可用,减少”缓存整体不可用”的发生概率

七、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实现更简洁,适合数据访问模式简单的场景。

Write-Behind模式

Write-Behind(异步回写)是Write-Through的变体:应用写入数据时,只写入缓存(立即返回),缓存层在后台异步批量写入数据库。优点:写入延迟极低(仅一次内存操作+可能的排队)。缺点:Redis故障可能导致数据丢失(未写入数据库的数据在内存中)。适用场景:对一致性要求不高但写入吞吐要求极高的场景,如用户行为日志、计数器的批量更新。

正常Write-Through:  应用 → 写Redis → 同步写MySQL → 返回 (延迟 ~10ms)
Write-Behind: 应用 → 写Redis → 立即返回 (延迟 ~1ms)
↓ (异步)
Redis → 批量写MySQL (每100ms或累积500条)

对于用户系统的核心鉴权信息(密码哈希、账户状态),不应使用Write-Behind(数据丢失风险不可接受);对于用户资料(昵称、头像),可以使用Write-Behind(丢失后可从历史记录恢复或由用户重新设置)。

八、缓存穿透、击穿与雪崩

这三个问题是缓存系统设计中的经典挑战。

缓存穿透(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进行熔断),直接返回降级数据或默认值。

热点用户缓存击穿的优化方案

对于超级热点用户(如顶级明星、知名KOL),其用户资料的访问量可能达到每秒数万次。上述分布式锁方案虽然有效,但仍有优化空间:

  1. 永不过期(逻辑过期):热点用户的缓存不设置物理TTL,而是设置一个逻辑过期时间(存储在缓存的value中)。后台线程异步检测逻辑过期后,主动更新缓存,而非被动等待过期后再重建。
  2. 多副本缓存:将同一个热点用户的数据存储在Redis的多个key上(如user:profile:bak1:{id}, user:profile:bak2:{id}),随机读取,分散压力。
  3. 本地缓存兜底:将Top 1000热点用户的资料预加载到API网关的本地缓存(Caffeine)中,设置短TTL(如30秒),在Redis故障时仍能提供服务。

九、数据库与缓存的最终一致性

Cache-Aside模式在数据库写入成功后、缓存删除之前,总有极短的窗口期内缓存中的值是旧数据。对于用户系统来说,这种短暂的不一致是可以接受的——用户修改了昵称后,其他人可能在几百毫秒内看到的是旧昵称。但对于一些强一致性要求的场景(如余额、库存),需要使用更严格的方案。

延迟双删(Double Delete):在更新数据库后,不是只删除一次缓存,而是延迟几百毫秒后再删除一次,以覆盖并发读写的不一致窗口。这种做法降低了不一致的概率,但不能完全消除。

订阅数据库Binlog:使用Canal(阿里开源)或Debezium监听MySQL的Binlog变更事件,当数据库数据发生变化时,异步更新或删除缓存。这种方案将缓存的更新与业务代码解耦,且能捕获所有数据库变更(包括非正常写入路径,如数据订正脚本的更新)。

对于用户系统,Cache-Aside + TTL已经足够。设置合理的TTL(如30分钟)保证了数据最终会达到一致。对于令牌黑名单等强一致性要求的数据,直接在Redis中作为唯一真实来源(Source of Truth),不依赖数据库的异步同步。

Binlog订阅方案的架构

MySQL (写入)


MySQL Binlog (二进制日志流)


Canal / Debezium (CDC中间件)


Kafka (变更事件)


缓存更新服务

├── 解析事件类型 (INSERT/UPDATE/DELETE)
├── 确定受影响的缓存key
├── 更新或删除Redis缓存
└── 追踪变更延迟 (Binlog timestamp → 处理完成时间)

这个方案的优势:业务代码无需任何缓存逻辑,只要正常写数据库即可;覆盖所有写入路径(包括DBA手动执行SQL、数据订正脚本、跨系统数据同步等);缓存刷新延迟可控(通常100-500ms)。缺点:需要运维Canal/Debezium集群,增加系统复杂度。对于体量较大的用户系统,建议逐步演进到Binlog方案。

十、OAuth2授权码流程的深入解析

OAuth2是用户系统中第三方登录的基石。理解其安全模型和每个步骤的设计意图,对于设计安全的用户系统至关重要。

授权码流程的完整步骤与安全分析

Step 1: 用户点击"微信登录"
Client → User-Agent: 重定向到 Authorization Server
GET /authorize?response_type=code&client_id=APP_ID&redirect_uri=CALLBACK&scope=profile&state=RANDOM_STATE
其中 state 参数防止CSRF攻击

Step 2: 用户在授权服务器上确认
User → Authorization Server: 输入凭据,确认授权范围

Step 3: 授权服务器返回授权码
Authorization Server → User-Agent: 302重定向到 redirect_uri
Location: CALLBACK?code=AUTH_CODE&state=RANDOM_STATE

Step 4: 客户端后端用授权码换取Token
Client Backend → Authorization Server: POST /token
Body: grant_type=authorization_code&code=AUTH_CODE&client_id=APP_ID&client_secret=APP_SECRET
(Client Secret 在服务端存储,不暴露给前端 → 保证安全性)

Step 5: 授权服务器返回Token
Authorization Server → Client Backend:
{ access_token: "xxx", refresh_token: "yyy", id_token: "zzz" }

Step 6: 客户端用Token获取用户信息
Client Backend → Resource Server (微信API):
GET /userinfo, Header: Authorization: Bearer access_token
返回: { openid: "...", nickname: "...", avatar: "..." }

Step 7: 客户端生成自己的JWT
Client Backend → User-Agent: 返回自签发的JWT

为什么授权码流程需要”先返回code,再用code换token”这个额外步骤?因为授权码通过前端URL返回(暴露在浏览器地址栏和历史记录中),如果直接返回Access Token,攻击者可以通过浏览器历史记录获取Token。而授权码是一次性的、短有效期(通常30-60秒),且需要client_secret才能换取Token(client_secret存储在服务端不暴露)。这个额外的”换码”步骤将不安全的”前端信道”转为安全的”后端信道”。

PKCE(Proof Key for Code Exchange)

PKCE是OAuth2的增强安全扩展,专为移动App和SPA(单页应用)设计——这些应用无法安全存储client_secret。PKCE的核心思想:

Step 1: 客户端生成 code_verifier (随机字符串, 43-128字符)
Step 2: 客户端计算 code_challenge = SHA256(code_verifier), Base64URL编码
Step 3: 授权请求中带上 code_challenge 和 code_challenge_method=S256
Step 4: 授权服务器返回授权码
Step 5: Token请求中带上 code_verifier
Step 6: 授权服务器验证 SHA256(code_verifier) == code_challenge

PKCE防止了”授权码拦截攻击”:即使攻击者截获了授权码,没有code_verifier无法换取Token(因为code_verifier从未通过网络传输,只在客户端本地生成和存储)。

OpenID Connect(OIDC)

OIDC是OAuth2之上的身份层,添加了ID Token(JWT格式)和UserInfo端点,标准化了身份验证。OIDC的ID Token包含标准化的Claims:

{
"iss": "https://accounts.google.com", // 签发者
"sub": "1234567890", // 用户唯一标识
"aud": "my-app-client-id", // 接收者(client_id)
"exp": 1620000000, // 过期时间
"iat": 1619996400, // 签发时间
"name": "John Doe",
"email": "john@example.com",
"picture": "https://example.com/avatar.jpg"
}

OIDC vs 纯OAuth2:OAuth2是授权协议(”应用A可以访问我在微信上的头像吗?”),OIDC是认证协议(”我是谁?”)。实际使用中两者通常一起部署——OAuth2授权 + OIDC认证。

十一、面试常见追问

问题一: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,然后同时写入映射索引和用户数据分片。

问题四:Token泄露后的防护和检测机制?

Token泄露后的防护:设置较短的Access Token有效期(15-30分钟),限制泄露后的危害窗口;使用Refresh Token Rotation,每次刷新时签发新Refresh Token并废弃旧的;基于IP和设备指纹(Device Fingerprint)的异常检测——如果同一Token在短时间内从地理位置差异巨大的IP使用,或从不认识的设备上使用,标记为可疑并通知用户;提供”退出所有设备”功能,强制所有Token失效(通过递增Token版本号)。检测机制:在Refresh Token层面实施Reuse Detection——如果检测到已被废弃的Refresh Token仍被使用(攻击者和合法用户都在使用同一个Refresh Token),则判定为Token泄露,立即作废该用户的所有Token,发送安全通知。

问题五:用户系统如何支持国际化与合规(GDPR等)?

用户系统在不同国家和地区需要遵循不同的数据保护法规。GDPR(欧洲通用数据保护条例)要求:用户有权访问其所有数据(数据可携带性);用户有权要求删除其数据(被遗忘权);数据收集需要明确用户同意;数据泄露需要在72小时内通知。技术实现:用户数据按地区分片存储(欧盟用户数据存储在欧盟境内的数据中心);提供数据导出API(导出用户个人数据为JSON/CSV格式);软删除+硬删除两级机制(软删除立即生效,硬删除在30天后执行);数据访问日志记录(谁在什么时间访问了什么数据)。对于CCPA(加州消费者隐私法),需要支持用户选择不出售其个人信息,技术上通过在用户表中添加do_not_sell标记并在数据流通环节检查该标记。

本篇文章从用户系统的需求出发,系统性地剖析了密码安全、Token鉴权、数据库设计、缓存策略等核心主题。掌握这些知识后,你不仅能设计一个亿级用户的用户系统,还能深入理解数据库与缓存在高并发系统中的协作模式。用户系统看似简单,但实际上涉及安全、性能、一致性、可用性等多个维度的权衡——每一个设计决策都是这些维度博弈的结果。

文章作者: Leo·Cheung
文章链接: http://tufusi.com/2021/11/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
打赏
  • 微信
  • 支付宝

评论