那些年,咱们见过的 Java 服务端“问题”

导读
明代著名的心学集大成者王阳明先生在《传习录》中有云:
道无精粗,人之所见有精粗。如这一间房,人初进来,只见一个大规模如此。处久,便柱壁之类,一一看得明白。再久,如柱上有些文藻,细细都看出来。然只是一间房。
是的,知识理论哪有什么精粗之分,只是人的认识程度不一样而已。笔者在初创公司摸爬滚打数年,接触了各式各样的Java服务端架构,见得多了天然也就认识深了,就能分辨出各类方案的优劣了。这里,笔者总结了一些初创公司存在的Java服务端问题,并尝试性地给出了一些不成熟的解决方案。
1.系统不是分布式
随着互联网的发展,计算机系统早就从单机独立工做过渡到多机器协同工做。计算机以集群的方式存在,按照分布式理论构建出庞大复杂的应用服务,早已深刻人心并获得普遍地应用。可是,仍然有很多创业公司的软件系统停留在"单机版"。
1.1.单机版系统抢单案例
这里,用并发性比较高的抢单功能为例说明:
// 抢取订单函数
public synchronized void grabOrder(Long orderId, Long userId) {程序员

// 获取订单信息
OrderDO order = orderDAO.get(orderId);
if (Objects.isNull(order)) {
    throw new BizRuntimeException(String.format("订单(%s)不存在", orderId));
}

// 检查订单状态
if (!Objects.equals(order.getStatus, OrderStatus.WAITING_TO_GRAB.getValue())) {
    throw new BizRuntimeException(String.format("订单(%s)已被抢", orderId));
}

// 设置订单被抢
orderDAO.setGrabed(orderId, userId);

}
以上代码,在一台服务器上运行没有任何问题。进入函数grabOrder(抢取订单)时,利用synchronized关键字把整个函数锁定,要么进入函数前订单未被人抢取从而抢取订单成功,要么进入函数前订单已被抢取致使抢取订单失败,绝对不会出现进入函数前订单未被抢取而进入函数后订单又被抢取的状况。
可是,若是上面的代码在两台服务器上同时运行,因为Java的synchronized关键字只在一个虚拟机内生效,因此就会致使两我的可以同时抢取一个订单,但会以最后一个写入数据库的数据为准。因此,大多数的单机版系统,是没法做为分布式系统运行的。
1.2.分布式系统抢单案例
添加分布式锁,进行代码优化:
// 抢取订单函数
public void grabOrder(Long orderId, Long userId) {数据库

Long lockId = orderDistributedLock.lock(orderId);
try {
    grabOrderWithoutLock(orderId, userId);
} finally {
    orderDistributedLock.unlock(orderId, lockId);
}

}缓存

