2023年4月

作者:京东物流 朱永昌

背景介绍

本文主要围绕应用部署引起上游服务抖动问题展开,结合百川分流系统实例,提供分析、解决思路,并提供一套切实可行的实践方案。

百川分流系统作为交易订单中心的专用网关,为交易订单中心提供统一的对外标准服务(包括接单、修改、取消、回传等),对内则基于配置规则将流量分发到不同业务线的应用上。随着越来越多的流量切入百川系统,因系统部署引起服务抖动导致上游系统调用超时的问题也逐渐凸显出来。为提供稳定的交易服务系统,提升系统可用率,需要对该问题进行优化。

经调研,集团内部现有两种预热方案:

(1)JSF官方提供的预热方案;

(2)行云编排部署结合录制回放的预热方案。两种方法均无法达到预期效果。

关于方案

(1)首先,使用的前提条件是JSF消费端必需升级JSF版本到1.7.6,百川分流系统上游调用方有几十个,推动所有调用方升级版本比较困难;其次,JSF平台预热规则以接口纬度进行配置,百川分流系统对外提供46个接口,配置复杂;最关键的是该方案的预热规则配置的是在一个固定预热周期(比如1分钟)内某个接口的预热权重(接收调用量比例),简单理解就是小流量试跑,这就决定了该方案无法对系统资源进行充分预热,预热周期过后全部流量进入依然会因需要创建或初始化资源引起服务抖动,对于交易接单服务来说,抖动就会导致接单失败,有卡单风险。

关于方案

(2)通过录制线上流量进行压测回放来实现预热,适合读接口,但对于写接口如果不做特殊处理会影响线上数据;针对这个问题,目前的解决方案是通过压测标识来识别压测预热流量,但交易业务逻辑复杂,下游依赖繁多,相关系统目前并不支持。单独改造的话,接口多、风险高。

基于以上情况,我们通过百川分流系统部署引起上游服务抖动这个实例,追踪其表象线索,深入研读JSF源码,最终找到导致服务抖动的关键因素,开发了一套更加有效的预热方案,验证结果表明该方案预热效果明显,服务调用方方法性能MAX值降低90%,降到了超时时间范围内,消除了因机器部署引起上游调用超时的问题。

问题现象

系统上线部署期间,纯配接单服务上游调用方反馈接单服务抖动,出现调用超时现象。

查看此服务UMP打点,发现此服务的方法性能监控MAX值最大3073ms,未超过调用方设置的超时时间10000ms(如图1所示)

图1 服务内部监控打点

查看此服务PFinder性能监控,发现上游调用方应用调用此服务的方法性能监控MAX值多次超过10000ms(可以直接查看调用方的UMP打点,若调用方无法提供UMP打点时,也可借助PFinder的应用拓扑功能进行查看,如图2所示)

图2 服务外部监控打点

分析思路

从上述问题现象可以看出,在系统上线部署期间服务提供方接口性能MAX值并无明显抖动,但服务调用方接口性能MAX值抖动明显。由此,可以确定耗时不在服务提供方内部处理逻辑上,而是在进入服务提供方内部处理逻辑之前(或者之后),那么在之前或者之后具体都经历了什么呢?我们不着急回答这个问题,先基于现有的一些线索逐步进行追踪探索。

线索一:部署过程中机器CPU会有短暂飙升(如图3所示)

如果此时有请求调用到当前机器,接口性能势必会受到影响。因此,考虑机器部署完成且待机器CPU平稳后再上线JSF服务,这可以通过调整
JSF延迟发布
参数来实现。具体配置如下:

 <jsf:provider id="createExpressOrderService" 
               interface="cn.jdl.oms.api.CreateExpressOrderService"
               ref="createExpressOrderServiceImpl"
               register="true"
               concurrents="400"
               alias="${provider.express.oms}"
               // 延迟发布2分钟
               delay="120000">
</jsf:provider>

然而,实践证明JSF服务确实延迟了2分钟才上线(如图4所示),且此时CPU已经处于平稳状态,但是JSF上线瞬间又引起了CPU的二次飙升,同时调用方仍然会出现服务调用超时的现象。

图3 机器部署过程CPU短暂飙升

图4 部署和JSF上线瞬间均导致CPU飙升

线索二:JSF上线瞬间JVM线程数飙升(如图5所示)

图5 JSF上线瞬间线程数飙升

使用jstack命令工具查看线程堆栈,可以发现数量增长最多的线程是
JSF-BZ
线程,且都处于
阻塞等待
状态:

"JSF-BZ-22000-137-T-350" #1038 daemon prio=5 os_prio=0 tid=0x00007f02bcde9000 nid=0x6fff waiting on condition [0x00007efa10284000]
   java.lang.Thread.State: WAITING (parking)
	at sun.misc.Unsafe.park(Native Method)
	- parking to wait for  <0x0000000640b359e8> (a java.util.concurrent.SynchronousQueue$TransferStack)
	at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175)
	at java.util.concurrent.SynchronousQueue$TransferStack.awaitFulfill(SynchronousQueue.java:458)
	at java.util.concurrent.SynchronousQueue$TransferStack.transfer(SynchronousQueue.java:362)
	at java.util.concurrent.SynchronousQueue.take(SynchronousQueue.java:924)
	at java.util.concurrent.ThreadPoolExecutor.getTask(ThreadPoolExecutor.java:1067)
	at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1127)
	at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617)
	at java.lang.Thread.run(Thread.java:745)

   Locked ownable synchronizers:
	- None

"JSF-BZ-22000-137-T-349" #1037 daemon prio=5 os_prio=0 tid=0x00007f02bcde7000 nid=0x6ffe waiting on condition [0x00007efa10305000]
   java.lang.Thread.State: WAITING (parking)
	at sun.misc.Unsafe.park(Native Method)
	- parking to wait for  <0x0000000640b359e8> (a java.util.concurrent.SynchronousQueue$TransferStack)
	at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175)
	at java.util.concurrent.SynchronousQueue$TransferStack.awaitFulfill(SynchronousQueue.java:458)
	at java.util.concurrent.SynchronousQueue$TransferStack.transfer(SynchronousQueue.java:362)
	at java.util.concurrent.SynchronousQueue.take(SynchronousQueue.java:924)
	at java.util.concurrent.ThreadPoolExecutor.getTask(ThreadPoolExecutor.java:1067)
	at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1127)
	at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617)
	at java.lang.Thread.run(Thread.java:745)

   Locked ownable synchronizers:
	- None

"JSF-BZ-22000-137-T-348" #1036 daemon prio=5 os_prio=0 tid=0x00007f02bcdd8000 nid=0x6ffd waiting on condition [0x00007efa10386000]
   java.lang.Thread.State: WAITING (parking)
	at sun.misc.Unsafe.park(Native Method)
	- parking to wait for  <0x0000000640b359e8> (a java.util.concurrent.SynchronousQueue$TransferStack)
	at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175)
	at java.util.concurrent.SynchronousQueue$TransferStack.awaitFulfill(SynchronousQueue.java:458)
	at java.util.concurrent.SynchronousQueue$TransferStack.transfer(SynchronousQueue.java:362)
	at java.util.concurrent.SynchronousQueue.take(SynchronousQueue.java:924)
	at java.util.concurrent.ThreadPoolExecutor.getTask(ThreadPoolExecutor.java:1067)
	at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1127)
	at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617)
	at java.lang.Thread.run(Thread.java:745)

   Locked ownable synchronizers:
	- None

...


通过关键字“JSF-BZ”可以在JSF源码中检索,可以找到关于“JSF-BZ”线程池初始化源码如下:

