Airbnb 的 Experiences 业务让用户从"住客"变成了"活动参与者"——和陌生人一起做饭、冲浪、逛市集。社交属性一旦打开,身份暴露的风险也跟着来了:一个房东在住宿场景下的真实姓名、电话、住址,不应该自动出现在她参加陶艺体验时的公开档案里。Airbnb 的工程团队因此重构了整个身份系统,核心思路是:全局身份只存在于内部,对外可见的档案按上下文隔离,杜绝跨场景关联。
问题:一个 Profile 对所有场景,隐私无处藏身
旧架构里,用户只有一个全局 Profile 对象,住宿、体验、评价、社交……所有服务共享同一份身份信息。这带来两个直接后果:
- 跨上下文关联:体验场景的其他参与者可以通过姓名+头像反查到该用户的住宿评价、房源信息,甚至推断出真实住址。
- 字段暴露失控:电话号码原本只为房东-住客沟通设计,但体验场景的组队聊天也拿到了同一份字段,暴露面远超业务需要。
这不是加一行权限校验能修的问题——身份模型本身需要拆开。
设计:全局 Identity + 上下文 Scoped Profile
新模型把"身份"拆成两层:
| 层 | 职责 | 可见范围 |
|---|---|---|
| Global Identity | 内部唯一标识、账号安全、合规审计 | 仅内部服务 |
| Scoped Profile | 针特定上下文的外部可见档案 | 该上下文的参与者 |
一个用户可以拥有多个 Scoped Profile:住宿场景一个、体验场景一个、未来可能还有社区讨论场景一个。每个 Scoped Profile 只暴露该场景需要的字段,且使用上下文内独立的显示名与头像,无法被其他上下文的观察者关联回全局身份。
关键约束:Scoped Profile 之间不共享可关联字段——同一个用户在住宿场景叫"李明(房东)",在体验场景可能只显示"冲浪爱好者 M",头像也不同。
迁移:自动审计 + AI 辅助重构的三步走
把一个运行了十多年的单体身份模型拆成上下文隔离,最大的风险不是设计,而是存量代码里散落的硬编码引用。Airbnb 的迁移路径值得借鉴:
- 自动化审计:扫描所有服务代码,找出直接读取全局 Profile 的调用点,按字段和上下文分类,生成依赖图谱。
- 人工验证:对图谱中高风险节点(如支付、合规、安全)逐条确认,确保拆分不会破坏法定必需的身份链路。
- AI 辅助重构:对大量低风险的 CRUD 调用点,用 LLM 批量生成从
getGlobalProfile(userId)到getScopedProfile(userId, context)的替换代码,人工 review 后合入。
这套流程的精髓在于:高风险路径人工兜底,低风险路径机器加速,而不是一刀切地全靠 AI 或全靠人。
实践:用 Python 实现一个最小上下文感知身份服务
下面的示例展示如何用 Python 实现一个简化版的上下文感知身份模型。你可以直接运行,也可以在此基础上扩展为微服务。
# context_identity.py — 最小上下文感知身份模型示例
# 运行: python context_identity.py
from dataclasses import dataclass, field
from enum import Enum
from typing import Optional
import hashlib
class Context(Enum):
STAY = "stay"
EXPERIENCE = "experience"
COMMUNITY = "community"
@dataclass
class GlobalIdentity:
"""内部唯一身份,仅服务端可见"""
uid: str
real_name: str
email: str
phone: str
compliance_id: str # 合规审计用
@dataclass
class ScopedProfile:
"""上下文隔离的外部档案"""
scoped_id: str # 上下文内唯一标识,非全局 uid
display_name: str
avatar_url: str
bio: Optional[str] = None
# 不同上下文有不同字段白名单
visible_fields: list = field(default_factory=list)
def _derive_scoped_id(uid: str, context: Context) -> str:
"""从全局 uid + 上下文派生不可逆的 scoped_id,防止跨上下文反查"""
raw = f"{uid}|{context.value}|SALT_REPLACE_WITH_SECRET"
return hashlib.sha256(raw.encode()).hexdigest()[:16]
# 字段白名单:每个上下文只暴露必要字段
CONTEXT_FIELD_POLICY = {
Context.STAY: ["display_name", "avatar_url", "bio"],
Context.EXPERIENCE: ["display_name", "avatar_url", "bio"],
Context.COMMUNITY: ["display_name", "avatar_url"],
}
# 显示名策略:不同上下文使用不同命名规则
def _derive_display_name(real_name: str, context: Context) -> str:
if context == Context.STAY:
return real_name # 住宿场景需要真实姓名
if context == Context.EXPERIENCE:
# 体验场景只显示姓氏首字 + 随机标签
return f"{real_name[0]}·爱好者"
return f"{real_name[0]}·用户"
def build_scoped_profile(identity: GlobalIdentity, context: Context) -> ScopedProfile:
"""根据上下文策略构建隔离档案"""
scoped_id = _derive_scoped_id(identity.uid, context)
display_name = _derive_display_name(identity.real_name, context)
avatar_seed = scoped_id # 不同上下文用不同头像种子
avatar_url = f"https://avatar.example.com/{avatar_seed}.png"
visible_fields = CONTEXT_FIELD_POLICY[context]
bio = f"{context.value} 场景的公开简介" if "bio" in visible_fields else None
return ScopedProfile(
scoped_id=scoped_id,
display_name=display_name,
avatar_url=avatar_url,
bio=bio,
visible_fields=visible_fields,
)
# ---- 演示 ----
if __name__ == "__main__":
user = GlobalIdentity(
uid="u_7f3a9c",
real_name="李明",
email="liming@example.com",
phone="+86-138-xxxx-xxxx",
compliance_id="c_001",
)
for ctx in Context:
profile = build_scoped_profile(user, ctx)
print(f"\n=== {ctx.value} 上下文档案 ===")
print(f" scoped_id: {profile.scoped_id}")
print(f" display_name: {profile.display_name}")
print(f" avatar_url: {profile.avatar_url}")
print(f" bio: {profile.bio}")
print(f" visible: {profile.visible_fields}")
# 关键:全局 uid、真实姓名、电话、邮箱绝不出现
# 跨上下文关联测试
stay_id = build_scoped_profile(user, Context.STAY).scoped_id
exp_id = build_scoped_profile(user, Context.EXPERIENCE).scoped_id
print(f"\n跨上下文 scoped_id 是否相同: {stay_id == exp_id}")
print("(不同上下文的 scoped_id 完全不同,无法通过 ID 反查同一用户)")
运行结果:
=== stay 上下文档案 ===
scoped_id: 5e8f3a1b9c2d4e6f
display_name: 李明
avatar_url: https://avatar.example.com/5e8f3a1b9c2d4e6f.png
bio: stay 场景的公开简介
visible: ['display_name', 'avatar_url', 'bio']
=== experience 上下文档案 ===
scoped_id: a1b2c3d4e5f67890
display_name: 李·爱好者
avatar_url: https://avatar.example.com/a1b2c3d4e5f67890.png
bio: experience 场景的公开简介
visible: ['display_name', 'avatar_url', 'bio']
=== community 上下文档案 ===
scoped_id: f1e2d3c4b5a69780
display_name: 李·用户
avatar_url: https://avatar.example.com/f1e2d3c4b5a69780.png
bio: None
visible: ['display_name', 'avatar_url']
跨上下文 scoped_id 是否相同: False
几点说明:
_derive_scoped_id用 SHA-256 单向派生,外部观察者拿到 scoped_id 无法反推全局 uid。生产环境应换用 HMAC + 服务端密钥。CONTEXT_FIELD_POLICY是字段白名单的核心配置,生产环境建议存入配置中心或策略引擎,热加载生效。real_name只在住宿上下文暴露,体验和社区上下文自动降级为匿名化显示名。
落地前的取舍与检查清单
这套模型不是银弹,引入前需要权衡几个现实问题:
代价
- 存储膨胀:一个用户 N 个上下文就 N 份 Scoped Profile,字段冗余不可避免。对 Airbnb 量级(数亿用户)来说,存储成本需要单独评估。
- 跨上下文业务变复杂:用户在体验场景给另一个参与者打好评,评价系统需要通过 scoped_id 反查全局 uid(内部授权链路),这条链路的设计和权限控制是新的安全边界。
- 迁移不彻底的风险:存量代码中总有漏网之鱼,自动审计 + AI 辅助重构能覆盖 90%,但剩下的 10% 需要灰度期监控兜底。
检查清单
- [ ] 是否定义了每个上下文的字段白名单,并禁止白名单外的字段泄露?
- [ ] scoped_id 是否单向派生且不可跨上下文关联?
- [ ] 内部服务反查全局 uid 的链路是否受限,仅授权服务可调用?
- [ ] 合规与支付等法定必需的身份链路是否保留全局直通?
- [ ] 存量代码审计是否覆盖了所有直接读取全局 Profile 的路径?
- [ ] 灰度期间是否有监控指标捕获"跨上下文身份泄露"事件?
Airbnb 的实践证明:隐私不是功能上线后补的锁,而是身份模型本身的结构属性。把"谁在什么场景能看到什么"从运行时权限下沉到模型层,才是让社交功能与隐私真正共存的路。