后台系统接口优化杂谈
开头闲言
此后台非彼“后台”,这里想说的是一些后台运营支撑类的系统,结合个人的经验之谈。
通常我们在谈到接口性能优化的时候,更多地是针对2C的、拥有大量用户的业务接口。这些接口的持续优化必不可少,因为用户流量就意味着收入,用户体验的优先级自然是非常高的。然而针对后台运营类的系统接口,通常都比较“包容”,可能大部分系统能做到所谓的2-5-10原则中的2秒级(甚至部分场景是5秒、10秒级)的响应即可。一是因为查询的数据量较大,二是后台运营场景下,并不是特别需要那么快速的响应,且运营人员的关注重点主要是在数据的完整性、准确性上。
当然,这并不意味着这些接口就可以“摆烂”。尤其是随着业务的扩张,运营效率的提升势在必行,而后端接口的优化首当其冲。在绝大部分业务场景下,查询无疑是最最最频繁的,后台系统当然也不例外。而且还多了一种形式 - 导出,只在返回结果上有差别。然后就是另外一种场景,导入。优化的重点也主要集中在这三种接口上面:查询、导出、导入。
本文仅做一些思路总结,具体细节不过多探究。
表和SQL
这里主要是针对关系型数据库,最具代表也最常用的就是MySQL、Oracle和PostgreSQL了。
表设计
表结构设计是基础,这里简单整理下一些要点。更深入的设计规范和约束以及原理,推荐去阅读相关的书籍。或者也可以参考一下类似阿里的《Java开发手册》总结的一些经验。
- 命名:精准,简要,规范。像Oracle推荐就是全部大写命名。
- 类型:选择合适的字段类型。如PostgreSQL就是强类型的,不同类型的比较是会直接抛错的。此外,尽量避免隐式转换,譬如我遇到过的:索引字段是int类型,传入数据时指定jdbcType=deicimal类型,都是数字类型,不会报错,但是会导致索引失效。
- 变长数据
varchar
/varchar2
- 定长数据
char
- 金额数字
decimal
/number
- 状态/类别/年龄
tinyint
/smallint
/int
- ID类型
bigint
/int
/number
- 时间类型
date
/datetime
/timestamp
- 只有在特殊的情况下才允许使用
text
/blob
等类型存储超长数据,且最好独立出一张表,主键关联 - 编码类型,像MySQL现在一般都使用
utf8mb4
的编码,预防特殊字符
- 变长数据
- 长度:为字段指定合适的长度,节省表及索引存储空间,更能提升检索速度。
- 变长字符串,根据具体字段含义指定长度,也要记得预留空间。如姓名
varchar(20)
- 定长类型,如身份证号
char(18)
。但是一般较少使用,固定长度可能为后续的扩展带来额外的改动成本,比如要兼容存储企业的社会信用代码 - 整型,如状态
tinyint(2)
,ID类bigint(20)
或者int(11)
- 金额,明确小数位精度和整数位长度,如
number(12,2)
或decimal(12,2)
,10位整数,2位小数
- 变长字符串,根据具体字段含义指定长度,也要记得预留空间。如姓名
- 冗余:如果是页面经常需要查询/导出的表,应该允许一定的字段冗余,方便展示,也避免了多表join或者再在程序中去调用RPC接口填充。
- 非空:必填字段标识为
NOT NULL
。既能在数据库层面做一个兜底的校验,也能保证字段的索引效率。
索引
选择合适的、常用的查询字段创建索引。单列索引、唯一索引、复合索引等等。
学会查看执行计划,关注索引类型、扫描行数,判断索引失效原因(类型隐式转换,file
sort,左模糊/全模糊等等)。允许的情况下像MySQL也提供了force index
手动强制指定索引,当然要慎重使用。
联表
禁止出现过多的表join!一个是确实遇到过此类情况,join滥用,导致性能急剧下降。二是从扩展的角度来看,耦合太多,一些表如果被大量的join(没有做好字段冗余的话),后续要对其进行分拆/修改,影响面会很大。还有就是分开也可以让业务代码逻辑更清晰,方便随时的需求变动,否则随着持续迭代,SQL就无法满足或者会变得很复杂(只针对普通的业务开发)。
曾经接触过这样的查询SQL,有多达8张表的join。仅是取申请人/创建人/更新人/审批人等的全名/部门数据就出现了n次join,即使它们之间的join条件是有索引的USER_ID字段。分页后单页居然超过4s的响应。后续通过冗余常用的查询和展示字段为json格式,再使用MySQL虚拟列来映射这些字段优化为2个表的join,大大的提升了查询效率,接口响应在400ms以内(非like查询)。
当然这只能说是一种奇技淫巧(这里还分享一个MySQL的奇技淫巧:STRAIGHT_JOIN),最好还是在表设计阶段就能做好冗余。而且像上面的场景,其实这些员工的数据也应该作为快照留档,而不应该实时的取最新的数据,可能会造成误判,比如人员调岗。
另外,多表join,当表数据分布情况发生变化时,可能会让优化器对驱动表的选择发生变化(像MySQL)。所以可能出现项目运行一段时间后SQL变慢,还得通过执行计划来判断如何调整join。
代码实现
代码实现层面的优化和数据库是相辅相成的。甚至可以说应用代码的设计还要显得更为重要一些。因为现如今流行的数据库,只要按照它们的要求去设计和构建表和索引,问题一般不会太大,大部分场景下交给自带的优化器足够了。
分页
页面查询的分页基本已经成了一种铁律,自不必多说。而针对导出也可以分页查询,分段写单个Sheet或者多个Sheet,像EasyExcel就可以支持。当然也需要注意普通查询下的深度分页问题。
循环与映射
查询时尽量直接使用SQL返回需要的字段,中间可能最多只是做一些DTO → VO的转换。
如果确实需要查询后循环处理一些逻辑或者补充一些字段信息(譬如微服务拆分后,需要通过RPC取一些基础信息),那尽量把循环中的I/O操作提到循环外,改为批量查询后再在内存中映射匹配。同样也适用于导入时的循环校验。
并行
一般是在导入/导出时使用。面对大量数据的导入/导出,可能还需要在循环中做一些校验/信息填充,即使是分页或者循环外批量查询映射后,也有可能还是达不到一个理想的响应速度,这时候就该并行处理登场了。
并行流/线程池:最标准的就是使用这两种方式。但是并行流的使用要注意,其底层默认提供的线程池的并行度是跟CPU核心数挂钩的。尤其是目前盛行微服务的容器化部署,可能单个pod的资源只有1个核心甚至少于1个核心,那“并行”其实就还是一个串行:
// ForkJoinPool#makeCommonPool() 并行度兜底设置 if (parallelism < 0 && // default 1 less than #cores (parallelism = Runtime.getRuntime().availableProcessors() - 1) <= 0)// availableProcessors - JVM可用的处理器数量 parallelism = 1;
而且这个默认的池是整个应用程序通用的,所以如果代码中大量的使用并行流且不手动调整并行度(通过系统变量
java.util.concurrent.ForkJoinPool.common.parallelism
来调整,不推荐)的话,并不能提升处理效率。所以请在条件允许的情况下使用并行流,推荐还是自定义线程池。协程/虚拟线程:这个在JDK 19中已经发布了预览版本(随着JDK 21的发布,已经是正式版了,实在是太快了orz)。国内像腾讯开源的TencentKona,移植了协程的特性,可以作为尝鲜和参考。
我尝试了一下,使用协程池可以将并行度提高至线程池的几十上百倍,基本上普通场景下的导入都可以做到整个文件的处理时间 ≈ 单笔数据的处理时间。当然作为池,在后台系统的使用场景中没必要设置如此之高的常驻核心线程数,仅作为测试。贴一下demo代码:
@Bean("fiberDemoExecutor") public Executor fiberDemoExecutor(FiberProperties fiberProperties) { ThreadPoolTaskExecutor poolExecutor = new ThreadPoolTaskExecutor(); // 这里设置的coreSize=2000 poolExecutor.setCorePoolSize(fiberProperties.getCoreSize()); poolExecutor.setMaxPoolSize(fiberProperties.getMaxSize()); poolExecutor.setKeepAliveSeconds(fiberProperties.getKeepAliveSeconds()); poolExecutor.setQueueCapacity(fiberProperties.getQueueSize()); poolExecutor.setThreadFactory(newThreadFactory(fiberProperties.getEnabled())); poolExecutor.setTaskDecorator((r) -> r); poolExecutor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy() { @Override public void rejectedExecution(Runnable r, ThreadPoolExecutor e) { String poolName = this.getClass().getSimpleName(); log.warn(poolName + " is full, caller run"); super.rejectedExecution(r, e); } }); poolExecutor.initialize(); return poolExecutor; } private static ThreadFactory newThreadFactory(boolean enabled) { if (enabled) { // 创建虚拟线程 return Thread.ofVirtual() .name("fiber-demo-", 0) .uncaughtExceptionHandler((t, e) -> log.error("thread [{}] got an unexpected exception.", t.getName(), e)) .factory(); } // 创建线程 return Thread.ofPlatform() .name("fiber-demo-", 0) .factory(); } @Component class ParallelDemo implements ApplicationListener<ApplicationStartedEvent> { @Resource private ThreadPoolTaskExecutor fiberDemoExecutor; @Override public void onApplicationEvent(ApplicationStartedEvent event) { // 协程模型 CountDownLatch countDownLatch = new CountDownLatch(2000); StopWatch stopWatch = new StopWatch(); stopWatch.start(); IntStream.range(0, 2000).forEach(value -> fiberDemoExecutor.execute(() -> { try { // 模拟业务逻辑执行时长 TimeUnit.MILLISECONDS.sleep(200); } catch (InterruptedException ignore) { } countDownLatch.countDown(); })); try { countDownLatch.await(); } catch (InterruptedException ignore) { } stopWatch.stop(); // timespan-virtual-thread: 229ms System.out.println("timespan-virtual-thread: " + stopWatch.getTotalTimeMillis() + "ms"); // 线程模型,并行流默认分配,i7-10700 8核16线程 StopWatch stopWatch1 = new StopWatch(); stopWatch1.start(); IntStream.range(0, 2000).parallel().forEach(value -> { try { // 模拟业务逻辑执行时长 TimeUnit.MILLISECONDS.sleep(200); } catch (InterruptedException ignore) { } }); stopWatch1.stop(); // timespan-thread: 31712ms System.out.println("timespan-thread: " + stopWatch1.getTotalTimeMillis()); } }
方案设计
跳出具体实现,从更高一层的方案设计上的改进也是很有必要的。
数据库选型
针对不同的业务场景选择不同的数据库,譬如什么场景应该用 MongoDB。海量日志数据的存储,可以选用ElasticSearch、Solr等。譬如用ES作埋点用户行为日志数据的存储,再在后台做分析统计。
数据库集群
像MySQL原生支持丰富的集群模式,如Replication、Group Replication、InnoDB Cluster,以此为基础做读写分离设计。但是,设计数据库集群读写分离并非易事。
分库分表
参考阿里《Java开发手册》:
【推荐】单表行数超过 500 万行或者单表容量超过 2GB,才推荐进行分库分表。
说明:如果预计三年后的数据量根本达不到这个级别,请不要在创建表时就分库分表。
我觉得很有参考的必要,确实大部分业务系统在它的生命周期内,都用不到分库分表。要么业务没了(orz),要么后续就完全推翻重构了。
当然,话说回来,可预见的数据膨胀和业务增长,尤其是在过去十年里,进行分库/分表/分区是也是必要的。
说到分表,可能大部分人都是想到一些中间件(Apache ShardingSphere)。但其实也不一定非要引入第三方组件,比如自定义规则,页面固定分表条件,手动拼接表后缀来处理分表也未尝不可,好处是不会有较高的学习成本和过多的依赖,缺点当然是SQL不够“优雅”,写起来比较麻烦。且随着数据持续性膨胀,也无法满足更多的分表要求。譬如先按业务渠道横向拆分表,再对日志表或者超大业务表按时间/ID取模等等纵向拆分。
Lucene
这里单独拎出来,是因为曾经利用Lucene实现过后台单据的综合搜索,不得不感叹倒排索引的设计之巧妙。而且ElasticSearch的底层核心就是它。在需要时,也可以尝试用它去做搜索优化。
定时任务
无需实时导出的业务,可以使用任务定时生成文件,提供下载。导入,提交任务,异步处理完成后通知。
而针对任务本身,可以引入类似Spring Batch的批处理框架来提升处理效率。或者针对集群化部署的大规模业务,引入分布式任务调度中间件如XXL-JOB、SchedulerX。再或者更高级的任务调度设计方案,支付宝定时任务怎么做?三层分发任务处理框架介绍。
消息队列
同样的,有条件的情况下(可能有些后台系统并不会一开始就去依赖消息队列),也可以通过MQ来实现一些异步的处理,可能比任务调度的方案更效率一些,当然要注意消息丢失、重试、事务消息等等。
缓存
一般后台系统其实很少说大量使用缓存,利用缓存提升效率的场景一般出现在对外的接口或者热点数据里面。毕竟数据一致性的维护还是比较麻烦的,会增加额外的开发运维成本。当然有需求的情况下也可以做缓存。
- MyBatis/JPA:框架自带特性,多级缓存。但是一定要注意合理且正确的使用,否则出现数据不一致会很头疼。
- 本地/Redis缓存:本地缓存使用简单但是能力有限,Redis使用复杂且增加了依赖,但是功能强大适合分布式系统。需要手动控制数据一致性,根据业务类型,选择定时任务同步、监听事件实时更新、全量或增量更新、固定时间失效后重做缓存等等。
结尾碎语
以上,个人经验之谈,像个大杂烩,也没有什么“新”技术。但是,也想借此分享一个Github大佬的经典案例:Partitioning GitHub’s relational databases to handle scale。其中提到的数字是亮点:
In 2019, mysql1 answered 950,000 queries/s on average, 900,000 queries/s on replicas, and 50,000 queries/s on the primary.
Github截至2019年仍然使用的是MySQL“朴素的”一主多从集群模式,却能够支撑如此之大的访问量,不得不说在技术深度这一块儿确实是炉火纯青。也正如他总结里所说的:
We often choose to leverage “boring” technology that has been proven to work at our scale, as reliability remains the primary concern.
不盲目的追求“新”技术,使用久经考验的、所谓“无聊”的技术来构建高效稳定可靠的产品和服务,或许才是普通开发者们应该多多关注的。