技术总结
技术总结(不定期更新)
记录我的日常工作的技术总结,属于个人感悟,不定期更新
代码开发
接口篇
- 接口支持高级分页(数据量过大深分页问题)
分页可以减少客户端接收的数据数目,但是当你需要将分页结果与不断接收的新条目结合时,通常的限制limit和偏移offset分页参数是低效的,因为每次当有个新条目在服务端被添加到先前的集合时,先前发送到客户端的偏移offset都变得无效,而且客户端无法得知在两次请求间新增了多少条目。保持客户端同步一个比较好的办法是使用before_id和after_id参数组合,比如客户端将已知的最新条目的id作为after_id请求参数,然后检索之后创建的新条目(当分页结果总数比较大时,获取靠后的分页结果会及其的慢,因为offset分页已经效率很低了,即使已经加了索引——实际工作场景中遇到的是要获取一个总数为10万的分页结果[该表做了8个分库操作],每页500条数据,当获取靠后页码的分页结果时,查询效率已经很低了,有的甚至超过了5秒,这对于分布式系统是无法接受的)
- 提供了分页的外部接口就不要再提供查询总数接口
过多的给外部提供接口会让后期维护成本变得更高,会使得难以管理和改动
- 为什么服务提供方不适合提供大批量的调用接口
- 背景:根据多区域查询用户数据信息的接口,可传区域数量限制是5个,对于某些业务场景来说太少,比如运营有上百个区域,调用方就需要调用多次,会影响性能,想要服务提供方将这个限制放大些(简单的来说,就是提供了一个批量查询的接口,为了兼顾能快速响应给服务调用方,入参中批量的列表只支持5个,但是调用方因为业务场景需要调用多次,希望调用提供方来调大批量的个数限制减少调用次数)
- 解释:该问题可以从技术维度探讨一下,建议可以设置个阈值做并行的循环调用(需要客户端和服务端支持动态调整阈值,该阈值用来调整批量的数量限制)。我们不妨使用反向论证法来剖析一下,考量点是这样,假如把大批量的逻辑放到服务提供方,可以有两种实现方式:其一是串行,其二是并行。串行肯定不能满足性能要求,会把调用端拖死,基本不能解决超时的问题,并行会存在计算量大,CPU压力大,会把CPU跑满。基于以上弊端,建议把并行放在客户端,客户端不存在计算的压力,循环后通过RPC的负载均衡会打到服务提供方集群的多个机器上,这样服务提供方的压力做了分散,理论上可以通过该集群的扩展获取想要的流量
事务篇
- 事务操作里面不要嵌套RPC、MQ等操作!!!
RPC、MQ、缓存等远程调用操作不要放在事务里面进行,这些远程调用可能会造成事务演变成一个大事务
远程调用存在很大不确定性,当网络不稳定或调用方处理数据过慢,就会造成响应时间过长,从而将这个事务变成大事务
- MQ消费、RPC调用等操作,在更新多表数据时,一定要保证更新操作在同一个事务里,同时要保证幂等性
若业务比较复杂,同时在业务完成的最后,在一个事务里需要保存或更新多数据表,尽量在事务操作前将数据的校验、数据转换、数据组装等操作完成,事务里只进行数据保存
// 此处仅仅是一个demo,具体视实际情况而定
// 数据校验,构建数据等操作不要在此进行,这里仅仅保存数据
public void saveMoreData(List<Object> obj1,Object obj2,Object obj3,Object obj4,Object obj5){
transactionTemplate.execute((status) => {
Integer saveResult = save(obj1);
if (saveResult == obj1.size){
save(obj2);
save(obj3);
save(obj4);
save(obj5);
} else {
log.error("保存obj1数据失败");
// 加告警等处理
}
})
}
- 大批量数据保存或更新操作不要放到一个事务里
大批量数据可以进行分批数据保存或更新,拆分成多个事务,减小事务的操作粒度。但是,如果是消费MQ来保存大批量数据,就需要来保证幂等,此时可以使用 INSERT…ON DUPLICATE KEY UPDATE 来保证幂等(根据业务场景设置唯一键,)
- 如果需要在事务后做更新缓存、保存日志、更新ES、发送MQ、调用其他RPC等其中几项操作,可考虑Spring事件监听器
示例如下:
@Getter
public class OrderChangeEvent extends ApplicationEvent {
private OrderData orderData;
private OrderOptType optType;
public OrderChangeEvent(OrderData source, OrderOptType optType) {
super(source);
this.orderData = source;
this.optType = optType;
}
}
@Service
public class OrderEventService {
@Resource
private ApplicationEventPublisher publisher;
public void createOrder(OrderData order) {
publisher.publishEvent(new OrderChangeEvent(order, OrderOptType.CREATE);
}
}
@Service
public class OrderService {
@Resource
private OrderEventService orderEventService;
@Transactional(rollbackFor = Exception.class)
public void createOrder(OrderData order) {
doSaveOrder(order);
orderEventService.createOrder(order);
}
}
// MQ发送
@Service
public class MqPushEventListener {
@TransactionalEventListener(fallbackException = true)
public void listenOrderCreateOrder(OrderChangeEvent event) {
sendMq(event.getOrderData);
}
}
// 缓存保存
@Service
public class CacheEventListener {
@TransactionalEventListener(fallbackException = true)
public void listenOrderCreateOrder(OrderChangeEvent event) {
saveCache(event.getOrderData);
}
}
MQ篇
- 消费方一定要尽可能的支持消息重放
工作中有很大可能会遇到消息消费失败或消息意外丢失的情况,此时就需要消息重放来恢复数据或进行数据补偿,如果消费方不支持消费重放可能会造成重大损失
- 消息消费失败场景:服务重启造成消费中断、业务数据幂等造成消费失败而抛出异常重试、获取分布式锁失败抛出异常重试、业务异常造成重试、业务未处理完成需人工进行重试、消费长时间未响应自动转重试
- 消息意外丢失场景:消息过滤错误导致丢弃、业务场景上时间匹配错误或处理逻辑错误导致大量丢失、消费消息未做好幂等导致消息跳过、不可抗力因素
- 消息重放:一定要考虑消费该消息的代码是否支持重放,重放后会造成什么后果,幂等性是否充分,需要从什么节点什么时间重放(消息的重置消费位点或人工补偿重放),重放是否会对现有消息有影响,重放是否会造成大量积压和重试,重放是否会压垮机器或导致机器的内存、CPU突然暴增
- 如果MQ由于种种原因无法保证数据库操作和发MQ在同一个事务下保证,则必须先进行数据库事务操作,再发MQ
- 如果先发送MQ再进行数据库事务操作,则可能会造成MQ已经发送成功,数据库事务提交失败,此种错误不易进行补偿
- 如果先进行数据库事务操作,则发送MQ,当MQ发送失败,需要记录失败日志,同时进行告警或者通知到相关人员,进行MQ重发操作进行补偿
- 如果是使用的MQ本地消息表或者其他方式能够保证数据库事务和MQ在同一事务,则无需关心此问题(RocketMQ、Seata、Hmily等来解决)
- MQ同一业务数据状态保证顺序:保证同一业务主键的MQ能落到同一个分区上,需要有一个路由策略组件,由它决定消息该放到哪个分区中。生产者可以根据topic的路由信息选择具体发到哪个queue上,consumer订阅消息时,会根据负载均衡策略决定订阅哪些queue的消息
- 用户的等级状态:根据用户的行为(如GMV、下单件数、下单笔数、注册时间等)圈定用户,并根据圈定阶梯发送MQ为用户更新等级,但是用户在多个圈定行为中重复存在,这时就要根据发送MQ的顺序确定一个最终等级,这时就需要用户的id作为主键,将同一用户id的多个等级按照顺序进行发送MQ到同一个分区上
- 用户的订单状态:一个订单会产生多条MQ消息,下单、付款、发货、买家确认收货,消费端需要严格按照业务状态机的顺序处理,否则,就会出现业务问题,只要保证一个订单的多条状态消息在同一个分区,便可以满足业务需求,所以我们可以以订单id作为MQ的业务主键,保障同一订单的多个MQ消息可以落到同一个分区上
- MQ生产速度大于消费速度产生消息积压:增加分区数(同时消费者数要大于等于分区数)、增加消费方每次拉取待消费的MQ数
- 如果仅仅只有一个分区或者较少的分区造成了消息的积压,可考虑增加分区的方式,提高消费方的消费速度。对于同一个消费组,一个分区只支持一个消费线程来消费消息。过少的分区,会导致消费速度大大落后于消息的生产速度,可能会造成消息的积压。所以在实际生产环境中,一个topic会设置成多分区的模式,来支持多个消费者
- 适量增加每次一次性从broker里面取的待消费的消息的个数
- 过大量级的MQ消息量一定要进行业务前置过滤,否则可能造成消息挤压或者消费者机器宕机
- 当比较通用的MQ(比如订单、红包、优惠券、答题结果等)需要接入时,一定要根据业务类型进行过滤,通用型的MQ一般数据量都会特别大,每天的量可能达到上亿级
- 接入埋点上报的MQ要进行业务前置过滤:埋点的MQ消息量也十分的庞大,可以进行业务逻辑的校验提前过滤掉无需往下处理的MQ
- MQ并发消费需要根据业务主键进行加锁和解锁,锁的粒度一定要最小
如订单类MQ接入,同一个订单有可能取消的消息先到,所以我们需要对这样的订单MQ进行并发处理,根据订单id进行加锁(同时要指定过期时间,过期时间长短可以视业务而定),当该订单的业务处理完毕,再根据订单id进行解锁,当同一个订单(重复或其他状态)的MQ过来,可以保障并发时的先后顺序
PageHepler篇
- PageHelper对需要分页的方法不生效
- 简略写法可不使用PageInfo
示例:
import com.github.pagehelper.Page;
import com.github.pagehelper.pageHelper;
// 查询总数并且返回当前页数据
public MyPage<User> queryUserList(Integer pageNum, Integer pageSize, GetUserRequest condition)
Page<User> page = PageHelper.startPage(pageNum,pageSize)
.doSelectPage(() -> userMapper.selectUserList(condition));
return new MyPage(page.getPageNum, page.getPageSize, page.getResult());
}
// 只查询总数,好处就是可以和查询接口通用,不需要再单独写个查询总数接口
public long queryUserList(GetUserRequest condition)
return PageHelper.startPage(0,1)
.doCount(() -> userMapper.selectUserList(condition));
}
- 查询出的列表结果额外属性赋值
- PageHelper深分页效率问题
数据转换与组装篇
- 尽可能在数据组装前把所需字段的数据获取到,尽量不要在组装的逻辑里写大量的RPC调用
- 批量数据的某个字段如果需要从外部RPC获取,尽量调用其RPC的批量数据接口,如果没有,可以使用线程池批量调用单个数据接口来提高效率
比如要展示一页10条的用户数据,但是用户的某些信息是需要从外部接口获取的。如果是CMS运营端访问,则可以for循环一条一条获取并赋值;但如果是小程序或APP之类的,对响应时间要求高的,则需要调用其批量接口,如果没有批量接口,则可以使用线程池来提高效率
- BeanUtils.copyProperties()针对两个对象中属性名相同属性类型不同的字段无法进行赋值,如两个对象中都有total这个字段,但objA的total是long类型,objB的total是int类型
- mapstructs框架转换可以将两个对象中属性名相同属性类型不同的字段进行赋值,如两个对象中都money这个字段,但objA的money是String类型,objB的money是Long类型
线程池篇
- 使用线程池如何保证同一个业务(或一连串事情)的顺序性
业务场景:保证多个团长等级变更,同时同一团长的等级变更消息要有先后顺序。参考:看到一个魔改线程池
指导文章
专题文章
梁的主页
非专题文章
大厂技术
美团技术 (github)
- 配置中心——MCC
- PRC——Mthrift(基于thrift)
- 任务调度——Crane
- 分布式监控——CAT【开源】
- 内部知识网——学城(大佬技术文章都有,有广度有深度)
- 缓存——squirrel(基于Redis)
- MySQL访问——Zebra【开源】
京东技术(gitee)
- 配置中心——DUCC
- PRC——JSF(基于Dubbo)
- 任务调度——天鸽(基于xxl-job)
- 分布式监控——UMP
- 内部知识网——CF
- 缓存——JIMDB(基于Redis)