// 不带锁的抢取订单函数
private void grabOrderWithoutLock(Long orderId, Long userId) {安全

// 获取订单信息
OrderDO order = orderDAO.get(orderId);
if (Objects.isNull(order)) {
    throw new BizRuntimeException(String.format("订单(%s)不存在", orderId));
}

// 检查订单状态
if (!Objects.equals(order.getStatus, OrderStatus.WAITING_TO_GRAB.getValue())) {
    throw new BizRuntimeException(String.format("订单(%s)已被抢", orderId));
}

// 设置订单被抢
orderDAO.setGrabed(orderId, userId);

}
优化后的代码,在调用函数grabOrderWithoutLock(不带锁的抢取订单)先后,利用分布式锁orderDistributedLock(订单分布式锁)进行加锁和释放锁,跟单机版的synchronized关键字加锁效果基本同样。
1.3.分布式系统的优缺点
分布式系统(Distributed System)是支持分布式处理的软件系统,是由通讯网络互联的多处理机体系结构上执行任务的系统,包括分布式操做系统、分布式程序设计语言及其编译系统、分布式文件系统分布式数据库系统等。
分布式系统的优势:
可靠性、高容错性:
一台服务器的崩溃,不会影响其它服务器,其它服务器仍能提供服务。
可扩展性:
若是系统服务能力不足,能够水平扩展更多服务器。
灵活性:
能够很容易的安装、实施、扩容和升级系统。
性能高:
拥有多台服务器的计算能力,比单台服务器处理速度更快。
性价比高:
分布式系统对服务器硬件要求很低,能够选用廉价服务器搭建分布式集群,从而获得更好的性价比。
分布式系统的缺点:
排查难度高:
因为系统分布在多台服务器上,故障排查和问题诊断难度较高。
软件支持少:
分布式系统解决方案的软件支持较少。
建设成本高:
须要多台服务器搭建分布式系统。
曾经有很多的朋友咨询我:"找外包作移动应用,须要注意哪些事项?"
首先,肯定是否须要用分布式系统。软件预算有多少?预计用户量有多少?预计访问量有多少?是否只是业务前期试水版?单台服务器可否解决?是否接收短期宕机?……若是综合考虑,单机版系统就能够解决的,那就不要采用分布式系统了。由于单机版系统和分布式系统的差异很大,相应的软件研发成本的差异也很大。
其次,肯定是否真正的分布式系统。分布式系统最大的特色,就是当系统服务能力不足时,可以经过水平扩展的方式,经过增长服务器来增长服务能力。然而,单机版系统是不支持水平扩展的,强行扩展就会引发一系列数据问题。因为单机版系统和分布式系统的研发成本差异较大,市面上的外包团队大多用单机版系统代替分布式系统交付。那么,如何肯定你的系统是真正意义上的分布式系统呢?从软件上来讲,是否采用了分布式软件解决方案;从硬件上来讲,是否采用了分布式硬件部署方案。
1.4.分布式软件解决方案
做为一个合格的分布式系统,须要根据实际需求采用相应的分布式软件解决方案。
1.4.1.分布式锁
分布式锁是单机锁的一种扩展,主要是为了锁住分布式系统中的物理块或逻辑块,用以此保证不一样服务之间的逻辑和数据的一致性。
目前,主流的分布式锁实现方式有3种:
基于数据库实现的分布式锁;
基于Redis实现的分布式锁;
基于Zookeeper实现的分布式锁。
1.4.2.分布式消息
分布式消息中间件是支持在分布式系统中发送和接受消息的软件基础设施。常见的分布式消息中间件有ActiveMQ、RabbitMQ、Kafka、MetaQ等。
MetaQ(全称Metamorphosis)是一个高性能、高可用、可扩展的分布式消息中间件,思路起源于LinkedIn的Kafka,但并非Kafka的一个拷贝。MetaQ具备消息存储顺序写、吞吐量大和支持本地和XA事务等特性,适用于大吞吐量、顺序消息、广播和日志数据传输等场景。
1.4.3.数据库分片分组
针对大数据量的数据库,通常会采用"分片分组"策略:
分片(shard):主要解决扩展性问题,属于水平拆分。引入分片,就引入了数据路由和分区键的概念。其中,分表解决的是数据量过大的问题,分库解决的是数据库性能瓶颈的问题。
分组(group):主要解决可用性问题,经过主从复制的方式实现,并提供读写分离策略用以提升数据库性能。
1.4.4.分布式计算
分布式计算( Distributed computing )是一种"把须要进行大量计算的工程数据分割成小块,由多台计算机分别计算;在上传运算结果后,将结果统一合并得出数据结论"的科学。
当前的高性能服务器在处理海量数据时,其计算能力、内存容量等指标都远远没法达到要求。在大数据时代,工程师采用廉价的服务器组成分布式服务集群,以集群协做的方式完成海量数据的处理,从而解决单台服务器在计算与存储上的瓶颈。Hadoop、Storm以及Spark是经常使用的分布式计算中间件,Hadoop是对非实时数据作批量处理的中间件,Storm和Spark是对实时数据作流式处理的中间件。
除此以外,还有更多的分布式软件解决方案,这里就再也不一一介绍了。
1.5.分布式硬件部署方案
介绍完服务端的分布式软件解决方案,就不得不介绍一下服务端的分布式硬件部署方案。这里,只画出了服务端常见的接口服务器、MySQL数据库、Redis缓存,而忽略了其它的云存储服务、消息队列服务、日志系统服务……
1.5.1.通常单机版部署方案服务器

架构说明:
只有1台接口服务器、1个MySQL数据库、1个可选Redis缓存,可能都部署在同一台服务器上。
适用范围:
适用于演示环境、测试环境以及不怕宕机且日PV在5万之内的小型商业应用。
1.5.2.中小型分布式硬件部署方案网络

