# op-user-center **Repository Path**: liyuncc/op-user-center ## Basic Information - **Project Name**: op-user-center - **Description**: 亿级用户中心的设计与实践 - **Primary Language**: Java - **License**: Apache-2.0 - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 2 - **Created**: 2023-11-17 - **Last Updated**: 2024-08-11 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # op-user-center ## 介绍 **亿级用户中心的设计与实践** 参考连接: > 作者:vivo互联网技术 > 链接:[亿级用户中心的设计与实践](https://juejin.cn/post/6937165940933165070) > 来源:稀土掘金 > > 作者:架构师之路 > 链接:[用户中心,1亿数据,架构如何设计?](https://cloud.tencent.com/developer/article/1663332) > 来源:腾讯云开发者社区 用户中心,顾名思义就是管理用户的地方。其核心功能包括注册与登录,主要功能是修改密码、换绑手机号码、获取用户信息、修改用户信息和一些延伸服务, 同时还有登录之后生成Token以及校验Token的功能。 ### 1、服务架构 基于业务特性,可以将用户中心拆分为3个独立的微服务:网关服务,核心服务,异步消费者服务。 #### 1.1、网关服务 提供http服务,聚合了各种业务逻辑和服务调用,比如登录时候需要校验的风控或者短信; #### 1.2、核心服务 处理简单的业务逻辑以及数据存储,核心服务处在调用链路的终端,几乎不依赖调用其他服务,比如校验Token或者获取用户信息,他们就只依赖于redis或者数据库; #### 1.3、异步消费者服务 处理并消费异步消息; #### 1.4、架构示意图 ![img.png](images/usercenter-architecture.png) 这样的设计之后,当有新功能上线时,核心服务和异步消费服务几乎不需要重新发布,只需要发布网关服务,依赖我们核心服务的第三方非常放心,层级也非常地清晰。 当然,这样做的代价就是服务的调用链路变长了。由于涉及到网关和核心服务,就需要发布两个服务,而且要做兼容性测试。 ### 2、接口设计 用户中心的接口涉及到用户的核心信息,安全性要求高;同时承接了较多的第三方调用,可用性也要求高。因此,对用户中心的接口做以下设计: - 接口可以拆分为面向Web和面向App的接口。Web接口需要做到跨域情况下的单点登录,加密、验签和token校验的方式也同App端的不一样。 - 对核心接口做处理。(在思考思考,还是有点不是很理解的透彻。) - 用户核心信息表做简单。可拆分为用户账号表(userId、username、手机号码、密码、盐值等)、用户资料表(性别、头像、昵称、邮箱、地址等); - 将登录的核心链路做短,短到只依赖于读库。一般情况下,用户登录后,需要记录用户登录信息,调用风控或者短信等服务。对于登录链路来说,任何一个环节出现问 题都有可能导致用户无法登录,那么怎么样才能做到最短的链路呢?方法就是依赖的服务可自动降级。比如说反欺诈校验出问题了,那么它自动降级后使用它的默认策略, 极端情况下只做密码校验,主库挂了之后还能到从库读取用户信息。 - 接口的安全性校验。对App接口我们需要做防重放和验签。得益于大数据的支持,结合终端,我们还可以把每个用户行为画像存储在系统中(或者调用第三方服务)。 用户发起请求后,我们的接口会根据用户画像对用户进行诸如手机号码校验、实名认证、人脸或者活体校验。 ### 3、数据存储 #### 3.1、分库分表 随着用户的增长,数据量过大,常见的方式就是分库分表。用户中心常见的一些表结构:用户信息表、第三方登录关联表、用户事件表等。从上述表中可以看出,用户相关的 数据表增长是相对缓慢的,因为用户的增长是有天花板的。用户事件表的增长是呈指数级增长,因为每个用户登录、变更等密码及变更手机号码等操作是不限次数。 ##### 3.1.1、用户信息表垂直切分 正如上面说的,将用户ID、密码、手机号、盐值等常见字段从用户信息表中拆分,其他用户相关的信息用单独一张表。另外,把用户事件表迁移至其他库中。 相比于水平切分,垂直切分的代价相对较少,操作起来相对简单。用户核心信息表由于数据量相对较少,即使是亿级别的数据,利用数据库缓存的机制,也能够解决性能问题。 ##### 3.1.2、业务的特性区分处理 可以利用前后台业务的特性采用不同的方式来区别对待;对于不同的业务特性使用不同的解决方案;有两类典型的业务需求:第一类是用户侧的前台访问;第二类是运营侧的后台访问。 **用户侧前台访问** 最典型的有两类需求:用户通过username/mobile登录或者通过uid来查询用户信息。 1. 用户登录查询:通过username/mobile查询用户账号信息,1%请求属于这种类型; 2. 用户信息查询:登录之后,通过UID来查询用户基本信息,99%属于这种请求; 用户侧信息的访问通常是单条数据的查询,访问量较大,服务需要高可用,并且对一致性的要求较高,我们可以通过索引多次查询来解决一致性和高可用问题。 **运营侧后台访问** 根据年龄、性别、登录时间段、注册时间段等来进行查询,基本上都是批量分页查询。但是由于是内部系统,查询量低,对一致性要求低。如果用户侧和运营侧的查询采用 同一个数据库,那么运营侧的排序查询会导致整个库的CPU上升,查询效率下降,影响到用户侧。因此,运营侧使用的数据库可以是和用户侧同样的MySQL离线库, 如果想要增加运营侧的查询效率,可以采用ES非关系型数据库。ES支持分片与复制,方便水平分割和扩展,复制保证了ES的高可用与高吞吐,同时能够满足运营侧的查询需求。 ##### 3.1.3、水平切分来保证系统性能 常见的方法有四种: 1. 索引表法 2. 缓存映射法 3. 生成UID法 4. 基因法 **索引表法** 索引表法的思路主要是UID能够直接定位到库,但是手机号码或者username是无法直接定位到库的,需要建立一个索引表来记录mobile与UID或者username与UID的 映射关系的方式来解决这个问题。通常这类数据比较少,可以不用分库分表,但是相比直接查询,多了一次数据库查询的同时,在新增数据的时候还多了一次映射关系的插入,事务变大。 具体解决方案如下: 1. 建立一个索引表记录username/mobile与UID的映射关系; 2. 用username/mobile来访问时,先通过username/mobile来访问时,先通过索引表查询到UID,再根据UID定位到相应的库; 3. 索引表属性较少,可以容纳非常多的数据,一般不需要分库分表; 4. 如果数据量较大,可以通过username/mobile来分库; 缺点:数据访问,会增加一次数据库查询,事务变大,性能会有所下降。 **缓存映射法** 缓存映射法的思路和索引表法类似,唯一不同的就是,访问索引表性能较低,把映射关系放在缓存里,能够提升性能。 具体解决方案如下: 1. 用username/mobile来访问时,先到cache中查询UID,再根据UID定位数据库; 2. 假设cache Miss,扫描所有分库,获取username/mobile对应的UID,放入cache; 3. username/mobile到UID的映射关系不会变化,映射关系一旦放入缓存,不会更改,无需淘汰,缓存命中率超高; 4. 如果数据量过大,可以通过login_name进行cache水平切分; 缺点:仍然会增加一次网络交互,即一次cache查询。 **生成IUD法** 生成IUD法的思路是不进行远程查询,由username/mobile直接得到UID; 具体解决方案如下: 1. 在用户注册时,设计函数username/mobile生成UID,UID=f(username/mobile),按UID分库插入数据; 2. 用username/mobile来访问时,先通过函数计算出UID,再根据UID定位数据库; 缺点:该函数设计需要非常讲究技巧,且有uid生成冲突风险;uid冲突,是业务无法接受的,故生产环境中,一般不使用这个方法。只适用于某些特定场景。 **基因法** 基因法的思路是我们将username或者mobile融入到UID中。 具体解决方案如下: 1. 用户注册时,根据用户的手机号码,利用函数生成N bit的基因uid_gen,使得uid_gen=f(username/mobile); 2. 生成M bit全局唯一的id,作为用户标识; 3. 拼接M和N,作为UID赋给用户; 4. 根据N bit来取余来插入到特定数据库; 5. 查找用户数据的时候,将用户UID的后N bit取余来落到最终的库中。 ![img.png](images/usercenter-uid-genetic.png) *注意:基因法只适用于某类经常查询的场景。* ##### 3.1.4、前台与后台分离 前台业务和后台业务共用一批服务和一个数据库,有可能导致,由于后台的“少数几个请求”的“批量查询”的“低效”访问,导致数据库的cpu偶尔瞬时100%, 影响前台正常用户的访问(例如,登录超时)。而且,为了满足后台业务各类“奇形怪状”的需求,往往会在数据库上建立各种索引,这些索引占用大量内存, 会使得用户侧前台业务uid/username/mobile上的查询性能与写入性能大幅度降低,处理时间增长。对于这一类业务,应该采用“前台与后台分离”的架构方案。 用户侧前台业务需求架构依然不变,产品运营侧后台业务需求则抽取独立的 web / service / db 来支持,解除系统之间的耦合, 对于“业务复杂”、“并发量低”、“无需高可用”、“能接受一定延时”的后台业务可以稍作调整: 1. 可以去掉service层,在运营后台web层通过dao直接访问db; 2. 不需要反向代理,不需要集群冗余; 3. 不需要访问实时库,可以通过MQ或者线下异步同步数据; 4. 在数据库非常大的情况下,可以使用更契合大量数据允许接受更高延时的“索引外置”(例如ES搜索系统)或者“大数据处理”(例如HIVE)的设计方案; ![img_1.png](images/usercenter-platform-separation.png) #### 3.2、缓存 将热门或频繁访问的用户数据缓存起来,减轻数据库的压力,并提高响应速度。 #### 3.3、数据一致性 采用分布式事务或最终一致性方案,保证不同存储节点之间的数据一致性。 ### 4、Token 用户登录之后,另一个重要的事情就是Token的生成与校验。用户的Token分为两类, 一类是web端登陆生成的Token,这个Token可以和Cookie结合,达到单点登陆的效果; 另外一类就是APP端登录生成的Token。 **Token生成** 用户在我们的APP输入用户名密码之后,服务端会对用户的用户名密码进行校验,成功之后从系统配置中心获取加密算法的版本以及秘钥,并按照一定的格式排列用户ID, 手机号、随机码以及过期时间,经过一系列的加密之后,生成了Token之后并将其存入Redis缓存。而Token的校验就是把用户ID和Token组合并校验是否在Redis中存在。 那么假如Redis不可用了怎么办呢?这里有一个高可用和自动降级的设计。当Redis不可用的时候, 服务端会生成一个特殊格式的Token。当校验Token的时候, 会对Token的格式进行一个判断。 **Token降级** 假如判断为Redis不可用时生成的Token,那么服务端会对Token进行解密,而Token的生成是由用户ID,手机号、随机码和过期时间等数据按照特定顺序排列并加密而来的, 那么解密出来的数据中也包含了ID,手机号码,随机码和过期时间。服务端会根据获取到的数据查询数据库,比对之后告诉用户是否登录成功。 由于内存缓存redis和数据库缓存性能的差距,在redis不可用的情况下,降级有可能会导致数据库无法及时响应,因此需要在降级的方法上加入限流。 ### 5、数据安全 数据安全对用户中心来说非常重要。敏感数据需要脱敏处理,对密码更是要做多重的加密处理。应用虽然有自己的安全策略,但如果把黑客限制在登录之前,那应用的安全性将得到大幅度的提升。 而即使使用了MD5和salt的加密方式,依然可以使用彩虹表的方式来破解。那么用户中心对用户信息是怎么保存的呢? **信息分离** 将不同的数据分离出来,比如用户账号、用户密码、手机号码等登录信息和其他信息的分离,并且保存在不同的数据库中。 **黑名单校验** 对用户设置的密码进行了黑名单校验,只要符合条件的弱密码,都会拒绝提交,因为不管使用了什么加密方式的弱密码,都极其容易破解。因为人的记性很差,大部分人总是最倾向于选择生日, 单词等来当密码。6位纯数字可以生成100万个不同的密码,8位小写字母和数字的组合大概可以生成2.8万亿个不同的密码。一个规模为7.8万亿的密码库足以覆盖大部分用户的密码, 对于不同的加密算法都可以拥有这样一个密码库,这也就是为什么大部分网站都建议用户使用8位以上数字加字母密码的原因。当然,如果一方面加了盐值, 另一方面对密钥分开保管,破解难度会指数级增加。 **加密方式** 可以用bcrypt/scrypt的方式来加密。bcrypt算法是基于Blowfish块密钥算法来实现的,bcrypt内部实现了随机加盐处理,使用bcrypt之后每次加密后的密文都不一样, 同时还会使用内存初始化hash过程。由于使用内存,虽然在CPU上运行很快,但是在GPU上并行运算并不快。随着新的FPGA集成了大型RAM,解决了内存密集IO的问题, 但是破解难度依然不小。而scrypt算法弥补了bcrypt算法的不足,它将CPU计算与内存使用开销都指数级提升了。bcrypt和scrypt算法能够有效抵御彩虹表, 但是安全性的提升带来了用户登录性能的下降。用户登录注册并不是一个高并发的接口,所以影响并不会特别大。因此在安全和性能方面需要依据业务类型和大小来做平衡, 并不是所有的应用都需要使用这种加密方式来保护用户密码。 ### 6、异步消费设计 用户在做完登录注册等操作后,需要记录用户的操作日志。同时,用户注册登录完毕后,下游业务需要对用户增加积分,赠送礼券等奖励操作。这些系统如果都同步依赖于用户中心, 那么整个用户中心将异常庞大,链路非常冗长,也不符合业内的“大系统做小“的原则。依赖的服务不可用之后将会造成用户无法登录注册。因此,用户中心在用户操作完之后, 将用户事件入库后发送至MQ,第三方业务监听用户事件。用户中心和下游业务解耦,同时用户操作事件入库后,在MQ不可用或者消息丢失的时候可做补偿处理。 用户的画像数据也在很大程度上来源于此处的数据。 ### 7、监控 用户中心涉及到用户的登录注册更改密码等核心功能,能否及时发现系统的问题成为关键指标,因此对业务的监控显得尤为重要。需要对用户中心重要接口的QPS、机器的内存使用量、 垃圾回收的时间、服务的调用时间等做详细的监控。当某个接口的调用量下降的时候,监控会及时发出报警。除了这些监控之外,还有对数据库Binlog的写入,前端组件, 以及基于ZipKin全链路调用时间的监控,实现从用户发起端到结束端的全面监控,哪怕出现一点问题,监控随时会告诉你哪里出问题了。比如运营互动推广注册量下降的时候, 用户中心就会发出报警,可以及时通知业务方改正问题,挽回损失。 ### 8、其他(待补充。。。) 用户中心的设计远不止这些,还会包含用户数据的分库分表,熔断限流,第三方登录等。 存在一些比较大的挑战:在鉴权服务增长的情况下,如何平滑的从用户中心剥离;监控的侵入性以及监控的粒度的完善;另外服务的安全性、可用性、性能的提升等。 ## 软件架构 软件架构说明 **使用版本** | 框架 | 版本 | |------------------------------|----------------| | spring.cloud.alibaba.version | 2022.0.0.0-RC2 | | spring.cloud.version | 2022.0.0 | | spring.boot.version | 3.0.0 | | java.version | 17 | **中间件** | 名称 | 部署版本 | 依赖版本 | 官方文档 | |--------------|--------|-------|--------------------------------------------------------------| | Nacos | 2.3.0 | ~ | [Nacos 文档](https://nacos.io/zh-cn/docs/quick-start.html) | | Apache Dubbo | ~ | 3.2.2 | [Dubbo 文档](https://cn.dubbo.apache.org/zh-cn/overview/home/) | | Redis | 6.2.14 | ~ | [Redis 文档](https://redis.com.cn/) | | Sentinel Version | Nacos Version | RocketMQ Version | Apache Dubbo Version | Seata Version | |------------------|---------------|------------------|----------------------|------------------| | 1.8.6 | 2022.0.0.0 | 4.9.4 | 3.2.2 | 1.7.0-native-rc2 | 注意:Spring Cloud Dubbo 从2021.0.1.0 起已被移除主干,不再随主干演进;因此我们使用Apache Dubbo。 **使用到的组件** > - 服务注册与发现:Nacos > - 分布式事务:Seata > - 网关:Spring Cloud Gateway > - 服务调用:OpenFeign (或者Apache Dubbo) > - 鉴权:Spring Authorization Server 、Oauth2.1 > - 消息队列:rocketmq > - 限流、熔断:sentinel > - 链路追踪:Micrometer Tracing > - 接口文档:knife4j ## 项目集成 ### 父级依赖集成 **父级工程POM** ```xml 17 17 UTF-8 3.0.0 2022.0.0 2022.0.0.0-RC2 3.2.2 1.8.6 4.9.4 1.7.0-native-rc2 org.springframework.boot spring-boot-dependencies ${spring-boot.version} pom import org.springframework.cloud spring-cloud-dependencies ${spring-cloud.version} pom import com.alibaba.cloud spring-cloud-alibaba-dependencies ${spring-cloud-alibaba.version} pom import ``` ### Nacos集成 **模块POM:pom.xml** ```xml com.alibaba.cloud spring-cloud-starter-alibaba-nacos-discovery com.alibaba.cloud spring-cloud-starter-alibaba-nacos-config org.springframework.cloud spring-cloud-starter-bootstrap org.springframework.cloud spring-cloud-starter-loadbalancer ``` **模块配置文件:bootstrap.yaml** ```yaml spring: application: name: your-application cloud: nacos: discovery: server-addr: hostname:port namespace: your-namespace username: your-nacos-username password: your-nacos-password config: server-addr: hostname:port namespace: your-namespace username: your-nacos-username password: your-nacos-password file-extension: yml ``` ### SpringCloud Gateway集成 **模块POM:pom.xml** ```xml com.alibaba.cloud spring-cloud-starter-alibaba-nacos-discovery com.alibaba.cloud spring-cloud-starter-alibaba-nacos-config org.springframework.cloud spring-cloud-starter-bootstrap org.springframework.cloud spring-cloud-starter-gateway ``` **模块配置文件:bootstrap.yaml** ```yaml spring: application: name: your-application-gateway cloud: nacos: discovery: server-addr: hostname:port namespace: your-namespace username: your-nacos-username password: your-nacos-password config: server-addr: hostname:port namespace: your-namespace username: your-nacos-username password: your-nacos-password file-extension: yml gateway: routes: - id: your-application uri: hostname:port predicates: - Method=GET,POST - Path=/your-service/** filters: # 让gateWay自动去掉一节路径 - StripPrefix=1 ``` **注意:** 1. 在Spring Cloud 2020版本以后,默认移除了对Netflix的依赖,其中就包括Ribbon,官方默认推荐使用Spring Cloud Loadbalancer正式 替换Ribbon,并成为了Spring Cloud负载均衡器的唯一实现。 ```xml org.springframework.cloud spring-cloud-loadbalancer ``` 2. 由于服务路径的问题,在gateway配置的content-path路径不是路由服务的content-path,可能回报404,需要检查配置路径;可以使用filters来 截取掉不匹配的路径。 ```yaml spring: cloud: gateway: routes: filters: - StripPrefix=1 # 需要截取几层,值就等于几 ``` ### Apache Dubbo集成 阿里早已把dubbo捐赠给了Apache,现在dubbo由Apache在维护更新,dubbo也已经成了Apache下的顶级项目。 #### 服务提供者 **模块POM:pom.xml** ```xml org.springframework.cloud spring-cloud-starter-bootstrap org.apache.dubbo dubbo-spring-boot-starter ${apache-dubbo.version} org.apache.dubbo dubbo-registry-nacos ${apache-dubbo.version} ``` **模块配置文件:bootstrap.yaml** ```yaml dubbo: application: name: your-application-name registry: address: nacos://host:port parameters: namespace: your-namespace username: your-nacos-username password: your-nacos-password protocol: name: dubbo port: 20880 # your-dubbo-port ``` **主程序启动类:Application添加 @EnableDubbo 注解** ```java @EnableDubbo(scanBasePackages = "your-interface-impl-package-path") // 开启dubbo @SpringBootApplication public class ServiceProviderApplication { public static void main(String[] args) { SpringApplication.run(ServiceProviderApplication.class, args); } } ``` **Dubbo通用接口实现类:添加 @DubboService 注解** ```java @DubboService public class DubboXXXApiImpl implements DubboXXXApi { // to impl interface } ``` #### 服务调用者 **模块POM:pom.xml** ```xml org.apache.dubbo dubbo-spring-boot-starter ${apache-dubbo.version} ``` **模块配置文件:bootstrap.yaml** ```yaml dubbo: application: name: your-application-name registry: address: nacos://host:port parameters: namespace: your-namespace username: your-nacos-username password: your-nacos-password protocol: name: dubbo port: 20880 # your-dubbo-port ``` **Dubbo通用接口调用:** ```java @Service public class XXXServiceImpl { @DubboReference private DubboXXXApi dubboXXXApi; } ``` ### Redis集成 **模块POM:pom.xml** ```xml org.springframework.boot spring-boot-starter-data-redis ``` **模块配置文件:bootstrap.yaml** ```yaml spring: data: redis: host: your-redis-host port: your-redis-port timeout: 3000ms lettuce: pool: max-active: 20 # 最大连接数,负值表示没有限制,默认8 max-wait: -1 # 最大阻塞等待时间,负值表示没限制,默认-1 max-idle: 8 # 最大空闲连接,默认8 min-idle: 0 # 最小空闲连接,默认0 ```