2024年7月

前段时间在
pulsar-client-go
社区里看到这么一个
issue

import "github.com/apache/pulsar-client-go/pulsar"

client, err := pulsar.NewClient(pulsar.ClientOptions{
    URL: "pulsar://localhost:6650",
})
if err != nil {
    log.Fatal(err)
}
consumer, err := client.Subscribe(pulsar.ConsumerOptions{
    Topic:             "persistent://public/default/mq-topic-1",
    SubscriptionName:  "sub-1",
    Type:              pulsar.Shared,
    ReceiverQueueSize: 0,
})
if err != nil {
    log.Fatal(err)
}


// 小于等于 0 时会设置为 1000
const (  
    defaultReceiverQueueSize = 1000  
)
if options.ReceiverQueueSize <= 0 {  
    options.ReceiverQueueSize = defaultReceiverQueueSize  
}

他发现手动将 pulsar-client-go 客户端的
ReceiverQueueSize
设置为 0 的时候,客户端在初始化时会再将其调整为 1000.

if options.ReceiverQueueSize < 0 {  
    options.ReceiverQueueSize = defaultReceiverQueueSize  
}

而如果手动将源码修改为可以设置为 0 时,却不能正常消费,消费者会一直处于 waiting 状态,获取不到任何数据。

经过我的排查发现是 Pulsar 的 Go 客户端缺少了一个
ZeroQueueConsumerImpl
的实现类,这个类主要用于可以精细控制消费逻辑。

If you'd like to have tight control over message dispatching across consumers, set the
consumers' receiver queue size very low (potentially even to 0 if necessary)
. Each consumer has a receiver queue that determines how many messages the consumer attempts to fetch at a time. For example, a receiver queue of 1000 (the default) means that the consumer attempts to process 1000 messages from the topic's backlog upon connection. Setting the receiver queue to 0 essentially means ensuring that each consumer is only doing one thing at a time.

https://pulsar.apache.org/docs/next/cookbooks-message-queue/#client-configuration-changes

正如官方文档里提到的那样,可以将 ReceiverQueueSize 设置为 0;这样消费者就可以一条条的消费数据,而不会将消息堆积在客户端队列里。

客户端消费逻辑

借此机会需要再回顾下 pulsar 客户端的消费逻辑,这样才能理解
ReceiverQueueSize
的作用以及如何在 pulsar-client-go 如何实现这个
ZeroQueueConsumerImpl

Pulsar 客户端的消费模式是基于推拉结合的:


如这张图所描述的流程,消费者在启动的时候会主动向服务端发送一个 Flow 的命令,告诉服务端需要下发多少条消息给客户端。

同时会使用刚才的那个
ReceiverQueueSize
参数作为内部队列的大小,将客户端下发的消息存储在内部队列里。

然后在调用
receive
函数的时候会直接从这个队列里获取数据。


每次消费成功后都会将内部的一个
AvailablePermit+1
,直到大于
MaxReceiveQueueSize / 2
就会再次向 broker 发送 flow 命令,告诉 broker 再次下发消息。

所以这里有一个很关键的事件:就是向 broker 发送
flow
命令,这样才会有新的消息下发给客户端。

之前经常都会有研发同学让我排查无法消费的问题,最终定位到的原因几乎都是消费缓慢,导致这里的
AvailablePermit
没有增长,从而也就不会触发 broker 给客户端推送新的消息。

看到的现象就是消费非常缓慢。

ZeroQueueConsumerImpl 原理

下面来看看
ZeroQueueConsumerImpl
是如何实现队列大小为 0 依然是可以消费的。


在构建 consumer 的时候,就会根据队列大小从而来创建普通消费者还是
ZeroQueueConsumerImpl
消费者。

@Override  
protected CompletableFuture<Message<T>> internalReceiveAsync() {  
    CompletableFuture<Message<T>> future = super.internalReceiveAsync();  
    if (!future.isDone()) {  
        // We expect the message to be not in the queue yet  
        increaseAvailablePermits(cnx());  
    }  
    return future;  
}

这是
ZeroQueueConsumerImpl
重写的一个消费函数,其中关键的就是
increaseAvailablePermits(cnx());
.

    void increaseAvailablePermits(ClientCnx currentCnx) {
        increaseAvailablePermits(currentCnx, 1);
    }

    protected void increaseAvailablePermits(ClientCnx currentCnx, int delta) {
        int available = AVAILABLE_PERMITS_UPDATER.addAndGet(this, delta);
        while (available >= getCurrentReceiverQueueSize() / 2 && !paused) {
            if (AVAILABLE_PERMITS_UPDATER.compareAndSet(this, available, 0)) {
                sendFlowPermitsToBroker(currentCnx, available);
                break;
            } else {
                available = AVAILABLE_PERMITS_UPDATER.get(this);
            }
        }
    }

从源码里可以得知这里的逻辑就是将 AvailablePermit 自增,达到阈值后请求 broker 下发消息。

因为在
ZeroQueueConsumerImpl
中队列大小为 0,所以
available >= getCurrentReceiverQueueSize() / 2
永远都会为 true。

也就是说每消费一条消息都会请求 broker 让它再下发一条消息,这样就达到了每一条消息都精确控制的效果。

pulsar-client-go 中的实现

为了在 pulsar-client-go 实现这个需求,我提交了一个
PR
来解决这个问题。

其实从上面的分析已经得知为啥手动将
ReceiverQueueSize
设置为 0 无法消费消息了。

根本原因还是在初始化的时候优于队列为 0,导致不会给 broker 发送 flow 命令,这样就不会有消息推送到客户端,也就无法消费到数据了。

所以我们依然得参考 Java 的
ZeroQueueConsumerImpl
在每次消费的时候都手动增加
availablePermits

为此我也新增了一个消费者
zeroQueueConsumer

// EnableZeroQueueConsumer, if enabled, the ReceiverQueueSize will be 0.  
// Notice: only non-partitioned topic is supported.  
// Default is false.  
EnableZeroQueueConsumer bool

consumer, err := client.Subscribe(ConsumerOptions{  
    Topic:                   topicName,  
    SubscriptionName:        "sub-1",  
    Type:                    Shared,  
    NackRedeliveryDelay:     1 * time.Second,  
    EnableZeroQueueConsumer: true,  
})

if options.EnableZeroQueueConsumer {  
    options.ReceiverQueueSize = 0  
}

在创建消费者的时候需要指定是否开启
ZeroQueueConsumer
,当开启后会手动将 ReceiverQueueSize 设置为 0.

// 可以设置默认值。
private int receiverQueueSize = 1000;

在 Go 中无法像 Java 那样在结构体初始化化的时候就指定默认值,再加上 Go 的 int 类型具备零值(也就是0),所以无法区分出 ReceiverQueueSize=0 是用户主动设置的,还是没有传入这个参数使用的零值。

所以才需要新增一个参数来手动区分是否使用
ZeroQueueConsumer