架构说明:
经过SLB/Nginx组成一个负载均衡的接口服务器集群,MySQL数据库和Redis缓存采用了一主一备(或多备)的部署方式。
适用范围:
适用于日PV在500万之内的中小型商业应用。
1.5.3.大型分布式硬件部署方案多线程

架构说明:
经过SLB/Nginx组成一个负载均衡的接口服务器集群,利用分片分组策略组成一个MySQL数据库集群和Redis缓存集群。
适用范围:
适用于日PV在500万以上的大型商业应用。
2.多线程使用不正确
多线程最主要目的就是"最大限度地利用CPU资源",能够把串行过程变成并行过程,从而提升了程序的执行效率。
2.1.一个慢接口案例
假设在用户登陆时,若是是新用户,须要建立用户信息,并发放新用户优惠券。例子代码以下:
// 登陆函数(示意写法)
public UserVO login(String phoneNumber, String verifyCode) {架构

// 检查验证码
if (!checkVerifyCode(phoneNumber, verifyCode)) {
    throw new ExampleException("验证码错误");
}

// 检查用户存在
UserDO user = userDAO.getByPhoneNumber(phoneNumber);
if (Objects.nonNull(user)) {
    return transUser(user);
}

// 建立新用户
return createNewUser(user);

}并发

// 建立新用户函数
private UserVO createNewUser(String phoneNumber) {负载均衡

// 建立新用户
UserDO user = new UserDO();
...
userDAO.insert(user);

// 绑定优惠券
couponService.bindCoupon(user.getId(), CouponType.NEW_USER);

// 返回新用户
return transUser(user);

}
其中,绑定优惠券(bindCoupon)是给用户绑定新用户优惠券,而后再给用户发送推送通知。若是随着优惠券数量愈来愈多,该函数也会变得愈来愈慢,执行时间甚至超过1秒,而且没有什么优化空间。如今,登陆(login)函数就成了名副其实的慢接口,须要进行接口优化。
2.2.采用多线程优化
经过分析发现,绑定优惠券(bindCoupon)函数能够异步执行。首先想到的是采用多线程解决该问题,代码以下:
// 建立新用户函数
private UserVO createNewUser(String phoneNumber) {

// 建立新用户
UserDO user = new UserDO();
...
userDAO.insert(user);

// 绑定优惠券
executorService.execute(()->couponService.bindCoupon(user.getId(), CouponType.NEW_USER));

// 返回新用户
return transUser(user);

}
如今,在新线程中执行绑定优惠券(bindCoupon)函数,使用户登陆(login)函数性能获得很大的提高。可是,若是在新线程执行绑定优惠券函数过程当中,系统发生重启或崩溃致使线程执行失败,用户将永远获取不到新用户优惠券。除非提供用户手动领取优惠券页面,不然就须要程序员后台手工绑定优惠券。因此,用采用多线程优化慢接口,并非一个完善的解决方案。
2.3.采用消息队列优化
若是要保证绑定优惠券函数执行失败后可以重启执行,能够采用数据库表、Redis队列、消息队列的等多种解决方案。因为篇幅优先,这里只介绍采用MetaQ消息队列解决方案,并省略了MetaQ相关配置仅给出了核心代码。
消息生产者代码:
// 建立新用户函数
private UserVO createNewUser(String phoneNumber) {

// 建立新用户
UserDO user = new UserDO();
...
userDAO.insert(user);

// 发送优惠券消息
Long userId = user.getId();
CouponMessageDataVO data = new CouponMessageDataVO();
data.setUserId(userId);
data.setCouponType(CouponType.NEW_USER);
Message message = new Message(TOPIC, TAG, userId, JSON.toJSONBytes(data));
SendResult result = metaqTemplate.sendMessage(message);
if (!Objects.equals(result, SendStatus.SEND_OK)) {
    log.error("发送用户({})绑定优惠券消息失败:{}", userId, JSON.toJSONString(result));
}

// 返回新用户
return transUser(user);

}
注意:可能出现发生消息不成功,可是这种几率相对较低。
消息消费者代码:
// 优惠券服务类
@Slf4j
@Service
public class CouponService extends DefaultMessageListener<String> {

// 消息处理函数
@Override
@Transactional(rollbackFor = Exception.class)
public void onReceiveMessages(MetaqMessage<String> message) {
    // 获取消息体
    String body = message.getBody();
    if (StringUtils.isBlank(body)) {
        log.warn("获取消息({})体为空", message.getId());
        return;
    }
    
    // 解析消息数据
    CouponMessageDataVO data = JSON.parseObject(body, CouponMessageDataVO.class);
    if (Objects.isNull(data)) {
        log.warn("解析消息({})体为空", message.getId());
        return;
    }

    // 绑定优惠券
    bindCoupon(data.getUserId(), data.getCouponType());
}

}
解决方案优势:
采集MetaQ消息队列优化慢接口解决方案的优势:
若是系统发生重启或崩溃,致使消息处理函数执行失败,不会确认消息已消费;因为MetaQ支持多服务订阅同一队列,该消息能够转到别的服务进行消费,亦或等到本服务恢复正常后再进行消费。
消费者可多服务、多线程进行消费消息,即使消息处理时间较长,也不容易引发消息积压;即使引发消息积压,也能够经过扩充服务实例的方式解决。
若是须要从新消费该消息,只须要在MetaQ管理平台上点击"消息验证"便可。
3.流程定义不合理
3.1.原有的采购流程
这是一个简易的采购流程,由库管系统发起采购,采购员开始采购,采购员完成采购,同时回流采集订单到库管系统。

