消息和用户是一对一关系,例如通过一对一才能知道哪个消息被用户已读,但MYSQL是无法支持海量数据库存储的。所以我们选择使用MongoDB存储消息记录(海量低价值的数据),MongoDB没有表结构,只有集合。message存储消息主体(正文、日期、发送人),message_re存储接收人(接收人、用户是否阅读等数据)。
如果瞬间写入海量记录,数据库正常的CRUD会受到影响,我们需要使用消息队列实现削峰填谷,即把消息发送到消息队列(RabbitMQ),当用户登陆时再把消息提取出来保存到MongoDB。
消息日积月累,MongoDB也会有撑不下来的时候,我们需要搞冷热数据分离,热数据定期归档,根据数据被使用的频率可以划分为热数据和冷数据,例如,一年内的数据被看做事热数据,超过一年的数据被当作冷数据。每天定期把冷数据从MongoDB_1转移到MongoDB_2,这样MongoDB_1的数据量减少就提高了速度,MongoDB_2存放数据量很大,但冷数据很少被使用,仅仅充当归档库而已。冷数据定期销毁,释放存储空间。假设五年以上的冷数据就被当成超期数据,删除超期数据之后归档库的空间就增加了。
引入消息队列MQ,在Web系统中,先CRUD消息主体(不包含接收人等信息)到MongoDB,用异步多线程把消息的ref集合发送到消息队列。每一个消息的topic则对应一个用户ID。
当用户登录系统后,Web系统利用异步线程方式从消息队列中把消息接收回来,把ref消息写入到MongoDB里。
RabbitMQ与Kafka不同,RabbitMQ既支持消息异步收发,又支持同步收发。能适应各种业务场景的优点便显现出来了。
将rabbitmq.tar.gz文件上传到CentOS系统,把镜像导入Docker环境
docker load < rabbitmq.tar.gz
创建rabbitmq容器
docker run -it -d --name mq -p 15672:15672 -p 5672:5672 rabbitmq
一个生产者(发送方)对应一个消费者(接收方),生产者和消费者必须是一对一的关系。
一个生产者对应多个消费者,但只有一个消费者能获得消息,具有排他性。
一个生产者首先将消息发送到fanout交换器,交换器绑定多个队列,然后与之对应的所有消费真呢个收到消息,不具有排他性。
生产者将消息发送到direct交换器,交换器按照关键字key把消息路由到某个队列,即符合规则才会转发到该队列。
生产者将消息发送到topic交换器,交换器按照复杂的规则将消息路由到某个队列
消息的可靠性是RabbitMQ的特色,可靠性由消息持久化实现,可以防止在异常情况下丢失数据,交换器和队列都能持久化。
默认情况下,消息是无限期存储在RabbitMQ上的,但我们可以设置过期时间,到期后无论是否已经被接受都会被RabbitMQ删除。
消费者接收消息之后,必须返回一个Ack应答,那么RabbitMQ才会认为这个消息接收成功,如果想要删除这条消息,消费者发送Ack应答的时候,附带一个deliverTag标志位就可以了。
异步接收消息消耗的系统资源较少,但是微信小程序和后端项目之间并不是长连接,后端项目异步方式接收到队列中的消息也无法推送移动端的小程序。所以后端的Java项目采用同步的方式接收队列的消息,在移动端,我们创建定时器,向后端Java项目发出轮询请求,后端项目接收到轮询秦桧后,用同步方式接收队列的消息,然后把消息存储在MongoDB上面。
MongoDB中没有数据表的概念,而是采用集合Collection存储数据,每一个数据就是一个Document,文档结构其实就是我们常用的JSON。
字段 | 类型 | 备注 |
---|---|---|
_id | UUID | 自动生成的主键值 |
uuid | UUID | UUID值,并设置有唯一索引,防止消息被重复消费 |
senderId | Integer | 发送者ID,就是用户ID,如果是系统发出,则ID为0 |
senderPhoto | String | 发送者头像URL |
senderName | String | 发送者名称 |
msg | String | 消息内容 |
sendTime | Date | 发送时间 |
针对重复消费:小程序每个5分钟进行轮询,如果积压得消息太多,Java系统没有接受完消息,这时候新的轮询到来,就会产生两个消费者共同接收同一个消息的情况,数据库就添加了同样的记录,如果每条MQ消息都有唯一的UUID值,第一个消费者把消息保存到数据库后,第二个消费者就无法继续保存了。
创建MessageEntity类映射message集合
@Data
@Document(collation = "message")
//标明由mongo来维护该表,collection里面的名字对应着mongodb里面的文档
public class MessageEntity implements Serializable {
//Serializable启用其序列化功能的接口
//serializable接口的作用:存储对象在存储介质中,以便在下次使用的时候,可以很快捷的重建一个副本;便于数据传输,尤其是在远程调用的时候。
@Id
private String _id;
@Indexed(unique = true)//唯一索引
private String uuid;
@Indexed//加索引后以该字段为条件检索将大大提高速度
private Integer sendId;
//可设置默认值为系统头像
private String senderPhoto="XXX";
private String senderName;
private String msg;
@Indexed
private Date sendTime;
}
虽然message集合记录是消息,里面有接收者ID,但群发消息是接收者ID空,这时候需要用上message_ref集合来记录接收人和已读状态。
字段 | 类型 | 备注 |
---|---|---|
_id | UUID | 主键 |
messageId | UUID | message记录的_id |
receiverId | String | 接收人ID |
readFlag | Boolean | 是否已读 |
lastFlag | Boolean | 是否为新接收的消息 |
创建MessageRefEntity类映射message_ref集合
public class MessageRefEntity implements Serializable {
@Id
private String _id;
@Indexed
private String messageId;
@Indexed
private Integer receiverId;
@Indexed
private Boolean readFlag;
@Indexed
private Boolean lastFlag;
}
MongoDB从3.X开始支持集合的连接查询,也就相当于MYSQL的表连接。
先插入两条数据
db.message.insert({
_id:ObjectId("600bea9ab5bafb311f147506"),
uuid:"bfcb7c47-5886-c528-5127-ce285bc2322a",
senderId:0,
senderPhoto:"https://static-1258386385.cos.ap-beijing.myqcloud.com/img/System.jpg",
senderName:"Emos系统",
msg:"HelloWord",
sendTime:ISODate("2021-01-23T17:21:30Z")
});
db.message_ref.insert({
_id:ObjectId("600beaf0d6310000830036f3"),
messageId:"600bea9ab5bafb311f147506",
receiverId:1,
readFlag:false,
lastFlag:true
});
再使用语句连接
db.message.aggregate(
//数据类型转换,将Oject转成String去连接
{
//set定义变量
$set:{
//临时变量Id
"id":{$toString:"$_id"}
}
},
//
{
$lookup:{
//连接message_ref,message出Id,跟message_ref的messageId连接
from:"message_ref",
localField:"id",
foreignField:"messageId",
//从message_ref取出来的数据保存在ref
as:"ref"
}
},
//寻找receiverId=1的消息内容,match为查询条件
{ $match:{"ref.receiverId":1} },
//按照发送时间降序
{ $sort:{sendTime:-1} },
//从0开始往后取50条数据
{ $skip:0 },
{ $limit:50 }
)
提供的功能包括:
@Repository
public class MessageDao {
@Autowired
private MongoTemplate mongoTemplate;
//插入数据
public String insert(MessageEntity entity){
//把北京时间转成格林时间再存到MongoDB
Date sendTime = entity.getSendTime();
//偏移8个小时
sendTime = DateUtil.offset(sendTime, DateField.HOUR,8);
entity.setSendTime(sendTime);
entity = mongoTemplate.save(entity);
return entity.get_id();
}
//按照分页查询消息,注意第二个参数为long
public List<HashMap> searchMessageByPage(int userId, long start, int length){
JSONObject json = new JSONObject();
//数据类型转换
json.set("$toString","$_id");
//集合连接
Aggregation aggregation = Aggregation.newAggregation(
//设置临时变量id,值为json中拿,
Aggregation.addFields().addField("id").withValue(json).build(),
//集合联合查询
Aggregation.lookup("message_ref","id","messageId","ref"),
//match设置查询条
Aggregation.match(Criteria.where("ref.receiverId").is(userId)),
//排序
Aggregation.sort(Sort.by(Sort.Direction.DESC,"sendTime")),
Aggregation.skip(start),
Aggregation.limit(length)
);
//跟message连接,返回为HashMap
AggregationResults<HashMap> results = mongoTemplate.aggregate(aggregation,"message",HashMap.class);
//获取数据
List<HashMap> list = results.getMappedResults();
//处理数据
list.forEach(one->{
//从ref中提取数据
List<MessageRefEntity> refList = (List<MessageRefEntity>) one.get("ref");
MessageRefEntity entity = refList.get(0);
boolean readFlag = entity.getReadFlag();
String refId = entity.get_id();
one.put("readFlag",readFlag);
one.put("refId",refId);
one.remove("ref");
//删除主体ID,到时候删除的时候我们是删除ref主体删除掉
one.remove("_id");
//时间转换,转回北京时间
Date sendTime = (Date)one.get("sendTime");
sendTime = DateUtil.offset(sendTime,DateField.HOUR,-8);
//取当前日期比较,sendTime相等,则代表消息是今天,显示发送时间不需要显示日期
//之前发送的消息才需要显示日期
String today = DateUtil.today();
if(today.equals(DateUtil.date(sendTime).toDateStr())){
one.put("sendTime",DateUtil.format(sendTime,"HH:mm"));
}else{
one.put("sendTime",DateUtil.format(sendTime,"yyyy/MM/dd"));
}
});
return list;
}
//根据ID查找数据
public HashMap searchMessageById(String id){
HashMap map = mongoTemplate.findById(id,HashMap.class,"message");
Date sendTime = (Date)map.get("sendTime");
sendTime = DateUtil.offset(sendTime,DateField.HOUR,-8);
map.replace("sendTime",DateUtil.format(sendTime,"yyyy/MM/dd HH:mm"));
return map;
}
}
@Repository
public class MessageRefDao {
@Autowired
private MongoTemplate mongoTemplate;
//插入
public String insert(MessageRefEntity entity){
entity = mongoTemplate.save(entity);
return entity.get_id();
}
//查询未读消息的数量(汇总统计都是long类型)
public long searchUnreadCount(int userId){
Query query = new Query();
//设置条件
query.addCriteria(Criteria.where("readFlag").is(false).and("receiverId").is(userId));
//查询条件跟映射类
long count = mongoTemplate.count(query,MessageRefEntity.class);
return count;
}
//修改更新条数
public long searchLastCount(int userId){
Query query = new Query();
query.addCriteria(Criteria.where("lastFlag").is(true).and("receiverId").is(userId));
Update update = new Update();
update.set("lastFlag",false);
UpdateResult result = mongoTemplate.updateMulti(query,update,"message_ref");
long count = result.getModifiedCount();
return count;
}
//修改消息状态,未读改为已读
public long updateUnreadMessage(String id){
//只修改一条,调用updateFirst
Query query = new Query();
query.addCriteria(Criteria.where("_id").is(id));
Update update = new Update();
update.set("readFlag",true);
UpdateResult result = mongoTemplate.updateFirst(query,update,"message_ref");
long count = result.getModifiedCount();
return count;
}
//根据主键值删除
public long deleteMessageRefById(String id){
Query query = new Query();
query.addCriteria(Criteria.where("_id").is(id));
DeleteResult result = mongoTemplate.remove(query,"message_ref");
long count = result.getDeletedCount();
return count;
}
//根据UserId删除所有消息
public long deleteUserMessageRef(int userId){
Query query = new Query();
query.addCriteria(Criteria.where("receiverId").is(userId));
DeleteResult result = mongoTemplate.remove(query,"message_ref");
long count = result.getDeletedCount();
return count;
}
}
public interface MessageService {
public String insertMessage(MessageEntity entity);
public List<HashMap> searchMessageByPage(int userId, long start, int length);
public HashMap searchMessageById(String id);
public String insertMessageRef(MessageRefEntity entity);
public long searchUnreadCount(int userId);
public long searchLastCount(int userId);
public long updateUnreadMessage(String id);
public long deleteMessageRefById(String id);
public long deleteUserMessageRef(int userId);
}
@Service
public class MessageServiceImpl implements MessageService {
@Autowired
private MessageDao messageDao;
@Autowired
private MessageRefDao messageRefDao;
@Override
public String insertMessage(MessageEntity entity) {
String id = messageDao.insert(entity);
return id;
}
@Override
public List<HashMap> searchMessageByPage(int userId, long start, int length) {
List<HashMap> list = messageDao.searchMessageByPage(userId,start,length);
return list;
}
@Override
public HashMap searchMessageById(String id) {
HashMap map = searchMessageById(id);
return map;
}
@Override
public String insertMessageRef(MessageRefEntity entity) {
String id = messageRefDao.insert(entity);
return id;
}
@Override
public long searchUnreadCount(int userId) {
long count = messageRefDao.searchLastCount(userId);
return count;
}
@Override
public long searchLastCount(int userId) {
long count = messageRefDao.searchLastCount(userId);
return count;
}
@Override
public long updateUnreadMessage(String id) {
long count = messageRefDao.updateUnreadMessage(id);
return count;
}
@Override
public long deleteMessageRefById(String id) {
long count = messageRefDao.deleteMessageRefById(id);
return count;
}
@Override
public long deleteUserMessageRef(int userId) {
long count = messageRefDao.deleteUserMessageRef(userId);
return count;
}
}
@Data
@ApiModel
public class SearchMessageByPageForm {
@NotNull
@Min(1)
private Integer page;
@NotNull
@Range(min=1,max=40)
private Integer length;
}
@Data
@ApiModel
public class SearchMessageByIdForm {
@NotNull
private String id;
}
@Data
@ApiModel
public class DeleteMessageRefByIdForm {
@NotNull
private String id;
}
@Data
@ApiModel
public class UpdateUnreadMessageForm {
@NotNull
private String id;
}
@RestController
@RequestMapping("/message")
@Api("消息模块网络接口")
public class MessageController {
@Autowired
private JwtUtil jwtUtil;
@Autowired
private MessageService messageService;
@PostMapping("/searchMessageByPage")
@ApiOperation("获取分页消息列表")
public R searchMessageByPage(@Valid @RequestBody SearchMessageByPageForm form,@RequestHeader("token") String token){
int userId = jwtUtil.getUserId(token);
int page = form.getPage();
int length = form.getLength();
long start = (page-1)*length;
List<HashMap> list = messageService.searchMessageByPage(userId,start,length);
return R.ok().put("result",list);
}
@PostMapping("/searchMessageById")
@ApiOperation("根据ID查询消息")
public R searchMessageById(@Valid @RequestBody SearchMessageByIdForm form){
HashMap map = messageService.searchMessageById(form.getId());
return R.ok().put("result",map);
}
@PostMapping("/updateUnreadMessage")
@ApiOperation("未读消息更新成已读消息")
public R updateUnreadMessage(@Valid @RequestBody UpdateUnreadMessageForm form){
long count = messageService.updateUnreadMessage(form.getId());
return R.ok().put("result",count==1?true:false);
}
@PostMapping("/deleteMessageRefById")
@ApiOperation("删除消息")
public R deleteMessageRefById(@Valid @RequestBody DeleteMessageRefByIdForm form){
long count = messageService.deleteMessageRefById(form.getId());
return R.ok().put("result",count==1?true:false);
}
}
如果收发消息的时候我们的Java代码选择了同步执行,当用户登录的时候消息没有接收完成,那么Java代码就不会继续往下执行,会造成等待事件过长。如果选择异步执行,收发消息是挂载在后台的,让一个线程去执行。
RabbitMQ提供了同步和异步两种收发消息模式,如果选择了异步,则需要创建一个消费者对象挂载在后台去运行,消费者对象不会退出,没有消息则处于等待状态,而且消费者对象只接收某个用户的消息,例如有一万人登录系统,则会创建一万个消费对象,这对操作系统虚拟机要求很高。同步收发消息,消费者对象销毁这才是可取的。所以我们可以采用异步线程同步收发消息。
<dependency>
<groupId>com.rabbitmqgroupId>
<artifactId>amqp-clientartifactId>
<version>5.9.0version>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-amqpartifactId>
dependency>
@Configuration
public class RabbitMQConfig {
@Bean
public ConnectionFactory getFactory(){
ConnectionFactory factory = new ConnectionFactory();
factory.setHost("192.168.10.105");
factory.setPort(5672);
return factory;
}
创建多线程任务来收发RabbitMQ消息
@Component
@Slf4j
public class MessageTask {
@Autowired
private ConnectionFactory factory;
@Autowired
private MessageService messageService;
//在RabbitMQ每个消息都有自己的名字,即topic
public void send(String topic, MessageEntity entity){
//保存在Message集合
String id = messageService.insertMessage(entity);
//向RMQ发送消息
try(Connection connection = factory.newConnection();
//创建通道
Channel channel = connection.createChannel();
){
//创建通道
//通道连接到Topic队列,true代表持久化存储,false-1取消排他
//false-2代表收发完消息后队列不会自动删除
channel.queueDeclare(topic,true,false,false,null);
//绑定额外的数据
//这里绑定id用于ref关联
HashMap map = new HashMap();
map.put("messageId",id);
//把map放到请求头中,AMQP协议用户连接队列
AMQP.BasicProperties properties = new AMQP.BasicProperties().builder().headers(map).build();
//发送消息
channel.basicPublish("",topic,properties,entity.getMsg().getBytes(StandardCharsets.UTF_8));
//留下日志提示
log.debug("消息发送成功");
}catch (Exception e){
log.error("执行异常",e);
throw new EmosException("向MQ发送消息失败");
}
}
//异步发送消息
@Async
public void sendAsync(String topic, MessageEntity entity){
send(topic,entity);
}
//接收消息
//接收消息数量
public int receive(String topic){
//参数代表从哪个topic队列接收数据
int i =0;
try(Connection connection = factory.newConnection();
Channel channel = connection.createChannel();
){
channel.queueDeclare(topic,true,false,false,null);
//从消息队列接收消息,保存到MongoDBREF集合
while(true){
//接收消息,false代表不自动发送ACK应答
GetResponse response = channel.basicGet(topic,false);
if(response!=null){
AMQP.BasicProperties properties = response.getProps();
//从properties获得请求数据
Map<String,Object> map = properties.getHeaders();
//获取MessageId
String messageId = map.get("messageId").toString();
//获取消息正文,返回的是Byte数组
byte[] body = response.getBody();
String message = new String(body);
//日志记录
log.debug("从RabbitMQ接收消息"+message);
MessageRefEntity entity = new MessageRefEntity();
entity.setMessageId(messageId);
entity.setReceiverId(Integer.parseInt(topic));
//设置两个标志位置
entity.setReadFlag(false);
entity.setLastFlag(true);
//写入MongoDB
messageService.insertMessageRef(entity);
long deliveryTag = response.getEnvelope().getDeliveryTag();
//返回ACK应答
channel.basicAck(deliveryTag,false);
i++;
}else{
break;
}
}
}catch (Exception e){
log.error("执行异常",e);
throw new EmosException("接收消息失败");
}
//从消息队列接收消息,保存到MongoDBREF集合
//返回ACK应答,表明成功接收消息
return i;
}
//异步接收
@Async
public int receiveAsync(String topic){
return receive(topic);
}
//删除消息队列
public void deleteQueue(String topic){
try(Connection connection = factory.newConnection();
Channel channel = connection.createChannel();
){
channel.queueDelete(topic);
log.debug("消息队列删除成功");
}catch (Exception e){
log.error("执行异常",e);
throw new EmosException("删除队列失败");
}
}
//异步删除
@Async
public void deleteQueueAsync(String topic){
deleteQueue(topic);
}
}
@Override
public int registerUser(String registerCode, String code, String nickname, String photo) {
if(registerCode.equals("000000")){
boolean bool = userDao.haveRootUser();
if(!bool){
String openId = getOpenId(code);
HashMap param = new HashMap();
param.put("openId",openId);
param.put("nickname",nickname);
param.put("photo",photo);
param.put("role","[0]");
param.put("status",1);
param.put("createTime", new Date());
param.put("root",true);
userDao.insert(param);
int id = userDao.searchIdByOpenId(openId);
//发送消息开始
MessageEntity entity = new MessageEntity();
entity.setSendId(0);
entity.setSenderName("系统消息");
entity.setUuid(IdUtil.simpleUUID());
entity.setSendTime(new Date());
messageTask.sendAsync(id+"",entity);
//发送消息结束
return id;
}else{
throw new EmosException("无法绑定超级管理员账号");
}
}else{
}
return 0;
}
@Override
public Integer login(String code) {
//根据临时授权拿到openId
String openId = getOpenId(code);
Integer id = userDao.searchIdByOpenId(openId);
//接收消息,topic ID是userId
messageTask.receiveAsync(id+"");
if(id==null){
throw new EmosException("账号不存在");
}
return id;
}
@GetMapping("/refreshMessage")
@ApiOperation("刷新用户数据")
public R refreshMessage(@RequestHeader("token") String token){
int userId = jwtUtil.getUserId(token);
messageTask.receiveAsync(userId+"");
long lastRows = messageService.searchLastCount(userId);
long unreadRows = messageService.searchUnreadCount(userId);
return R.ok().put("lastRows",lastRows).put("unreadRows",unreadRows);
}