private static synchronized ThreadPoolExecutor initPool(ServerTransportConfig transportConfig) {
    final int minPoolSize, aliveTime, port = transportConfig.getPort();

    int maxPoolSize = transportConfig.getServerBusinessPoolSize();
    String poolType = transportConfig.getServerBusinessPoolType();
    if ("fixed".equals(poolType)) { minPoolSize = maxPoolSize;
    aliveTime = 0;
    } else if ("cached".equals(poolType)) { minPoolSize = 20;
    maxPoolSize = Math.max(minPoolSize, maxPoolSize);
    aliveTime = 60000;
    } else { throw new IllegalConfigureException(21401, "server.threadpool", poolType);
    }

    String queueType = transportConfig.getPoolQueueType();
    int queueSize = transportConfig.getPoolQueueSize();
    boolean isPriority = "priority".equals(queueType);
    BlockingQueue<Runnable> configQueue = ThreadPoolUtils.buildQueue(queueSize, isPriority);

    NamedThreadFactory threadFactory = new NamedThreadFactory("JSF-BZ-" + port, true);
    RejectedExecutionHandler handler = new RejectedExecutionHandler() {
        private int i = 1;

        public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) { if (this.i++ % 7 == 0) {
            this.i = 1;
            BusinessPool.LOGGER.warn("[JSF-23002]Task:{} has been reject for ThreadPool exhausted! pool:{}, active:{}, queue:{}, taskcnt: {}", new Object[] { r, Integer.valueOf(executor.getPoolSize()), Integer.valueOf(executor.getActiveCount()), Integer.valueOf(executor.getQueue().size()), Long.valueOf(executor.getTaskCount()) });
        }

        RejectedExecutionException err = new RejectedExecutionException("[JSF-23003]Biz thread pool of provider has bean exhausted, the server port is " + port);

        ProviderErrorHook.getErrorHookInstance().onProcess(new ProviderErrorEvent(err));
        throw err;
        }
    };
    LOGGER.debug("Build " + poolType + " business pool for port " + port + " [min: " + minPoolSize + " max:" + maxPoolSize + " queueType:" + queueType + " queueSize:" + queueSize + " aliveTime:" + aliveTime + "]");

    return new ThreadPoolExecutor(minPoolSize, maxPoolSize, aliveTime, TimeUnit.MILLISECONDS, configQueue, (ThreadFactory)threadFactory, handler);
}

public static BlockingQueue<Runnable> buildQueue(int size, boolean isPriority) {
    BlockingQueue<Runnable> queue;
    if (size == 0) {
      queue = new SynchronousQueue<Runnable>();
    }
    else if (isPriority) {
      queue = (size < 0) ? new PriorityBlockingQueue<Runnable>() : new PriorityBlockingQueue<Runnable>(size);
    } else {
      queue = (size < 0) ? new LinkedBlockingQueue<Runnable>() : new LinkedBlockingQueue<Runnable>(size);
    } 
    
    return queue;
  }


另外,JSF官方文档关于线程池的说明如下:

结合JSF源码以及JSF官方文档说明,可以知道JSF-BZ线程池的阻塞队列用的是
SynchronousQueue
,这是一个同步阻塞队列,其中每个put必须等待一个take,反之亦然。JSF-BZ线程池默认使用的是伸缩无队列线程池,初始线程数为20个,那么在JSF上线的瞬间,大批量并发请求进入,初始化线程远不够用,因此新建了大量线程。

既然知道了是由于JSF线程池初始化线程数量不足导致的,那么我们可以考虑在应用启动时对JSF线程池进行预热,也就是说在应用启动时创建足够数量的线程备用。通过查阅JSF源码,我们找到了如下方式实现JSF线程池的预热:

// 从Spring上下文获取JSF ServerBean,可能有多个
Map<String, ServerBean> serverBeanMap = applicationContext.getBeansOfType(ServerBean.class);
if (CollectionUtils.isEmpty(serverBeanMap)) {
    log.error("application preheat, jsf thread pool preheat failed, serverBeanMap is empty.");
    return;
}

// 遍历所有serverBean,分别做预热处理
serverBeanMap.forEach((serverBeanName, serverBean) -> {
    if (Objects.isNull(serverBean)) {
        log.error("application preheat, jsf thread pool preheat failed, serverBean is null, serverBeanName:{}", serverBeanName);
        return;
    }
    // 启动ServerBean,启动后才可以获取到Server
    serverBean.start();
    Server server = serverBean.getServer();
    if (Objects.isNull(server)) {
        log.error("application preheat, jsf thread pool preheat failed, JSF Server is null, serverBeanName:{}", serverBeanName);
        return;
    }

    ServerTransportConfig serverTransportConfig = server.getTransportConfig();
    if (Objects.isNull(serverTransportConfig)) {
        log.error("application preheat, jsf thread pool preheat failed, serverTransportConfig is null, serverBeanName:{}", serverBeanName);
        return;
    }
    // 获取JSF业务线程池
    ThreadPoolExecutor businessPool = BusinessPool.getBusinessPool(serverTransportConfig);
    if (Objects.isNull(businessPool)) {
        log.error("application preheat, jsf biz pool preheat failed, businessPool is null, serverBeanName:{}", serverBeanName);
        return;
    }

    int corePoolSize = businessPool.getCorePoolSize();
    int maxCorePoolSize = Math.max(corePoolSize, 500);

    if (maxCorePoolSize > corePoolSize) {
        // 设置JSF server核心线程数
        businessPool.setCorePoolSize(maxCorePoolSize);
    }
    // 初始化JSF业务线程池所有核心线程
    if (businessPool.getPoolSize() < maxCorePoolSize) {
        businessPool.prestartAllCoreThreads();
    }
}


线索三:JSF-BZ线程池预热完成后,JSF上线瞬间JVM线程数仍有升高

继续使用jstack命令工具查看线程堆栈,对比后可以发现数量有增长的线程是
JSF-SEV-WORKER
线程:

"JSF-SEV-WORKER-139-T-129" #1295 daemon prio=5 os_prio=0 tid=0x00007ef66000b800 nid=0x7289 runnable [0x00007ef627cf8000]
   java.lang.Thread.State: RUNNABLE
	at sun.nio.ch.EPollArrayWrapper.epollWait(Native Method)
	at sun.nio.ch.EPollArrayWrapper.poll(EPollArrayWrapper.java:269)
	at sun.nio.ch.EPollSelectorImpl.doSelect(EPollSelectorImpl.java:79)
	at sun.nio.ch.SelectorImpl.lockAndDoSelect(SelectorImpl.java:86)
	- locked <0x0000000644f558b8> (a io.netty.channel.nio.SelectedSelectionKeySet)
	- locked <0x0000000641eaaca0> (a java.util.Collections$UnmodifiableSet)
	- locked <0x0000000641eaab88> (a sun.nio.ch.EPollSelectorImpl)
	at sun.nio.ch.SelectorImpl.select(SelectorImpl.java:97)
	at sun.nio.ch.SelectorImpl.select(SelectorImpl.java:101)
	at io.netty.channel.nio.SelectedSelectionKeySetSelector.select(SelectedSelectionKeySetSelector.java:68)
	at io.netty.channel.nio.NioEventLoop.select(NioEventLoop.java:805)
	at io.netty.channel.nio.NioEventLoop.run(NioEventLoop.java:457)
	at io.netty.util.concurrent.SingleThreadEventExecutor$4.run(SingleThreadEventExecutor.java:989)
	at io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74)
	at java.lang.Thread.run(Thread.java:745)

   Locked ownable synchronizers:
	- None

"JSF-SEV-WORKER-139-T-128" #1293 daemon prio=5 os_prio=0 tid=0x00007ef60c002800 nid=0x7288 runnable [0x00007ef627b74000]
   java.lang.Thread.State: RUNNABLE
	at sun.nio.ch.EPollArrayWrapper.epollWait(Native Method)
	at sun.nio.ch.EPollArrayWrapper.poll(EPollArrayWrapper.java:269)
	at sun.nio.ch.EPollSelectorImpl.doSelect(EPollSelectorImpl.java:79)
	at sun.nio.ch.SelectorImpl.lockAndDoSelect(SelectorImpl.java:86)
	- locked <0x0000000641ea7450> (a io.netty.channel.nio.SelectedSelectionKeySet)
	- locked <0x0000000641e971e8> (a java.util.Collections$UnmodifiableSet)
	- locked <0x0000000641e970d0> (a sun.nio.ch.EPollSelectorImpl)
	at sun.nio.ch.SelectorImpl.select(SelectorImpl.java:97)
	at sun.nio.ch.SelectorImpl.select(SelectorImpl.java:101)
	at io.netty.channel.nio.SelectedSelectionKeySetSelector.select(SelectedSelectionKeySetSelector.java:68)
	at io.netty.channel.nio.NioEventLoop.select(NioEventLoop.java:805)
	at io.netty.channel.nio.NioEventLoop.run(NioEventLoop.java:457)
	at io.netty.util.concurrent.SingleThreadEventExecutor$4.run(SingleThreadEventExecutor.java:989)
	at io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74)
	at java.lang.Thread.run(Thread.java:745)

   Locked ownable synchronizers:
	- None