之后在创建
consumer
的时候进行判断,只有使用的是单分区的
topic
并且开启了
EnableZeroQueueConsumer
才能创建
zeroQueueConsumer


使用 PARTITIONED_METADATA 命令可以让 broker 返回分区数量。


func (z *zeroQueueConsumer) Receive(ctx context.Context) (Message, error) {
	if state := z.pc.getConsumerState(); state == consumerClosed || state == consumerClosing {
		z.log.WithField("state", state).Error("Failed to ack by closing or closed consumer")
		return nil, errors.New("consumer state is closed")
	}
	z.Lock()
	defer z.Unlock()
	z.pc.availablePermits.inc()
	for {
		select {
		case <-z.closeCh:
			return nil, newError(ConsumerClosed, "consumer closed")
		case cm, ok := <-z.messageCh:
			if !ok {
				return nil, newError(ConsumerClosed, "consumer closed")
			}
			return cm.Message, nil
		case <-ctx.Done():
			return nil, ctx.Err()
		}
	}

}

其中的关键代码:
z.pc.availablePermits.inc()

消费时的逻辑其实和 Java 的
ZeroQueueConsumerImpl
逻辑保持了一致,也是每消费一条数据之前就增加一次
availablePermits

pulsar-client-go 的运行原理与 Java 客户端的类似,也是将消息存放在了一个内部队列里,所以每次消费消息只需要从这个队列
messageCh
里获取即可。

值得注意的是, pulsar-client-go 版本的
zeroQueueConsumer
就不支持直接读取内部的队列了。

func (z *zeroQueueConsumer) Chan() <-chan ConsumerMessage {  
    panic("zeroQueueConsumer cannot support Chan method")  
}

会直接 panic,因为直接消费 channel 在客户端层面就没法帮用户主动发送 flow 命令了,所以这个功能就只能屏蔽掉了,只可以主动的
receive
消息。

许久之前我也画过一个关于 pulsar client 的消费流程图,后续考虑会再写一篇关于 pulsar client 的原理分析文章。

参考链接:

多线程编程是每一个开发必知必会的技能,在实际项目中,为了避免频繁创建和销毁线程,我们通常使用池化的思想,用线程池进行多线程开发。
线程池在开发中使用频率非常高,也包含不少知识点,是一个高频面试题,本篇总结线程池的使用经验和需要注意的问题,更好的应对日常开发和面试。
如有更多知识点,欢迎补充~

异常处理

正如我们在
异常处理机制
所讲,如果你没有对提交给线程池的任务进行异常捕获,那么异常信息将会丢失,不利于问题排查。
通常异常处理要么是手动处理掉,要么是往上抛由全局异常处理器统一处理,切勿吃掉异常。
在实际开发中,我们可以使用装饰器模式对TheradPoolExecutor进行封装,重写它的execute和submit方法,进行try-catch处理,打印日志,防止开发同学直接使用ThreadPoolExecutor提交任务而漏了异常处理。

traceid

完整的日志链路对日志分析,问题排查是至关重要的,否则拿到一堆日志没有关联性,根本无从下手。
一个完整的请求可能经过很多个方法调用,服务调用,mq消息发送等,要串联起来需要一个全局id,称为traceid。
例如我们使用spring cloud sleuth链路跟踪,它就会在上下文(MDC)塞一个traceid,并不断传递下去。
很遗憾,如果你在请求过程使用线程池(直接new ThreadPoolExecutor),那么traceid将会丢失,例如你会看到如下日志:
tp-q1.png
很明显,线程池里打印的日志跟外面的关联不起来了,这会影响我们分析排查问题。
解决方案,可以使用spring提供的ThreadPoolTaskExecutor,它内部也包装了ThreadPoolExecutor,提供更多功能。将其注册到spring容器中,使用spring cloud sleuth时,它会判断如果实现了ExecutorService接口的bean,就会进行动态代理为TraceableExecutorService,它会将当前上下文的traceid传递给线程池的线程,那么就可以关联起来了。如:
tp-q2.png

当然你也可以像前面说的,封装自己的ThreadPoolExecutor注册到spring容器,也一样会被代理。
关于traceid我们还在xxl这边有写到,可以参考
给xxl新增traceId和spanId

ThreadLocal

ThreadLocal是线程内一块数据结构(Thread类内有一个ThreadLocal.ThreadLocalMap),线程间互不干扰,没有并发问题。上面我们提到使用MDC可以在各个位置打印traceid,实际就是利用了ThreadLocal,如使用logback:
tp-q3.png

ThreadLocal在线程内传递数据是没有问题的,但涉及到子线程怎么办呢?这个时候就无法传递过去了,不过Thread类内还有一个ThreadLocal.ThreadLocalMap inheritableThreadLocals,当创建子线程的时候会把父线程的ThreadLocal“继承”过来。
tp-q4.png

但使用线程池场景又不太一样了,因为线程池里的线程是只创建一次,后续复用的,而前面说的“继承”是创建时一次性传递过来,后续就不会更改,很明显不符合线程池的场景,使用线程池时希望线程每次都从父线程获取最新的ThreadLocal。
解决方案,可以使用阿里的
transmittable-thread-local
,它的原理也不复杂,就是在每次提交任务给线程池的时候,拷贝ThreadLocal。 关于ThreadLocal我们之前有过介绍,有兴趣可以看下
面试再也不怕问ThreadLocal了

ThreadLocal扩展

核心参数

这是入门级八股文了吧,基本烂到面试官都不问了,但有一些点我仍想发掘一下亮点。

    public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue,
                              ThreadFactory threadFactory,
                              RejectedExecutionHandler handler)

如上是参数最全的构造方法,参数解释:

  • corePoolSize
    核心线程数,默认是不初始化创建核心线程,也不回收。可以通过prestartCoreThread/prestartAllCoreThreads方法对线程池进行预热,也可以通过allowCoreThreadTimeOut方法对核心线程进行回收。
  • maximumPoolSize
    最大线程数,当核心线程开到最大,且任务队列也满了,还有任务提交,就会继续开线程到直到最大线程数。
  • keepAliveTime
    当线程数大于核心线程数,线程最大空闲时间,超过就会被销毁回收。
  • unit
    keepAliveTime的时间单位。
  • workQueue
    队列,核心线程满了,任务会暂存到队列,等待执行。
  • threadFactory
    线程工厂,默认是Executors.defaultThreadFactory(),线程名称由:pool-数字-thread 组成。
  • RejectedExecutionHandler
    拒绝策略,当队列满了,最大线程数也满了,还提交任务就会触发拒绝策略,jdk提供了4种拒绝策略,默认是AbortPolicy,也可以自定义拒绝策略。
    tp-q5.png

需要提到的点是:

  • 根据业务定义不同的线程池
    有的人喜欢定义一个所谓“通用”的线程池来处理各种业务,这种做法不好,每种业务的核心参数需求不一样,且会相互竞争资源,正确的做法应该是每种业务定义一个线程池。
  • 有意义的线程名称
    从上面可以看到默认的线程名称不是特别友好,我们可以根据业务取一个有意义的名称。这里我用到guava里的ThreadFactoryBuilder,例如:
new ThreadFactoryBuilder()
    .setNameFormat(“taskDispatchPool” + "-%d")
    .build();
  • 合适的队列长度
    过长的队列长度可能导致应用OOM,实际要根据具体情况指定,不宜过大,禁止使用无界队列。
    jdk的Executors辅助类创建的线程池队列长度很多都是无界的,稍有不慎就会导致内存溢出,这也是为什么阿里java开发规范明确禁止使用Executors创建线程池的原因。
  • 与tomcat/hystirx线程池的区别
    jdk的线程池是在核心线程满了,任务先进入队列,队列满了再继续创建线程到最大线程数。
    而tomcat/hystrix的线程池策略是,核心线程满了,就继续创建线程到最大线程数,再有任务就进入队列。
  • 设置合适的核心参数
    一般任务可以分为cpu密集型或io密集型,对于cpu密集型比较容易设置,一般设置为cpu核数即可,不宜过大,因为cpu密集型线程过大会有大量的线程切换,反而降低性能。
    io密集型就不好估算了,而且大部分情况下都是io密集型,没有万能公式,只能根据经验,测试,生产运行观察,调整,才能达到一个比较合适的值,这就要求我们的线程池要支持动态调整参数,下面还会说到。

如果跟面试官提这些点,说明你不是单纯背八股文,是有真的在思考总结~

动态线程池

上面我们说到线程池的核心参数(主要是线程数和队列长度)不太好估算,设置过小可能任务处理不过来导致阻塞,设置过大可能影响整体服务或影响下游服务,所以生产环境的线程池要支持动态调整。幸运的是ThreadPoolExecutor提供了方法可以直接对核心参数进行修改,例如setCorePoolSize,setMaximumPoolSize,我们可以在运行过程进行设置,线程池内部就会根据参数调整线程了。
笔者所在的项目一份代码会部署在各个国家(环境),每个环境的业务,数据量不一样,机器配置也不一样,所以需要根据环境设置不同的参数。在实际运行过程中,有时候为了提升处理效率设置比较大的线程数,而忽略对下游服务的影响,导致下游服务被压垮。也有时候开始估算太小,后面业务增长,导致处理不过来的情况。所以需要动态调整,我们把线程池参数配置接入到apollo,随时可以调整生效,同时我们也把线程池的运行情况上报到grafana,可以进行监控,告警。
关于动态线程池,之前也写过,可以参考:
加强版ThreadPoolExecutor升级
Java线程池实现原理及其在美团业务中的实践
hippo4j
如何设置线程池参数?美团给出了一个让面试官虎躯一震的回答

任务统计

有时候我们需要对提交给线程池的任务进行统计,例如本次执行成功多少,失败多少,过滤多少。线程池就没有提供这种实现了,因为它是一直运行的状态,区分不了业务上的东西,只能简单获取总体完成成功次数(getCompletedTaskCount),或触发拒绝策略次数(getRejectCount)。
业务上的统计我们就可以结合CountDownLatch来进行计数,主线程提交完要进行await等待线程池所有任务完成,每个线程完成一次任务就countDown一次,任务计数可以使用LongAdder统计,相比AtomicLong更加高效。这也回应了我们上面说要根据业务定义不同的线程池的原因,不同类型的统计不会相互影响。
后来我们有个同学提醒jdk还有个Phaser类,比CountDownLatch好用,也可以用它来完成,具体参见:
并发工具类Phaser

默认线程池

你是否在你的团队见过如下代码:

	@Test
	public void test4() throws InterruptedException {
		List<String> list = Lists.newArrayList();
		list.parallelStream().forEach(item->{
			//run async
		});

		CompletableFuture.runAsync(()->{
			//run async
		});
	}

这些写法确实都是异步的,但底层都是用了系统默认的线程池ForkJoinPool.commonPool(),默认线程数是Runtime.getRuntime().availableProcessors() - 1。
如果是cpu密集型任务,这么使用也没啥问题。如果是io密集型,就会相互影响。如下,业务2的任务会卡住,知道业务1执行完成。

		//业务1
		for (int i = 0; i < ForkJoinPool.commonPool().getParallelism(); i++) {
			CompletableFuture.runAsync(() -> {
				try {
					Thread.sleep(5000);
				} catch (InterruptedException e) {
				}
			});
		}

		//业务2
		for (int i = 0; i < ForkJoinPool.commonPool().getParallelism(); i++) {
			CompletableFuture.runAsync(() -> {
				System.out.println("running...");
			});
		}

笔者所在的项目,最开始有的使用上面的写法,有的使用Executors创建,有的使用new ThreadPoolTaskExecutor,有的使用spring ThreadPoolTaskExecutor...,五花八门,直到后来我们统一使用自己封装的线程池,这种情况才得以纠正。

父子线程公用一个线程池

这也是一个实际案例,在我们团队有同学这么使用线程池,逻辑很简单,想要查一批数据的时间和还款概率,实际情况还查了更多信息,做了简化,这些查询需要调用外部接口,为了提升接口性能,把这些查询都丢到线程池里去并发执行,通过Future.get获取结果。同时主线程希望这两个操作也可以并发执行,所以通过CompletableFuture也提交到这个线程池里,最终通过CompletableFuture.join等待所有任务完成。
实际代码比较复杂,简化后如下:

        //查一个时间
	CompletableFuture<Void> timeFuture = CompletableFuture.runAsync(() -> {
		initTime(pageResult);
	}, executor);

        //预测还款概率
	CompletableFuture<Void> repayDesireFuture = CompletableFuture.runAsync(() -> {
		initRepayDesire(pageResult);
	}, executor);
		
        //主线程等待
	CompletableFuture.allOf(timeFuture, repayDesireFuture).join();

        private void initTime(List<JobListVo> data) {
		List<List<JobListVo>> lists = Lists.partition(data, CommonConst.PAGE_SIZE_50);
		List<Future<Result<List<TimeInfo>>>> futures = new ArrayList<>();
		lists.forEach(item -> futures.add(executor.submit(() -> client.getTimeList(ids))));
		futures.forEach(
			item -> {
				try {
					Result<List<TimeInfo>> result = item.get();
				} catch (Exception e) {
					log.error("fetch error", e);
				}
			}
		);
	    }

        private void initRepayDesire(List<JobListVo> data) {
		List<List<JobListVo>> lists = Lists.partition(data, CommonConst.PAGE_SIZE_50);
		List<Future<Result<List<RepayDesire>>>> futures = new ArrayList<>();
		lists.forEach(item -> futures.add(executor.submit(() -> client.queryRepayDesire(ids))));
		futures.forEach(
			item -> {
				try {
                            		Result<List<RepayDesire>> result = item.get();
                        	} catch (Exception e) {
                            		log.error("fetch error", e);
                        	}
                    	}
            	);
        }