其中,完成采购动做的核心代码以下:
/* 完成采购动做函数(此处省去获取采购单/验证状态/锁定采购单等逻辑) /
public void finishPurchase(PurchaseOrder order) {

// 完成相关处理
......

// 回流采购单(调用HTTP接口)
backflowPurchaseOrder(order);

// 设置完成状态
purchaseOrderDAO.setStatus(order.getId(), PurchaseOrderStatus.FINISHED.getValue());

}
因为函数backflowPurchaseOrder(回流采购单)调用了HTTP接口,可能引发如下问题:
该函数可能耗费时间较长,致使完成采购接口成为慢接口;
该函数可能失败抛出异常,致使客户调用完成采购接口失败。
3.2.优化的采购流程
经过需求分析,把"采购员完成采购并回流采集订单"动做拆分为"采购员完成采购"和"回流采集订单"两个独立的动做,把"采购完成"拆分为"采购完成"和"回流完成"两个独立的状态,更方便采购流程的管理和实现。

拆分采购流程的动做和状态后,核心代码以下:
/* 完成采购动做函数(此处省去获取采购单/验证状态/锁定采购单等逻辑) /
public void finishPurchase(PurchaseOrder order) {

// 完成相关处理
......

// 设置完成状态
purchaseOrderDAO.setStatus(order.getId(), PurchaseOrderStatus.FINISHED.getValue());

}

/* 执行回流动做函数(此处省去获取采购单/验证状态/锁定采购单等逻辑) /
public void executeBackflow(PurchaseOrder order) {

// 回流采购单(调用HTTP接口)
backflowPurchaseOrder(order);

// 设置回流状态
purchaseOrderDAO.setStatus(order.getId(), PurchaseOrderStatus.BACKFLOWED.getValue());

}
其中,函数executeBackflow(执行回流)由定时做业触发执行。若是回流采购单失败,采购单状态并不会修改成"已回流";等下次定时做业执行时,将会继续执行回流动做;直到回流采购单成功为止。
3.3.有限状态机介绍
3.3.1.概念
有限状态机(Finite-state machine,FSM),又称有限状态自动机,简称状态机,是表示有限个状态以及在这些状态之间的转移和动做等行为的一个数学模型。
3.3.2.要素
状态机可概括为4个要素:现态、条件、动做、次态。

现态:指当前流程所处的状态,包括起始、中间、终结状态。
条件:也可称为事件;当一个条件被知足时,将会触发一个动做并执行一次状态的迁移。
动做:当条件知足后要执行的动做。动做执行完毕后,能够迁移到新的状态,也能够仍旧保持原状态。
次态:当条件知足后要迁往的状态。“次态”是相对于“现态”而言的,“次态”一旦被激活,就转变成新的“现态”了。
3.3.3.状态
状态表示流程中的持久状态,流程图上的每个圈表明一个状态。
初始状态: 流程开始时的某一状态;
中间状态: 流程中间过程的某一状态;
终结状态: 流程完成时的某一状态。
使用建议:
状态必须是一个持久状态,而不能是一个临时状态;
终结状态不能是中间状态,不能继续进行流程流转;
状态划分合理,不要把多个状态强制合并为一个状态;
状态尽可能精简,同一状态的不一样状况能够用其它字段表示。
3.3.4.动做
动做的三要素:角色、现态、次态,流程图上的每一条线表明一个动做。
角色: 谁发起的这个操做,能够是用户、定时任务等;
现态: 触发动做时当前的状态,是执行动做的前提条件;
次态: 完成动做后达到的状态,是执行动做的最终目标。
使用建议:
每一个动做执行前,必须检查当前状态和触发动做状态的一致性;
状态机的状态更改,只能经过动做进行,其它操做都是不符合规范的;
须要添加分布式锁保证动做的原子性,添加数据库事务保证数据的一致性;
相似的动做(好比操做用户、请求参数、动做含义等)能够合并为一个动做,并根据动做执行结果转向不一样的状态。
4.系统间交互不科学
4.1.直接经过数据库交互
在一些项目中,系统间交互不经过接口调用和消息队列,而是经过数据库直接访问。问其缘由,回答道:"项目工期太紧张,直接访问数据库,简单又快捷"。
仍是以上面的采购流程为例——采购订单由库管系统发起,由采购系统负责采购,采购完成后通知库管系统,库管系统进入入库操做。采购系统采购完成后,通知库管系统数据库的代码以下:
/* 执行回流动做函数(此处省去获取采购单/验证状态/锁定采购单等逻辑) /
public void executeBackflow(PurchaseOrder order) {

// 完成原始采购单
rawPurchaseOrderDAO.setStatus(order.getRawId(), RawPurchaseOrderStatus.FINISHED.getValue());

// 设置回流状态
purchaseOrderDAO.setStatus(order.getId(), PurchaseOrderStatus.BACKFLOWED.getValue());

}
其中,经过rawPurchaseOrderDAO(原始采购单DAO)直接访问库管系统的数据库表,并设置原始采购单状态为已完成。
通常状况下,直接经过数据访问的方式是不会有问题的。可是,一旦发生竞态,就会致使数据不一样步。有人会说,能够考虑使用同一分布式锁解决该问题。是的,这种解决方案没有问题,只是又在系统间共享了分布式锁。
直接经过数据库交互的缺点:
直接暴露数据库表,容易产生数据安全问题;
多个系统操做同一数据库表,容易形成数据库表数据混乱;
操做同一个数据库表的代码,分布在不一样的系统中,不便于管理和维护;
具备数据库表这样的强关联,没法实现系统间的隔离和解耦。
4.2.经过Dubbo接口交互
因为采购系统和库管系统都是内部系统,能够经过相似Dubbo的RPC接口进行交互。
库管系统代码:
/* 采购单服务接口 /
public interface PurchaseOrderService {

/** 完成采购单函数 */
public void finishPurchaseOrder(Long orderId);

}
/* 采购单服务实现 /
@Service("purchaseOrderService")
public class PurchaseOrderServiceImpl implements PurchaseOrderService {

/** 完成采购单函数 */
@Override
@Transactional(rollbackFor = Exception.class)
public void finishPurchaseOrder(Long orderId) {
    // 相关处理
    ...

    // 完成采购单
    purchaseOrderService.finishPurchaseOrder(order.getRawId());
}

}
其中,库管系统经过Dubbo把PurchaseOrderServiceImpl(采购单服务实现)以PurchaseOrderService(采购单服务接口)定义的接口服务暴露给采购系统。这里,省略了Dubbo开发服务接口相关配置。
采购系统代码:
/* 执行回流动做函数(此处省去获取采购单/验证状态/锁定采购单等逻辑) /
public void executeBackflow(PurchaseOrder order) {

// 完成采购单
purchaseOrderService.finishPurchaseOrder(order.getRawId());

// 设置回流状态
purchaseOrderDAO.setStatus(order.getId(), PurchaseOrderStatus.BACKFLOWED.getValue());

}
其中,purchaseOrderService(采购单服务)为库管系统PurchaseOrderService(采购单服务)在采购系统中的Dubbo服务客户端存根,经过该服务调用库管系统的服务接口函数finishPurchaseOrder(完成采购单函数)。
这样,采购系统和库管系统本身的强关联,经过Dubbo就简单地实现了系统隔离和解耦。固然,除了采用Dubbo接口外,还能够采用HTTPS、HSF、WebService等同步接口调用方式,也能够采用MetaQ等异步消息通知方式。
4.3.常见系统间交互协议
4.3.1.同步接口调用
同步接口调用是以一种阻塞式的接口调用机制。常见的交互协议有:
HTTP/HTTPS接口;
WebService接口;
Dubbo/HSF接口;
CORBA接口。
4.3.2.异步消息通知
异步消息通知是一种通知式的信息交互机制。当系统发生某种事件时,会主动通知相应的系统。常见的交互协议有:
MetaQ的消息通知;
CORBA消息通知。
4.4.常见系统间交互方式
4.4.1.请求-应答

适用范围:
适合于简单的耗时较短的接口同步调用场景,好比Dubbo接口同步调用。
4.4.2.通知-确认

适用范围:
适合于简单的异步消息通知场景,好比MetaQ消息通知。
4.4.3.请求-应答-查询-返回

适用范围:
适合于复杂的耗时较长的接口同步调用场景,好比提交做业任务并按期查询任务结果。
4.4.4.请求-应答-回调

适用范围:
适合于复杂的耗时较长的接口同步调用和异步回调相结合的场景,好比支付宝的订单支付。
4.4.5.请求-应答-通知-确认

适用范围:
适合于复杂的耗时较长的接口同步调用和异步消息通知相结合的场景,好比提交做业任务并等待完成消息通知。
4.4.6.通知-确认-通知-确认

适用范围:
适合于复杂的耗时较长的异步消息通知场景。
5.数据查询不分页
在数据查询时,因为未能对将来数据量作出正确的预估,不少状况下都没有考虑数据的分页查询。
5.1.普通查询案例
如下是查询过时订单的代码:
/* 订单DAO接口 /
public interface OrderDAO {

/** 查询过时订单函数 */
@Select("select * from t_order where status = 5 and gmt_create < date_sub(current_timestamp, interval 30 day)")
public List<OrderDO> queryTimeout();

}