"JSF-SEV-WORKER-139-T-127" #1291 daemon prio=5 os_prio=0 tid=0x00007ef608001000 nid=0x7286 runnable [0x00007ef627df9000]
   java.lang.Thread.State: RUNNABLE
	at sun.nio.ch.EPollArrayWrapper.epollWait(Native Method)
	at sun.nio.ch.EPollArrayWrapper.poll(EPollArrayWrapper.java:269)
	at sun.nio.ch.EPollSelectorImpl.doSelect(EPollSelectorImpl.java:79)
	at sun.nio.ch.SelectorImpl.lockAndDoSelect(SelectorImpl.java:86)
	- locked <0x0000000641e93998> (a io.netty.channel.nio.SelectedSelectionKeySet)
	- locked <0x0000000641e83730> (a java.util.Collections$UnmodifiableSet)
	- locked <0x0000000641e83618> (a sun.nio.ch.EPollSelectorImpl)
	at sun.nio.ch.SelectorImpl.select(SelectorImpl.java:97)
	at sun.nio.ch.SelectorImpl.select(SelectorImpl.java:101)
	at io.netty.channel.nio.SelectedSelectionKeySetSelector.select(SelectedSelectionKeySetSelector.java:68)
	at io.netty.channel.nio.NioEventLoop.select(NioEventLoop.java:805)
	at io.netty.channel.nio.NioEventLoop.run(NioEventLoop.java:457)
	at io.netty.util.concurrent.SingleThreadEventExecutor$4.run(SingleThreadEventExecutor.java:989)
	at io.netty.util.internal.ThreadExecutorMap$2.run(ThreadExecutorMap.java:74)
	at java.lang.Thread.run(Thread.java:745)

   Locked ownable synchronizers:
	- None


那么JSF-SEV-WORKER线程是做什么的?我们是不是也可以对它做预热操作?带着这些疑问,再次查阅JSF源码:

private synchronized EventLoopGroup initChildEventLoopGroup() {
     NioEventLoopGroup nioEventLoopGroup = null;
     int threads = (this.childNioEventThreads > 0) ? this.childNioEventThreads : Math.max(8, Constants.DEFAULT_IO_THREADS);
 
     NamedThreadFactory threadName = new NamedThreadFactory("JSF-SEV-WORKER", isDaemon());
     EventLoopGroup eventLoopGroup = null;
     if (isUseEpoll()) {
       EpollEventLoopGroup epollEventLoopGroup = new EpollEventLoopGroup(threads, (ThreadFactory)threadName);
     } else {
       nioEventLoopGroup = new NioEventLoopGroup(threads, (ThreadFactory)threadName);
     } 
     return (EventLoopGroup)nioEventLoopGroup;
}


从JSF源码中可以看出JSF-SEV-WORKER线程是JSF内部使用Netty处理网络通信创建的线程,仔细研读JSF源码同样可以找到预热JSF-SEV-WORKER线程的方法,代码如下:

// 通过serverTransportConfig获取NioEventLoopGroup
// 其中,serverTransportConfig的获取方式可参考JSF-BZ线程预热代码
NioEventLoopGroup eventLoopGroup = (NioEventLoopGroup) serverTransportConfig.getChildEventLoopGroup();

int threadSize = this.jsfSevWorkerThreads;
while (threadSize-- > 0) {
    new Thread(() -> {
        // 通过手工提交任务的方式创建JSF-SEV-WORKER线程达到预热效果
        eventLoopGroup.submit(() -> log.info("submit thread to netty by hand, threadName:{}", Thread.currentThread().getName()));
    }).start();
}


JSF-BZ线程、JSF-SEV-WORKER线程预热效果如下图所示:

图6 JSF-BZ/JSF-SEV-WORKER线程预热效果

挖掘源码线索

至此,经过JSF延迟发布、JSF内部线程池预热后,系统部署引起服务调用方抖动超时的现象有一定缓解(从原来的10000ms-20000ms降低到5000ms-10000ms),虽然说是有效果,但还有些不尽如人意。应该还是有优化空间的,现在是时候考虑我们最开始留下的那个疑问了:“服务调用方在进入服务提供方内部处理逻辑之前(或者之后),具体都经历了什么?”。最容易想到的肯定是中间经过了网络,但是网络因素基本可以排除,因为在部署过程中机器网络性能正常,那么还有哪些影响因素呢?此时我们还是要回归到JSF源码中去寻找线索。

图7 JSF源码中Provider内部处理过程

经过仔细研读JSF源码,我们可以发现JSF内部对于接口出入参有一系列编码、解码、序列化、反序列化的操作,而且在这些操作中我们有了惊喜的发现:
本地缓存
,部分源码如下:

DESC_CLASS_CACHE

private static final ConcurrentMap<String, Class<?>> DESC_CLASS_CACHE = new ConcurrentHashMap<String, Class<?>>();

private static Class<?> desc2class(ClassLoader cl, String desc) throws ClassNotFoundException {
  switch (desc.charAt(0)) {
    case 'V':
      return void.class;
    case 'Z': return boolean.class;
    case 'B': return byte.class;
    case 'C': return char.class;
    case 'D': return double.class;
    case 'F': return float.class;
    case 'I': return int.class;
    case 'J': return long.class;
    case 'S': return short.class;
    case 'L':
      desc = desc.substring(1, desc.length() - 1).replace('/', '.');
      break;
    case '[':
      desc = desc.replace('/', '.');
      break;
    default:
      throw new ClassNotFoundException("Class not found: " + desc);
  } 
  
  if (cl == null)
    cl = ClassLoaderUtils.getCurrentClassLoader(); 
  Class<?> clazz = DESC_CLASS_CACHE.get(desc);
  if (clazz == null) {
    clazz = Class.forName(desc, true, cl);
    DESC_CLASS_CACHE.put(desc, clazz);
  } 
  return clazz;
}


NAME_CLASS_CACHE

private static final ConcurrentMap<String, Class<?>> NAME_CLASS_CACHE = new ConcurrentHashMap<String, Class<?>>();

private static Class<?> name2class(ClassLoader cl, String name) throws ClassNotFoundException {
  int c = 0, index = name.indexOf('[');
  if (index > 0) {
    
    c = (name.length() - index) / 2;
    name = name.substring(0, index);
  } 
  if (c > 0) {
    
    StringBuilder sb = new StringBuilder();
    while (c-- > 0) {
      sb.append("[");
    }
    if ("void".equals(name)) { sb.append('V'); }
    else if ("boolean".equals(name)) { sb.append('Z'); }
    else if ("byte".equals(name)) { sb.append('B'); }
    else if ("char".equals(name)) { sb.append('C'); }
    else if ("double".equals(name)) { sb.append('D'); }
    else if ("float".equals(name)) { sb.append('F'); }
    else if ("int".equals(name)) { sb.append('I'); }
    else if ("long".equals(name)) { sb.append('J'); }
    else if ("short".equals(name)) { sb.append('S'); }
    else { sb.append('L').append(name).append(';'); }
     name = sb.toString();
  }
  else {
    
    if ("void".equals(name)) return void.class; 
    if ("boolean".equals(name)) return boolean.class; 
    if ("byte".equals(name)) return byte.class; 
    if ("char".equals(name)) return char.class; 
    if ("double".equals(name)) return double.class; 
    if ("float".equals(name)) return float.class; 
    if ("int".equals(name)) return int.class; 
    if ("long".equals(name)) return long.class; 
    if ("short".equals(name)) return short.class;
  
  } 
  if (cl == null)
    cl = ClassLoaderUtils.getCurrentClassLoader(); 
  Class<?> clazz = NAME_CLASS_CACHE.get(name);
  if (clazz == null) {
    clazz = Class.forName(name, true, cl);
    NAME_CLASS_CACHE.put(name, clazz);
  } 
  return clazz;
}


SerializerCache

private ConcurrentHashMap _cachedSerializerMap;

public Serializer getSerializer(Class<?> cl) throws HessianProtocolException {
  Serializer serializer = (Serializer)_staticSerializerMap.get(cl);
  if (serializer != null) {
    return serializer;
  }
  
  if (this._cachedSerializerMap != null) {
    serializer = (Serializer)this._cachedSerializerMap.get(cl);
    if (serializer != null) {
      return serializer;
    }
  } 
  
  int i = 0;
  for (; serializer == null && this._factories != null && i < this._factories.size(); 
    i++) {

    
    AbstractSerializerFactory factory = this._factories.get(i);
    
    serializer = factory.getSerializer(cl);
  } 
  
  if (serializer == null)
  {
    if (isZoneId(cl)) {
      ZoneIdSerializer zoneIdSerializer = ZoneIdSerializer.getInstance();
    } else if (isEnumSet(cl)) {
      serializer = EnumSetSerializer.getInstance();
    } else if (JavaSerializer.getWriteReplace(cl) != null) {
      serializer = new JavaSerializer(cl, this._loader);
    }
    else if (HessianRemoteObject.class.isAssignableFrom(cl)) {
      serializer = new RemoteSerializer();


    
    }
    else if (Map.class.isAssignableFrom(cl)) {
      if (this._mapSerializer == null) {
        this._mapSerializer = new MapSerializer();
      }
      serializer = this._mapSerializer;
    } else if (Collection.class.isAssignableFrom(cl)) {
      if (this._collectionSerializer == null) {
        this._collectionSerializer = new CollectionSerializer();
      }
      
      serializer = this._collectionSerializer;
    } else if (cl.isArray()) {
      serializer = new ArraySerializer();
    } else if (Throwable.class.isAssignableFrom(cl)) {
      serializer = new ThrowableSerializer(cl, getClassLoader());
    } else if (InputStream.class.isAssignableFrom(cl)) {
      serializer = new InputStreamSerializer();
    } else if (Iterator.class.isAssignableFrom(cl)) {
      serializer = IteratorSerializer.create();
    } else if (Enumeration.class.isAssignableFrom(cl)) {
      serializer = EnumerationSerializer.create();
    } else if (Calendar.class.isAssignableFrom(cl)) {
      serializer = CalendarSerializer.create();
    } else if (Locale.class.isAssignableFrom(cl)) {
      serializer = LocaleSerializer.create();
    } else if (Enum.class.isAssignableFrom(cl)) {
      serializer = new EnumSerializer(cl);
    } 
  }
  if (serializer == null) {
    serializer = getDefaultSerializer(cl);
  }
  
  if (this._cachedSerializerMap == null) {
    this._cachedSerializerMap = new ConcurrentHashMap<Object, Object>(8);
  }
  
  this._cachedSerializerMap.put(cl, serializer);
  
  return serializer;
}


