1 业务背景
商家的域名在A站使用,有买家保存了结算页地址。后来商家将域名绑定到B站使用。买家再次访问保存的地址时,弹出无法找到该笔订单的商品信息。原因是订单是在A站产生的,商品也是属于A站的。当域名绑到B站后,再次访问域名,就会解析到B站,而B站是没有订单上的商品ID的,因此无法找到该笔订单的商品信息。
2 数据库表的背景
造成上面的原因是因为结算页的临时订单信息表是没有存储站点主键ID,导致加载结算页仍会继续去寻找订单上的商品。
3 需求确立
给结算页的临时订单信息表添加一个字段
shop_id
,存储站点主键ID。根据结算页对应的token去寻找结算页的临时订单信息表,找到shop_id
。判断该shop_id
是否与当前域名指向的shop_id
是否一致。不一致则直接抛异常说明“不存在此订单”。
由于是新增字段,插入数据时需要补上该字段。而历史数据需要做数据补偿。
4 需求分析
临时订单的数据如下:
截图中已存在shop_id
,暂且不用管他,因为这个已经是笔者实现了的解决方案,已完成数据补偿。
目标是要找到每个临时订单对应的站点主键ID
shop_id
。正式订单的数据包含token
,但是临时订单不一定就被买家付款从而成为正式订单。因此不能通过token找对应的shop_id
。param
中含有productId
(存储spui主键ID),而productId必定是会对应一个shop_id
,因此我们可以考虑通过productId
找shop_id
。但笔者在实现的过程中发现有些临时订单根本没有productId
,却有variantId
(存储sku主键ID)。因此,分析下来,通过productId
或者variantId
寻找shop_id
。
5 可行性分析
上一小节分析得出,通过
productId
或者variantId
寻找shop_id
。
5.1 直接查库?
引入缓存。待数据补偿的数据有500w行左右,如果每次都用product_id
或者variant_id
去查数据库,会给数据库增加很大压力,同时生产环境上也有流量打到数据库。因此引入缓存,把product_id
与shop_id
的关系缓存起来。每次先查缓存,差不多再统一批量查询。
5.2 用Redis?
引入LRU缓存。生产环境上同样也会有流量打到Redis,不考虑使用Redis。考虑使用JVM缓存`,比如LRU缓存,更加轻量,也不会影响生产环境的主业务。
5.3 用MQ还是多线程?
MQ:
MQ有重试机制,并且消费者也是可以有多个线程去消费,并且MQ内部会维护一个线程池,当消费者线程满了后,MQ则不会继续发送消息给消费者消费。总之就是开发者不需要考虑线程池的使用,MQ会维护好。但是使用MQ创建Topic、消费者Group,还需要写消费者的代码并发版上线。而且也会提高MQ的负载导致影响正常业务使用MQ。
多线程:
使用多线程会更加轻量,并且不会影响生产环境MQ的负载,也不需要创建Topic、消费者Group(需要写代码并发版,上线。而多线程则只需用xxl-job的一个glue模式即可,热插拔,粘贴代码到xxl-job上面即可。)但是多线程需要小心使用线程池的几个核心参数,如核心线程数、最大线程数、阻塞队列等。
决定采用:
引入多线程。总共需要更新500w行。每个线程负责各自范围的数据(用select * from t_checkout_order where id >= a and id <= b
,目的是用上where走主键索引)。比如线程A处理0-1000行,线程B处理1001-2000行。每个线程里面组装好待更新的数据,假如1000条数据里面,有3种shop_id
,则按照shop_id
分类更新(即update t_checkout_order set shop_id = #{shopId} where id in ();
,目的是为了减少提交sql的次数,提高MySQL的执行效率)。
5.4 线程安全问题?
引入ThreadLocal
。多个线程操作同一个LRU缓存,会出现线程安全问题。引入ThreadLocal
,每个线程都有属于自己的一份LRU缓存数据。线程用完ThreadLocal后,需要手动执行remove()
清理ThreadLocal
的值避免内存泄漏。
6 技术实现
- 使用线程池的多线程,每个线程负责处理1000条数据
- LRU缓存使用
LinkedHashMap
实现。 - 使用
ThreadLocal
存储LRU缓存,这样每个线程拥有各自的LRU缓存。根据product_id
和variant_id
从LRU缓存查shop_id
,将所有查不到shop_id
的product_id
或者variant_id
收集起来,批量一起查询数据库。查完再写入LRU缓存。 - 将待更新的数据按照
shop_id
分类,一整批提交给MySQL更新。 - 线程池无子线程可用(即线程池的队列已经满了),则主线程休眠5s。
- 线程池的核心参数设置的考虑:(1)核心线程数与最大线程数一样(笔者设置10)。这样就不会创建非核心线程,也不需要销毁、回收非核心线程,减少了一些占用资源的操作(比如创建线程占用CPU负载、回收时会尝试获取锁等)。并且不需要很大的吞吐量,不需要使用非核心线程提高系统吞吐量。我使用不会被回收的核心线程去稳定地执行任务即可。(2)阻塞队列使用
ArrayBlockingQueue
的数组实现有界队列,限制队列大小为100。提交任务前,判断队列是否已经满了,如果满了,则主线程休眠5s 。一直循环判断,直到队列有空间存储新提交的任务。这样做的原因是防止队列满了并且已达到最大线程数导致触发拒绝策略,因为选用默认的拒绝策略会抛出异常。虽然抛出异常不会影响其他线程的执行,但是最好让线程池掌握在自己控制中,阻塞队列满了后适当阻塞会比较好。同时我的sql支持增量处理,已处理的数据不会重复处理。(3)使用默认的线程池拒绝策略ThreadPoolExecutor.AbortPolicy
,线程数达到最大线程数并且阻塞队列已经满了,直接抛异常,快速失败减少无效的资源占用。针对未补偿的数据再执行多一次任务调度即可(因为sql脚本只会查询并处理未补偿的数据)。 - 扩展延申:第6点提到核心线程数与最大线程数一样,也是一个知识点。涉及到许多动态扩展的场景,由于动态扩展会占用系统资源,因此很多时候都会将最小配置设置得与最大配置一样。比如jvm最小内存最大内存。数据库连接池。http远程调用的连接池。凡是与池有关的都可以这么设置。