看到这段代码,可能有的人会说主线程那两个操作并发的意义不大,串行执行即可。但实际分析还是有意义的,假如只有一个请求,查询时间的任务提交到线程池后,线程池资源还有剩余,这个时候并发执行还款概率的任务,是可以加速整个查询速度的。
笔者在review这段代码的时候,感觉总是怪怪的,但逻辑上又好像说得通,直到我看到这边文章
线程池遇到父子任务,有大坑,要注意!
,这不就跟我们的场景几乎一样吗,确实可能会有问题。

根据文章所述,父子任务提交到同一个线程池可能导致父子任务相互等待,最终卡死。极端一点,假设线程池核心线程数是1,队列长度是1,现在主线程提交了一个任务,使用了这个核心线程,开始执行,并等待子线程执行完成。它的执行逻辑是在子线程内再提交一个任务给线程池,由于只有一个核心线程,所以这个任务进入队列等待。等到什么时候呢,等到主线程那个任务完成线程才能释放,主线程又什么时候完成呢,等待子线程执行完成,死循环了...

总结:父子任务,不要公用一个线程池。

shutdown/shutdownNow

这两个方法都是关闭线程池的,区别是shutdown是不再接收新任务,但提交的任务还会执行,而shutdownNow除了不再接收新任务,已提交的任务也不会执行,正在执行中的任务会终止。
一般在线程池不再使用或应用下线前,就会调用线程关闭方法。如果关闭后再提交任务,就会触发拒绝策略。
最好确保在关闭后不再有任务提交给线程池,否则可能会有问题,笔者之前就遇到线程池关闭后还有请求(服务下线前)进来,导致报TimeoutException错误,具体分析在这篇
/线程池shutdown引发TimeoutException
,有兴趣的可以看下。

内存泄漏

将线程池变量定义为局部变量时,可能会发生内存泄漏。如下:

	@GetMapping(value = "/test")
	public Result<Void> info() {
		ExecutorService executorService = Executors.newFixedThreadPool(1);
		executorService.submit(() -> System.out.println("active thread"));
		return Result.success();
	}

在我们印象中,executorService作为一个局部变量,在方法返回时,生命周期就结束了,这个时候应该是可以被gc回收的,怎么会发生内存泄漏呢?
使用线程池时有点不同,因为这里有个隐含的条件是,虽然方法返回结束了,但线程仍存活这,而存活的线程是可以作为gc root的。
我们知道线程池里的线程会被包装为Worker对象,这是定义在ThreadPoolExecutor里的非静态内部类。如下代码,Worker也实现了Runnable接口,把它作为参数传递给Thread对象。
tp-q6.png
tp-q7.png

活跃的线程作为gc root的,也就是不会被垃圾回收,而这个线程又引用着这个Worker对象,所以它也不会被回收。
那个线程池对象又有什么关系呢?上面说到Worker是作为ThreadPoolExecutor里的非静态内部类,非静态内部类有一个规则就是持有外部类的引用,例如我们可以在InnerClass里调用外部类的方法。
或者通过编译后查看class文件也可以看到InnerClass持有外部类的this引用。

public class OuterClass {

	public OuterClass(Runnable runnable) {
	}
	
	public void outterFunction() {

	}

	class InnerClass {
		public InnerClass() {
			outterFunction();
		}
	}
}

所以,由于Worker持有ThreadPoolExecutor的引用,所以它也不会被回收,用一张图表示就是:
tp-q8.png

解决方案,1.不要使用局部线程池变量,定义为全局变量。2.调用shutdown/shutdownNow关闭线程池,关闭后线程就会被回收,线程池也可以被回收。

附chatgpt:jdk8中,哪些对象可以作为gc root?
tp-q11.png

虚拟线程

在jdk21以前,我们在java里使用的线程都称为平台线程,与内核线程是一对一的关系,开篇就说到,平台线程的使用成本比较高,所以才使用线程线程池来缓存复用它。
tp-q9.png

jdk21虚拟线程成为正式功能,可以投入生产使用。java里的虚拟线程类似于goland中的协程,虚拟线程与内核线程不再是一对一的关系,而是多对一,在jvm层面进行调度,可以大大内核线程的数量。
如图,可以看到多个虚拟线程在底层还是公用一个内核线程,它们之间的执行调度由jvm自动完成,虚拟线程的创建成本非常低,可以创建的虚拟线程数量可以远大于平台线程。
当我们的程序遇到io的时候,以往的方式是将当前线程挂起,cpu进行线程切换,执行另外的任务。使用虚拟线程则不需要,任务的调度执行是在jvm层面完成的,cpu还是一直在执行同一个线程。
tp-q10.png

代码示例:

Thread.ofVirtual().start(()->{});

springboot3.2 tomcat开启虚拟线程:

spring.threads.virtual.enabled = true

那么使用虚拟线程,还需要线程池吗?答案是不需要的,我们之所以池化是因为对象的创建、销毁成本较高,每次使用都创建,用完就丢弃,太浪费了,但虚拟线程的使用成本很低,所以它不需要池化了。
虚拟线程的设计虽然不是为了取代传统线程和编程方式,可以看到jdk是通过扩展支持虚拟线程的,我们依然可以像以前一样编程开发,但在高并发场景虚拟线程是更好的选择,这也是大势所趋,说不定以后哪一天线程池也会被无情的标记上@Deprecated。

更多分享,欢迎关注我的github:
https://github.com/jmilktea/jtea

CI/CD是持续性集成和持续性部署,简单来讲就是自动化构建和自动化部署。目前有很多集成方案,也有很多组装方案,只要能实现自动化构建出制品,再自动部署到生产环境就行。

目前很多源代码都集成了CI/CD功能,drone也是目前比较流行的一个方案,简单易用,高性能。

前提条件

已经使用Gitea部署作用源码管理,
使用gitea搭建源码管理 。

已经使用Harbor部署私库,
harbor 搭建和部署。

已经使用Portainer部署,
portainer安装,配置,使用。

准备工作

拉取我们接下来CI/CD工作有可能会用到的docker镜像,后面会继续解释这些镜像的作用。

#自动构建
docker pull drone/drone
docker pull drone/drone-runner-docker

#自动部署
docker pull drone/drone-runner-ssh
docker pull docker
docker pull plugins/docker
docker pull curlimages/curl

自动构建

drone是服务中心或者理解成任务中心,Git的webhook会通知到这里。

drone-runner-docker相当于实际参于构建工作的作业容器,drone-runner-docker定时与drone通讯获取构建任务,然后按规定的流程执行相应工作。这个流程的设计由.drone.yml配置文件决定,所以编写.drone.yml也是主要核心。

步骤一
,设置Gitea的OAuth2登陆,因为drone采用OAuth2登陆方案。

登陆Gitea -> 右上角 -> 管理后台 -> 管理设置 -> 集成 -> 应用。创建应用,重定向 URI地址填写你部署的dron域名,例如:htts://drone.xxxx.com/login