DeserializerCache

private ConcurrentHashMap _cachedDeserializerMap;

public Deserializer getDeserializer(Class<?> cl) throws HessianProtocolException {
  Deserializer deserializer = (Deserializer)_staticDeserializerMap.get(cl);
  if (deserializer != null) {
    return deserializer;
  }
  if (this._cachedDeserializerMap != null) {
    deserializer = (Deserializer)this._cachedDeserializerMap.get(cl);
    if (deserializer != null) {
      return deserializer;
    }
  } 
  
  int i = 0;
  for (; deserializer == null && this._factories != null && i < this._factories.size(); 
    i++) {
    
    AbstractSerializerFactory factory = this._factories.get(i);
    
    deserializer = factory.getDeserializer(cl);
  } 
  
  if (deserializer == null)
    if (Collection.class.isAssignableFrom(cl)) {
      deserializer = new CollectionDeserializer(cl);
    }
    else if (Map.class.isAssignableFrom(cl)) {
      deserializer = new MapDeserializer(cl);
    }
    else if (cl.isInterface()) {
      deserializer = new ObjectDeserializer(cl);
    }
    else if (cl.isArray()) {
      deserializer = new ArrayDeserializer(cl.getComponentType());
    }
    else if (Enumeration.class.isAssignableFrom(cl)) {
      deserializer = EnumerationDeserializer.create();
    }
    else if (Enum.class.isAssignableFrom(cl)) {
      deserializer = new EnumDeserializer(cl);
    }
    else if (Class.class.equals(cl)) {
      deserializer = new ClassDeserializer(this._loader);
    } else {
      
      deserializer = getDefaultDeserializer(cl);
    }  
  if (this._cachedDeserializerMap == null) {
    this._cachedDeserializerMap = new ConcurrentHashMap<Object, Object>(8);
  }
  this._cachedDeserializerMap.put(cl, deserializer);
  
  return deserializer;
}


如上述源码所示,我们找到了四个本地缓存,遗憾的是,这四个本地缓存都是私有的,我们并不能直接对其进行初始化。但是我们还是从源码中找到了可以间接对这四个本地缓存进行初始化预热的方法,代码如下:

DESC_CLASS_CACHE、NAME_CLASS_CACHE预热代码

// DESC_CLASS_CACHE预热
ReflectUtils.desc2classArray(ReflectUtils.getDesc(Class.forName("cn.jdl.oms.express.model.CreateExpressOrderRequest")));
// NAME_CLASS_CACHE预热
ReflectUtils.name2class("cn.jdl.oms.express.model.CreateExpressOrderRequest");



SerializerCache、DeserializerCache预热代码

public class JsfSerializerFactoryPreheat extends HessianSerializerFactory {

    public static void doPreheat(String className) {
        try {
            // 序列化
            JsfSerializerFactoryPreheat.SERIALIZER_FACTORY.getSerializer(Class.forName("cn.jdl.oms.express.model.CreateExpressOrderRequest"));
            // 反序列化
            JsfSerializerFactoryPreheat.SERIALIZER_FACTORY.getDeserializer(Class.forName(className));
        } catch (Exception e) {
            // do nothing
            log.error("JsfSerializerFactoryPreheat failed:", e);
        }
    }
}