/* 订单服务接口 /
public interface OrderService {

/** 查询过时订单函数 */
public List<OrderVO> queryTimeout();

}
当过时订单数量不多时,以上代码不会有任何问题。可是,当过时订单数量达到几十万上千万时,以上代码就会出现如下问题:
数据量太大,致使服务端的内存溢出;
数据量太大,致使查询接口超时、返回数据超时等;
数据量太大,致使客户端的内存溢出。
因此,在数据查询时,特别是不能预估数据量的大小时,须要考虑数据的分页查询。
这里,主要介绍"设置最大数量"和"采用分页查询"两种方式。
5.2.设置最大数量
"设置最大数量"是一种最简单的分页查询,至关于只返回第一页数据。例子代码以下:
/* 订单DAO接口 /
public interface OrderDAO {

/** 查询过时订单函数 */
@Select("select * from t_order where status = 5 and gmt_create < date_sub(current_timestamp, interval 30 day) limit 0, #{maxCount}")
public List<OrderDO> queryTimeout(@Param("maxCount") Integer maxCount);

}

/* 订单服务接口 /
public interface OrderService {

/** 查询过时订单函数 */
public List<OrderVO> queryTimeout(Integer maxCount);

}
适用于没有分页需求、但又担忧数据过多致使内存溢出、数据量过大的查询。
5.3.采用分页查询
"采用分页查询"是指定startIndex(开始序号)和pageSize(页面大小)进行数据查询,或者指定pageIndex(分页序号)和pageSize(页面大小)进行数据查询。例子代码以下:
/* 订单DAO接口 /
public interface OrderDAO {

/** 统计过时订单函数 */
@Select("select count(*) from t_order where status = 5 and gmt_create < date_sub(current_timestamp, interval 30 day)")
public Long countTimeout();
/** 查询过时订单函数 */
@Select("select * from t_order where status = 5 and gmt_create < date_sub(current_timestamp, interval 30 day) limit #{startIndex}, #{pageSize}")
public List<OrderDO> queryTimeout(@Param("startIndex") Long startIndex, @Param("pageSize") Integer pageSize);

}