步骤二,
启动容器,可以单个容器启动,这里使用docker-compose进行管理。

version: "3"
 
services:
  drone-server:
    image: drone:latest
    container_name: drone
    environment:
      - DRONE_GITEA_SERVER=https://gitea.xxxx.com   #你部署的源码地址
      - DRONE_GITEA_CLIENT_ID=dadda5f7-a951-4e8a-a2de-ebf737a5bef5 #Oauth2登陆,客户端Id
      - DRONE_GITEA_CLIENT_SECRET=******************************** #Oauth2登陆,客户端密钥
      - DRONE_RPC_SECRET=aaaaaaaaaaaaaaaaaaa #与drone-runner通讯的凭据,随便填写
      - DRONE_SERVER_HOST=drone.xxxxx.com #绑定你的drone域名,用IP也行
      - DRONE_SERVER_PROTO=https #http,https 都可以
      - DRONE_TLS_CERT=/SSL/SSL.crt  #HTTPS证书
      - DRONE_TLS_KEY=/SSL/SSL.key   #HTTPS证书
      - DRONE_USER_CREATE=username:giteauser,admin:true #创建账号,这里需要填写你的gitea账号,默认管理员
      - DRONE_USER_FILTER=giteauser  #限制指定账号登陆
    restart: always
    volumes:
      - /SSL:/SSL #挂载证书
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro
    ports:
      - "443:443"

  drone-runner:
    image: 	drone-runner-docker:latest
    container_name: drone-runner
    environment:
      - DRONE_RPC_PROTO=https
      - DRONE_RPC_HOST=drone.xxxxx.com:443  #drone的域名或IP
      - DRONE_RPC_SECRET=aaaaaaaaaaaaaaaaaaa #drone通讯的凭据,填写上面的凭据
      - DRONE_RUNNER_CAPACITY=2 #工作线程数
      - DRONE_RUNNER_NAME=my-runner
    restart: always
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock  #挂载docker.sock,必需
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro

启动容器之后,访问drone域名就可以看到登陆页面,点击“CONTINUE”,就会跳转Gitea登陆,登陆就会授权成功返回drone界面。

步骤三,
设置webhook。

进入drone界面之后,点击右上角的"SYNC"按钮,就会把Gitea项目同步过来,点击项目 -> Settings -> ACTIVATE REPOSITORY ,就自动在gitea相应的项目上面添加webhook。

查看Gitea的webhook。

在drone界面,设置项目的“Trusted”,这点非常重要,否则在后续构建中尽管挂载了docker.sock,仍然会提示如下错误,如果没有这个选项,是由于启动drone容器没有填写DRONE_USER_CREATE的原因。

Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?
exit status 1

步骤四,
至此自动构建就算搭建成功,接下来就是编写.drone.yml文件,需要将/var/run/docker.sock挂载进去,drone-runner实际上使用母机的docker在构建制品,也就是说构建成功之后镜像在母机上通过docker images就能查询出来。

下面示例使用Dockerfile文件进行构建,plugins/docker:latest镜像是预先设置好,具有基于dockerfile构建和推送功能的镜像。

kind: pipeline
type: docker
name: default

steps:
  - name: 构建&推送  #管道名称
    image: plugins/docker:latest
    volumes:
      - name: dockersock
        path: /var/run/docker.sock
    settings:
      registry: harbor.xxxxx.com #你部署的docker私库地址
      repo: harbor.xxxxx.com/project/web
      tags: latest
      dockerfile: ./Dockerfile
      pull_image: false #构建的时候是否强制拉取最新镜像
      username: abc
      password: 123

volumes: #挂载
- name: dockersock
  host:
    path: /var/run/docker.sock

也可以使用docker镜像,自行编写命令进行构建。

kind: pipeline
type: docker
name: default

steps:
  - name: 构建&推送  #管道名称
    image: docker:latest
    volumes:
      - name: dockersock
        path: /var/run/docker.sock
    commands:
      - docker build -t web:latest .
      - docker image tag web:latest harbor.xxxxx.com/project/web:latest
      - docker push web:latest harbor.xxxxx.com/project/web:latest

volumes: #挂载
- name: dockersock
  host:
    path: /var/run/docker.sock

步骤五,
至此自动构建和推送到私库,基本就搭建完成。每次打包制品之后镜像都不会主动删除,所以我们可以增加多一个流程来删除剩余的镜像。

kind: pipeline
type: docker
name: default

steps:
  - name: 清理镜像  #管道名称
    image: docker:latest
    volumes:
      - name: dockersock
        path: /var/run/docker.sock
    commands:
      - docker images --filter=reference='harbor.xxxxx.com/project/web:*' --format '{{.ID}}' | xargs -I {} docker rmi -f {}

volumes: #挂载
- name: dockersock
  host:
    path: /var/run/docker.sock

自部部署

方式一,
SSH远程登陆,拉取镜像,重新部署。

kind: pipeline
type: docker
name: default

steps:
  - name: SSH部署  #管道名称
    image: drone/drone-runner-ssh:latest
    settings:
      host: 192.168.3.78
      username: root
      password: 123
      port: 22
      script: #部署执行动的命令
        - echo ====开始部署=======
        - docker pull harbor.xxxxx.com/project/web:latest
        - docker stop web
        - docker rm web
        - docker run -d --name web -p 80:8080  harbor.xxxxx.com/project/web:latest
        - echo ====部署成功======

方式二,
通过Portainer的webhook进行更新。

进入portainer管理界面,进入容器详情页,开启容器的webhook,复制通地址。

kind: pipeline
type: docker
name: default

steps:
  - name: Portainer部署 #管道名称
    image: curlimages/curl:latest
    commands:
      - curl -X POST "https://portainer.xxxx.com/api/webhooks/983DB2D1-34B8-4527-087B-08D76FE58AE5"

部署完成通知

部署完成之后,开发者可以通过drone管理界面查看进度,或者通过webhook通知推送成功消息到微信,短信,邮件,钉钉等。

kind: pipeline
type: docker
name: default

steps:
  - name: 部署完成通知 #管道名称
    image: curlimages/curl:latest
    commands:
      - curl -X POST "通知的webhook地址"

一个完整的示例如下:

kind: pipeline
type: docker
name: default

steps:
  - name: 构建&推送  #管道名称
    image: plugins/docker:latest
    volumes:
      - name: dockersock
        path: /var/run/docker.sock
    settings:
      registry: harbor.xxxxx.com #你部署的docker私库地址
      repo: harbor.xxxxx.com/project/web
      tags: latest
      dockerfile: ./Dockerfile
      pull_image: false #构建的时候是否强制拉取最新镜像
      username: abc
      password: 123

  - name: 清理镜像  #管道名称
    image: docker:latest
    volumes:
      - name: dockersock
        path: /var/run/docker.sock
    commands:
      - docker images --filter=reference='harbor.xxxxx.com/project/web:*' --format '{{.ID}}' | xargs -I {} docker rmi -f {}

  - name: Portainer部署 #管道名称
    image: curlimages/curl:latest
    commands:
      - curl -X POST "https://portainer.xxxx.com/api/webhooks/983DB2D1-34B8-4527-087B-08D76FE58AE5"

  - name: 部署完成通知 #管道名称
    image: curlimages/curl:latest
    commands:
      - curl -X POST "通知的webhook地址"