由JSF源码对于接口出入参编码、解码、序列化、反序列化操作,我们又想到应用接口内部有对出入参进行Fastjson序列化的操作,而且Fastjson序列化时需要初始化SerializeConfig,对性能会有一定影响(可参考
https://www.ktanx.com/blog/p/3181)。我们可以通过以下代码对Fastjson进行初始化预热:

JSON.parseObject(JSON.toJSONString(Class.forName("cn.jdl.oms.express.model.CreateExpressOrderRequest").newInstance()), Class.forName("cn.jdl.oms.express.model.CreateExpressOrderRequest"));


到目前为止,我们针对应用启动预热做了以下工作:

•JSF延迟发布

•JSF-BZ线程池预热

•JSF-SEV-WORKER线程预热

•JSF编码、解码、序列化、反序列化缓存预热

•Fastjson初始化预热

经过以上预热操作,应用部署引起服务抖动的现象得到了明显改善,由治理前的10000ms-20000ms降低到了 2000ms-3000ms(略高于日常流量抖动幅度)。

解决方案

基于以上分析,将JSF线程池预热、本地缓存预热、Fastjson预热整合打包,提供了一个简单可用的预热小工具,Jar包已上传私服,如有意向请参考使用说明:应用启动预热工具使用说明。

应用部署导致服务抖动属于一个共性问题,针对此问题目前有如下可选方案:

1、
JSF官方提供的预热方案

https://cf.jd.com/pages/viewpage.action?pageId=1132755015)

原理:利用JSF1.7.6的预热策略动态下发,通过服务器负载均衡能力,对于上线需要预热的接口进行流量权重调整,小流量试跑,达到预热目的。

优点:平台配置即可,接入成本低。

缺点:按权重预热,资源预热不充分;需要服务调用方JSF版本升级到1.7.6,对于上游调用方较多的情况下推动版本升级困难。

2、
流量录制回放预热方案

原理:录制线上真实流量,然后通过压测的方式将流量回放到新部署机器达到预热目的。

优点:结合了行云部署编排,下线、部署、预热、上线,以压测的方式可以使得预热更加充分。

缺点:使用流程较繁琐;仅对读接口友好,写接口需要关注数据是否对线上有影响。

3、
本文方案

原理:通过对服务提供方JSF线程池、本地缓存、Fastjson进行初始化的方式进行系统预热。

优点:资源预热充分;使用简单,支持自定义扩展。

缺点:对除JSF以外的其他中间件如Redis、ES等暂不支持,但可以通过自定义扩展实现。

预热效果

预热前:

预热后:

使用本文提供的预热工具,预热前后对比效果明显,如上图所示,调用方方法性能MAX值从原来的10000ms-20000ms降低到了2000ms-3000ms,已经基本接近日常MAX抖点。

总结

应用部署引起上游服务抖动是一个常见问题,如果上游系统对服务抖动比较敏感,或会因此造成业务影响的话,这个问题还是需要引起我们足够的重视与关注。本文涉及的百川分流系统,单纯对外提供JSF服务,且无其他中间件的引入,特点是接口多,调用量大。

此问题在系统运行前期并不明显,上线部署上游基本无感,但随着调用量的增长,问题才逐渐凸显出来,如果单纯通过扩容也是可以缓解这个问题,但是这样会带来很大的资源浪费,违背“降本”的原则。为此,从已有线索出发,逐步深挖JSF源码,对线程池、本地缓存等在系统启动时进行充分初始化预热操作,从而有效降低JSF上线瞬间的服务抖动。

前言:

本文作为解决如何通过 Golang 来编写 Web 应用这个问题的前瞻,对 Golang 中的 Web 基础部分进行一个简单的介绍。目前 Go 拥有成熟的 Http 处理包,所以我们去编写一个做任何事情的动态 Web 程序应该是很轻松的,接下来我们就去学习了解一些关于 Web 的相关基础,了解一些概念,以及 Golang 是如何运行一个 Web 程序的。
文章预计分为四个部分逐步更新
2023-04-13 星期四 一更 全文共计约 3800 字 阅读大约花费 5 分钟
2023-04-14 星期五 二更 全文共计约 2000 字 阅读大概花费 4 分钟


文章目录:

  1. Web 的工作方式
  2. 用 Go 搭建一个最简单的 Web 服务
  3. 了解 Golang 运行 web 的原理
  4. Golang http 包详解(源码剖析)
  5. 总结

正文:

用 Go 搭建一个最简单的 Web 服务

在前面一节我们介绍了 Web 的工作方式,知道了 Web 是基于 HTTP 协议的一个服务, Go 语言里面提供了一个完善的 net/http 包,通过 http 包可以很方便的就搭建起来一个可以运行的 Web 服务。使用这个包也能很简单地对 Web 的路由、静态文件、模板、Cookie 等数据进行设置和操作。

  • http 包建立 Web 服务器


先贴个go代码

@


用户找回密码,确切地说是
重置密码
,为了保证用户账号安全,原始密码将不再以明文的方式找回,而是通过短信或者邮件的方式发送一个随机的重置校验码(带校验码的页面连接),用户点击该链接,跳转到重置密码页面,输入新的密码。这个重置校验码是一次性的,用户重置密码后立即失效。

用户找回密码是在用户没有登录时进行的,因此需要先校验身份(除用户名+密码外的第二种身份验证方式)。
第二种身份验证的前提是绑定了手机号或者邮箱,如果没有绑定,那么只能通过管理员进行原始密码重置。

密码强制过期策略,是指用户在一段时间内没有修改密码,在下次登录时系统阻止用户登录,直到用户修改了密码后方可继续登录。此策略提高用户账号的安全性。

在这里插入图片描述

找回密码和密码过期重置密码,两种机制有相近的业务逻辑,即密码重置。今天我们来实现这个功能。

重置密码

Abp框架中,AbpUserBase类中已经定义了重置校验码PasswordResetCode属性,以及SetNewPasswordResetCode方法,用于生成新的重置校验码。

[StringLength(328)]
public virtual string PasswordResetCode { get; set; }
public virtual void SetNewPasswordResetCode()
{
    PasswordResetCode = Guid.NewGuid().ToString("N").Truncate(328);
}

在UserAppService中添加ResetPasswordByCode,用于响应重置密码的请求。
在其参数ResetPasswordByLinkDto中携带了校验信息PasswordResetCode,因此添加了特性
[AbpAllowAnonymous]
,不需要登录认证即可调用此接口

密码更新完成后,立刻将PasswordResetCode重置为null,以防止重复使用。

[AbpAllowAnonymous]
public async Task<bool> ResetPasswordByCode(ResetPasswordByLinkDto input)
{
    await _userManager.InitializeOptionsAsync(AbpSession.TenantId);

    var currentUser = await _userManager.GetUserByIdAsync(input.UserId);
    if (currentUser == null || currentUser.PasswordResetCode.IsNullOrEmpty() || currentUser.PasswordResetCode != input.ResetCode)
    {
        throw new UserFriendlyException("PasswordResetCode不正确");
    }

    var loginAsync = await _logInManager.LoginAsync(currentUser.UserName, input.NewPassword, shouldLockout: false);
    if (loginAsync.Result == AbpLoginResultType.Success)
    {
        throw new UserFriendlyException("重置的密码不应与之前密码相同");
    }

    if (currentUser.IsDeleted || !currentUser.IsActive)
    {
        return false;
    }

    CheckErrors(await _userManager.ChangePasswordAsync(currentUser, input.NewPassword));
    currentUser.PasswordResetCode = null;
    currentUser.LastPasswordModificationTime = DateTime.Now;
    await this._userManager.UpdateAsync(currentUser);

    return true;
}

找回密码

发送验证码

使用
AbpBoilerplate.Sms
作为短信服务库。

之前的项目中,我们定义好了ICaptchaManager接口,已经实现了验证码的发送、验证码校验、解绑手机号、绑定手机号

这4个功能,通过定义用途(purpose)字段以校验区分短信模板

public interface ICaptchaManager
{
    Task BindAsync(string token);
    Task UnbindAsync(string token);
    Task SendCaptchaAsync(long userId, string phoneNumber, string purpose);
    Task<bool> VerifyCaptchaAsync(string token, string purpose = "IDENTITY_VERIFICATION");
}

添加一个用于重置密码的purpose,在CaptchaPurpose枚举类型中添加
RESET_PASSWORD

public class CaptchaPurpose
{
    ...

    public const string RESET_PASSWORD = "RESET_PASSWORD";

}

在SMS服务商管理端后台申请一个短信模板,用于重置密码。

在这里插入图片描述

打开短信验证码的领域服务类SmsCaptchaManager, 添加
RESET_PASSWORD
对应短信模板的编号

public async Task SendCaptchaAsync(long userId, string phoneNumber, string purpose)
{
    var captcha = CommonHelper.GetRandomCaptchaNumber();
    var model = new SendSmsRequest();
    model.PhoneNumbers = new string[] { phoneNumber };
    model.SignName = "MatoApp";
    model.TemplateCode = purpose switch
    {
        ...
        CaptchaPurpose.RESET_PASSWORD => "SMS_1587660"    //添加重置密码对应短信模板的编号
    };

    ...
}

接下来我们创建ResetPasswordManager类,用于处理找回密码和密码过期重置密码的业务逻辑。
注入UserManager,ISmsService,SmsCaptchaManager,EmailCaptchaManager。

public class ResetPasswordManager : ITransientDependency
{
    private readonly UserManager userManager;
    private readonly ISmsService smsService;
    private readonly SmsCaptchaManager smsCaptchaManager;
    private readonly EmailCaptchaManager emailCaptchaManager;

    public ResetPasswordManager(
        UserManager userManager,
        ISmsService smsService,
        SmsCaptchaManager smsCaptchaManager,
        EmailCaptchaManager emailCaptchaManager
        )
    {
        this.userManager = userManager;
        this.smsService = smsService;
        this.smsCaptchaManager = smsCaptchaManager;
        this.emailCaptchaManager = emailCaptchaManager;
    }

在ResetPasswordManager中添加SendForgotPasswordCaptchaAsync方法,用于短信或邮箱方式的身份验证。

public async Task SendForgotPasswordCaptchaAsync(string provider, string phoneNumberOrEmail)
{

    User user;
    if (provider == "Email")
    {
        user = await userManager.FindByEmailAsync(phoneNumberOrEmail);
        if (user == null)
        {
            throw new UserFriendlyException("未找到绑定邮箱的用户");
        }
        await emailCaptchaManager.SendCaptchaAsync(user.Id, user.EmailAddress, CaptchaPurpose.RESET_PASSWORD);


    }
    else if (provider == "Phone")
    {
        user = await userManager.FindByNameOrPhoneNumberAsync(phoneNumberOrEmail);
        if (user == null)
        {
            throw new UserFriendlyException("未找到绑定手机号的用户");
        }
        await smsCaptchaManager.SendCaptchaAsync(user.Id, user.PhoneNumber, CaptchaPurpose.RESET_PASSWORD);
    }


}

校验验证码

添加VerifyAndSendResetPasswordLinkAsync方法,用于校验验证码,并发送重置密码的链接。

public async Task VerifyAndSendResetPasswordLinkAsync(string token, string provider)
{
    if (provider == "Email")
    {
        EmailCaptchaTokenCacheItem currentItem = await emailCaptchaManager.GetToken(token);
        if (currentItem == null || currentItem.Purpose != CaptchaPurpose.RESET_PASSWORD)
        {
            throw new UserFriendlyException("验证码不正确或已过期");
        }

        var user = await userManager.GetUserByIdAsync(currentItem.UserId);
        var emailAddress = currentItem.EmailAddress;
        await SendEmailResetPasswordLink(user, emailAddress);

        await emailCaptchaManager.RemoveToken(token);
    }

    else if (provider == "Phone")
    {

        SmsCaptchaTokenCacheItem currentItem = await smsCaptchaManager.GetToken(token);
        if (currentItem == null || currentItem.Purpose != CaptchaPurpose.RESET_PASSWORD)
        {
            throw new UserFriendlyException("验证码不正确或已过期");
        }

        var user = await userManager.GetUserByIdAsync(currentItem.UserId);
        var phoneNumber = currentItem.PhoneNumber;
        await SendSmsResetPasswordLink(user, phoneNumber);

        await smsCaptchaManager.RemoveToken(token);
    }
    else
    {
        throw new UserFriendlyException("验证码提供者错误");
    }

}

发送重置密码链接

创建SendSmsResetPasswordLink,用于对当前用户产生一个NewPasswordResetCode,并发送重置密码的短信链接。

private async Task SendSmsResetPasswordLink(User user, string phoneNumber)
{
    var model = new SendSmsRequest();
    user.SetNewPasswordResetCode();
    var passwordResetCode = user.PasswordResetCode;
    model.PhoneNumbers = new string[] { phoneNumber };
    model.SignName = "MatoApp";
    model.TemplateCode = "SMS_255330989";
    //for aliyun
    model.TemplateParam = JsonConvert.SerializeObject(new { username = user.UserName, code = passwordResetCode });

    //for tencent-cloud
    //model.TemplateParam = JsonConvert.SerializeObject(new string[] { user.UserName, passwordResetCode });


    var result = await smsService.SendSmsAsync(model);

    if (string.IsNullOrEmpty(result.BizId) && result.Code != "OK")
    {
        throw new UserFriendlyException("验证码发送失败,错误信息:" + result.Message);
    }
}

创建接口

在UserAppService暴露出SendForgotPasswordCaptcha和VerifyAndSendResetPasswordLink两个接口,

注意这两个接口都需要添加
[AbpAllowAnonymous]
特性,因为在用户未登录的情况下,也需要使用这两个接口。

[AbpAllowAnonymous]
public async Task SendForgotPasswordCaptcha(ForgotPasswordProviderDto input)
{
    var provider = input.Provider;
    var phoneNumberOrEmail = input.ProviderNumber;

    await forgotPasswordManager.SendForgotPasswordCaptchaAsync(provider, phoneNumberOrEmail);

}

[AbpAllowAnonymous]
public async Task VerifyAndSendResetPasswordLink(SendResetPasswordLinkDto input)
{
    var provider = input.Provider;
    var token = input.Token;
    await forgotPasswordManager.VerifyAndSendResetPasswordLinkAsync(token, provider);

}

这两个接口分别在用户忘记密码的两个阶段调用,

  1. 第一阶段是发送验证码,
  2. 第二阶段是校验验证码并发送重置密码的链接。

在这里插入图片描述

密码强制过期策略

在User实体中添加一个属性,用于记录密码最后修改时间,在登录时验证这个时间至此时的时间跨度,如果超过一定时间(例如90天),强制用户重置密码。

[Required]
public DateTime LastPasswordModificationTime { get; set; }

改写接口

将重置校验码PasswordResetCode添加到AuthenticateResultModel中

public string PasswordResetCode { get; set; }

打开TokenAuthController,注入ResetPasswordManager服务对象

登录验证终节点方法Authenticate中,添加对密码强制过期的逻辑代码

[HttpPost]
public async Task<AuthenticateResultModel> Authenticate([FromBody] AuthenticateModel model)
{

    var loginResult = await GetLoginResultAsync(
                model.UserNameOrEmailAddress,
                model.Password,
                GetTenancyNameOrNull()
            );


    ...

    //Password Expiration Check
    if (DateTime.Now - loginResult.User.LastPasswordModificationTime > TimeSpan.FromDays(90))
    {
        loginResult.User.SetNewPasswordResetCode();

        return new AuthenticateResultModel
        {
            PasswordResetCode = loginResult.User.PasswordResetCode,
            UserId = loginResult.User.Id,
        };
    }

}

当登录账号的LastPasswordModificationTime距此时大于90天时,将阻止登录,并提示账户密码已过期,需要修改密码
在这里插入图片描述
在这里插入图片描述

Vue网页端开发

重置密码页面

创建Web端的重置密码页面,用于用户重置密码。
在这里插入图片描述

当用户通过短信或邮箱接收到重置密码的链接后,点击链接,会跳转到重置密码的页面,用户输入新密码后,点击提交,就可以完成密码重置。

连接格式如下

http://localhost:8080/reset-password-sample/reset.html?code=f16b5fbb057d4a04bce5b9e7f24e1d56&userId=1

项目参与实际生产中请加密参数,在此为了简单起见采用明文传递。

<template>
  <div id="app">
    <div class="title-container center">
      <h3 class="title">修改密码</h3>
    </div>
    <el-row>
      <el-form
        ref="loginForm"
        :model="input"
        class="login-form"
        autocomplete="on"
        label-position="left"
      >
        <el-form-item label="验证码">
          <el-input v-model="input.code" placeholder="请输入验证码" clearable />
        </el-form-item>
        <el-form-item label="新密码" prop="newPassword">
          <el-input
            v-model="input.newPassword"
            placeholder="请输入新密码"
            clearable
            show-password
          />
        </el-form-item>
        <el-form-item label="新密码(确认)" prop="newPassword2">
          <el-input
            v-model="input.newPassword2"
            placeholder="请再次输入新密码"
            clearable
            show-password
          />
        </el-form-item>

        <el-row type="flex" class="row-bg">
          <el-col :offset="6" :span="10">
            <el-button
              type="primary"
              style="width: 100%"
              @click.native.prevent="submit"
              >修改
            </el-button>
          </el-col>
        </el-row>
      </el-form>
    </el-row>
  </div>
</template>

创建页面时会根据url中的参数,获取code和userId。

created: async function () {
    var url = window.location.href;
    var reg = /[?&]([^?&#]+)=([^?&#]+)/g;
    var param = {};
    var ret = reg.exec(url);
    while (ret) {
      param[ret[1]] = ret[2];
      ret = reg.exec(url);
    }
    if ("code" in param) {
      this.input.code = param["code"];
    }
    if ("userId" in param) {
      this.input.userId = param["userId"];
    }
  },

点击修改时会触发submit方法,这个方法会调用ResetPasswordByCode接口,将UserId,newPassword以及resetCode回传。

 async submit() {
      if ((this.input.newPassword != this.input.newPassword2) == null) {
        this.$message.warning("两次输入的密码不一致!");
        return;
      }

      await request(
        `${this.host}${this.prefix}/User/ResetPasswordByCode`,
        "post",
        {
          userId: this.input.userId,
          newPassword: this.input.newPassword,
          resetCode: this.input.code,
        }
      )
        .catch((re) => {
          var res = re.response.data;
          this.errorMessage(res.error.message);
        })
        .then(async (res) => {
          var data = res.data.result;
          this.successMessage("密码修改成功!");
          
          window.location.href = "/reset-password-sample.html";
        })
        .finally(() => {
          setTimeout(() => {
            this.loading = false;
          }, 1.5 * 1000);
        });
    },

忘记密码控件

在登录页面中,添加忘记密码的控件。

在这里插入图片描述

resetPasswordStage 是判定当前是哪个阶段的变量,
0表示正常用户名密码登录(初始状态),1表示输入手机号或邮箱验证身份,2表示通过验证即将发送重置密码的链接。

默认两种方式,一种是短信验证码,一种是邮箱验证码,这里我们采用了elementUI的tab组件,来实现两种方式的切换。

<template v-else-if="resetPasswordStage == 1">
    <p>
    请输入与要找回的账户关联的手机号或邮箱。我们将为你发送密码重置连接
    </p>
    <el-tabs tab-position="top" v-model="forgotPasswordProvider.provider">
    <el-tab-pane :lazy="true" label="通过手机号找回" name="Phone">
        <el-row>
        <el-col :span="24">
            <el-input
            v-model="forgotPasswordProvider.providerNumber"
            :placeholder="'请输入手机号'"
            tabindex="2"
            >
            <el-button
                slot="append"
                @click="sendResetPasswordLink"
                :disabled="forgotPasswordProvider.providerNumber == ''"
                >下一步</el-button
            >
            </el-input>
        </el-col>
        </el-row>
    </el-tab-pane>

    <el-tab-pane :lazy="true" label="通过邮箱找回" name="Email">
        <el-row>
        <el-col :span="24">
            <el-alert
            v-if="showResetRequireSuccess"
            title="密码重置连接已发送至登录用户对应的邮箱,请查收"
            type="info"
            >
            </el-alert>
        </el-col>
        <el-col :span="24">
            <p>建设中..</p>
        </el-col>
        </el-row>
    </el-tab-pane>
    </el-tabs>
</template>

不通的阶段,将分别调用不同的接口,sendResetPasswordLink以及verifyAndSendResetPasswordLink。

调用verifyAndSendResetPasswordLink接口完毕时,resetPasswordStage将设置位初始状态,即0。

async sendResetPasswordLink() {
    await request(
    `${this.host}${this.prefix}/User/SendForgotPasswordCaptcha`,
    "post",
    this.forgotPasswordProvider
    )
    .catch((re) => {
        var res = re.response.data;
        this.errorMessage(res.error.message);
    })
    .then(async (re) => {
        if (re) {
        this.successMessage("发送验证码成功");
        this.resetPasswordStage++;
        }
    });
},
async verifyAndSendResetPasswordLink() {
    await request(
    `${this.host}${this.prefix}/User/VerifyAndSendResetPasswordLink`,
    "post",
    {
        provider: this.forgotPasswordProvider.provider,
        token: this.captchaToken,
    }
    )
    .catch((re) => {
        var res = re.response.data;
        this.errorMessage(res.error.message);
    })
    .then(async (re) => {
        if (re) {
        this.successMessage("发送连接成功");
        this.resetPasswordStage = 0;
        }
    });
},

密码过期提示

主页面中添加对passwordResetCode的响应,当passwordResetCode不为空时,显示一个提示框,提示用户密码已超过90天未修改,请修改密码。

<el-alert
    v-if="passwordResetCode != null"
    close-text="点此修改密码"
    title="密码已超过90天未修改,为了安全,请修改密码"
    type="info"
    @close="
        gotoUrl(
        '/reset-password-sample/reset.html?code=' +
            passwordResetCode +
            '&userId=' +
            userId
        )
    "
    >
</el-alert>

在这里插入图片描述
用户点击
点此修改密码
按钮时将跳转至重置密码页面。

项目地址

Github:matoapp-samples

最近博主看完了《SQL进阶教程》这本书,看完后给博主打开了SQL世界的新大门,对于 SQL 的理解不在局限于以前的常规用法。借用其他读者的评论,

读完醍醐灌顶,对SQL做到了知其然更能知其所以然。全书从头到尾强调了 SQL的内在逻辑是基于集合论和谓词逻辑,而着两条主线恰恰在使用SQL起到了至关重要的指导作用。

本文给大家总结如何让SQL起飞(优化)

一、SQL写法优化

在SQL中,很多时候不同的SQL代码能够得出相同结果。从理论上来说,我们认为得到相同结果的不同SQL之间应该有相同的性能,但遗憾的是,查询优化器生成的执行计划很大程度上受到SQL代码影响,有快有慢。因此如果想优化查询性能,我们必须知道如何写出更快的SQL,才能使优化器的执行效率更高。

1.1 子查询用EXISTS代替IN

当IN的参数是子查询时,数据库首先会执行子查询,然后将结果存储在一张临时的工作表里(内联视图),然后扫描整个视图。很多情况下这种做法都非常耗费资源。使用EXISTS的话,数据库不会生成临时的工作表。但是从代码的可读性上来看,IN要比EXISTS好。使用IN时的代码看起来更加一目了然,易于理解。因此,如果确信使用IN也能快速获取结果,就没有必要非得改成EXISTS了。

这里用Class_A表和Class_B举例,

我们试着从Class_A表中查出同时存在于Class_B表中的员工。下面两条SQL语句返回的结果是一样的,但是使用EXISTS的SQL语句更快一些。

--慢
SELECT *
  FROM Class_A
 WHERE id IN (SELECT id
                FROM Class_B);

--快
SELECT *
  FROM Class_A  A
 WHERE EXISTS
        (SELECT *
          FROM Class_B  B
          WHERE A.id = B.id);

使用EXISTS时更快的原因有以下两个。

  1. 如果连接列(id)上建立了索引,那么查询 tb_b 时不用查实际的表,只需查索引就可以了。(同样的IN也可以使用索引,这不是重要原因)
  2. 「如果使用EXISTS,那么只要查到一行数据满足条件就会终止查询,不用像使用IN时一样扫描全表」 。在这一点上NOT EXISTS也一样。

实际上,大部分情况在子查询数量较小的场景下EXISTS和IN的查询性能不相上下,由EXISTS查询更快第二点可知,子查询数量较大时使用EXISTS才会有明显优势。

1.2 避免排序并添加索引

在SQL语言中,除了ORDER BY子句会进行显示排序外,还有很多操作默认也会在暗中进行排序,如果排序字段没有添加索引,会导致查询性能很慢。SQL中会进行排序的代表性的运算有下面这些。

  • GROUP BY子句
  • ORDER BY子句
  • 聚合函数(SUM、COUNT、AVG、MAX、MIN)
  • DISTINCT
  • 集合运算符(UNION、INTERSECT、EXCEPT)
  • 窗口函数(RANK、ROW_NUMBER等)

如上列出的六种运算(除了集合运算符),它们后面跟随或者指定的字段都可以添加索引,这样可以加快排序。

「实际上在DISTINCT关键字、GROUP BY子句、ORDER BY子句、聚合函数跟随的字段都添加索引,不仅能加速查询,还能加速排序。」

1.3 用EXISTS代替DISTINCT

为了排除重复数据,我们可能会使用DISTINCT关键字。如1.2中所说,默认情况下,它也会进行暗中排序。如果需要对两张表的连接结果进行去重,可以考虑使用EXISTS代替DISTINCT,以避免排序。这里用Items表和SalesHistory表举例:

我们思考一下如何从上面的商品表Items中找出同时存在于销售记录表SalesHistory中的商品。简而言之,就是找出有销售记录的商品。

在一(Items)对多(SalesHistory)的场景下,我们需要对item_no去重,使用DISTINCT去重,因此SQL如下:

SELECT DISTINCT I.item_no
  FROM Items I INNER JOIN SalesHistory SH
    ON I. item_no = SH. item_no;

item_no
-------
    10
    20
    30

使用EXISTS代替DISTINCT去重,SQL如下:

SELECT item_no
  FROM Items I
 WHERE EXISTS
          (SELECT *
              FROM SalesHistory SH
            WHERE I.item_no = SH.item_no);
item_no
-------
    10
    20
    30

这条语句在执行过程中不会进行排序。而且使用EXISTS和使用连接一样高效。

1.4 集合运算ALL可选项

SQL中有UNION、INTERSECT、EXCEPT三个集合运算符。在默认的使用方式下,这些运算符会为了排除掉重复数据而进行排序。

MySQL还没有实现INTERSECT和EXCEPT运算

如果不在乎结果中是否有重复数据,或者事先知道不会有重复数据,请使用UNION ALL代替UNION。这样就不会进行排序了。

1.5 WHERE条件不要写在HAVING字句

例如,这里继续用SalesHistory表举例,下面两条SQL语句返回的结果是一样的:

--聚合后使用HAVING子句过滤
SELECT sale_date, SUM(quantity)
  FROM SalesHistory
 GROUP BY sale_date
HAVING sale_date = '2007-10-01';

--聚合前使用WHERE子句过滤
SELECT sale_date, SUM(quantity)
  FROM SalesHistory
 WHERE sale_date = '2007-10-01'
 GROUP BY sale_date;

但是从性能上来看,第二条语句写法效率更高。原因有两个:

  1. 使用GROUP BY子句聚合时会进行排序,如果事先通过WHERE子句筛选出一部分行,就能够减轻排序的负担。
  2. 在WHERE子句的条件里可以使用索引。HAVING子句是针对聚合后生成的视图进行筛选的,但是很多时候聚合后的视图都没有继承原表的索引结构。

二、真的用到索引了吗

2.1 隐式的类型转换

如下,col_1字段是char类型:

SELECT * FROM SomeTable WHERE col_1 = 10; -- 走了索引
SELECT * FROM SomeTable WHERE col_1 ='10'; -- 没走索引
SELECT * FROM SomeTable WHERE col_1 = CAST(10, AS CHAR(2)); -- 走了索引

当查询条件左边和右边类型不一致时会导致索引失效。

2.2 在索引字段上进行运算

如下:

SELECT *
  FROM SomeTable
 WHERE col_1 * 1.1 > 100;

在索引字段col_1上进行运算会导致索引不生效,把运算的表达式放到查询条件的右侧,就能用到索引了,像下面这样写就OK了。

WHERE col_1 > 100 / 1.1

如果无法避免在左侧进行运算,那么使用函数索引也是一种办法,但是不太推荐随意这么做。
「使用索引时,条件表达式的左侧应该是原始字段请牢记」
,这一点是在优化索引时首要关注的地方。

2.3 使用否定形式

下面这几种否定形式不能用到索引。

  • <>
  • !=
  • NOT

这个是跟具体数据库的优化器有关,如果优化器觉得即使走了索引,还是需要扫描很多很多行的哈,他可以选择直接不走索引。平时我们用!=、<>、not in的时候,要注意一下。

2.4 使用OR查询前后没有同时使用索引

例如下表:

CREATE TABLE test_tb ( 
 id int(11) NOT NULL AUTO_INCREMENT, 
 name varchar(55) NOT NULL
 PRIMARY KEY (id)
) 
ENGINE=InnoDB DEFAULT CHARSET=utf8;

使用OR条件进行查询

SELECT * 
FROM test_tb 
WHERE id = 1 OR name = 'tom'

这个SQL的执行条件下,很明显id字段查询会走索引,但是对于OR后面name字段的查询是需要进行全表扫描的。在这个场景下,优化器直接进行一遍全表扫描就完事了。

2.5 使用联合索引时,列的顺序错误

使用联合索引需要满足最左匹配原则,即最左优先。如果你建立一个(col_1, col_2, col_3)的联合索引,相当于建立了 (col_1)、(col_1,col_2)、(col_1,col_2,col_3) 三个索引。如下例子:

-- 走了索引
SELECT * FROM SomeTable WHERE col_1 = 10 AND col_2 = 100 AND col_3 = 500;
-- 走了索引
SELECT * FROM SomeTable WHERE col_1 = 10 AND col_2 = 100 ;
-- 没走索引
SELECT * FROM SomeTable WHERE col_1 = 10 AND col_3 = 500 ;
-- 没走索引
SELECT * FROM SomeTable WHERE col_2 = 100 AND col_3 = 500 ;
-- 没走索引
SELECT * FROM SomeTable WHERE col_2 = 100 AND col_1 = 10 ;

联合索引中的第一列(col_1)必须写在查询条件的开头,而且索引中列的顺序不能颠倒。

2.6 使用LIKE查询

并不是用了like通配符,索引一定会失效,而是like查询是以%开头,才会导致索引失效。

-- 没走索引
SELECT  *  FROM  SomeTable  WHERE  col_1  LIKE'%a';
-- 没走索引
SELECT  *  FROM  SomeTable  WHERE  col_1  LIKE'%a%';
-- 走了索引
SELECT  *  FROM  SomeTable  WHERE  col_1  LIKE'a%';

2.7 连接字段字符集编码不一致

如果两张表进行连接,关联字段编码不一致会导致关联字段上的索引失效,这是博主在线上经历一次SQL慢查询后的得到的结果,举例如下,有如下两表,它们的name字段都建有索引,但是编码不一致,user表的name字段编码是utf8mb4,user_job表的name字段编码是utf8,

CREATE TABLE `user` (
  `id` int NOT NULL AUTO_INCREMENT,
  `name` varchar(255) CHARACTER
  SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL,
  `age` int NOT NULL,
  PRIMARY KEY (`id`),
  KEY `idx_name` (`name`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8;

CREATE TABLE `user_job` (
  `id` int NOT NULL,
  `userId` int NOT NULL,
  `job` varchar(255) DEFAULT NULL,
  `name` varchar(255) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `idx_name` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

进行SQL查询如下:

EXPLAIN
SELECT * 
from `user` u 
join user_job j on u.name = j.name


由结果可知,user表的查询没有走索引。想要user表也走索引,那就需要把user表name字段的编码改成utf8即可。

三、减少中间表

在SQL中,子查询的结果会被看成一张新表,这张新表与原始表一样,可以通过代码进行操作。这种高度的相似性使得SQL编程具有非常强的灵活性,但是如果不加限制地大量使用中间表,会导致查询性能下降。

频繁使用中间表会带来两个问题,一是展开数据需要耗费内存资源,二是原始表中的索引不容易使用到(特别是聚合时)。因此,尽量减少中间表的使用也是提升性能的一个重要方法。

3.1 使用HAVING子句

对聚合结果指定筛选条件时,使用HAVING子句是基本原则。不习惯使用HAVING子句的人可能会倾向于像下面这样先生成一张中间表,然后在WHERE子句中指定筛选条件。例如下面:

SELECT * 
  FROM (
    SELECT sale_date, MAX(quantity) max_qty
      FROM SalesHistory
       GROUP BY sale_date
     ) tmp
 WHERE max_qty >= 10

然而,对聚合结果指定筛选条件时不需要专门生成中间表,像下面这样使用HAVING子句就可以。

SELECT sale_date, MAX(quantity)
  FROM SalesHistory
 GROUP BY sale_date
HAVING MAX(quantity) >= 10;

HAVING子句和聚合操作是同时执行的,所以比起生成中间表后再执行的WHERE子句,效率会更高一些,而且代码看起来也更简洁。

3.2 对多个字段使用IN

当我们需要对多个字段使用IN条件查询时,可以通过 || 操作将字段连接在一起变成一个字符串处理。

SELECT *
  FROM Addresses1 A1
 WHERE id || state || city
    IN (SELECT id || state|| city
          FROM Addresses2 A2);

这样一来,子查询不用考虑关联性,而且只执行一次就可以。

3.3 先进行连接再进行聚合

连接和聚合同时使用时,先进行连接操作可以避免产生中间表。原因是,从集合运算的角度来看,连接做的是“乘法运算”。连接表双方是一对一、一对多的关系时,连接运算后数据的行数不会增加。而且,因为在很多设计中多对多的关系都可以分解成两个一对多的关系,因此这个技巧在大部分情况下都可以使用。


到此本文讲解完毕,感谢大家阅读,感兴趣的朋友可以点赞加关注,你的支持将是我更新动力

Podman Desktop安装与使用-Windows10

下载

containers/podman 地址

Podman Desktop Downloads 地址

我这里演示的是podman-v4.4.4.msi和podman-desktop-0.13.0-setup.exe

安装

先决条件:由于 Podman 使用 WSL,因此您需要最新版本的 Windows 10 或 Windows 11。在 x64 上,WSL 需要内部版本 18362 或更高版本,arm19041 系统需要 64 或更高版本。在内部,WSL 使用虚拟化,因此系统必须支持并启用硬件虚拟化。如果在 VM 上运行 Windows,则必须具有支持嵌套虚拟化的 VM。

依次安装podman-v4.4.4.msi和podman-desktop-0.13.0-setup.exe

img

查看安装情况

podman -v

img

行命令来创建第一台机器。
podman machine init

自动 WSL 安装
如果系统上尚未安装 WSL,则第一个计算机 init 命令将提示对话框以开始自动安装。如果接受,此过程将安装必要的Windows组件,重新启动系统,登录后,在终端窗口中重新启动计算机创建过程。请务必等待一两分钟以重新启动,因为 Windows 在执行启动项之前有延迟。或者,可以拒绝自动安装并手动安装 WSL。但是,这将需要额外的下载和设置时间。

由于我之前已经安装过,现在删除重新开始初始化。

img

然后执行
podman machine start
开启machine,

使用一个镜像来快速尝试运行

podman run ubi8-micro date

img

端口转发

端口转发和docker差不多,将端口绑定到本机(127.0.0.1)。值得注意的是,以非root方式(默认是非root)运行时,端口必须要大于1023

podman run --rm -d -p 8080:80 --name httpd docker.io/library/httpd

curl http://localhost:8080/ -UseBasicParsing

img

Rootful & Rootless

在嵌入式WSL Linux发行版上,podman既可以在根用户(root)下运行,也可以在非特权用户(root - tless)下运行。为了与Linux上的Podman保持行为一致性,默认为无根。注意:有根容器和无根容器是不同的,彼此隔离。针对其中一个的Podman命令(例如Podman ps)不会代表另一个的结果/状态。

Podman 是一个容器引擎工具,允许用户在 Linux 上运行应用程序容器。通常,在 Linux 上运行容器需要 root 权限,但 Podman 可以允许非 root 用户使用容器。

简单来说,如果你是一个非 root 用户,并且想要使用 Podman 运行容器,那么你可以直接安装和运行 Podman 软件,而无需获取 root 权限。这个功能非常有用,因为它可以避免不必要的安全风险和权限访问问题。

请注意,虽然 Podman 允许非 root 用户使用容器,但这也意味着您的容器将受到更多限制,例如无法运行全部操作系统等。因此,建议您在需要时与管理员协商并根据情况选择正确的权限。

Docker 默认情况下需要在 Linux 上以 root 权限运行,这就意味着如果您想使用 Docker 运行容器,您必须作为 root 用户登录到系统中。这可能会带来一些安全风险,因为 root 用户拥有系统上所有资源的完全控制权,而这并不总是安全或必要的。

不过,Docker 也提供了对非 root 用户访问容器的支持。与 Podman 不同的是,Docker 要求您手动将非 root 用户添加到 docker 组中以实现这一点。这个方法需要管理员权限,并且需要特殊的配置才能正常使用。

与 Docker 相比,Podman 更适合需要以非 root 用户身份管理容器的用户。Podman 可以直接在普通用户下运行,无需任何特殊配置或添加用户到任何组中。这可以使整个容器生命周期更加安全和可靠。

总之,Podman 和 Docker 的区别在于如何允许非 root 用户访问容器。Podman 允许非 root 用户直接运行容器,而 Docker 则需要特殊配置和权限来实现类似的功能。

切换rootful

podman machine stop
podman machine set --rootful

切换Rootless

Podman machine stop
Podman machine set --rootful=false

Volume Mounting(挂载)

Windows Style Paths:

podman run --rm -v c:\Users\User\myfolder:/myfolder ubi8-micro ls /myfolder

Unixy Windows Paths:

podman run --rm -v /c/Users/User/myfolder:/myfolder ubi8-micro ls /myfolder

Linux paths local to the WSL filesystem:

podman run --rm -v /var/myfolder:/myfolder ubi-micro ls /myfolder

常用命令

https://docs.podman.io/en/latest/index.html

直接在这个网站上找吧,如果你使用过docker,其实上手非常简单。