C端产品接入多渠道最常见的技术债是在用户表上为每个渠道加一组字段。微信来了加wx_open_id和wx_session_key支付宝来了加alipay_user_id和alipay_access_token抖音来了再加一组。等渠道到了四五个这张表已经有十几个渠道字段了其中大部分对任何一个具体用户来说都是空的。更麻烦的是每次新增渠道都要改表结构、改实体类、改查询逻辑、改缓存策略改动面大到让人不敢动。换一个思路把用户身份和渠道接入拆成两层。一张主账号表存用户的核心身份信息手机号、积分、等级一张渠道账号表存各渠道的登录凭证openId、unionId。新增渠道只往渠道表里加记录主账号表一个字段都不用动。代码层面用策略模式隔离各渠道的登录差异新增渠道只加一个策略类已有代码不改。这套方案在实际项目中跑过千万级用户、六个渠道经过了生产环境验证。需求是什么样的一个C端产品最初只上了微信小程序。用户量做起来之后业务方陆续提了几个需求接入支付宝小程序、接入抖音小程序、上线独立App。这几个需求看着是四件事背后的技术诉求就三个统一身份。同一个人从微信进来是一个账号从抖音进来又是一个账号积分和优惠券各算各的。这在用户视角是不可接受的。不管用户从哪个渠道进来系统必须识别出这是同一个人对应同一份数据。资产共享。积分、等级、优惠券、订单历史这些业务数据必须跨渠道通用。用户在微信上赚的积分到抖音上也能用。快速接入。新增一个渠道开发周期要可控。不能每加一个渠道就把登录模块翻一遍那业务方永远等不到上线。还有一个容易被忽略的点用户不是一上来就是完整用户。大部分C端产品的用户有三个状态游客从某个渠道进来还没绑手机号。能浏览商品但不能下单没有积分和等级。系统里有一条主账号记录但手机号为空用户绑了手机号。能下单、能收货、能领优惠券。手机号是跨渠道识别这个人的关键会员在用户基础上有了积分、等级、会员卡号等业务属性从游客到用户的转换靠绑定手机号从用户到会员的转换靠完成特定动作比如首次下单、手动开卡。三个状态的能力边界不同系统设计时必须考虑到。整体方案先看全局。上面是各个渠道入口微信小程序、支付宝小程序、抖音小程序、独立App以后可能还有百度小程序、快手等。每个渠道有自己的用户标识体系微信用openId支付宝用userId抖音也用openIdApp用手机号加设备ID。中间是渠道账号层。每个渠道对应一条记录存的是该渠道的登录凭证。一个用户如果从三个渠道都登录过渠道账号表里就有三条记录都指向同一个主账号。下面是主账号层。一个自然人对应一条主账号记录存的是手机号、昵称、积分、等级这些跟渠道无关的信息。所有业务域订单、积分、营销、支付都挂在主账号ID上。这个分层的好处在于新增渠道只影响渠道层不影响主账号层和业务层。业务代码从头到尾只认main_user_id不需要知道用户是从哪个渠道进来的。有人可能会问一张表不行吗每个渠道的openId加一个字段查询的时候多写几个OR条件。在渠道只有两三个的时候单表确实够用。问题是随着渠道增加这个方案有几个绕不过去的麻烦维度单表加字段主账号 渠道账号新增渠道要DDL加字段线上大表加字段有锁表风险只加数据不改表结构空字段大量字段为空微信用户没有支付宝字段每条记录都是有效数据查询逻辑渠道相关查询散落各处容易遗漏渠道逻辑集中在渠道层代码改动每次加渠道要改实体类、改查询、改缓存主账号层和业务层零改动渠道隔离所有渠道数据混在一张表天然隔离互不干扰这张对比不是说单表方案不能用。如果业务确定只有两三个渠道且未来不会再加单表方案更简单。做选型的判断标准是未来渠道数量是否会持续增长。如果答案是会在一开始就做分层设计后面的维护成本会低很多。数据库设计两张核心表main_user和channel_user。main_user主账号表核心字段CREATETABLEmain_user(idBIGINTAUTO_INCREMENTPRIMARYKEY,-- 全局唯一标识通常是手机号用于跨渠道识别同一个人identifierVARCHAR(128)NOTNULLDEFAULT,phoneVARCHAR(32)NOTNULLDEFAULT,-- 用户类型0游客1用户2会员user_typeTINYINTNOTNULLDEFAULT0,-- ...其他业务字段昵称、头像、积分、等级等直接放主账号跨渠道共享UNIQUEKEYuk_identifier(identifier))ENGINEInnoDBDEFAULTCHARSETutf8mb4;几个设计要点identifier字段是跨渠道识别的关键。当用户绑定手机号时手机号写入identifier同时建唯一索引。后续任何渠道的用户绑了同一个手机号通过这个唯一索引就能定位到已有的主账号实现跨渠道合并。游客阶段identifier怎么处理不能用空字符串因为MySQL的InnoDB引擎中空字符串参与唯一索引校验多条空字符串记录会冲突。实际做法是用一个不会重复的临时值比如UUID前缀加时间戳等用户绑手机号时再替换成真实手机号。这个临时值不参与任何业务逻辑纯粹是为了满足唯一索引的约束。积分、等级这些业务属性直接放在主账号表上。好处是跨渠道天然共享不需要额外的同步逻辑。业务属性不多的情况下十个字段以内放在主账号表里查询效率更高也省了一次关联查询。channel_user渠道账号表核心字段CREATETABLEchannel_user(idBIGINTAUTO_INCREMENTPRIMARYKEY,-- 关联主账号main_user_idBIGINTNOTNULL,-- 渠道标识WX微信ZFB支付宝DY抖音APP独立应用channelVARCHAR(16)NOTNULL,-- 渠道内的用户唯一标识openId/userId等open_idVARCHAR(128)NOTNULLDEFAULT,union_idVARCHAR(128)NOTNULLDEFAULT,-- ...其他字段会话凭证、渠道侧用户信息、账号状态等-- 同一渠道内openId唯一UNIQUEKEYuk_channel_openid(channel,open_id),INDEXidx_main_user_id(main_user_id))ENGINEInnoDBDEFAULTCHARSETutf8mb4;channel_user存的是渠道侧的登录凭证。一个用户从微信和支付宝都登录过这张表里就有两条记录main_user_id相同channel不同。联合唯一索引uk_channel_openid保证同一渠道内不会出现重复的openId。idx_main_user_id索引用于通过主账号ID反查该用户在所有渠道的登录信息。字段该放哪张表这张表列出了两张表各自的字段职责边界遇到「这个字段该放哪张表」的问题时可以直接参考字段类型放main_user放channel_user判断依据手机号✅可选手机号是全局身份标识必须在主账号积分/等级✅❌业务属性跨渠道共享昵称/头像✅✅主账号存展示用的渠道表存渠道侧原始数据openId❌✅渠道侧的标识和主账号无关session_key❌✅渠道侧的会话凭证access_token❌✅渠道侧的授权令牌订单数/消费额✅❌跨渠道汇总值放主账号用户类型✅❌身份状态是全局的账号状态❌✅某个渠道冻结不影响其他渠道最后登录时间❌✅各渠道的登录时间独立补充一点如果你所在的公司并发流量确实非常高实际项目中可能不止这两张表。像最后登录时间、登录次数这类变化频率极高的字段每次请求都在写和用户基本信息放在同一张表里会产生大量的行锁竞争。常见的做法是把这些高频写入的字段拆到独立的辅助表里比如user_login_stat主表只存相对稳定的数据。大部分中小体量的项目两张表足够了到了日活千万级别再考虑这个拆分也不迟。用户状态流转三个状态的能力边界状态触发条件main_user.user_type能力限制游客首次从任意渠道进入0浏览商品、加购物车不能下单、没有积分等级用户绑定手机号1下单、收货、领券没有会员专属权益会员首次下单或手动开卡2积分累计、等级升级、会员价无状态流转是单向的游客 → 用户 → 会员不可逆。游客到用户的转换发生在绑定手机号的时候。这个环节同时触发跨渠道账号合并的检测后面会讲。用户到会员的转换取决于业务规则。有的产品是首次下单自动成为会员有的需要用户手动点击开卡。技术上就是把main_user.user_type从1改成2同时初始化积分、等级等会员属性。需要注意的地方状态流转只改主账号表不改渠道账号表。渠道账号表只负责存登录凭证不参与业务状态的管理。这也是分层设计的价值业务逻辑只和主账号打交道不需要关心渠道层。登录流程和策略模式多渠道登录的代码组织是整个方案里最值得讲的一环。微信登录要解密加密数据拿手机号支付宝登录要调支付宝的API获取用户信息抖音登录要调抖音的接口解密App登录要验证短信验证码。每个渠道的获取手机号流程不同但拿到手机号之后的操作是一样的查找或创建主账号、创建渠道账号记录、生成token、缓存会话。如果用if-else来判断渠道类型写到第三四个渠道代码就膨胀了。每加一个渠道就要在已有的if-else链条上继续加而且所有渠道的代码挤在一个方法里改一个渠道的逻辑有可能误伤另一个渠道。用策略模式来隔离各渠道的差异。渠道枚举publicenumChannelType{WX(WX),ZFB(ZFB),DY(DY),APP(APP);privatefinalStringcode;ChannelType(Stringcode){this.codecode;}publicStringgetCode(){returncode;}}策略接口每个渠道的登录逻辑实现这个接口publicinterfaceChannelLoginService{LoginResultlogin(LoginRequestrequest);}公共基类把各渠道共用的逻辑抽到这里publicabstractclassAbstractChannelLoginServiceimplementsChannelLoginService{AutowiredprivateMainUserServicemainUserService;AutowiredprivateChannelUserServicechannelUserService;AutowiredprivateTokenServicetokenService;// 各渠道自己实现从渠道侧获取手机号protectedabstractStringresolvePhone(LoginRequestrequest);// 各渠道自己实现从渠道侧获取openIdprotectedabstractStringresolveOpenId(LoginRequestrequest);OverridepublicLoginResultlogin(LoginRequestrequest){StringopenIdresolveOpenId(request);StringphoneresolvePhone(request);// 通过openId查找已有的渠道账号ChannelUserchannelUserchannelUserService.findByChannelAndOpenId(request.getChannel(),openId);MainUsermainUser;if(channelUser!null){// 渠道账号已存在直接取主账号mainUsermainUserService.getById(channelUser.getMainUserId());}else{// 新渠道用户走创建或合并逻辑mainUserfindOrCreateMainUser(phone);channelUserService.create(mainUser.getId(),request.getChannel(),openId,phone);}// 拿到了手机号但主账号还没绑定执行绑定if(StringUtils.isNotBlank(phone)StringUtils.isBlank(mainUser.getPhone())){mainUserService.bindPhone(mainUser.getId(),phone);}// 生成token缓存会话StringtokentokenService.createToken(mainUser.getId(),request.getChannel());returnLoginResult.success(token,mainUser);}privateMainUserfindOrCreateMainUser(Stringphone){if(StringUtils.isNotBlank(phone)){// 有手机号尝试查找已有主账号可能从其他渠道注册过MainUserexistingmainUserService.findByPhone(phone);if(existing!null){returnexisting;}}// 没找到创建新主账号returnmainUserService.create(phone);}}这个基类定义了登录的标准流程获取openId → 获取手机号 → 查找或创建主账号 → 建立渠道关联 → 生成token。各渠道只需要实现resolvePhone和resolveOpenId两个方法告诉基类怎么从渠道侧拿到手机号和openId剩下的逻辑基类全包了。微信登录的策略实现Service(LOGIN_WX_PHONE)publicclassWxPhoneLoginServiceextendsAbstractChannelLoginService{AutowiredprivateWxMiniAppClientwxMiniAppClient;OverrideprotectedStringresolvePhone(LoginRequestrequest){// 调微信接口解密手机号returnwxMiniAppClient.decryptPhone(request.getEncryptedData(),request.getSessionKey());}OverrideprotectedStringresolveOpenId(LoginRequestrequest){returnrequest.getOpenId();}}支付宝登录的策略实现Service(LOGIN_ZFB_PHONE)publicclassZfbPhoneLoginServiceextendsAbstractChannelLoginService{AutowiredprivateAlipayUserClientalipayUserClient;OverrideprotectedStringresolvePhone(LoginRequestrequest){// 调支付宝API获取手机号returnalipayUserClient.getPhoneNumber(request.getAuthCode());}OverrideprotectedStringresolveOpenId(LoginRequestrequest){// 支付宝用userId作为渠道内唯一标识returnalipayUserClient.getUserId(request.getAuthCode());}}每个策略类只关注一件事怎么从自己的渠道拿到手机号和openId。微信需要解密加密数据支付宝需要用授权码换用户信息各走各的路。路由枚举定义渠道加登录方式到具体策略Bean的映射关系publicenumLoginRouteEnum{WX_PHONE(WX,PHONE,LOGIN_WX_PHONE),WX_SILENT(WX,SILENT,LOGIN_WX_SILENT),ZFB_PHONE(ZFB,PHONE,LOGIN_ZFB_PHONE),ZFB_SILENT(ZFB,SILENT,LOGIN_ZFB_SILENT),DY_PHONE(DY,PHONE,LOGIN_DY_PHONE),DY_SILENT(DY,SILENT,LOGIN_DY_SILENT),APP_SMS(APP,SMS,LOGIN_APP_SMS),APP_PWD(APP,PWD,LOGIN_APP_PWD);privatefinalStringchannel;privatefinalStringloginType;privatefinalStringbeanName;// 根据渠道和登录方式找到对应的Bean名称publicstaticStringresolve(Stringchannel,StringloginType){for(LoginRouteEnumroute:values()){if(route.channel.equals(channel)route.loginType.equals(loginType)){returnroute.beanName;}}thrownewBizException(不支持的登录方式: channel_loginType);}}路由器把请求分发到对应的策略实现ComponentpublicclassLoginRouter{// Spring会自动收集所有ChannelLoginService的实现类// key是Bean名称value是Bean实例AutowiredprivateMapString,ChannelLoginServiceloginServiceMap;publicLoginResultroute(LoginRequestrequest){StringbeanNameLoginRouteEnum.resolve(request.getChannel(),request.getLoginType());ChannelLoginServiceserviceloginServiceMap.get(beanName);if(servicenull){thrownewBizException(找不到登录处理器: beanName);}returnservice.login(request);}}这里用了Spring的一个特性当你声明MapString, SomeInterface类型的字段并用Autowired注入时Spring会把容器里所有实现了SomeInterface的Bean收集起来以Bean名称为key放进这个Map。不需要手动维护工厂类Spring帮你做了。整个登录的入口就一行调用PostMapping(/login)publicResultLoginResultlogin(RequestBodyLoginRequestrequest){returnResult.ok(loginRouter.route(request));}Controller完全不知道有哪些渠道也不需要知道。路由器根据请求参数找到对应的策略策略处理完返回结果Controller原样返回。新增渠道只加代码不改代码假设现在要接入抖音小程序需要做什么加一个策略类。继承AbstractChannelLoginService实现resolvePhone和resolveOpenId两个方法Service(LOGIN_DY_PHONE)publicclassDyPhoneLoginServiceextendsAbstractChannelLoginService{AutowiredprivateDyMiniAppClientdyMiniAppClient;OverrideprotectedStringresolvePhone(LoginRequestrequest){// 调抖音接口获取手机号returndyMiniAppClient.getPhoneNumber(request.getCode());}OverrideprotectedStringresolveOpenId(LoginRequestrequest){returnrequest.getOpenId();}}加两个枚举值。在LoginRouteEnum里加上抖音的路由条目DY_PHONE(DY,PHONE,LOGIN_DY_PHONE),DY_SILENT(DY,SILENT,LOGIN_DY_SILENT),在ChannelType枚举里加一个DY。做完了。不需要改LoginRouter不需要改AbstractChannelLoginService不需要改Controller不需要改任何业务层代码。Spring会自动发现新的Bean自动注入到loginServiceMap里路由枚举会把请求正确地分发到新的策略类。这就是开闭原则在实际项目里的样子对扩展开放对修改关闭。新增渠道是纯粹的加法操作不碰已有代码不引入回归风险。有人可能觉得路由枚举也是一种「修改」因为要在已有的枚举类里加值。严格意义上确实是。如果连枚举都不想改可以用注解的方式在策略类上声明自己处理哪个渠道和登录方式然后在路由器里通过反射扫描注解来建立映射关系。不过在实际项目里枚举路由表的好处是所有映射关系集中在一个地方一眼就能看到系统支持哪些渠道和登录方式可读性和可维护性都比注解扫描更好。为了追求「一个字都不改」而引入反射和注解扫描在渠道数量可控十个以内的情况下属于过度设计。跨渠道账号合并用户从微信进来浏览了一圈系统给他创建了一个游客主账号有main_user_id没手机号。后来同一个人从抖音进来绑了手机号系统又给他创建了一个主账号。这时候系统里有两条主账号记录属于同一个人需要合并。账号合并的时机是绑定手机号的时候。流程是这样的用户在某个渠道触发绑定手机号用手机号去main_user表的identifier字段做唯一查询如果没有找到同手机号的主账号直接把手机号写入当前主账号的identifier和phone字段完成绑定如果找到了说明这个手机号已经在另一个渠道注册过。把当前渠道账号的main_user_id指向已有的主账号然后删除当前的空主账号核心逻辑TransactionalpublicvoidbindPhone(LongcurrentMainUserId,Stringphone){// 加分布式锁防止并发绑定创建重复账号StringlockKeybind_phone:phone;RLocklockredissonClient.getLock(lockKey);lock.lock(10,TimeUnit.SECONDS);try{MainUserexistingmainUserMapper.selectByIdentifier(phone);if(existingnull){// 没有同手机号的主账号直接绑定mainUserMapper.updateIdentifier(currentMainUserId,phone);mainUserMapper.updatePhone(currentMainUserId,phone);mainUserMapper.updateUserType(currentMainUserId,UserType.USER.getCode());return;}if(existing.getId().equals(currentMainUserId)){// 就是当前账号不需要合并return;}// 发现同手机号的已有主账号执行合并// 把当前主账号下的所有渠道账号迁移到已有主账号下channelUserMapper.updateMainUserId(currentMainUserId,existing.getId());// 删除当前的空主账号mainUserMapper.deleteById(currentMainUserId);}finally{lock.unlock();}}这段代码里有一个不能省的东西分布式锁。如果不加锁两个渠道同时绑定同一个手机号可能都查到existing为空然后各创建一条主账号记录identifier唯一索引就冲突了。即使数据库层面唯一索引能兜底不会产生脏数据但报错重试的用户体验很差。加分布式锁保证同一个手机号的绑定操作是串行的。锁的粒度是手机号级别不是全局锁。不同手机号的绑定操作互不影响不会有性能瓶颈。手机号换绑绑定手机号解决了跨渠道识别的问题换手机号的场景也需要处理。在这套分层设计下手机号是main_user表的identifier是所有渠道账号的汇聚点。换绑手机号意味着修改这个汇聚点。好消息是channel_user表不受影响所有渠道账号还是指向同一个main_user_id需要关注的只有主账号表上identifier和phone两个字段的变更。最常见的情况用户的新手机号在系统里不存在。直接更新main_user的identifier和phone字段就行。麻烦的情况新手机号已经被另一个主账号占用了。比如用户A要把手机号换成138xxxx而138xxxx已经是用户B的identifier。这时候直接拒绝换绑提示用户该手机号已被其他账号使用。理论上也可以走账号合并但两个账号都有业务数据时合并的复杂度很高大部分产品不会选这条路。换绑操作同样需要分布式锁。和首次绑定不同的是换绑要同时锁住旧手机号和新手机号。只锁一个会有并发问题两个用户同时换绑到同一个新号码或者一个用户换绑的同时另一个用户正在绑定同一个号码。锁的获取顺序按手机号字典序排列避免死锁。换绑次数也需要限制。有些用户会频繁换绑不加限制的话数据关系会越来越乱排查问题时很难追溯历史。通常的做法是限制每个主账号每月最多换绑3次超过次数需要联系客服。用一张绑定日志表记录每次绑定和解绑操作校验次数时查最近30天的日志数。这张日志表同时也是审计需要的操作记录。多端用户体系设计速查表遇到类似需求时可以直接参考这张表设计要点建议做法踩坑提醒表结构两张表主账号 渠道账号不要在主账号表上加渠道相关字段跨渠道标识手机号作为全局唯一标识identifier字段必须加唯一索引游客处理创建主账号但identifier用临时值空字符串在唯一索引下会冲突用UUID状态流转游客→用户→会员单向不可逆用user_type字段只在主账号表上维护登录路由策略模式 Spring Map注入枚举路由表比注解扫描更好维护新增渠道加策略类 加枚举值不改已有代码不改Controller账号合并绑定手机号时检测并合并必须加分布式锁锁粒度是手机号手机号换绑优先拒绝冲突不做自动合并同时锁旧号和新号按字典序获取锁避免死锁绑定限制每月限制换绑次数用绑定日志表记录不要只存最后一次token管理token里带渠道信息同一用户在不同渠道可以同时在线渠道下线渠道账号标记解绑不物理删除主账号不受影响业务数据不丢失小结做了几个多渠道用户体系之后有一个体会这件事最难的部分不是代码实现而是在项目初期就把主账号和渠道账号的边界想清楚。很多项目一开始图快把渠道信息直接塞进用户表里等到第三四个渠道要接入的时候才发现表结构不对要做数据迁移。那个时候线上已经有几百万用户了迁移的复杂度和风险比一开始就做分层设计高了一个数量级。技术方案的成本不只是开发成本还有未来的变更成本。策略模式在这个场景下的价值不在于它多高级而在于它给了团队一个约定新增渠道的代码放在哪里、怎么组织、边界在哪里。团队里的人照着这个约定写不容易出错代码评审也有明确的检查标准。这种约定比任何文档都管用。希望这篇内容可以帮到你。最近在知乎出了秒杀专栏感兴趣的可以订阅一下。至于知识星球的可以搜老码头的技术浮生录它是一个能实际帮你解决难题的星球。有问题的找知心的Sam哥支持无限次语音一对一解决你遇到的难题。另外后续我新写的所有对外的付费专栏在星球内都是免费的且可以拿到所有源代码。我的知乎账号:SamDeepThinking