volumes: #挂载点,共用
- name: dockersock
  host:
    path: /var/run/docker.sock

运行效果:

更多系列文章

构建高性能,可伸缩,高可用,安全,自动化,可溯源,整体式应用构架体系

我们是
袋鼠云数栈 UED 团队
,致力于打造优秀的一站式数据中台产品。我们始终保持工匠精神,探索前端道路,为社区积累并传播经验价值。

本文作者:琉易
liuxianyu.cn

这一篇是系列文章:
搭建自动化 Web 页面性能检测系统 —— 设计篇
搭建自动化 Web 页面性能检测系统 —— 实现篇

作为一个前端想去做全栈的项目时,可能第一个思路是 node + vue/react。一开始可能会新建多个工程目录去实现,假设分别为 web 和 server,也许还有管理后台的代码 admin,那么就有了三个工程的代码。此时为了方便管理就需要在远程仓库新建一个 group 统一管理代码,一般这种方式称之为 MultiRepo。

file

这显然是不够简洁的,对于开发者而言也不便于开发和部署。这类多模块的项目我们可以引入 Monorepo 的概念,下面是一些优化方法的尝试,以
yice-performance(易测)
作为例子讲解,本地设备为 M1 芯片的 arm64v8 平台。

一、node 托管静态页面

可以将 web 打包的代码交给 node 托管,此时就可以将 web 的代码作为一个文件夹放到 server 的目录中,这时候我们一般直接访问后端接口的根路径即可。如:
yice-performance - v1.0
对应的 nginx 配置一般为:

server {
    listen          80;
    server_name     yice.dtstack.cn;

    location / {
        proxy_pass http://localhost:4000/;
    }
}

常见的 node 框架都支持托管静态文件目录:

// express
app.use(express.static(path.join(__dirname, 'web/dist')));


// NestJS
import { ServeStaticModule } from '@nestjs/serve-static';

ServeStaticModule.forRoot({
    serveRoot: '/',
    rootPath: join(__dirname, '.', 'web/dist'),
}),

// egg
{
    static: {
        dir: path.join(appInfo.baseDir, 'web/dist'),
    }
}

代码基本大同小异,从 nginx 配置和项目结构我们也能看出这还是属于一个 node 项目的结构,前端项目的 nginx 配置一般为:

server {
    listen          80;
    server_name     yice.dtstack.cn;
    root						/opt/dtstack/yice-performance/web/dist/

    location /api {
        proxy_pass http://localhost:4000/;
    }
    location / {
        try_files $uri $uri/ /index.html;
    }
}

二、Turborepo

Turborepo 是用于 JavaScript 和 TypeScript 代码库的高性能构建系统。

借助
Turborepo
我们可以并行的运行和构建代码,当我们使用传统的 yarn workspace 管理代码时,我们的一般会执行以下命令:

# server
yarn
yarn dev

# web
cd web
yarn
yarn dev

此时,本地开发不仅需要同时开启两个终端,而且还得分别注意两个终端所在的路径,lint、build、test 等命令皆如此。

file

想要更快的完成以上工作,可以使用
turbo run lint test build

file

新项目往往更容易使用 Turborepo,使用
create-turbo
创建即可,参考
官方文档
。历史项目想要使用 Turborepo 时需要注意一下项目结构:

yice-performance
├─package.json
├─pnpm-lock.yaml
├─pnpm-workspace.yaml
├─turbo.json
├─apps
|  ├─server
|  └─web

