Golang重构站内信消息系统之优化代码设计

1. 消息系统业务功能说明简介:

首先消息系统需求目的就是为了实现后台商家可以主动管理推送消息到对应相关用户,以及查看用户的业务被动触发消息。

其中包括,站内信的消息 与 微信公众号模板消息推送

说明:目前我们系统主要是WEB为主,客户端只有H5链接纯WEB界面,并木有APP客户端,所以不考虑想APP那种推送消息。

主动推送:推送系统消息+优惠活动;推送人到:全部用户、指定用户、顾客、团长等…

业务触发推送:用户下单成功,售后退款成功,物流发货等。都会有被动业务触发记录到站内信消息,并且触发主动公众号模板推送到对应你关注企业商家阿德微信公众号上。


2. 表结构功能设计替换:

PHP 版本表包括如下两个:

把业务分为了 单发与群发的区分, 这张表 letter_multiple 就是群发表。

CREATE TABLE `letter_multiple` (
  `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
  `letter_type_id` tinyint(3) unsigned NOT NULL DEFAULT '0' COMMENT '消息类型',
  `company_id` int(10) unsigned NOT NULL DEFAULT '0',
  `user_type_id` int(10) NOT NULL DEFAULT '0' COMMENT '用户等级id,0表示所有用户,其他取值于商店系统member_type',
  `user_type_name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '用户类型名称,如全部用户,全部店主等',
  `params_value` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT '参数值',
  `belong_product` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '所属产品',
  `title` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '标题',
  .......
  `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`) USING BTREE,
  KEY `letter_template_index` (`letter_template_id`) USING BTREE,
  ........
  KEY `user_type_name` (`user_type_name`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=86 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='群发消息表';

单发表:

CREATE TABLE `letter_record` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
  `company_id` int(10) unsigned NOT NULL DEFAULT '0',
  `letter_type_id` tinyint(3) unsigned NOT NULL DEFAULT '0' COMMENT '消息类型',
  `letter_multiple_id` tinyint(3) unsigned NOT NULL DEFAULT '0',
  `user_type_id` int(10) NOT NULL DEFAULT '0' COMMENT '用户类型 对应店铺那边member_type表 -1指定用户',
  `params_value` text CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci COMMENT '参数值',
  `title` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '模板标题',
 ......
  `user_id` bigint(20) NOT NULL DEFAULT '0' COMMENT '接受用户',
  `user_type_name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '0',
  ......
  `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`) USING BTREE,
  KEY `letter_template_index` (`letter_template_id`) USING BTREE,
    ......
  KEY `letter_type_id` (`letter_type_id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=162604 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='单发消息表';

首先这套设计方案在 单发和群发上做了区分,将消息来源分别 记录到了两张表中,并且又把消息的已读未读已冗余大哦了单反表中进行记录。(这显然不符合我们单一职责的设计原则)。

重构表结构设计方案:

大体思路就是 消息来源表1 只记录消息本身不区分单群发。

然后再加一个 letter_read 消息已读表2 这表只记录已读取和用户已经删除的数据,未读数据不进行记录。

由于 PHP主体服务 在各方面的业务触发处都会将触发数据记录到 lettert_record 这张表中,所以就拿 letter_record 表作为消息记录表,兼容之前的业务处理等。

表 letter_read 结构如下:

表 letter_record 基本不动。只需要添加一个,user_type_count 字段表示发送时用户类型对应当前的数量

CREATE TABLE `letter_read` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 'ID',
  `company_id` bigint(20) NOT NULL DEFAULT 0 COMMENT '企业ID',
  `customer_id` bigint(20) NOT NULL DEFAULT 0 COMMENT '用户ID',
  `customer_name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '用户名'
  `letter_record_id` bigint(20) NOT NULL DEFAULT 0 COMMENT '消息ID',
  `letter_type_id` int(10) NOT NULL DEFAULT 0 COMMENT '消息类型ID',
  `user_type_id` int(10) NOT NULL DEFAULT '0' COMMENT '用户类型 对应店铺那边member_type表 -1指定用户',
  `user_type_name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '用户类型名称,全部用户、指定用户等',
  `deleted_at` bigint(20) NOT NULL DEFAULT 0 COMMENT '删除时间',
  `created_at` bigint(20) NOT NULL DEFAULT 0 COMMENT '创建时间',
  `updated_at` bigint(20) NOT NULL DEFAULT 0 COMMENT '更新时间',
  PRIMARY KEY (`id`) USING BTREE,
  KEY `dix_company_id` (`company_id`) USING BTREE,
  ......
  KEY `dix_letter_record_id` (`letter_record_id`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='消息已读表';

3. 代码实现:

第一版本 sql 原句联表 + for 循环 多 IO 版:

for _, letterType := range letterTypes {
        // 1 一次联表的sql查询
		nums, err := s.Store.SelectLetterUnReadNums(letterType.ID, userID, userTypeID)
		if err != nil {
			s.Logger.Error("GetMessageTypeList err.err:%v", err)
			s.RespOKCode(c, constant.ErrServerError, err, "")
			return
		}
		GetRecordsItem := protocol.GetRecordsItem{
			LetterType: protocol.NewLetterType(&letterType),
			UnreadNum:  nums,
		}
		
		// 2 第二次 联表的sql查询
		letters, err := s.Store.SelectLetters(letterType.ID, userID, userTypeID, 0, 1)
		if err != nil {
			s.Logger.Error("GetMessageTypeList err.err:%v", err)
			s.RespOKCode(c, constant.ErrServerError, err, "")
			return
		}
		for _, letter := range letters {
			GetRecordsItem.Item = protocol.NewLetterRecord(&letter)

		}
		ret = append(ret, GetRecordsItem)
	}

其中 SelectLetterUnReadNums 方法包含了个 sql联表:

"select count(1) from letter_record l left join letter_read r on l.letter_type_id=r.id and (l.user_id=? or l.user_type_id=? or l.user_type_id=0) and l.letter_type_id=? and l.user_id=r.customer_id and l.company_id=r.company_id where l.is_read=0 and r.id is null and (l.user_id=? or (l.user_id=0 and (l.user_type_id = ? or l.user_type_id = ?)))and l.letter_type_id = ?"

其中 SelectLetters 方法也有一个sql如下自行体验:

"select l.*,l.id<=>r.letter_record_id as is_read from letter_record as l  left join letter_read as r on l.id=r.letter_record_id and r.deleted_at=0 where (l.letter_type_id = ? and (l.user_id=0 and (l.user_type_id = ? or l.user_type_id=0))) or l.user_id = ? and l.status = 1 and r.deleted_at is null order by id desc limit ?,?"

这一版实现上来看,从代码逻辑的可维护性和for 里面循环多IO来看都是不太友好的一版,so~ ,重构没的说。

第二版本 goroutine 并发执行多类型sql:

引入辅助工具包: ““golang.org/x/sync/errgroup””

letterTypeIds := make([]int64, len(letterTypeList))
	for i, v := range letterTypeList {
		letterTypeIds[i] = v.ID
	}

	// todo: 考虑一次性查表 并加入 时间阶段 预期 30天内的
	var g errgroup.Group
	for i, letterType := range letterTypeList {
		i, letterType := i, letterType
		g.Go(func() error {
			resp, err := s.getUnReadAndLastRecord(letterType, userID, userTypeID)
			if err == nil {
				result[i] = resp
			}
			return err
		})
	}

	if err := g.Wait(); err != nil {
		s.RespErr(ctx, constant.ErrServerError, err)
		return
	}

	RespOK(ctx, result)

这里只是优化了 把同步执行多个 消息类型去查询 改为异步并发执行。

然后从设计上去掉了联表查询 由以前的一条联表 sql 拆成了两条简单 sql,2条变4条 sql。

但是这里依旧还是有 for 循,依旧需要优化。

只是刚好想到 errorgroug 捡起来用一下。

最终版,一次性批量查询多消息类型,从设计上去掉联表查询。

letterTypeIds := make([]int64, len(letterTypeList))
	for i, v := range letterTypeList {
		letterTypeIds[i] = v.ID
		result[i].ID = strconv.FormatInt(v.ID, 10)
		result[i].Title = v.Title
		result[i].Group = v.Group
	}

	// 获取未读数 与 最近一条消息
	lastListMap, err := s.getUnReadAndLastRecord(letterTypeIds, userID, userTypeID)
	if err != nil {
		s.RespErr(ctx, constant.ErrServerError, err)
		return
	}

	for i, v := range result {
		letterTypeID, _ := strconv.ParseInt(v.ID, 10, 64)
		if _, ok := lastListMap[letterTypeID]; !ok {
			lastListMap[letterTypeID] = &protocol.GetMessageTypeListResp{}
		}
		result[i].UnreadNum = lastListMap[letterTypeID].UnreadNum
		result[i].Item = lastListMap[letterTypeID].Item
	}

这里其实 把所有的消息类型信息 查出来,进行一次性的批量查询,从而取消了 再 for 循环里面的反复的多次sql。

业务重构后设计说明:

  1. 对于群发给全部用户的消息,不做未读已读处理。
  2. 对于未读标记,直接交给前端处理,后端只需要根据每次请求的分页对应的record_id 给出对应已读的 ids 集合即可。
  3. 对于用户调用已读接口,直接在read插入对应消息数据即可,没读就不插入数据。

方案一压测:

开始启动  并发数:200 请求数:1 请求参数:
request:
 form:http
 url:http://127.0.0.1:8081/notice-v1/customers/letter/cover/list?company_id=10018&user_id=259394&user_type_id=140
 method:GET
 headers:map[Postman-Token:d8709940-b501-4e2c-989f-3bc3511c4285 cache-control:no-cache]
 data:
 verify:statusCode
 timeout:30s
 debug:false



─────┬───────┬───────┬───────┬────────┬────────┬────────┬────────┬────────
 耗时│ 并发数│ 成功数│ 失败数│   qps  │最长耗时│最短耗时│平均耗时│ 错误码
─────┼───────┼───────┼───────┼────────┼────────┼────────┼────────┼────────
   1s15415401757.50150.3783.220.57200:154
   2s15415401757.50150.3783.220.57200:154
   3s15415401757.50150.3783.220.57200:154
   4s15415401757.50150.3783.220.57200:154
   5s15415401757.50150.3783.220.57200:154
   6s15415401757.50150.3783.220.57200:154
   7s1911910142.836911.2083.227.00200:191
   8s1981980123.567911.5983.228.09200:198
   8s2002000118.788171.7683.228.42200:200


*************************  结果 stat  ****************************
处理协程数量: 200
请求总数(并发数*请求数 -c * -n): 200 总请求时间: 8.181successNum: 200 failureNum: 0
*************************  结果 end   ****************************

方案二压测:

开始启动  并发数:200 请求数:1 请求参数:
request:
 form:http
 url:http://127.0.0.1:8081/notice-v1/customers/letter/cover/list?company_id=10018&user_id=259394&user_type_id=140
 method:GET
 headers:map[Postman-Token:d8709940-b501-4e2c-989f-3bc3511c4285 cache-control:no-cache]
 data:
 verify:statusCode
 timeout:30s
 debug:false



─────┬───────┬───────┬───────┬────────┬────────┬────────┬────────┬────────
 耗时│ 并发数│ 成功数│ 失败数│   qps  │最长耗时│最短耗时│平均耗时│ 错误码
─────┼───────┼───────┼───────┼────────┼────────┼────────┼────────┼────────
   1s747403054.09103.0540.230.33200:74
   2s1601600206.481895.3540.234.84200:160
   3s2002000157.952690.1540.236.33200:200


*************************  结果 stat  ****************************
处理协程数量: 200
请求总数(并发数*请求数 -c * -n): 200 总请求时间: 2.718successNum: 200 failureNum: 0
*************************  结果 end   ****************************

方案三压测:

开始启动  并发数:200 请求数:1 请求参数:
request:
 form:http
 url:http://127.0.0.1:8081/notice-v1/customers/letters/cover/list?company_id=10018&user_id=259394&user_type_id=140
 method:GET
 headers:map[Postman-Token:d8709940-b501-4e2c-989f-3bc3511c4285 cache-control:no-cache]
 data:
 verify:statusCode
 timeout:30s
 debug:false



─────┬───────┬───────┬───────┬────────┬────────┬────────┬────────┬────────
 耗时│ 并发数│ 成功数│ 失败数│   qps  │最长耗时│最短耗时│平均耗时│ 错误码
─────┼───────┼───────┼───────┼────────┼────────┼────────┼────────┼────────
   1s1981980489.54986.0230.752.04200:198
   1s2002000482.72996.6230.752.07200:200


*************************  结果 stat  ****************************
处理协程数量: 200
请求总数(并发数*请求数 -c * -n): 200 总请求时间: 1.014successNum: 200 failureNum: 0
*************************  结果 end   ****************************

以上只是本地外网简单测试,不具有严谨性。

但是也能看出来 同样的并发数量都是200 ,差距一倍的耗时,因为大部分都是在等 IO。


总结:

这种站内信消息系统,对于传统解决方案就是,每发一次消息就对应去推送给对应的目标用户,进行记录到表中。但是如果当你的用户量数量足够庞大的时候,在批量用户推送插入数据表的时候很吃数据库的IO的,导致表不停的加锁阻塞其他线程吃不到资源。

  1. 利用用户端主动拉取消息列表时,采用被动查询(可能涉及多表)的方式能解决问题,如果发布消息端会有时间选择时也可以避免设计出定时任务的资源损耗,直接在查询时比较下发布时间即可。
  2. 同样的业务逻辑设计方式在 电商中的 优惠券下发 场景也可以采用这种被动查询的方式。