移动互联网时代,社交媒体应用彻底改变了我们联系和共享信息的方式。这些平台在幕后处理庞大的用户群、数据存储和实时交互。
在本文中,我们将深入探讨如何设计一个可扩展且高性能的社交媒体应用系统。我们将探讨关键组件、流程图、功能需求以及容量规划策略,这些都是构建成功社交媒体平台所必需的。
上图是整个系统的架构图,每个服务或模块这里先简单介绍:
- API Gateway:作为客户端流量通往后端服务的入口,处理所有的API请求,路由请求到相应的服务、身份验证、负载均衡。
- Follower Service:处理用户关注和被关注的关系。
- Profile Service:管理用户的个人信息和资料。
- Post Service:处理用户发布内容(帖子)。
- URL Shortener:缩短URL地址,可能用于分享帖子链接。
- User Feed Service::生成和管理用户的动态(feed),包括用户关注的人的新帖通知。
- Comment & Like Service:管理用户对帖子进行的评论和点赞。
- Chat Service:处理用户之间的实时聊天功能。
- Search Service:提供搜索功能,允许用户搜索其他用户或内容。
- Load Balancer (LB):分发请求到多个后端服务,确保系统的高可用性和负载均衡。
- CloudFront (CDN):用于缓存和快速访问存储在S3中的媒体文件。
要求
功能要求
功能需求定义了系统或软件应用程序应该做什么来满足用户的需求。以下是基于社交媒体系统的功能需求:
- 发布帖子
- 点赞、评论他人帖子
- 关注/取消关注其他用户
- 搜索用户
- 用户Feed流
- 聊天功能
非功能性需求
非功能需求指定了系统的质量,而不是具体的功能。以下是一些非功能需求:
- 低延迟
- 高可用性
数据模型设计
用户管理
用户数据表(user_data)
该表将存储每个用户的基本信息,例如其姓名、生日、描述、用户名、电话号码和个人资料图片。“id”属性作为主键,用于唯一标识表中的每个用户。
CREATE TABLE user_data (user_id INT AUTO_INCREMENT PRIMARY KEY,name VARCHAR(255) NOT NULL,password VARCHAR(255) NOT NULL,user_bday DATE,user_desc TEXT,user_name VARCHAR(255) NOT NULL,user_phone VARCHAR(20),user_pic VARCHAR(255)
);* user_id:每个用户的唯一标识符(主键)。
* name:用户的全名。
* user_bday:用户的出生日期。
* user_desc:用户的描述或简历。
* user_name:用户选择的用户名(应该是唯一的)。
* user_phone:用户的联系电话。
* user_pic:用户个人资料图片的文件路径或引用。
用户-关注者表(user_follower)
对于“user_follower”表,它表示用户和他的粉丝/关注者之间的关系,它主要有以下字段:
CREATE TABLE User_Followers (id INT AUTO_INCREMENT PRIMARY KEY,follower INT,following INT,FOREIGN KEY (follower) REFERENCES user_data(user_id),FOREIGN KEY (following) REFERENCES user_data(user_id)
);* id:表中每个条目的唯一标识符(主键)。
* follower:引用关注者的用户的外键(引用 user.id)。
* following:引用被关注用户的外键(引用 user.id)。
“user_follower”表中的每条记录代表一个用户关注另一个用户。“follower”字段引用正在关注的用户(粉丝),“following”字段引用被关注的用户。“id”字段作为主键,唯一标识表中的每条记录。
帖子管理
帖子表(post_data)
对于帖子管理,“帖子”表代表用户发布的帖子,具有以下属性:
CREATE TABLE post_data (post_id INT AUTO_INCREMENT PRIMARY KEY,user_id INT,content TEXT,date_posted DATE,media VARCHAR(255),likes INT,FOREIGN KEY (user_id) REFERENCES user_data(user_id)
);* post_id:每个 帖子的唯一标识符。
* content:帖子文本内容。
* media:FileField,允许用户上传与帖子相关的媒体(例如图像、视频)。
* user_id:引用发布帖子的用户的 ForeignKey。这在 user_data 和 post_data 表之间建立了一对多关系,其中一个用户可以发布多篇帖子。这意味着单个 user_id 可以与多篇帖子相关联,但单个 post_id 不能与多个 user_id 相关联。
* likes:一个 ManyToManyField,表示对帖子的喜欢。它在 Post 和 User 表之间建立多对多关系,允许多个用户喜欢一个帖子,并且一个用户可以喜欢多个帖子。
评论表(comment)
“评论”表代表用户对特定帖子发表的评论,评论表一般具有以下字段:
CREATE TABLE Comment (comment_id INT AUTO_INCREMENT PRIMARY KEY,post_id INT,user_id INT,comment TEXT,created_date DATE,FOREIGN KEY (post_id) REFERENCES post_data(post_id),FOREIGN KEY (user_id) REFERENCES user_data(user_id)
);* comment_id:每个评论的主键。
* comment:评论内容。
* created_id:一个 DateTimeField,表示评论创建的日期和时间。
* user_id:引用发表评论的用户的外键。User 表和 Comment 表之间存在一对多关系,其中一个用户可以有多条评论。这里,单个 user_id 可以与多条评论相关联,但单个 commet_id 不能与多个用户相关联。
* post:与评论相关联的帖子,引用帖子表的主键(foreign_key)。这在帖子和评论表之间建立了一对多的关系,其中一个帖子可以有多条评论。
聊天管理
聊天消息(chat_message)
对于聊天管理,“chat_message”表代表对话线程内的单个消息,其主要字段如下:
CREATE TABLE chat_message (id INT AUTO_INCREMENT PRIMARY KEY,thread_id INT,user_id INT,message TEXT,timestamp DATE,FOREIGN KEY (thread_id) REFERENCES Thread(thread_id),FOREIGN KEY (user_id) REFERENCES User_Data(user_id)
);* id :每个聊天消息的主键。
* thread_id:引用 thread 模型的 ForeignKey。它表示消息所属的线程。
* user_id:引用 user_data 的 ForeignKey。它代表发送消息的用户。
* message:存储消息文本内容的 CharField。
* timestamp: 存储消息创建日期和时间的 DateField
线程表(thread)
“thread”表代表两个用户之间的对话线程:
CREATE TABLE thread (thread_id INT AUTO_INCREMENT PRIMARY KEY,user1 INT,user2 INT,timestamp DATE,FOREIGN KEY (user1) REFERENCES User_Data(user_id),FOREIGN KEY (user2) REFERENCES User_Data(user_id)
);* thread_id:每个线程的主键。
* user1:引用 User 模型并代表参与对话的用户之一的 ForeignKey。
* user2:引用 User 模型并代表参与对话的其他用户的 ForeignKey。
* timestamp: 一个 DateField,存储线程创建的日期和时间。
这里介绍下聊天管理中的Thread 模型,Thread 模型代表两个用户之间的对话。这种对话被称为线程,每个线程都有一个唯一的 thread_id 来标识。通过 Thread 模型,可以捕获两个用户之间的关系,并在这个线程中存储和组织聊天消息。每条消息通过 chat_message 表与特定的线程和发送消息的用户相关联。
举个例子,用户 A 和用户 B 进行聊天对话,那么thread 表中:
- thread_id: 1
- user1: 用户 A
- user2: 用户 B
- timestamp: 2024-07-28 10:00:00
如果A和B只进行了一来一回两次对话,那么chat_message表有2条记录:
第一条记录:
- id: 101
- thread: 1
- user: 用户 A
- message: “你好,B!”
- timestamp: 2024-07-28 10:01:00
第二条记录:
- id: 102
- thread: 1
- user: 用户 B
- message: “你好,A!”
- timestamp: 2024-07-28 10:02:00
用户资料和关注服务
在我们的系统设计中,用户资料服务用于处理和存储用户信息,如用户名、地址和年龄等。其他服务可以来请求用户资料服务的接口获取用户的详细信息(带上用户ID作为参数)。
另一方面,关注服务管理用户之间的关系。当一个用户关注另一个用户时,带有用户ID的API请求会发送到关注服务,后者更新其数据库。在访问用户资料时,用户资料服务需要与关注服务通信以获取其关注者和其粉丝的数量。
此外,关注服务还可以根据用户 ID 与与用户资料服务通信来获取用户详细信息,以显示关注者和被关注者的信息。
注:两个服务的代码逻辑见文末代码部分。
帖子服务与媒体管理
帖子服务会提供3个API,分别用于发布帖子,获取帖子,更新帖子:
- /createPost (POST)
- /getPost/:id (GET)
- /updatePost/:id (POST)
这些请求通过 API 网关路由,然后 API 网关将它们定向到 Post 服务。Post 服务将帖子内容、用户 ID 和其他相关数据保存在数据库中。任何附带的文件或媒体都会上传到 Amazon S3(一种分布式对象存储服务)。
为了优化内容交付,我们将 CloudFront 用作 S3 前面的 CDN 层。上传文件的 URL 存储在数据库中。此外,我们可以实施 URL 缩短服务来缩短 S3 生成的长 URL。
注:帖子服务的代码逻辑见文末代码部分。
用户动态服务
在我们的系统中,用户动态服务负责为用户提供相关的动态。为了实现这一点,我们遵循一个简单的算法:我们显示他们关注的用户的最新帖子,然后是其他相关推荐。
处理数百万条帖子并直接加载数千条帖子到用户的动态中这个操作会非常昂贵,会导致高延迟。
为了解决这个问题,我们实现了分页系统。当用户向下滚动时,客户端发出类似于/getUserFeed/:userId/?offset=10/?page=page_number的API请求,其中偏移量表示要跳过的帖子数量。通过逐步加载帖子,我们减少了服务器的负载。
为了优化性能,我们在服务器上预先计算了feed,并根据客户端的请求对数据进行切片。这种方法可确保客户端收到最新的帖子,而无需过多的服务器请求。
为了让用户及时了解最新帖子,我们在用户资料服务和用户动态服务之间建立了关联。这使我们能够同步用户信息并保持相关的动态。
为了增强可扩展性,我们使用了基于最近最少使用(LRU)缓存模型的分布式缓存服务。信息流数据存储在缓存中,访问频率较低的帖子将从缓存中剔除。这种方法确保缓存中存储的最小数据,提高了系统性能和可扩展性。
后面会单独写一篇关于Feed流设计的文章。
搜索服务
搜索服务允许用户根据用户名查找其他用户。当用户使用/search/:username API 发出搜索请求时,搜索服务会通过用户输入的用户名与发出请求的用户关注的用户及其关注者的用户名进行匹配来执行搜索。此外,它还包括其他用户在搜索结果中。
上面这段话比较拗口,为了让它更容易理解,我们可以把它拆开理解,当用户在搜索框中输入一个用户名例如tom时,系统会进行以下操作:
- 搜索其关注的用户:首先,系统会查看当前用户tom关注的所有用户。
- 匹配其关注者:接着,系统会查看所有关注了用户tom的用户(即tom的粉丝)
- 包括其他用户:最后,系统还会在搜索结果中包括其他与输入的用户名匹配的用户,这些用户可能既不是用户关注的也不是用户的关注者。
为了检索搜索结果中的用户详细信息,搜索服务会调用个人资料服务的接口。它会获取必要的用户信息并将其纳入搜索推荐中。
为了优化性能并减少数据库查询,我们实现了缓存机制。搜索服务将最常搜索的用户名其对应的详细信息存储在分布式缓存中。这使我们能够直接从缓存中检索用户名,而不必每次都查询数据库,从而缩短响应时间并提高效率。
聊天服务
会单独写一篇关于聊天系统设计的文章。
相关代码
注:以下代码只是为了展示以上服务相关接口的主要逻辑帮助大家理解整个系统,因此没有考虑到参数校验、异常处理、性能优化等方面。
用户服务
用户服务主要有2个接口,创建新用户,查询用户。
//UserController.java
@RestController
@RequestMapping("/users")
public class UserController {@Autowiredprivate UserService userService;@GetMapping("/{userId}")public ResponseEntity<User> getUserById(@PathVariable Long userId) {User user = userService.getUserById(userId);return user != null ? ResponseEntity.ok(user) : ResponseEntity.notFound().build();}@PostMappingpublic ResponseEntity<User> createUser(@RequestBody User user) {//还有一系列新建用户的流程,如校验密码等这里省略了User savedUser = userService.saveUser(user);return ResponseEntity.status(HttpStatus.CREATED).body(savedUser);}@PostMapping("/login")public ResponseEntity<?> login(@RequestParam String userName, @RequestParam String password) {User user = userService.authenticate(userName, password);if (user != null) {return ResponseEntity.ok(user);}return ResponseEntity.status(401).body("Invalid username or password");}
}//UserService.java
@Service
public class UserService {@Autowiredprivate UserMapper userMapper;@Autowiredprivate RedisTemplate<String, User> redisTemplate;private static final String USER_CACHE = "USER";public User getUserById(Long userId) {// Check cache firstUser user = redisTemplate.opsForValue().get(USER_CACHE + userId);if (user != null) {return user;}// Fetch from database if not in cacheuser = userMapper.selectUserById(userId);if (user != null) {redisTemplate.opsForValue().set(USER_CACHE + userId, user);}return user;}public User saveUser(User user) {// 加密密码user.setPassword(passwordEncoder.encode(user.getPassword()));userMapper.insertUser(user);redisTemplate.opsForValue().set(USER_CACHE + user.getUserId(), user);return user;}public User authenticate(String userName, String rawPassword) {User user = userMapper.getUserByName(userName);if (user != null && passwordEncoder.matches(rawPassword, user.getPassword())) {return user;}return null;}
}
关注服务
关注服务主要提供以下几个功能:
- 查询粉丝数量
- 查询关注者数量
- 关注
- 取关
//FollowController.java
@RestController
@RequestMapping("/follows")
public class FollowController {@Autowiredprivate FollowService followService;@GetMapping("/followers/count/{userId}")public ResponseEntity<Long> countFollowers(@PathVariable Long userId) {long count = followService.countFollowers(userId);return ResponseEntity.ok(count);}@GetMapping("/following/count/{userId}")public ResponseEntity<Long> countFollowing(@PathVariable Long userId) {long count = followService.countFollowing(userId);return ResponseEntity.ok(count);}@PostMapping("/{followerId}/{followingId}")public ResponseEntity<Follow> followUser(@PathVariable Long followerId, @PathVariable Long followingId) {Follow follow = followService.followUser(followerId, followingId);return ResponseEntity.status(HttpStatus.CREATED).body(follow);}
}//FollowService.java
@Service
public class FollowService {@Autowiredprivate FollowMapper followMapper;@Autowiredprivate UserService userService;@Autowiredprivate RedisTemplate<String, Long> redisTemplate;private static final String FOLLOWERS_COUNT_CACHE = "FOLLOWERS_COUNT_";private static final String FOLLOWING_COUNT_CACHE = "FOLLOWING_COUNT_";public long countFollowers(Long userId) {String cacheKey = FOLLOWERS_COUNT_CACHE + userId;Long count = redisTemplate.opsForValue().get(cacheKey);if (count != null) {return count;}User user = userService.getUserById(userId);if (user == null) {throw new IllegalArgumentException("User not found");}count = followMapper.countFollowers(userId);redisTemplate.opsForValue().set(cacheKey, count);return count;}public long countFollowing(Long userId) {String cacheKey = FOLLOWING_COUNT_CACHE + userId;Long count = redisTemplate.opsForValue().get(cacheKey);if (count != null) {return count;}User user = userService.getUserById(userId);if (user == null) {throw new IllegalArgumentException("User not found");}count = followMapper.countFollowing(userId);redisTemplate.opsForValue().set(cacheKey, count);return count;}public Follow followUser(Long followerId, Long followingId) {User follower = userService.getUserById(followerId);User following = userService.getUserById(followingId);if (follower == null || following == null) {throw new IllegalArgumentException("User not found");}Follow follow = new Follow();follow.setFollowerId(followerId);follow.setFollowingId(followingId);followMapper.insertFollow(follow);return follow;}
}
帖子服务与媒体管理
帖子服务主要提供以下几个功能:
- 发布帖子
- 获取帖子
- 更新帖子
其它配置
......
# AWS S3 Configuration
aws.s3.bucket-name=your_bucket_name
aws.s3.region=your_region
//PostController.java
@RestController
@RequestMapping("/posts")
public class PostController {@Autowiredprivate PostService postService;@PostMapping("/createPost")public ResponseEntity<Post> createPost(@RequestParam("userId") Long userId,@RequestParam("content") String content,@RequestParam("mediaFile") MultipartFile mediaFile) throws IOException {Post post = new Post();post.setUserId(userId);post.setContent(content);Post createdPost = postService.createPost(post, mediaFile);return ResponseEntity.status(HttpStatus.CREATED).body(createdPost);}@GetMapping("/getPost/{postId}")public ResponseEntity<Post> getPost(@PathVariable Long postId) {Post post = postService.getPostById(postId);return post != null ? ResponseEntity.ok(post) : ResponseEntity.notFound().build();}@PostMapping("/updatePost/{postId}")public ResponseEntity<Post> updatePost(@PathVariable Long postId,@RequestParam("content") String content,@RequestParam("mediaFile") MultipartFile mediaFile) throws IOException {Post updatedPost = new Post();updatedPost.setContent(content);Post post = postService.updatePost(postId, updatedPost, mediaFile);return ResponseEntity.ok(post);}
}//PostService.java
@Service
public class PostService {@Autowiredprivate PostMapper postMapper;@Autowiredprivate RedisTemplate<String, Post> redisTemplate;@Autowiredprivate AmazonS3 amazonS3;@Value("${aws.s3.bucket-name}")private String bucketName;private static final String POST_CACHE = "POST_";public Post getPostById(Long postId) {// Check cache firstPost post = redisTemplate.opsForValue().get(POST_CACHE + postId);if (post != null) {return post;}// Fetch from database if not in cachepost = postMapper.selectPostById(postId);if (post != null) {redisTemplate.opsForValue().set(POST_CACHE + postId, post);}//这里拿到mediaUrl后还要请求S3拿内容,这里省略了return post;}public Post createPost(Post post, MultipartFile mediaFile) throws IOException {// Upload media file to S3String mediaUrl = uploadFileToS3(mediaFile);post.setMedia(mediaUrl);post.setDatePosted(new Date());postMapper.insertPost(post);//可以进行优化:只有大V或者热门帖子才缓存下来,这样可以节省成本redisTemplate.opsForValue().set(POST_CACHE + post.getPostId(), post);return post;}public Post updatePost(Long postId, Post updatedPost, MultipartFile mediaFile) throws IOException {Post existingPost = getPostById(postId);if (existingPost == null) {throw new IllegalArgumentException("Post not found");}if (mediaFile != null && !mediaFile.isEmpty()) {String mediaUrl = uploadFileToS3(mediaFile);updatedPost.setMedia(mediaUrl);} else {updatedPost.setMedia(existingPost.getMedia());}updatedPost.setPostId(postId);updatedPost.setDatePosted(new Date());postMapper.updatePost(updatedPost);redisTemplate.opsForValue().set(POST_CACHE + postId, updatedPost);return updatedPost;}private String uploadFileToS3(MultipartFile file) throws IOException {String fileName = System.currentTimeMillis() + "_" + file.getOriginalFilename();ObjectMetadata metadata = new ObjectMetadata();metadata.setContentLength(file.getSize());amazonS3.putObject(bucketName, fileName, file.getInputStream(), metadata);return amazonS3.getUrl(bucketName, fileName).toString();}
}