将历史项目的代码整合到单个文件夹后移入 apps ,注意需要修改相对路径等代码,比如
tsconfig.json
文件中关于
@/*
等路径别名的写法,以及
import
依赖的路径,将公共依赖包统一提到根目录的 package.json 中。
在根目录添加
turbo.json
文件,这里是 dev 和 build 命令为例:

{
  "$schema": "https://turbo.build/schema.json",
  "tasks": {
    "build": {
      "dependsOn": ["^build"],
      "outputs": [".apps/server/dist/**", "!.apps/server/cache/**"]
    },
    "dev": {
      "persistent": true,
      "cache": false
    }
  }
}

然后在 apps 下的产品中依次添加两种命令:

{
  "scripts": {
    "dev": "NODE_ENV=development nest start --watch",
    "build": "NODE_ENV=production nest build"
  }
}
{
  "scripts": {
    "dev": "NODE_ENV=development vite --port 7001",
    "build": "tsc && NODE_ENV=production vite build"
  }
}

这样就可以通过
pnpm dev
一条命令同时启多个服务了,
pnpm build
可以快速完成多个项目的打包工作。

file

三、docker

以易测依赖的 Puppeteer 为例,对于设备环境的要求就比较多,参考
Puppeteer 故障排除
;再比如易测 v2.x 版本新增的数据周报功能使用到 node 端的 echarts,最终依赖
node-canvas
,对设备环境的要求也很苛刻。
同时,部署命令写的脚本中还需要考虑不同环境的差异,比如 Windows 中的情况。
docker
在这里的作用就是抹平不同设备间的环境差异,减少补充安装依赖包的痛苦,amd64、arm64 等环境差异导致的依赖包安装失败问题,我们可以构建适用于不同平台的 docker 镜像包(以下以
linux/amd64
为例,也就是常说的
x86_64
架构)。

Dockerfile

本地编写
Dockerfile
文件,然后执行
docker build
命令构建镜像。在构建镜像之前,需要注意下 Dockerfile 构建镜像时有一个

的概念,对于构建时间会有较大影响。

Docker 镜像是由多个只读的层叠加而成的,每一层都是基于前一层构建。Dockerfile 文件中的每条指令都会创建一个新的层,并对镜像进行修改,执行
docker build
命令时会使用缓存,当前面的层不发生变化时,我们再次构建镜像时就会更快速。但因为每一层都是基于前一层构建,所以我们应该把变化可能性小的操作放到前面,后续改动只会构建变化的内容,而无需构建整个镜像,这能大大加快镜像的构建速度。

比如下方
Dockerfile.server
中的
nodejs
的安装,如果放在
COPY . .
之后,则每次构建都需要安装一次
nodejs
,我们利用缓存可以大大减少构建时间。

FROM ubuntu:22.04

# 设置时区
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone \
     && apt-get update -y && apt-get install -y tzdata

# puppeteer 和 node-canvas 对系统依赖的要求
# https://github.com/Automattic/node-canvas?tab=readme-ov-file#compiling
# https://github.com/puppeteer/puppeteer/blob/puppeteer-v19.6.3/docs/troubleshooting.md#chrome-headless-doesnt-launch-on-unix
RUN apt-get update -y \
     && apt-get install -y build-essential libcairo2-dev libpango1.0-dev libnss3 libatk1.0-0 \
     && apt-get install -y ca-certificates fonts-liberation libasound2 libatk-bridge2.0-0 \
     && apt-get install -y libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 \
     && apt-get install -y libgbm1 libgcc1 libglib2.0-0 libgtk-3-0 libnspr4 libpangocairo-1.0-0 \
     && apt-get install -y libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 \
     && apt-get install -y libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 \
     && apt-get install -y libxss1 libxtst6

# 处理 chromium 等依赖问题
# https://github.com/puppeteer/puppeteer/blob/puppeteer-v19.6.3/docker/Dockerfile
RUN apt-get update -y \
     && apt-get install -y wget gnupg \
     && wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | gpg --dearmor -o /usr/share/keyrings/googlechrome-linux-keyring.gpg \
     && sh -c 'echo "deb [arch=amd64 signed-by=/usr/share/keyrings/googlechrome-linux-keyring.gpg] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' \
     && apt-get update -y \
     && apt-get install -y google-chrome-stable fonts-ipafont-gothic fonts-wqy-zenhei fonts-thai-tlwg fonts-khmeros fonts-kacst fonts-freefont-ttf libxss1 --no-install-recommends \
     && rm -rf /var/lib/apt/lists/* \
     && apt-get remove -y wget gnupg
# deb [arch=amd6 配置可能会在 /etc/apt/sources.list.d/google.list 和 /etc/apt/sources.list.d/google-chrome.list 中重复,再尝试一次
RUN  rm -rf /etc/apt/sources.list.d/google-chrome.list \
     && apt-get update -y \
     && apt-get install -y google-chrome-stable fonts-ipafont-gothic fonts-wqy-zenhei fonts-thai-tlwg fonts-khmeros fonts-kacst fonts-freefont-ttf libxss1 --no-install-recommends

# 安装 nodejs
RUN apt-get update -y && apt-get install -y curl \
     && curl -fsSL https://deb.nodesource.com/setup_18.x | bash - \
     && apt-get remove -y curl \
     && apt-get install -y nodejs \
     && npm config set registry https://registry.npmmirror.com/ \
     && npm install pnpm@6.35.1 -g

# 设置工作目录
WORKDIR /yice-performance

# 拷贝代码安装依赖
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
COPY apps/server/package.json ./apps/server/
COPY apps/web/package.json ./apps/web/
RUN pnpm install

# 复制项目文件
COPY apps .env ./
# 减少 node_modules 的磁盘占用
RUN pnpm build \
     && find . -name "node_modules" -type d -prune -exec rm -rf '{}' + \
     && pnpm install --production

# 暴露端口
EXPOSE 4000

# 定义环境变量
ENV NODE_ENV=production
# Dockerfile 中需指定 chromium 路径
ENV PUPPETEER_EXECUTABLE_PATH='google-chrome-stable'

VOLUME [ "/yice-performance/apps/server/yice-report" ]

# 启动应用程序
CMD ["node", "apps/server/dist/main.js"]
ARG BASE_IMAGE=mysql:5.7
FROM ${BASE_IMAGE}

# 当容器启动时,会自动执行 /docker-entrypoint-initdb.d/ 下的所有 .sql 文件
COPY ./mysql/demo-data.sql /docker-entrypoint-initdb.d/
# 附加的 mysql 配置
COPY ./mysql/my_custom.cnf /etc/mysql/conf.d/

# 设置 MySQL root 用户的密码
ENV MYSQL_ROOT_PASSWORD=123456
ENV MYSQL_DATABASE=yice-performance

# 设置时区
RUN cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime

# 暴露端口
EXPOSE 3306

根据 Dockerfile 文件本地构建镜像,构建完成后在 Docker Desktop 中就可以看到刚刚构建的镜像。我们新建一个脚本文件来统一管理命令,并在
package.json
中添加
build:docker
命令:

#!/bin/sh

cd docker

# amd64
docker buildx build --platform linux/amd64 -f Dockerfile.mysql -t liuxy0551/yice-mysql .
docker buildx build --platform linux/amd64 -f Dockerfile.server -t liuxy0551/yice-server ../

此时执行
pnpm build:docker
即可打包镜像。

多平台打包镜像

由于我们目前使用的 Mac M 系列芯片较多,这是 arm64 v8 平台的,但往往我们打包后的镜像是在 x86 的机器上使用,比如 Centos、Ubuntu 等服务器系统,这就要求我们应该兼容 x86 平台。
使用
docker inspect
的命令可以查看镜像架构,如下:

docker pull alpine
docker inspect alpine | grep Architecture

修改刚刚写的
Dockerfile
文件,支持通过
docker build
命令的
build argument
传递参数,这在明确不同平台使用的基础镜像时比较有用。有些常用的基础镜像是支持多平台,只需要添加
--platform linux/amd64, linux/arm64
即可,docker buildx 会自动处理一切,
yice-mysql
支持了
arm64 v8
,其他内容可以自行研究。

镜像发布

这里使用的是阿里云容器镜像服务:
https://cr.console.aliyun.com/

 docker login --username=your_username -p your_password registry.cn-hangzhou.aliyuncs.com
docker tag liuxy0551/yice-mysql registry.cn-hangzhou.aliyuncs.com/liuxy0551/yice-mysql:latest
docker tag liuxy0551/yice-server registry.cn-hangzhou.aliyuncs.com/liuxy0551/yice-server:latest

docker push registry.cn-hangzhou.aliyuncs.com/liuxy0551/yice-mysql:latest
docker push registry.cn-hangzhou.aliyuncs.com/liuxy0551/yice-server:latest

docker run

为了保证
yice-server
可以访问到
yice-mysql

两个容器需要使用同一个网络

docker network create yice-network
docker run -p 3306:3306 -d --name yice-mysql --network=yice-network -v /opt/dtstack/yice-performance/yice-mysql/conf:/etc/mysql/conf.d -v /opt/dtstack/yice-performance/yice-mysql/log:/var/log/mysql -v /opt/dtstack/yice-performance/yice-mysql/data:/var/lib/mysql registry.cn-hangzhou.aliyuncs.com/liuxy0551/yice-mysql:latest
docker run -p 4000:4000 -d --name yice-server --network=yice-network -v /opt/dtstack/yice-performance/yice-report:/yice-performance/apps/server/yice-report registry.cn-hangzhou.aliyuncs.com/liuxy0551/yice-server:latest
  • -p
    表示端口映射,
    -p 宿主机 port:容器 port
    ,这里暴漏端口是为了外部可以通过 GUI 工具查看数据
  • -d
    表示后台运行并返回容器 id
  • --name
    表示给容器指定的名称
  • -v /opt/dtstack/yice-performance/yice-mysql:/etc/mysql/conf.d
    等挂载路径表示将容器中的配置项、数据、日志都挂载到主机的
    /opt/dtstack/yice-performance/yice-mysql
  • -v /opt/dtstack/yice-performance/yice-report:/yice-performance/apps/server/yice-report
    表示将容器中的检测报告挂载到宿主机
  • 挂载的目的是为了在删除容器时数据不丢失,且尽量保持容器存储层不发生写操作。

执行
docker run
命令生成容器并运行,访问
http://localhost:4000
即可看到页面了。

docker-compose

docker-compose
是 Docker 官方提供的一个工具,用于管理多个 Docker 容器的应用程序,使用
docker-compose
可以协同多个容器运行。
新增
docker-compose.yml
文件,在这个文件里定义应用程序所需的服务和容器,包括镜像、环境变量、端口映射、挂载目录等信息。

version: '3'

services:
    mysql-service:
        container_name: yice-mysql
        image: registry.cn-hangzhou.aliyuncs.com/liuxy0551/yice-server:latest
        ports:
            - '3306:3306'
        restart: always
        networks:
            - yice-network

    server-service:
        container_name: yice-server
        image: registry.cn-hangzhou.aliyuncs.com/liuxy0551/yice-mysql:latest
        ports:
            - '4000:4000'
        restart: always
        depends_on:
            - mysql-service
        networks:
            - yice-network

networks:
  yice-network:
    driver: bridge
docker-compose -f docker/docker-compose.yml -p yice-performance up -d
命令 作用
docker-compose up 启动程序,
-d
后台运行
docker-compose down 停止并移除容器、卷、镜像等
docker-compose ps 列出正在运行的容器
docker-compose logs 查看日志
docker-compose stop 停止服务
docker-compose start 启动服务
docker-compose restart 重启服务

四、常见问题

yice-server
无法启动

可能是 docker 版本较低,建议升级到 docker v24 及以上,升级前应当备份。

yum install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

image.png
image.png
image.png

node[1]: ../src/node_platform.cc:61:std::unique_ptr<long unsigned int> node::WorkerThreadsTaskRunner::DelayedTaskScheduler::Start(): Assertion `(0) == (uv_thread_create(t.get(), start_thread, this))' failed.
 1: 0xb090e0 node::Abort() [node]
 2: 0xb0915e  [node]
 3: 0xb7512e  [node]
 4: 0xb751f6 node::NodePlatform::NodePlatform(int, v8::TracingController*) [node]
 5: 0xacbf74 node::InitializeOncePerProcess(int, char**, node::InitializationSettingsFlags, node::ProcessFlags::Flags) [node]
 6: 0xaccb59 node::Start(int, char**) [node]
 7: 0x7f2ffac64d90  [/lib/x86_64-linux-gnu/libc.so.6]
 8: 0x7f2ffac64e40 __libc_start_main [/lib/x86_64-linux-gnu/libc.so.6]
 9: 0xa408ec  [node]
gcc 版本过低

主机部署时建议使用 Ubuntu。
主机模式部署时
CentOS7
上启动服务时报错:
Error: /lib64/libstdc++.so.6: version 'CXXABI_1.3.9' not found
,这是因为
CentOS7

gcc
版本过低,需要升级到
gcc-4.8.5
以上,执行下方命令可以看到没有
CXXABI_1.3.9

strings /lib64/libstdc++.so.6 | grep CXXABI

相关链接:
https://github.com/Automattic/node-canvas/issues/1796
https://gist.github.com/nchaigne/ad06bc867f911a3c0d32939f1e930a11
https://ftp.gnu.org/gnu/gcc/

cd /etc/gcc
wget https://ftp.gnu.org/gnu/gcc/gcc-9.5.0/gcc-9.5.0.tar.gz
tar xzvf gcc-9.5.0.tar.gz
mkdir obj.gcc-9.5.0
cd gcc-9.5.0
./contrib/download_prerequisites
cd ../obj.gcc-9.5.0
../gcc-9.5.0/configure --disable-multilib --enable-languages=c,c++
make -j $(nproc)
make install

最后

欢迎关注【袋鼠云数栈UED团队】~
袋鼠云数栈 UED 团队持续为广大开发者分享技术成果,相继参与开源了欢迎 star

Git是现代软件开发中不可或缺的版本控制工具。它能帮助开发者跟踪项目的所有变更,并与团队成员高效协作。本文将介绍一些在项目实操中常见的Git命令,帮助你更好地管理代码。

1. 初始化和配置

初始化仓库

在一个新的项目目录中,初始化Git仓库:

git init

配置用户信息

在提交代码之前,需要配置用户信息,这些信息将记录在每次提交中:

git config --global user.name "Your Name"git config --global user.email"your.email@example.com"

2. 基本操作

克隆远程仓库

从远程仓库克隆项目到本地:

git clone <repository_url>

查看仓库状态

检查当前工作目录的状态,包括已修改、未跟踪的文件:

git status

添加文件到暂存区

将文件添加到暂存区,为下一次提交做准备:

git add<file_name>  # 添加单个文件
git
add . # 添加所有更改的文件

提交更改

提交暂存区的文件,并添加提交信息:

git commit -m "提交信息"

查看提交历史

查看项目的提交历史记录:

git log

3. 分支管理

创建和切换分支

创建一个新分支并切换到该分支:

git checkout -b <new_branch>

切换到已有的分支:

git checkout <branch_name>

查看分支

列出所有分支,并标记当前所在的分支:

git branch

合并分支

将指定分支的变更合并到当前分支:

git merge <branch_name>

删除分支

删除不再需要的分支:

git branch -d <branch_name>

4. 远程操作

查看远程仓库

查看当前配置的远程仓库:

git remote -v

添加远程仓库

为本地仓库添加一个远程仓库:

git remote add origin <repository_url>

推送到远程仓库

将本地分支推送到远程仓库:

git push origin <branch_name>

拉取远程更新

从远程仓库拉取更新并与本地分支合并:

git pull origin <branch_name>

克隆远程仓库

如果你还没有本地仓库,可以通过以下命令将远程仓库克隆到本地:

git clone <repository_url>

5. 高级操作

撤销更改

将工作目录恢复到上一次提交的状态(慎用):

git checkout -- <file_name>

取消暂存

将文件从暂存区移除,但保留工作目录的更改:

git reset <file_name>

修改最后一次提交

如果你需要修改最后一次提交的提交信息或包含新的更改,可以使用:

git commit --amend

查看提交历史的简洁格式

使用一行显示每个提交记录:

git log --oneline

查看变更内容

查看工作目录中与暂存区的差异:

git diff

查看暂存区与上一次提交的差异:

git diff --cached

6. 实用技巧

交互式暂存

使用交互式模式选择要暂存的文件部分:

git add -p

标签管理

为项目创建一个标签:

git tag <tag_name>

推送标签到远程仓库:

git push origin <tag_name>

清理未跟踪的文件

清理工作目录中的未跟踪文件和目录(慎用):

git clean -f -d