/* 订单服务接口 /
public interface OrderService {

/** 查询过时订单函数 */
public PageData<OrderVO> queryTimeout(Long startIndex, Integer pageSize);

}
适用于真正的分页查询,查询参数startIndex(开始序号)和pageSize(页面大小)可由调用方指定。
5.3.分页查询隐藏问题
假设,咱们须要在一个定时做业(每5分钟执行一次)中,针对已经超时的订单(status=5,建立时间超时30天)进行超时关闭(status=10)。实现代码以下:
/* 订单DAO接口 /
public interface OrderDAO {

/** 查询过时订单函数 */
@Select("select * from t_order where status = 5 and gmt_create < date_sub(current_timestamp, interval 30 day) limit #{startIndex}, #{pageSize}")
public List<OrderDO> queryTimeout(@Param("startIndex") Long startIndex, @Param("pageSize") Integer pageSize);
/** 设置订单超时关闭 */
@Update("update t_order set status = 10 where id = #{orderId} and status = 5")
public Long setTimeoutClosed(@Param("orderId") Long orderId)

}

/* 关闭过时订单做业类 /
public class CloseTimeoutOrderJob extends Job {

/** 分页数量 */
private static final int PAGE_COUNT = 100;
/** 分页大小 */
private static final int PAGE_SIZE = 1000;
/** 做业执行函数 */
@Override
public void execute() {
    for (int i = 0; i < PAGE_COUNT; i++) {
        // 查询处理订单
        List<OrderDO> orderList = orderDAO.queryTimeout(i * PAGE_COUNT, PAGE_SIZE);
        for (OrderDO order : orderList) {
            // 进行超时关闭
            ......
            orderDAO.setTimeoutClosed(order.getId());
        }

        // 检查处理完毕
        if(orderList.size() < PAGE_SIZE) {
            break;
        }
    }
}

}
粗看这段代码是没有问题的,尝试循环100次,每次取1000条过时订单,进行订单超时关闭操做,直到没有订单或达到100次为止。可是,若是结合订单状态一块儿看,就会发现从第二次查询开始,每次会忽略掉前startIndex(开始序号)条应该处理的过时订单。这就是分页查询存在的隐藏问题:
当知足查询条件的数据,在操做中再也不知足查询条件时,会致使后续分页查询中前startIndex(开始序号)条知足条件的数据被跳过。
能够采用"设置最大数量"的方式解决,代码以下:
/* 订单DAO接口 /
public interface OrderDAO {

/** 查询过时订单函数 */
@Select("select * from t_order where status = 5 and gmt_create < date_sub(current_timestamp, interval 30 day) limit 0, #{maxCount}")
public List<OrderDO> queryTimeout(@Param("maxCount") Integer maxCount);
/** 设置订单超时关闭 */
@Update("update t_order set status = 10 where id = #{orderId} and status = 5")
public Long setTimeoutClosed(@Param("orderId") Long orderId)

}

