之前写了一篇文章,从分布式系统遇到的难点开始,对分布式系统的架构进行了介绍。有兴趣可以看下这篇文章「分布式系统遇到的难点与问题解决」。
这篇文章就不介绍那么多架构方面的东西了,直接依据一个具体的场景,给出核心代码。
一个大家都容易理解的场景是即时通讯系统,不过这个即时通讯系统加入了现在比较火的GPT,于是会稍微多一些概念,因此也会遇到新的棘手的问题,在这篇文章里,都会解决。
因为之前的文章讲解了架构,这篇文章不再讲解架构,从核心的关键点出发,会有不一样的收获和思考。
先提前交代下技术栈:Nest.js,TypeORM,PostgreSQL,RabbitMQ,Redis。
核心模型
系统的模型不是一开始就完全固定下来的,它是不断演化的,但是这里不讲演化过程,直接上结果,然后简单分析:
typescript
import { MaxLength } from 'class-validator';
import {
Entity,
Column,
Index,
CreateDateColumn,
PrimaryColumn,
DeleteDateColumn,
} from 'typeorm';
// 用户表,用于存储用户信息,这个表包含了GPT代理,设计的时候,是希望用户和代理同样对待
@Entity()
export class User {
@PrimaryColumn({ length: 16 }) // 在业务中生成ID
id: string;
@MaxLength(32)
@Column()
name: string;
@MaxLength(100)
@Index()
@Column({ nullable: true })
email: string;
@Column({ length: 60, nullable: true })
password: string;
@MaxLength(128)
@Column({ nullable: true })
avatar: string;
@MaxLength(512)
@Column({ type: 'text', nullable: true })
description: string;
@Column({ nullable: true, length: 16 })
creatorId?: string;
@MaxLength(2048)
@Column({ type: 'text', nullable: true })
instructions: string;
@Column({ nullable: true })
labels: string;
@Column({ default: false })
instructionsIsPublic: boolean;
@Column({ default: 'user' })
type: 'user' | 'agent';
@Column({ default: false })
isPublic: boolean;
@Column({ nullable: true })
model: string;
@Column({ nullable: true })
vipEndTime: Date;
@CreateDateColumn()
createdAt: Date;
@DeleteDateColumn({ nullable: true })
deletedAt?: Date;
}
// 好友表,用于存储用户之间的好友关系
@Entity()
export class Friend {
@PrimaryColumn({ length: 16 })
userId: string;
@PrimaryColumn({ length: 16 })
friendId: string;
}
// 聊天表,用于存储聊天信息
@Entity()
export class Chat {
@PrimaryColumn({ length: 16 }) // 在业务中生成ID
id: string;
@MaxLength(32)
@Column({ nullable: true })
name: string;
@MaxLength(512)
@Column({ type: 'text', nullable: true })
description: string;
@CreateDateColumn()
createdAt: Date;
@DeleteDateColumn({ nullable: true })
deletedAt?: Date;
}
// 消息表,用于存储消息
@Entity()
export class Message {
@PrimaryColumn({ length: 16 }) // 在业务中生成ID
id: string;
@Column({ length: 16 })
chatId: string;
@Column({ length: 16 })
senderId: string;
@Column({ nullable: true })
type: string;
@Column('text', { nullable: true })
content?: string;
@CreateDateColumn()
createdAt: Date;
}
// 消息状态表,用于存储消息的状态
@Entity()
export class MessageStatus {
@PrimaryColumn({ length: 16 })
messageId: string; // 关联到Message实体的ID
@PrimaryColumn({ length: 16 })
userId: string; // 接收消息的用户ID
@Column({ default: false })
isDelivered: boolean; // 消息是否已经送达
@Column({ default: false })
isRead: boolean; // 消息是否已被阅读
@CreateDateColumn()
createdAt: Date;
}
// 聊天成员表,用于存储聊天成员
@Entity()
export class ChatMember {
@PrimaryColumn({ length: 16 })
chatId: string;
@PrimaryColumn({ length: 16 })
userId: string;
@Column({ nullable: true })
role: string;
@Column({ default: true })
isVisible: boolean;
}
模型分析
这些表都没有添加主外键约束,是为了容易分库分表,约束关系在业务中去完成。
user
:存放用户的账户和密码等信息,当然,密码一定要加密,且不可逆。其余还有类型,比如分真正的用户和agent。frient
:这个目前设计的简单,无非就是我加你好友,你加我好友。再复杂些就是添加状态字段,可以让满足更加细腻的用户关系。chat
:即时通信软件会有单聊和群聊,无论哪种,都可以认为是一个对话,如果复杂些,这里也可以加类型等字段,区分群聊还是单聊或者是临时性的信息流。信息流适合做一场直播等。message
:用户发送的任何消息,都记录在这个表,关键的信息是chatid和senderid(userid),这样在业务中,就能正确的投递信息了。message_status
:这个表是为了标记接收者对这条消息的状态,比如用不不在线,就标记为未送达,查看了这条消息,就标记为已读,看业务需要是否添加这些字段。目前微信是没有已读未读的状态的。chat_member
:这个表就关系到当消息要投递的时候,应该投递到哪些用户了,也可以根据自己系统增加活着删除一些字段,看业务需要了。核心的差不就这些。
一个即时通讯的核心模型基本就这些,这六个表和这些字段,在不涉及到复杂业务的情况下,基本上就可以了。比如想接入微信登录、微信字符,只需要简单加表,就可以了,在业务中去处理表之间的关系。
认证模块
有了核心的模型,遇到的遇到的第一个问题就是账户认证,说白了就是注册和登录。我们先理解一个简单认证流程,暂时什么都不考虑:
- 用户输入账号和密码进行注册,账号和密码存入到数据库。
- 用户通过账号密码登录,后端进行数据库查询
- 查询数据发现密码账号对的上,把账号和密码写入到cookie里
- 用户再访问其它API,接口从cookie里取出用户名和密码判断用户是否正常,是否允许后续
不考虑安全的情况下,就是这么简单。
简单改造,引入安全测试:
- 密码存入数据库之前进行加密,可以选择bcrypt
- 登录查询的时候也选择bcrypt
- 密码验证成功之后,改成生成token,这里选择 JWT,把token写入cookie。也可以不写入cookie。
- 用户访问API的时候,从cookie里取出token进行验证。
安全与不安全的边界就在这里,我们总能找到各种方式,优化系统。
核心配置
- 首先是JwtModule模块:
typescript
// jwt.module.ts
import { Module } from '@nestjs/common';
import { JwtModule as NestJwtModule } from '@nestjs/jwt';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { readFileSync } from 'fs';
import { join } from 'path';
@Module({
imports: [
NestJwtModule.registerAsync({
imports: [ConfigModule],
useFactory: (configService: ConfigService) => {
const privateKeyPath = join(
process.cwd(),
configService.get('JWT_PRIVATE_KEY_PATH'),
);
const publicKeyPath = join(
process.cwd(),
configService.get('JWT_PUBLIC_KEY_PATH'),
);
return {
privateKey: readFileSync(privateKeyPath),
publicKey: readFileSync(publicKeyPath),
signOptions: {
expiresIn: configService.get('JWT_EXPIRES_IN'),
algorithm: 'RS256',
},
};
},
inject: [ConfigService],
}),
],
exports: [NestJwtModule],
})
export class JwtModule {}
这个就是JWT的基本配置,核心是公钥和私钥,然后是过期时间和加密算法。生成token的时候,就会使用这些配置。
- 其次就是路由守卫:
typescript
// jwt-auth.guard.ts
import { Reflector } from '@nestjs/core';
import { AuthGuard } from '@nestjs/passport';
import { Injectable, ExecutionContext } from '@nestjs/common';
import { IS_PUBLIC_KEY } from './public.decorator';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private reflector: Reflector) {
super();
}
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) {
return true;
}
return super.canActivate(context);
}
}
这里额外有个装饰品:
javascript
// public.decorator.ts
import { SetMetadata } from '@nestjs/common';
export const IS_PUBLIC_KEY = 'isPublic';
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
这个路由守卫和这个装饰器结合,就可以对一些路由做更加细致化的处理了,比如登录和注册接口需要开放,那么,就使用这个装饰器进行装饰就可以了。
不满足这个装饰器的,金进去下一个阶段,进行签名认证或者其它的一些逻辑。
- 其次是策略类:
typescript
// jwt.strategy.ts
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { join } from 'path';
import { readFileSync } from 'fs';
import { RedisService } from 'src/services/redis.service';
interface JwtPayload {
id: string;
username: string;
email: string;
role: string;
}
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(
private readonly configService: ConfigService,
private readonly redisService: RedisService,
) {
const publicKeyPath = join(
process.cwd(),
configService.get('JWT_PUBLIC_KEY_PATH'),
);
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: readFileSync(publicKeyPath),
algorithms: ['RS256'],
});
}
async validate(payload: JwtPayload) {
const user = this.redisService.getUserInfo(payload.id);
if (!user) return false;
return payload;
}
}
这个类的作用就是,每当用户调用受保护的资源时,都会调用这个类,它配置了从哪里获取token,是否忽略过期时间,加密算法和公钥(用户验证token的签名)。
最后还可以对payload进行一些验证,比如用户是否存在。如果还有更加个性化的判断,可以创建其它路由守卫。
- 最后就是把他们配置到 AppModule里
typescript
// app.module.ts
import { Module } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core';
import { JwtAuthGuard } from './auth/jwt-auth.guard';
import { JwtModule } from './modules/jwt.module';
import { JwtStrategy } from "./auth/jwt.strategy";
@Module({
imports: [
JwtModule, // 注册 JWT 模块
],
providers: [
JwtStrategy, // JWT 策略
{
provide: APP_GUARD, // 注册守卫
useClass: JwtAuthGuard, // 使用 JWT 守卫
},
],
})
export class AppModule {}
自此,一个在应用层面上已经具备了极高安全认证的策略已经完成。
有了模型和认证,应用层面上已经足够安全了,接下来该实现业务了。业务都有什么呢?注册、登录、修改密码、添加好友、删除好友、单聊、群聊、消息发送、消息接收等等很多,这些很多没必要单独拿出来说。
在业务开始之前,我们需要关心分布式系统下的ID该如何生成。
分布式系统中的 ID
在单机系统中,ID 直接使用数据库的自增字段就行,如果数据库分库分表,这种就不适合了。分布式系统下有很多解决方法,最简单的使用uuid这类算法,但是这类算法不支持有序性,且也比较长。或者可以使用redis的自增key,但是这会给redis带来巨大的压力。
雪花算法
直白来说,ID是拼接而成的,又具有自增的性质。我在这里做了一些改进,且容易操作,性能又高,逻辑如下:
- ID最终是以字符串的方式存储,且比较大小是字符串的ASCII码值大小比较的。
- ID的组成:0-8位是精确的毫秒的时间戳 + 9-11位的自增计数器 + 12-16位的实例ID。
- 为什么只需要8位就可以表示毫秒级的时间戳,这里使用把10进制转换成62进制的算法,位数自然就大大缩小,其余也是如此。
- 总共16位字符串,其实太长了,其实可以减少到12位左右,12位左右的一个全局唯一性的ID,是非常短的了。
两个关键点
- 10进制转62进制算法,还要保证转换后的字符串大小跟10进制大小是有序的:
typescript
/**
* 将十进制数转换为62进制数
* @param decimal 十进制数
* @param length 生成的62进制数的长度
* @returns 62进制数
*/
export default function decimalTo62(
decimal: number,
length: number = 0,
): string {
const chars =
'0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
let result = [];
// 处理decimal为0的情况,直接根据length返回结果
if (decimal === 0) {
return '0'.repeat(Math.max(length, 1));
}
// 转换过程
while (decimal > 0) {
result.unshift(chars[decimal % 62]); // 直接在数组前端插入,避免后续反转
decimal = Math.floor(decimal / 62);
}
// 补足前导零
const neededZeros = Math.max(length - result.length, 0);
if (neededZeros > 0) {
result = Array(neededZeros).fill('0').concat(result); // 一次性补足前导零
}
return result.join('');
}
转换后依然有序的逻辑是因为给定的字符集也是有一个由小到大的顺序,在转换的时候,就会形成由小到大的字符串排序。
- 实例ID,也称之为节点ID,由于nodejs是单线程,在一台多核机器上(一个节点)可以使用pm2启动多个实例,因此每个实例分配一个实例ID。这个实例ID是实例启动的时候,通过redis获取一个ID(或者通过其它方式,比如容器ID等等),然后这个ID可以全局访问。在后续生成ID的时候,作为雪花算法的一部分。
现在,除了核心模型之外,还没有涉及到即时通信模块。这篇文章也不会把所有代码都粘贴出来,也不会讲解所有业务逻辑,因为都很好理解。接下来继续讲解分布式系统中的消息投递逻辑。
多实例下的消息投递
这个业务逻辑在分布式系统中是比较麻烦的,也是考验设计思路的。在即时通讯系统中,一般都是通过WebSocket连接,假设有a和b两个实例。用户u1与a连接之后,在整个生命周期内,就不会断开了,同样u2与b连接也是如此。
u1如何给u2发消息?
消息中间件
在投递消息之前,我们先把消息缓存起来。我们可以借助一个消息中间件RabbitMQ先把消息缓存起来,有了消息中间件之后,还可以提高系统对消息的吞吐量,不至于当有大量消息时,实例把资源都用在了处理消息上。
实例相关的消息队列
我们引入消息中间件之后,还要做一些设置,我们应该创建多少消息队列呢?消息队列之间的关系又该如何呢?
我们给每个实例定义如下消息队列:
- 当前实例下未处理消息队列(csuq+id):只要是连接这个实例的用户,只要发送消息,首先先达到这个队列,在到达这个队列之前,不做任何处理。
- 当前实例待投递消息队列(csdq+id):只要是这实例中的用户需要接收的消息,都先投递到这个队列(后续将如何做到)。
- GPT消息队列(pgtq):需要GPT生成的回复内容,都需要先进入这个消息队列。
- 离线消息队列(offq):未在线的用户的消息都会被发送到这个消息队列。
- 当前实例突然挂掉的死信队列(deadq):如果当前实例突然挂掉了,与此强相关的当前两个队列的消息都会进入这个死信队列。
其中,1和2两个队列和当前实例强相关,每次有实例启动的时候,都会创建两个队列与此关联,管理逻辑可以使用实例ID,同雪花ID里的实例ID。当前实例挂掉之后,这个两个队列在一定时间内也会被删除,没有来得及处理的消息就进入死信队列。
3、4、5这几个队列是永久消息队列。每个实例启动的时候,也会产于消费这几个队列里的消息。
问题来了,每个实例都有一个 csuq+id 队列,这个队列里的消息是如何投递到其它 csdq+id 队列里的?
Redis
现在,需要Redis登场了。
每当有用户登录的时候,都会向redis存入一个键值对,键就是用户的id(为了业务清晰,可以适当+前缀),值就是这个实例的id。
消息投递到当前实例
因此每个实例在收到当前用户发送的消息之后,再消费这个队列里的消息。还记得上面的模型吗?每个用户发送的消息都会携带chatId,通过chatId就可以找到对应的成员(这些也都可以存入redis中),然后就可以检查每个用户所在的实例了。进一步就可以把这个消息发送到那个实例关联的消息消息队列。
当消息进入到当前实例真正要消费的消息队列时,这个实例就可以通过这个消息中携带的接收者id,找到这个用户对应的socket了,然后消息就可以通过这个socket发送给用户了。
这个整个系统就这样串联起来了。那其它消息队列干什么呢?
GPT消息队列及其他
首先说说gpt消息队列,在消费 csuq+id 的消息时,会检查接收者的用户类型,如果是gpt,就直接把消息放入到gpt消息队列。
然后派发给一个实例,让GPT生成回复,再一次把消息发送到当前实例的 csuq+id 队列,为什么还要发送到这个队列?因为这样消息就不容易丢失,而且,消息队列接收消息的算力还是很小的。并且这个队列的消费者有一套完备的消息处理逻辑。
离线消息,可以接入其它平台,短信或者邮件通知有消息发送,让他登录查看消息,看业务如何处理了。
死信队列,每个实例也都会消费这个这个队列,与 csuq+id 不同是,这个消息不必要存入数据库之类的动作,总体逻辑和 csuq+id 队列差不多,也是需要进一步把消息投递到 csdq+id 队列。
消息消费逻辑总结
文字看着很多,会不会觉得很乱?其实很简单,如果做个动画的话,应该会很容理解,不过这里做了一个图,来展示整个逻辑:
删减了其它分支,只留下主流程,这张图就浓缩了一个即时通讯中关键点。
缺点
一个消息的投递经历太多步骤,意味着消耗很多资源,不过也有可以优化的地方,比如都在一个实例上时,有些步骤可以省略。
还有这张图不支持一个用户在多端登录,其实只需要在redis中存储多个键值对就可以了,存储和查询的时候需要特别设计一下,总之,是可以完成的,也是需要好好思考和设计一下。
其它
即时通讯的一些难点基本解决,但是,上面说到的GPT队列没有介绍,它也不算即时通讯系统里的重要部分,这里就不介绍了。不过他也涉及到很难解决的问题,比如节流问题,如果有时间再来介绍这一部分吧。
总之,通过这样的设计,这个系统在横向扩展上具备了巨大的空间,上限是多少,一下子不好评估。
- ID的消耗不用考虑,16位足够了,再不行,扩展两位,就会产生几千倍的空间,因为每一位代表62倍。
- 如果实例不够,就横向扩展实例。
- 如果redis存储太多信息,一个redis集群无法满足,那就把redis分类,分集群,比如用户的登录信息一个集群,用户的群聊对象是一个集群...,也可以使用其它压缩技术,比如布隆过滤器(通过降低准确率来减少内存)
- 消息队列也是如此,如果一个集群不够,可以按消息分类创建集群。
- 数据库也是如此,分库分表都是常见操作。
总之,这个设计的上限是多少,很难评估,但是,如果出一定的资源,计算可以支持多少用户同时在线,并且计算消息的发送量是多少。这是可以大概计算出来的,毕竟什么资源占多少内存,还是可以有一个粗略的估计的,这里就不去计算了。现在评估这些,为时过早,用户是一点点涨出来的,可以通过不断的评估,得出更加合理的结果。
最后,你完全可以通过认真的评估,来信任这套设计,如果不太能看得懂,先看上一篇「分布式系统遇到的难点与问题解决」。然后再来看这一篇,或许就容易理解了。
希望对正在设计分布式系统的你,所有帮助。