ES索引恢复流程解析
文章目录
- 背景
- 主分片恢复流程
- INIT阶段
- INDEX阶段
- VERIFY_INDEX阶段
- TRANSLOG阶段
- FINALIZE阶段
- DONE阶段
- 副分片恢复流程
- 流程概述
- 副分片节点处理过程
- INIT阶段
- INDEX阶段
- VERIFY_INDEX阶段
- TRANSLOG阶段
- FINALIZE阶段
- DONE阶段
- 主分片节点处理过程
- phase1
- phase2
背景
索引恢复是ES数据恢复过程。待恢复的数据是客户端写入成功,但未执行刷盘(flush)的Lucene分段。根据数据分片性质,索引恢复过程可分为主分片恢复流程和副分片恢复流程。
- 主分片从translog中自我恢复,尚未执行flush到磁盘的Lucene分段可以从translog中重建;
- 副分片需要从主分片中拉取Lucene分段和translog进行恢复。但是有机会跳过拉取Lucene分段的过程。
例如,当节点异常重启时,写入磁盘的数据先到文件系统的缓冲,未必来得及刷盘,如果不通过某种方式将未刷盘的数据找回来,则会丢失一些数据,这是保持数据完整性的体现;另一方面,由于写入操作在多个分片副本上没有来得及全部执行,副分片需要同步成和主分片完全一致,这是数据副本一致性的体现。
recovery由clusterChanged触发,从触发到开始执行恢复的调用关系如下:
indicesClusterStateService#applyClusterState
->createOrUpdateShards()
->createShard()
->indicesService.createShard()
->indexShard.startRecovery()
IndexShard#startRecovery执行对一个特定分片的恢复流程,根据此分片不同的恢复类型执行相应的恢复过程。
// IndexShard.java#startRecovery
public void startRecovery(RecoveryState recoveryState, PeerRecoveryTargetService recoveryTargetService,PeerRecoveryTargetService.RecoveryListener recoveryListener, RepositoriesService repositoriesService,BiConsumer<String, MappingMetaData> mappingUpdateConsumer,IndicesService indicesService) {switch (recoveryState.getRecoverySource().getType()) {case EMPTY_STORE:case EXISTING_STORE:threadPool.generic().execute(() -> {try {// 主分片从本地恢复if (recoverFromStore()) {recoveryListener.onRecoveryDone(recoveryState);}}});break;case PEER:try {// 副分片从远程主分片恢复recoveryTargetService.startRecovery(this, recoveryState.getSourceNode(), recoveryListener);}break;case SNAPSHOT:threadPool.generic().execute(() -> {try {final Repository repository = repositoriesService.repository(recoverySource.snapshot().getRepository());// 从快照恢复if (restoreFromRepository(repository)) {recoveryListener.onRecoveryDone(recoveryState);}}});break;case LOCAL_SHARDS:threadPool.generic().execute(() -> {try {// 从本节点的其他分片恢复(shrink时)if (recoverFromLocalShards(mappingUpdateConsumer, startedShards.stream().filter((s) -> requiredShards.contains(s.shardId())).collect(Collectors.toList()))) {recoveryListener.onRecoveryDone(recoveryState);}}});break;default:throw new IllegalArgumentException("Unknown recovery source " + recoveryState.getRecoverySource());}
}
我们主要介绍主分片和副分片的恢复流程。恢复工作一般经历以下几个阶段(stage):
- INIT:恢复尚未启动;
- INDEX:恢复Lucene文件,以及在节点间复制索引数据;
- VERIFY_INDEX:验证索引;
- TRANSLOG:启动engine,重放translog,建立Lucene索引;
- FINALIZE:清理工作;
- DONE:完毕。
主分片恢复流程
INIT阶段
一个分片的恢复流程中,从开始执行恢复的那一刻起,被标记为INIT阶段,INIT标记在IndexShard#startRecovery函数的参数中传入,在判断此分片属于哪种恢复类型之前就被设置为INIT阶段。然后在新的线程池中执行主分片恢复流程。
// IndexShard.java#startRecovery
public void startRecovery(RecoveryState recoveryState, PeerRecoveryTargetService recoveryTargetService,PeerRecoveryTargetService.RecoveryListener recoveryListener, RepositoriesService repositoriesService,BiConsumer<String, MappingMetaData> mappingUpdateConsumer,IndicesService indicesService) {switch (recoveryState.getRecoverySource().getType()) {case EMPTY_STORE:case EXISTING_STORE:// 标记为INIT阶段,INIT阶段作为函数入参传入markAsRecovering("from store", recoveryState);// 在新的线程池中执行主分片恢复流程:threadPool.generic().execute(() -> {try {// 主分片从本地恢复if (recoverFromStore()) {recoveryListener.onRecoveryDone(recoveryState);}}});break;
}
接下来,恢复流程在新的线程池中开始执行,开始阶段主要是一些验证工作,例如,校验当前分片是否为主分片,分片状态是否异常等。
// IndexShard.java#recoverFromStore
public boolean recoverFromStore() {assert shardRouting.primary() : "recover from store only makes sense if the shard is a primary shard";assert shardRouting.initializing() : "can only start recovery on initializing shard";StoreRecovery storeRecovery = new StoreRecovery(shardId, logger);return storeRecovery.recoverFromStore(this);
}
做完简单的校验工作后,调用StoreRecovery#recoverFromStore执行恢复流程,实际的逻辑在StoreRecovery#internalRecoverFromStore中,其通过调用IndexShard#prepareForIndexRecovery将状态设置为INDEX,从而进入INDEX阶段:
// IndexShard.java
public void prepareForIndexRecovery() {recoveryState.setStage(RecoveryState.Stage.INDEX);
}
INDEX阶段
本阶段从Lucene读取最后一次提交的分段信息,获取其中的版本号,更新当前索引版本:
// StoreRecovery.java
// 从store中恢复该分片的状态
private void internalRecoverFromStore(IndexShard indexShard) throws IndexShardRecoveryException {// 标记进入INDEX阶段indexShard.prepareForIndexRecovery();long version = -1;SegmentInfos si = null;final Store store = indexShard.store();store.incRef();try {si = store.readLastCommittedSegmentsInfo();version = si.getVersion();recoveryState.getIndex().updateVersion(version);}
}
VERIFY_INDEX阶段
VERIFY_INDEX中的INDEX指Lucene index,因此本阶段的作用是验证当前分片是否损坏(默认设置为不执行检查,因为在索引的数据量较大时,分片检查会消耗更多的时间)。其通过在StoreRecovery#internalRecoverFromStore在调用IndexShard#innerOpenEngineAndTranslog实现。实际验证工作在IndexShard#checkIndex函数中完成。验证过程通过对比元信息中记录的checksum与Lucene文件的实际值,或者调用Lucene CheckIndex类中的checkIndex、exorciseIndex方法完成。
// IndexShard.java
private void innerOpenEngineAndTranslog() throws IOException {// 设置为进入VERIFY_INDEX阶段recoveryState.setStage(RecoveryState.Stage.VERIFY_INDEX);// 检查配置是否需要执行分片检查if (Booleans.isTrue(checkIndexOnStartup) || "checksum".equals(checkIndexOnStartup)) {checkIndex();}
}
TRANSLOG阶段
一个Lucene索引由许多分段组成,每次搜索时遍历所有分段。内部维护了一个称为“提交点”的信息,其描述了当前Lucene索引都包括哪些分段,这些分段已经被fsync系统调用,从操作系统的cache刷入磁盘。每次提交操作都会将分段刷入磁盘实现持久化。
本阶段需要重放事务日志中尚未刷入磁盘的信息,因此,根据最后一次提交的信息做快照,来确定事务日志中哪些数据需要重放。重放完毕后将新生成的Lucene数据刷入磁盘。遍历所有需要重放的事务日志,执行具体的写操作,如同写入过程一样:
// IndexShard.java
// 在已存在的lucene engine和translog基础上启动engine
// 重放该translog中的操作以更新lucene
public void openEngineAndRecoverFromTranslog() throws IOException {final RecoveryState.Translog translogRecoveryStats = recoveryState.getTranslog();final Engine.TranslogRecoveryRunner translogRecoveryRunner = (engine, snapshot) -> {translogRecoveryStats.totalOperations(snapshot.totalOperations());translogRecoveryStats.totalOperationsOnStart(snapshot.totalOperations());return runTranslogRecovery(engine, snapshot, Engine.Operation.Origin.LOCAL_TRANSLOG_RECOVERY,translogRecoveryStats::incrementRecoveredOperations);};// 标记进入TRANSLOG阶段,并更新全局检查点信息innerOpenEngineAndTranslog();final Engine engine = getEngine();engine.reinitializeMaxSeqNoOfUpdatesOrDeletes();// 重放这些translog日志操作engine.recoverFromTranslog(translogRecoveryRunner, Long.MAX_VALUE);
}
Translog日志重放完毕后,StoreRecovery#internalRecoveryFromStore方法调用indexShard.finalizeRecovery()进入FINALIZE阶段。
// StoreRecovery.java
private void internalRecoverFromStore(IndexShard indexShard) throws IndexShardRecoveryException {indexShard.postRecovery("post recovery from shard_store");
}
FINALIZE阶段
本阶段执行刷新(refresh)操作,将缓冲的数据写入文件,但不刷盘,数据在操作系统的cache中。
// IndexShard.java
public void finalizeRecovery() {recoveryState().setStage(RecoveryState.Stage.FINALIZE);Engine engine = getEngine();engine.refresh("recovery_finalization");engine.config().setEnableGcDeletes(true);
}
还是在StoreRecovery#internalRecoverFromStore方法中调用indexShard.postRecovery,将阶段设置为DONE。
// StoreRecovery.java
private void internalRecoverFromStore(IndexShard indexShard) throws IndexShardRecoveryException {indexShard.finalizeRecovery();
}
DONE阶段
DONE阶段是恢复工作的最后一个阶段,进入DONE阶段之前再次执行refresh,然后更新分片状态。
// IndexShard.java
public IndexShard postRecovery(String reason)throws IndexShardStartedException, IndexShardRelocatedException, IndexShardClosedException {synchronized (mutex) {getEngine().refresh("post_recovery");recoveryState.setStage(RecoveryState.Stage.DONE);changeState(IndexShardState.POST_RECOVERY, reason);}return this;
}
至此,主分片恢复完毕,对恢复结果进行处理。如果恢复成功,则执行IndicesClusterStateService.RecoveryListener#onRecoveryDone主要实现是向Master发送action为internal:cluster/shard/started的RPC请求。如果恢复失败,则执行IndicesClusterStateService#handleRecoveryFailure。主要实现是关闭Engine,向Master发送internal:cluster/shard/failure的RPC请求。
副分片恢复流程
流程概述
副分片恢复的核心思想是从主分片拉取Lucene分段和translog进行恢复。按数据传递的方向,主分片节点称为Source,副分片节点称为Target。为什么需要拉取主分片的translog?因为在副分片恢复期间允许新的写操作,从复制Lucene分段的那一刻开始,所恢复的副分片数据不包括新增的内容,而这些内容存在于主分片的translog中,因此副分片需要从主分片节点拉取translog进行重放,以获取新增内容。
恢复核心处理过程由两个内部阶段(phase)组成。
- phase1:在主分片所在节点,获取translog保留锁,从获取保留锁开始,会保留translog不受其刷盘清空的影响。然后调用Lucene接口把shard做快照,快照含有shard中已经刷到磁盘的文件引用,把这些shard数据复制到副本节点。在phase1结束前,会向副分片节点发送告知对方启动Engine,在phase2开始之前,副分片就可以正常处理写请求了。
- phase2:对translog做快照,这个快照里包含从phase1开始,到执行translog快照期间的新增索引。将这些translog发送到副分片所在节点进行重放。
由于phase1需要通过网络复制大量数据,过程非常漫长,在ES 6.x中,有两个机会可以跳过phase1: 1. 如果可以基于恢复请求中的SequenceNumber进行恢复,则跳过phase1。2. 如果主副两分片有相同的syncid且doc数相同,则跳过phase1。
副分片恢复流程主要包含和副分片节点的处理过程和主分片节点处理过程两大部分。
副分片节点处理过程
副分片恢复的VERIFY_INDEX、TRANSLOG、FINALIZE三个阶段由主分片节点发送的RPC调用触发,如下图所示。
类似的,主分片节点也会向副分片节点发送一些RPC请求,副分片节点对这些请求的处理XXXRequestHandler的方式注册在PeerRecoveryTargetService类中,包括接收Lucene文件、接收translog并重放、执行清理等操作。
INIT阶段
本阶段在副分片节点执行。与主分片恢复的INIT阶段类似,恢复任务开始时被设置为INIT阶段,进行副分片恢复时,在新的线程池中执行恢复任务。
// PeerRecoveryTargetService.java
public void startRecovery(final IndexShard indexShard, final DiscoveryNode sourceNode, final RecoveryListener listener) {// 创建一个新的恢复状态并执行final long recoveryId = onGoingRecoveries.startRecovery(indexShard, sourceNode, listener, recoverySettings.activityTimeout());// 在新的generic线程池里面执行恢复任务threadPool.generic().execute(new RecoveryRunner(recoveryId));
}
然后进入INDEX阶段。
// PeerRecoveryTargetService.java
private void doRecovery(final long recoveryId) {// 进入INDEX阶段recoveryTarget.indexShard().prepareForIndexRecovery();
}
INDEX阶段
INDEX阶段负责将主分片的Lucene数据复制到副分片节点。向主分片节点发送action为internal:index/shard/recovery/start_recovery的RPC请求,并阻塞当前线程,等待响应,直到对方处理完成(主分片节点收到请求后把Lucene和translog发送给副分片)。然后设置为DONE阶段。
// PeerRecoveryTargetService.java
private void doRecovery(final long recoveryId) {// 进入INDEX阶段recoveryTarget.indexShard().prepareForIndexRecovery();try {cancellableThreads.executeIO(() ->// 向主分片节点发送action为internal:index/shard/recovery/start_recovery的RPC请求transportService.submitRequest(request.sourceNode(), PeerRecoverySourceService.Actions.START_RECOVERY, request,new TransportResponseHandler<RecoveryResponse>() {@Overridepublic void handleResponse(RecoveryResponse recoveryResponse) {final TimeValue recoveryTime = new TimeValue(timer.time());// 设置为DONE阶段onGoingRecoveries.markRecoveryAsDone(recoveryId);}}));}
}
线程阻塞等待INDEX阶段完成,然后直接到DONE阶段。在这期间主分片节点会发送几次RPC调用,通知副分片节点启动Engine,执行清理等操作。VERIFY_INDEX和TRANSLOG阶段也是由主分片节点的RPC调用触发的。
VERIFY_INDEX阶段
副分片的索引验证过程与主分片相同,是否进行验证取决于配置。默认为不执行索引验证。主分片节点执行完phase1后,调用prepareTargetForTranslog方法,向副分片节点发送action为internal:index/shard/recovery/prepare_translog的RPC请求。副分片对此action的主要处理是启动Engine,使副分片可以正常接收写请求。副分片的VERIFY_INDEX、TRANSLOG两阶段也是在对这个action的处理中触发的。调用链如下:
recoveryRef.target().prepareForTranslogOperations()
RecoveryTarget#prepareForTranslogOperations()
IndexShard#openEngineAndSkipTranslogRecovery()
IndexShard#innerOpenEngineAndTranslog()
主分片和副分片的VERIFY_INDEX、TRANSLOG都在IndexShard#innerOpenEngineAndTranslog方法中实现。
// IndexShard.java
private void innerOpenEngineAndTranslog() throws IOException {// 进入VERIFY_INDEX阶段recoveryState.setStage(RecoveryState.Stage.VERIFY_INDEX);// 默认为不执行索引验证if (Booleans.isTrue(checkIndexOnStartup) || "checksum".equals(checkIndexOnStartup)) {checkIndex();}// 进入TRANSLOG阶段recoveryState.setStage(RecoveryState.Stage.TRANSLOG);
}
TRANSLOG阶段
TRANSLOG阶段负责将主分片的translog数据复制到副分片节点进行重放。先创建新的Engine,此时主分片的phase2尚未开始,接下来的TRANSLOG阶段就是等待主分片节点将translog发到副分片节点进行重放,也就是phase2的执行过程。
// IndexShard.java
private void innerOpenEngineAndTranslog() throws IOException {// 进入TRANSLOG阶段recoveryState.setStage(RecoveryState.Stage.TRANSLOG);final EngineConfig config = newEngineConfig();synchronized (mutex) {verifyNotClosed();// 在互斥锁下创建新的Enginefinal Engine newEngine = engineFactory.newReadWriteEngine(config);onNewEngine(newEngine);currentEngineReference.set(newEngine);active.set(true);}
}
FINALIZE阶段
主分片节点执行完phase2,向副分片节点发送action为internal:index/shard/recovery/finalize的RPC请求,副分片节点对此action的处理为先更新全局检查点,然后执行与主分片相同的清理操作。
DONE阶段
副分片节点等待INDEX阶段执行完成后,调用RecoveriesCollection#markRecoveryAsDone进入DONE阶段,主要处理是调用IndexShard#postRecovery,与主分片的postRecovery处理过程相同,包括对恢复成功或失败的处理,也和主分片的处理过程相同。
主分片节点处理过程
副分片恢复的INDEX阶段向主分片节点发送action恢复请求,主分片对此请求的处理过程是副分片恢复的核心流程。
phase1
主分片节点收到副分片节点发送的恢复请求,执行恢复,然后返回结果,这里也是阻塞处理的过程,下面的消息处理在generic线程池中执行。主要处理流程位于RecoverySourceHandler#recoverToTarget。首先获取一个保留锁,使得translog不被清理:
// RecoverySourceHandler.java
public void recoverToTarget(ActionListener<RecoveryResponse> listener) {final Closeable retentionLock = shard.acquireRetentionLock();
}
判断是否可以从SequenceNumber恢复,如果可以基于SequenceNumber恢复,则跳过phase1。
// RecoverySourceHandler.java
public void recoverToTarget(ActionListener<RecoveryResponse> listener) {final boolean isSequenceNumberBasedRecovery = request.startingSeqNo() != SequenceNumbers.UNASSIGNED_SEQ_NO &&isTargetSameHistory() && shard.hasCompleteHistoryOperations("peer-recovery", request.startingSeqNo());if (isSequenceNumberBasedRecovery) {logger.trace("performing sequence numbers based recovery. starting at [{}]", request.startingSeqNo());startingSeqNo = request.startingSeqNo();sendFileResult = SendFileResult.EMPTY;}
}
如果不可以从SequenceNumber恢复,则调用Lucene接口对分片做快照,执行phase1。
// RecoverySourceHandler.java
public void recoverToTarget(ActionListener<RecoveryResponse> listener) {final Engine.IndexCommitRef phase1Snapshot;phase1Snapshot = shard.acquireSafeIndexCommit();startingSeqNo = 0;final int estimateNumOps = shard.estimateNumberOfHistoryOperations("peer-recovery", startingSeqNo);sendFileResult = phase1(phase1Snapshot.getIndexCommit(), () -> estimateNumOps);
}
phase2
等待phase1执行完毕,主分片节点通知副分片节点启动此分片的Engine。该方法会阻塞处理,直到分片Engine启动完毕。待副分片启动Engine完毕,就可以正常接收写请求了。注意,此时phase2尚未开始,此分片的恢复流程尚未结束。
// RecoverySourceHandler.java
public void recoverToTarget(ActionListener<RecoveryResponse> listener) {prepareTargetForTranslog(isSequenceNumberBasedRecovery == false,shard.estimateNumberOfHistoryOperations("peer-recovery", startingSeqNo), prepareEngineStep);
}
等待当前操作处理完成后,以startingSeqNo为起始点,对translog做快照,开始执行phase2。
// RecoverySourceHandler.java
public void recoverToTarget(ActionListener<RecoveryResponse> listener) {final StepListener<SendSnapshotResult> sendSnapshotStep = new StepListener<>();prepareEngineStep.whenComplete(prepareEngineTime -> {runUnderPrimaryPermit(() -> shard.initiateTracking(request.targetAllocationId()),shardId + " initiating tracking of " + request.targetAllocationId(), shard, cancellableThreads, logger);final long endingSeqNo = shard.seqNoStats().getMaxSeqNo();final Translog.Snapshot phase2Snapshot = shard.getHistoryOperations("peer-recovery", startingSeqNo);resources.add(phase2Snapshot);final long maxSeenAutoIdTimestamp = shard.getMaxSeenAutoIdTimestamp();final long maxSeqNoOfUpdatesOrDeletes = shard.getMaxSeqNoOfUpdatesOrDeletes();final RetentionLeases retentionLeases = shard.getRetentionLeases();phase2(startingSeqNo, endingSeqNo, phase2Snapshot, maxSeenAutoIdTimestamp, maxSeqNoOfUpdatesOrDeletes,retentionLeases, sendSnapshotStep);sendSnapshotStep.whenComplete(r -> IOUtils.close(phase2Snapshot),e -> {IOUtils.closeWhileHandlingException(phase2Snapshot);onFailure.accept(new RecoveryEngineException(shard.shardId(), 2, "phase2 failed", e));});}, onFailure);
}
如果基于SequenceNumber恢复,则startingSeqNo取值为恢复请求中的序列号,从请求的序列号开始快照translog。否则取值为0,快照完整的translog。
最后调用RecoverySourceHandler#finalizeRecovery执行清理工作,该方法向副分片节点发送action为internal:index/shard/recovery/finalize的RPC请求告知对方执行清理,同时把全局检查点发送过去,等待对方执行成功,主分片更新全局检查点。
// RecoverySourceHandler.java
public void recoverToTarget(ActionListener<RecoveryResponse> listener) {final StepListener<Void> finalizeStep = new StepListener<>();sendSnapshotStep.whenComplete(r -> finalizeRecovery(r.targetLocalCheckpoint, finalizeStep), onFailure);
}
ES索引恢复流程解析相关推荐
- Linux0.11系统调用之execve流程解析
Linux0.11系统调用之execve流程解析 前言 execve功能介绍 execve本质 execve系统调用流程 总结 前言 本文是基于Linux0.11源码来叙述该功能,源码可以在oldli ...
- HBase - 数据写入流程解析
本文由 网易云 发布. 作者:范欣欣 本篇文章仅限内部分享,如需转载,请联系网易获取授权. 众所周知,HBase默认适用于写多读少的应用,正是依赖于它相当出色的写入性能:一个100台RS的集群可以轻 ...
- mysql索引 和 es索引_MySQL索引 VS ElasticSearch索引
今天MySQL数据库栏目介绍MySQL索引与ElasticSearch索引的对比. 前言 这段时间在维护产品的搜索功能,每次在管理台看到 elasticsearch 这么高效的查询效率我都很好奇他是如 ...
- Linux基础自学记录六-引导流程解析2
第5讲.Linux引导流程解析 9.GRUB配置文件 GRUB的配置文件默认在/boot/grub/grub.conf,/etc/grub.conf是它的软链接:在备份系统时,/boot目录首先应做 ...
- es动态分配分片_解决ES索引分片均衡问题
运行了两年的ES集群,今天索引节点突然不均衡,新建索引分片都集中到一个节点上了,如下图: 同时还有部分节点分片在恢复,如上图,有432个待恢复分片,而且只有两个分片在同时恢复,网络吞吐也很小,这要等到 ...
- Plugin工具类-Unreal4源码拆解-UnrealBuildTool功能流程解析
Unreal4源码拆解-UnrealBuildTool功能流程解析-Plugin 知乎专栏:UBT源码解析 4.2x功能不会差太多 主要功能 Plugin静态类,功能上大致是一个工具类,根据文件夹保存 ...
- 经纬M300赛尔102S航测全流程解析
前方高能干货!经纬M300&赛尔102S航测全流程解析在这里~ 一. 测试前准备 硬件准备: 1)M300 飞机 1 台: 遥控器 1 台: 电池 2 组: 测绘相机 1 台: 电池充电箱 1 ...
- 12306之余票查询流程解析
前言 本套教程共分3章: 12306之登录流程解析 12306之余票查询解析 12306之下单流程解析 本套内容主要用于分析12306购票流程,意在编写一套自动购票小程序.12306接口 api 经常 ...
- 深入浅出WMS之入库流程解析
深入浅出WMS之入库流程解析 前言 创建入库单 入库单管理 入库单管理-组盘 入库单管理-关单 入库任务管理 入库任务管理-货位分配 入库任务管理-任务撤销 入库任务管理-入库完成 结尾 前言 古人十 ...
最新文章
- 配置网口相机(大恒水星相机)
- linux 7.4 不能转发dns_linux深度攻略学习
- unity 解决乱码_Unity3d中IOS应用出现乱码怎么办?
- C# 如何生成一个时间戳
- 微信小程序使用adb工具
- hdfs统计某个目录下的文件数
- oracle awr 数据删除,Oracle AWR 删除历史快照 说明【转自dave偶像大神】
- html常用标签6-表单标签
- html显示当前时间_HTML基础教程:超链接的使用
- wordpress 文章php,wordpress怎么发长文章
- JS--微信浏览器复制到剪贴板实现
- vbyone接口引脚定义_USB3.1 Type-C 高速接口设计指南
- Python的自省(学习笔记)
- 隐式图层动画 (Implicit Layer Animation)
- 三次B样条插值和误差分析
- 一次性针头滤器-市场现状及未来发展趋势
- 很多人想自己创业当老板!亚马逊测评这个项目到底行不行?
- linux显示目录和文件颜色
- 【电商】电商后台---商品上架前的最后准备
- Abz-G-F(4NO2)-P-OH, 67482-93-3
热门文章
- ajax请求是宏任务还是微任务_微服务编排引擎Cadence简介
- BugkuCTF-Crypto题python_jail
- 计算机辅助设计ca,《AutoCA计算机辅助设计》课程标准.doc
- java计算机毕业设计漫画网站系统源码+系统+mysql数据库+lw文档
- 资料搜集-JAVA系统的梳理知识
- 社区折腾日志:基于python搭建个人微信/支付宝免签支付功能
- 郭依婷——大学生的创业故事
- 抖音直播卖茶效果如何;揭秘抖音养生茶暴利项目。丨国仁网络资讯
- 计算机毕业设计Python+django 宠物领养中心小程序(源码+系统+mysql数据库+Lw文档)
- STM32开发 --- 1.8寸显示屏ST7735_输出英文、汉字、图片