关于性能优化能说的点太多了,在这篇博客中我将结合我的个人经验来介绍性能优化可以从哪些角度来思考,希望能抛砖引玉,在做功能开发的时候多多思考,相信随着时间的积累每个人都有自己的一套性能优化方法论。
在我的高并发技巧系列文章中我已经介绍了不少性能优化的技巧,为什么保证这篇文章的完整性,所以可能会有一些重复。
C端开发有几个重要的中间件,MySQL/Redis/MQ/RPC,所以我会通过介绍这几个中间件如何来提高我们的性能,并且在最后会也会介绍一些奇淫巧技。
MySQL
索引
关于MySQL优化相关的帖子网上已经太多了,所以我只说注意的点,不做过多的案例介绍了,如果这些点都做到了,面对绝大多数C端场景绰绰有余。
- 索引:MySQL的所有C端查询我们要尽量保证必须用到索引,尤其是那种数据量增长快的表。所以不要在索引字段进行函数运算,不要使用取反等等。
- 联合索引:联合索引使用可能是最多的了,sql没写对可能就导致了全表扫描,记住最左匹配原则,将区分度高的字段放在前面。
- 分页查询:C端经常展示一些列表,比如文章列表,作品列表等,基本都用到了分页查询,这里需要注意的是order by fieldName。要保证fieldName在索引中,不然就可能会出现filesort了,这是个外查询,在数据量大的时候是很致命的。这我们这个项目中,我没说的时候,所有的分页查询都有这个问题。
- 养成个好习惯:创建表的时候就设计好索引,不要到最后出事了再去加,那会数据量很大再加就迟了。
分库分表
别无脑分就行,尤其在中小公司,徒增维护,一般预估年增量记录在500W以内都不需要分,我们之前是1000W以内就不分。如果只是临时做活动使用的就更不需要分了,除非活动期间量真的达到了几亿。简单介绍下分的原则
-
数据量大就分表,否则单表的查询性能肯定是下降很多
-
并发高就分库,因为单库的资源就那么多,支持的连接数不会太高。
-
大多数情况下分库分表是一起的,因为量大和并发高经常是同时出现。
-
一开始分就给足资源,二次分表成本太大,在快手一般上来就是10个库和1000张表。
-
需要特别注意的是shardKey,围绕用户展开的就用userId来做shardKey,这在绝大多数情况下没问题的。
-
如何分,见仁见智,可以使用shardingjdbc,优点很明显是减少开发成本。也可以自己手动分,有点也很明显,便于定制化和制定规则。如果你犹豫不决就shardingjdbc吧,尤其是小团队的时候。
注意事项
减少操作数据库次数
比如我要批量更新用户的奖励,有人会写这样的代码
java
for (UserReward r : rewardList) {
rewardDao.update(r);
}
如果rewardList
的size=10,那会操作数据库10次,也就是10次IO,而明显我们可以一次批量更新解决
java
rewardDao.batchUpdate(rewardList);
虽然看着是那么理所当然,我们的项目甚至不少地方有下面类似的代码
java
User user = userService.getById(userId);
// doSomething
userService.updateById(user);
// doSomething2
userService.updateById(user);
// doSomething3
userService.updateById(user);
明明可以一次update就解决的为什么要update多次?
引申一下,有的时候甚至都不需要操作数据库,这种情况很多时候不容易发现。举个例子,之前快手直播活动的时候在活动页面会展示任务信息和用户获得的奖励
java
// 查询用户任务
UserTask userTask = userTaskService.getById(id, userId);
// 构建外显响应
UserTaskResp userTaskVo = buildVo(userTask);
// 从结算表查询结算信息,从而得知获得的奖励
UserTaskSettlement s = userTaskSettlementService.getByTaskId(userTaskId, userId);
// 填充奖励信息
fillReward(userTaskVo, s);
活动页的流量是很大的,平白无故增大了结算表的流量。因为大部分用户是完不成任务的,所以根本没有获得奖励
,所以可以判断用户是否完成了任务,只有完成了任务才去查询结算表即可。下图是优化后的流量变化
使用迭代器查询
如果我们要查询的数据量很多,比如瓜分活动需要查出来所有参与并完成活动的用户,假设就一张表,有2000W数据,满足要求的用户可能有百万。如何做?
直接一个批量查询的sql?肯定是不行的
- 会出现包溢出异常
- 一次查询大量数据到内存,会占用大量内存甚至full gc。
正确的做法是分批查询,比如每次查询200条,然后进行处理。当然,不介意写while这种循环,一来不优雅,二来容易写出死循环的代码,最后也没法复用。可以参考下面的迭代器写法
java
import com.google.common.collect.AbstractIterator;
import org.apache.commons.collections.CollectionUtils;
import java.util.Iterator;
import java.util.List;
import java.util.function.BiFunction;
import java.util.function.Function;
/**
* @Description 大批量数据迭代器 主要是针对表中数据过多循环查询问题
* List<User> list = Lists.newArrayList(new User(1,"1"), new User(2,"1"), new User(3,"1"),
* new User(4,"1"),new User(5,"1"),new User(6,"1"),new User(7,"1"));
* SimpleSingeFieldIterable<Integer, User> iterable = new SimpleSingeFieldIterable<>(0, 2,
* (position, count) -> list.stream().sorted(Comparator.comparing(User::getId))
* .filter(x -> x.getId() > position).limit(count).collect(Collectors.toList()), User::getId);
* for (List<User> list1 : iterable) {
* System.out.println(ObjectJsonMapper.toJSON(list1));
* }
* @Author bmjin
* @Date 2023/9/8 16:26
*/
public class SimpleSingeFieldIterable<I extends Comparable<I>, R> implements Iterable<List<R>> {
private final BiFunction<I, Integer, List<R>> searchDAO;
private I searchPosition;
private final Integer count;
private final Function<R, I> model2IdFunction;
public SimpleSingeFieldIterable(I searchPosition, Integer count,
BiFunction<I, Integer, List<R>> searchDAO, Function<R, I> model2IdFunction) {
this.searchPosition = searchPosition;
this.count = count;
this.searchDAO = searchDAO;
this.model2IdFunction = model2IdFunction;
}
@Override
public Iterator<List<R>> iterator() {
return new SingeFieldIterator();
}
class SingeFieldIterator extends AbstractIterator<List<R>> {
private boolean needContinue = true;
@Override
protected List<R> computeNext() {
if (!needContinue) {
return endOfData();
}
List<R> result = searchDAO.apply(searchPosition, count);
if (CollectionUtils.isEmpty(result)) {
needContinue = false;
return endOfData();
}
if (result.size() < count) {
needContinue = false;
}
searchPosition = model2IdFunction.apply(result.get(result.size() - 1));
return CollectionUtils.isNotEmpty(result) ? result : endOfData();
}
}
}
使用
java
// 每次查询出来200条DocLibrary
SimpleSingeFieldIterable<Long, DocLibrary> docIterable =
new SimpleSingeFieldIterable<>(0L, 200,
(minId, count) -> baseMapper.getAllList(minId, count), DocLibrary::getId);
AtomicInteger counter = new AtomicInteger(0);
docIterable.forEach(list -> {
LOGGER.info("start batchUpdate! count : {}", counter.get());
List<DocLibrary> effectiveList = list.stream()
.filter(x -> StringUtils.isEmpty(x.getFileType())).collect(Collectors.toList());
for (DocLibrary doc : effectiveList) {
String fileName = doc.getFileName();
String suffix = StringUtils.substringAfterLast(fileName, ".");
doc.setFileType(suffix);
}
baseMapper.batchUpdate(list);
LOGGER.info("start batchUpdate! count : {}", counter.addAndGet(list.size()));
});
@Mapper
public interface DocLibraryMapper extends BaseMapper<DocLibrary> {
void batchUpdate(List<DocLibrary> list);
@Select("select * from doc_library where id > #{minId} order by id asc limit #{size}")
List<DocLibrary> getAllList(@Param("minId") long minId, @Param("size") int size);
}
做好sql监控
对于大型C端项目,数据量可能很大,一定要做好SQL监控,也就是sql调用量,qps,耗时等,上图就是sql每分钟请求量的监控图。毕竟人是不可靠的,即使你能保证你没问题,但你保证不了别人。如果你使用mybatis是话可以参考下面的demo
java
@Intercepts({
@Signature(
type = Executor.class,
method = "query",
args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}
),
@Signature(
type = Executor.class,
method = "query",
args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class}
)
})
@Component
public class SQLExecuteCostInterceptor implements Interceptor {
private static final Logger LOGGER = LoggerFactory.getLogger(SQLExecuteCostInterceptor.class);
@Autowired
private PerfHelper perfHelper;
@Override
public Object intercept(Invocation invocation) throws Exception {
Object[] args = invocation.getArgs();
MappedStatement statement = (MappedStatement) args[0];
Object parameter = args[1];
BoundSql boundSql;
if (args.length == 4) {
boundSql = statement.getBoundSql(parameter);
} else {
boundSql = (BoundSql) args[5];
}
StopWatch stopWatch = StopWatch.createStarted();
Object result = invocation.proceed();
// 监控打点
perfHelper.perf("database-name", boundSql.getSql())
.micros(stopWatch.getNanoTime())
.logstash();
LOGGER.info("执行 {}, cost : {}", boundSql.getSql(), stopWatch.getNanoTime());
return result;
}
}
缓存
关于缓存,尤其是redis,在高并发技巧系列文章里面有花很多篇幅讲过,这里只做一些简单罗列。
-
使用redis加快访问:大厂里面肯定没问题,小厂里面很多项目几乎看不使用redis,全是直接搂数据库。注意下缓存一致性即可,大部分场景使用Cache Aside策略即可。
-
redis大key问题:大的string,比如1M。集合数据太多,比如5000个。这都是大key问题,redis处理仍然是单线程,大key会拖慢整个redis,并且影响带宽。常用解决方案就是拆分成多个小key,放到多个节点即可。
-
redis热key问题:如果某个redis的key访问量很高,那么这个key就是热key,比如我们可以认为该key的qps达到5000(看业务)那么就是热key,解决方案:
- 使用本地缓存:可以是guava的cache也可以是快手开源的全局本地缓存。
- 使用memcached:redis扛热点能力可比redis好多了。
- 冗余写,随机读:也就是写的时候写多个副本到不同的redis节点,读的时候选其中一个节点读。
-
关于缓存击穿,一般发生在缓存过期后大量请求落到数据库,这个缓存不一定是说redis,也可以是本地缓存
-
如果是本地缓存,一般就是guava的cache,使用load miss方法即可,也就是cache.get(key, Callable),内部是会加锁的。
-
如果是redis
-
那么最容易想到的就是分布式锁了;
-
当然,也可以写个cacheSetter服务,思路是保证相同的key落到同一台服务器,然后使用jvm级别的锁去加锁处理即可;
-
如果能接受一定的脏数据,那么甚至可以设置缓存永不过期,但是要设置个逻辑过期字段,如果过期了异步加载即可。
javaString get(String key) { V v = redis.get(key); if (v.getLogicTime() < System.currentTimeMills()) { String mutexkey = buildMutexKey(key); if (redis.set(mutexKey, "1", "ex", 100, "nx")) { executor.execute(() -> {//重建缓存}) } } return v.getValue(); }
-
-
-
memcached双活架构,即一个逻辑机房对应两个一模一样的集群(即双活)。因为memcached不支持持久化和数据迁移,所以为了保证可用性性可以使用双活架构,每次写的时候两个memcached集群都写,读的时候读一个,当读取失败的时候再读另外一个并且写入当前的那个。
-
集群隔离,为了保证业务直接不互相影响,最好做下集群隔离,比如任务系统使用task集群,奖励系统使用reward集群。
-
多级缓存:如果并发超高,可以考虑使用多级缓存,比如对于活动系统,在后台创建活动然后下发给用户,活动相关的基础信息是不变的,完全可以使用本地缓存+redis+mysql,设置可以加一层全局本地缓存(快手开源)。
消息队列与流量聚合
MQ
MQ(消息队列)主要是来做异步削峰的,比如直播间点赞,收礼等,如果服务端收到请求后直接同步操作数据库那么晚高峰的时候对数据库就是灾难的,所以必须使用消息队列来处理。包括秒杀,快手抖音发作品等也是类似。
我们需要关注的是消费速度和性能之间的平衡,如果消费过快,那么下游扛不住,如果消费过慢又会导致消息堆积,消费到冷数据并影响业务。
拿RocketMQ来举例,消费者从broker拉取的消息是极快的,一般这里不会成为性能瓶颈,往往成为性能瓶颈的是业务的IO操作,所以发现消息堆积了的话,先从IO那块想办法解决。比如是不是流量增大了导致消费跟不上,这时候可以考虑在不影响性能的情况下调整消费线程数或者消费者扩容。或者是不是一些IO接口出问题了。
流量聚合
如果流量超高,并且能接受一定延迟(超高流量大多能接受一定延迟),这时候我们可以考虑使用流量聚合策略,也就是收到消息后存起来,每隔一段时间,或者存的量达到一定阈值的时候再一次消费。
比如我要统计每天消耗大模型的token数,当每次有相关接口调用的时候就将消耗的token数发到消息队列,然后在消费者侧进行流量聚合。
再比如,我们使用binlog监听工具用户任务表(分库分表),每次产生用户任务信息我们就往ES写,然后在后台我们去ES搜索,我们也可以每次监听到消息后进行聚合,最后批量写到ES。
关于流量聚合工具,可以参考下面的demo(快手开源的简化版)
java
public class BufferTrigger<E> {
private static final Logger logger = getLogger(BufferTrigger.class);
private final BlockingQueue<E> queue;
private final int batchSize;
private final long lingerMs;
private final ThrowableConsumer<List<E>, Exception> consumer;
private final ScheduledExecutorService scheduledExecutorService;
private final ReentrantLock lock = new ReentrantLock();
private final AtomicBoolean running = new AtomicBoolean();
private BufferTrigger(long lingerMs, int batchSize, int bufferSize,
ThrowableConsumer<List<E>, Exception> consumer) {
this.lingerMs = lingerMs;
this.batchSize = batchSize;
this.queue = new LinkedBlockingQueue<>(max(bufferSize, batchSize));
this.consumer = consumer;
this.scheduledExecutorService = Executors.newSingleThreadScheduledExecutor();
// 执行一次调度
this.scheduledExecutorService.schedule(new BatchConsumerRunnable(), this.lingerMs,
MILLISECONDS);
}
public void enqueue(E element) {
try {
queue.put(element);
tryTrigBatchConsume();
} catch (InterruptedException e) {
currentThread().interrupt();
}
}
private void tryTrigBatchConsume() {
if (queue.size() >= batchSize) {
if (lock.tryLock()) {
try {
if (queue.size() >= batchSize) {
if (!running.get()) { // prevent repeat enqueue
this.scheduledExecutorService.execute(this::doBatchConsumer);
running.set(true);
}
}
} finally {
lock.unlock();
}
}
}
}
public void manuallyDoTrigger() {
doBatchConsumer();
}
private void doBatchConsumer() {
lock.lock();
try {
running.set(true);
while (!queue.isEmpty()) {
List<E> toConsumeData = new ArrayList<>(min(batchSize, queue.size()));
queue.drainTo(toConsumeData, batchSize);
if (!toConsumeData.isEmpty()) {
doConsume(toConsumeData);
}
}
} finally {
running.set(false);
lock.unlock();
}
}
private void doConsume(List<E> toConsumeData) {
try {
consumer.accept(toConsumeData);
} catch (Throwable e) {
logger.error("doConsume failed", e);
}
}
private class BatchConsumerRunnable implements Runnable {
@Override
public void run() {
try {
doBatchConsumer();
} finally {
scheduledExecutorService.schedule(this, lingerMs, MILLISECONDS);
}
}
}
public long getPendingChanges() {
return queue.size();
}
public static <E> BufferTriggerBuilder<E> newBuilder() {
return new BufferTriggerBuilder<>();
}
public static class BufferTriggerBuilder<E> {
private int batchSize;
private int bufferSize;
private Duration duration;
private ThrowableConsumer<List<E>, Exception> consumer;
public BufferTriggerBuilder<E> batchSize(int batchSize) {
this.batchSize = batchSize;
return this;
}
public BufferTriggerBuilder<E> bufferSize(int bufferSize) {
this.bufferSize = bufferSize;
return this;
}
public BufferTriggerBuilder<E> duration(Duration duration) {
this.duration = duration;
return this;
}
public BufferTriggerBuilder<E> consumer(ThrowableConsumer<List<E>, Exception> consumer) {
this.consumer = consumer;
return this;
}
public BufferTrigger<E> build() {
Preconditions.checkArgument(batchSize > 0, "batchSize 必须大于0");
Preconditions.checkArgument(bufferSize > 0, "bufferSize 必须大于0");
Preconditions.checkNotNull(duration, "duration未设置");
Preconditions.checkNotNull(consumer, "消费函数未设置");
return new BufferTrigger<>(duration.toMillis(), batchSize, bufferSize, consumer);
}
}
}
测试
java
public class BufferTriggerDemo {
private static AtomicInteger counter = new AtomicInteger(0);
public static void main(String[] args) throws InterruptedException {
BufferTrigger<Long> trigger = BufferTrigger.<Long>newBuilder()
.batchSize(10)
.bufferSize(100)
.duration(Duration.ofSeconds(5))
.consumer(BufferTriggerDemo::consume)
.build();
for (long i = 0; i < 10000; i ++) {
trigger.enqueue(i);
TimeUnit.MILLISECONDS.sleep(100);
}
}
private static void consume(List<Long> list) {
System.out.printf("次数 %d, result : %s \n", counter.incrementAndGet(), list);
}
}
线程池
线程池作为jdk的一个重要组件,同时也是性能优化的常客,不得不谈。
如果异步,尤其现在大模型的场景,基本上是必不可少。比如你要请求的两个接口没有关联性,可以考虑使用线程池去并发请求。
对于非核心逻辑我们也可以使用线程池处理,比如用户完成任务后要给他发个触达(短信,私信等),可以使用线程池去异步处理,当然对可靠性要求高的话我们就使用MQ。
当然,存在明显的问题就是服务重启就没了,所以对性能要求高的服务还是得使用MQ。
服务端大多是IO型操作,所以能够将线程数调大一点,起始值我们可以设置为核数*2。要设置一个合理的值,我们可以加上监控,尤其要监控活跃线程数和堆积数,从而来调整一个合适的值,并且帮助快速发现问题。
之前出现一个问题可以作为借鉴,线上大模型相关接口经常卡住,查了半天猜测是线程池里面的任务处理耗时太长,甚至卡住了,导致新的请求进了队列并一直得不到消费,为了验证这个问题,加上了线程池监控,发现确实和猜测的吻合。
如果你使用rpc,比如thirft,那么一定要使用自定义的线程池,并且监控,之前在小爱的时候就出现rpc接口在早高峰耗时上涨,究其原因是因为流量上涨,导致线程池处理不过来,跟上面说的问题很类似。
此外建议给线程池命名,这样在发生问题使用jstack来dump线程堆栈的时候好分析问题。
css
[root@turbodesk-api-canary-c9b685f9-ctclm home]# jstack 1 > thread.log
[root@turbodesk-api-canary-c9b685f9-ctclm home]# ll
total 752
-rw-r--r-- 1 root root 769667 Jan 17 17:47 thread.log
[root@turbodesk-api-canary-c9b685f9-ctclm home]# grep java.lang.Thread.State thread.log | awk '{print $2$3$4$5}' | sort | uniq -c
69 RUNNABLE
3 TIMED_WAITING(onobjectmonitor)
8 TIMED_WAITING(parking)
6 TIMED_WAITING(sleeping)
2 WAITING(onobjectmonitor)
706 WAITING(parking)
我们可以使用fastthread.io/来帮忙分析
如果你的接口是间歇性的有大量请求,可以将核心线程数调小一些,避免白创建太多线程处于waitting状态。系统能创建的线程数是有限的,如何计算我就不谈了,比如我们的系统最多能创建1W个线程,之前有大神的接口,在每次请求的时候创建了只有一个线程的线程池,导致服务经常重启。