/* 关闭过时订单做业(定时做业) /
public class CloseTimeoutOrderJob extends Job {

/** 分页数量 */
private static final int PAGE_COUNT = 100;
/** 分页大小 */
private static final int PAGE_SIZE = 1000;
/** 做业执行函数 */
@Override
public void execute() {
    for (int i = 0; i < PAGE_COUNT; i++) {
        // 查询处理订单
        List<OrderDO> orderList = orderDAO.queryTimeout(PAGE_SIZE);
        for (OrderDO order : orderList) {
            // 进行超时关闭
            ......
            orderDAO.setTimeoutClosed(order.getId());
        }

        // 检查处理完毕
        if(orderList.size() < PAGE_SIZE) {
            break;
        }
    }
}

}
后记
本文是《那些年,咱们见过的Java服务端“乱象”》的姐妹篇,前文主要介绍的是Java服务端规范上的问题,而本文主要介绍的是Java服务端方案上的问题。
谨以此文献给当年"E代驾"下的"KK拼车"团队,怀念曾经一块儿奋斗过的兄弟们,怀念那段为代驾司机深夜返程保驾护航的岁月。深感遗憾的是,"KK拼车"刚刚崭露头角,还没来得及好好发展,就被公司断臂裁撤了。值得欣慰的是,"KK拼车"自在人心,听说如今已经成为了一个"民间组织"。
本文做者:中间件小哥
原文连接:https://yq.aliyun.com/article...本文为云栖社区原创内容,未经容许不得转载。

相关文章
相关标签/搜索