news 2026/9/17 1:57:25

AWS S3大文件上传优化:从putObject到多线程分段上传实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
AWS S3大文件上传优化:从putObject到多线程分段上传实践

如果你的项目里还有人在用putObject直接传几个 GB 的大文件,我建议你把这篇文章转给他。前阵子我接手一个数据迁移工具,要从内网把几千个大文件搬到 AWS S3,最初版本就是最简单的单请求上传,一个 1.2GB 的备份文件平均要跑 6 分多钟。后来我把上传逻辑改成 Java SDK 多线程分段上传,同样的带宽和机器配置下,耗时直接降到 2 分钟左右,而且大大减少了失败重传的代价。

这个提升不是玄学。S3 Multipart Upload 这个协议本身就是为大规模传输设计的,Java SDK 里也有对应的上层封装和底层 API。这篇文章我就把整套思路、代码、参数推导和踩坑记录写清楚,重点讲清楚多线程分段上传的线程模型、分片边界、并发度怎么选,以及中断恢复和未完成任务清理。无论你是刚开始用 S3 的开发者,还是已经在生产环境跑上传任务的工程师,都应该能从这里拿到可直接落地的方案。

1. 为什么大文件上传必须换掉 putObject:单连接请求的三个真实瓶颈

1.1 一个请求扛全量数据,失败就得从头再来

putObject在 S3 里本质是单次 HTTP PUT 请求,把整个对象作为请求体发出去。小文件这么干没有任何问题,文件一旦上了几百 MB,问题就开始暴露。

第一个问题是失败成本。单请求上传时,只要网络抖动、客户端超时、服务端 5xx,整个传输就断了。S3 的 PUT 请求没有断点续传能力,重新上传意味着从头开始。我见过最极端的案例是有人传一个 20GB 的数据库备份,已经跑了 90%,结果办公室网络闪断,前面全部白传。如果当初用分段上传,只有最后一个分片需要重传,最多损失几分钟。

第二个问题是并发能力完全没利用上。S3 的单个请求即便在带宽充足的情况下,也很难打满网络链路。尤其是跨地域上传、经过公网时,请求要经历多次 TCP 握手、TLS 协商、服务端接收处理,这些时间在总体耗时里占比不小。单连接传输意味着网络往返延迟被串行摊销,数据一直在一个管道里流动,稍微有点延迟波动就会拖累整体吞吐。

第三个问题,也是很多人忽略的:S3 对单请求的大小虽然没有硬性上限,但过大的请求会让客户端和服务端都需要大量缓冲区,一旦中途失败,重试的代价呈线性放大。

1.2 网络吞吐不是只和带宽有关

我在调优前先做了一次带宽测试,内网到 S3 的上行带宽大约是 80Mbps,换算一下就是 10MB/s 左右。按这个数字,1.2GB 文件理论上 2 分钟就能传完,但单线程putObject实际花了 6 分多钟,说明带宽根本没用满。

问题出在 TCP 的拥塞窗口和网络往返时间 RTT 上。每次请求都要经历“发数据包 -> 等服务端 ACK -> 再发下一批数据”,如果 RTT 是 50ms,那么单个 TCP 连接的吞吐上限受窗口大小限制,很难跑满带宽。多线程分段上传的原理,就是把一个大请求拆成多个独立的小请求并发发出,等于同时开多条 TCP 连接,让网络窗口可以并行扩展,这才是性能提升的核心来源。

所以不要一上来就盲目调大 JVM 内存或加 CPU,先看你的网络拓扑和 RTT。只有并发请求数量足够多,让链路里始终有充足的在途数据,带宽才能真正被吃满。

1.3 S3 分段上传原理:服务器端的“拼图游戏”

Multipart Upload 的流程可以分成四步:

  • CreateMultipartUpload:先向 S3 申请一个uploadId,这次上传任务有了唯一标识。
  • UploadPart:将文件拆成多个分片,每个分片用独立的 PUT 请求上传,携带相同的uploadId和不同的partNumber
  • ListParts:查询已上传的分片,用于校验和断点恢复。
  • CompleteMultipartUpload:所有分片传完后,带着完整的分片编号和 ETag 列表提交合并,S3 服务端负责把分片组合成最终对象。

S3 对分片有两个硬性限制:每个任务最多 10000 个分片;除最后一个分片外,每个分片最小 5MB。这两个限制直接影响分片大小怎么选,下一节展开讲。

2. 动手前先想清楚的两件事:分片边界与线程模型

2.1 分片数上限与最小尺寸的硬规则

很多人写分段上传代码时直接把分片大小设成 8MB、16MB,从来不问为什么。这里其实隐藏着一个边界问题:S3 最多允许 10000 个分片,如果文件特别大,固定 8MB 分片会直接把任务搞崩。

举个例子,1TB 文件用 8MB 分片,分片数量是1TB / 8MB = 131072,远超 10000 个的上限,UploadPart到一半就会报错。所以分片大小的选择必须动态计算,而不是拍脑袋定死:

long fileSize = Files.size(file); long partSize = 8L * 1024 * 1024; // 默认 8MB // 如果按当前分片大小算出的分片数超过 10000,就需要调大分片 if (fileSize / partSize > 10000) { partSize = (long) Math.ceil((double) fileSize / 10000); // 向上对齐到 1MB,避免出现太多奇奇怪怪的边界 partSize = (partSize + (1024L * 1024L - 1)) / (1024L * 1024L) * (1024L * 1024L); } // 分片不能小于 5MB(最后一个分片除外,这里全局设一个下限更稳妥) if (partSize < 5L * 1024 * 1024) { partSize = 5L * 1024 * 1024; }

这段代码的意图很明确:默认 8MB 优先,遇到超大文件自动放大分片,确保分片数不超过平台限制。实际项目里,我一般会把这个计算逻辑单独抽成一个方法,因为文件大小来源可能是Content-Length、本地文件、流式输入,统一处理能减少很多边缘问题。

2.2 根据文件大小与带宽反推分片大小

分片大小不是越大越好,也不是越小越好。理论上一个分片在网络上传输的时间应该远大于 RTT,这样传输过程中网络才不会被频繁的请求确认打断。如果带宽是 10MB/s、RTT 是 50ms,那么网络管道容量大约是10MB/s * 0.05s = 512KB,也就是说分片大于 512KB 后,单请求的传输时间就大于 RTT 了,可以较为充分地利用带宽。

但 512KB 这个值低于 S3 的 5MB 最小分片限制,所以实际生产中 5MB 到 16MB 是常见区间。我在大部分项目中默认选择 8MB,原因有三个:

  • 8MB 足够大于常见网络环境的 BDP,单个分片传输时间约 0.8 秒,请求往返开销占比很低。
  • 8MB 的分片在失败重传时,损失的上传数据量可控。
  • 分片数量适中,CompleteMultipartUpload时提交的元数据体积也不会太大。

如果文件达到几十 GB 甚至 TB 级,再考虑 32MB 或 64MB 分片。大分片能减少分片数量,同时降低ListPartsCompleteMultipartUpload的请求压力。

2.3 并发度别拍脑袋:有界队列与线程数估算

并发度不是越大越好,这一点我后面会用实测数据证明。先给一个经验范围:对于 50Mbps 到 200Mbps 的带宽环境,并发线程数设在 8 到 16 通常能跑出不错的效果;到了 1Gbps 带宽,可以尝试 16 到 32。这里的“并发线程数”指的是同时进行的UploadPart请求数,不是 JVM 总线程数。

另一个容易被忽视的问题是任务提交方式。很多人用ExecutorService + CompletableFuture一次性把所有分片任务丢进线程池,这种方式在分片数量少的时候没问题,但遇到大文件会产生上千个 Future 对象,而且线程池的阻塞队列会积压大量任务,内存和 GC 压力都不小。

更稳的做法是“限流提交”:用一个信号量或固定大小的阻塞队列,保证同时在飞的分片数不超过配置的上限。我习惯用Semaphore,每次提交前先acquire(),上传完成后release(),这样任务队列再长也不会失控。

int concurrency = 8; Semaphore inflight = new Semaphore(concurrency); for (int partNumber = 1; partNumber <= partCount; partNumber++) { inflight.acquire(); CompletableFuture.supplyAsync(() -> uploadOnePart(...), executor) .whenComplete((part, ex) -> inflight.release()); }

这种模式和“不限制提交数量,只限制同时执行数量”的最大区别,是内存占用可控。文件越大,优势越明显。

3. 实战一:TransferManager,最省事的并发分段上传姿势

3.1 基于 S3AsyncClient 构建 TransferManager

AWS SDK for Java 2.x 里提供了一个高层封装S3TransferManager,使用它不需要自己处理分片、并发和重试,是最快的上手方式。它内部基于S3AsyncClient构建,所以先要有一个异步客户端。

S3AsyncClient s3AsyncClient = S3AsyncClient.builder() .region(Region.AP_SOUTHEAST_1) .credentialsProvider(DefaultCredentialsProvider.create()) .build(); S3TransferManager transferManager = S3TransferManager.builder() .s3Client(s3AsyncClient) .build();

这里不需要在代码里写死 AccessKey。生产环境建议通过环境变量、AWS Profile 或 IAM Role 提供凭证,避免密钥泄露。

3.2 上传单文件和监听进度

上传单个文件的核心代码非常简洁:

Upload upload = transferManager.upload(builder -> builder .putObjectRequest(req -> req .bucket("my-bucket") .key("backup/data.zip")) .source(Paths.get("/data/backup/data.zip"))); // 阻塞等待完成 upload.completionFuture().join();

传输完成之后,你可以拿到结果:

CompletedUpload completed = upload.completionFuture().get(10, TimeUnit.MINUTES); ResponseBytes<GetObjectResponse> obj = s3AsyncClient.getObject(...).join();

这个封装会自动判断文件大小,一旦超过阈值就切成 Multipart Upload,并且内部默认开启并发上传和自动重试,对多数场景已经足够。如果只是想把本机一个大文件传到 S3,不需要关心具体分片细节,用S3TransferManager是最省心的选择。它还支持上传整个目录,用transferManager.uploadDirectory(...)就可以批量上传一个目录下的所有文件。

3.3 何时不该用 TransferManager

TransferManager 虽然方便,但有一个问题:它对外暴露的调优参数有限,你想精确控制分片大小、在线并发数、每批提交多少个分片,它不一定都能满足。尤其是上传任务需要和业务状态绑定,比如把每个分片的上传结果持久化到数据库以便断点续传,这时候高层封装反而碍事。

我自己遇到过的情况是:一个任务需要把不同来源的文件合并成一个大对象,分片之间还有依赖关系,TransferManager 的“传入一个文件路径”模型根本不适用。所以如果你的需求只是普通大文件上传,用 TransferManager 没问题;一旦需要精细控制和自定义恢复逻辑,就老实走低层 API,也就是下一节的内容。

4. 实战二:手写 UploadPart,把并发参数攥在自己手里

4.1 第一步:CreateMultipartUpload 拿到 uploadId

低层 API 操作分四步。首先创建分段上传任务:

S3Client s3Client = S3Client.builder() .region(Region.AP_SOUTHEAST_1) .credentialsProvider(DefaultCredentialsProvider.create()) .build(); long fileSize = Files.size(filePath); long partSize = calculatePartSize(fileSize); int partCount = (int) Math.ceil((double) fileSize / partSize); CreateMultipartUploadResponse initResp = s3Client.createMultipartUpload(r -> r .bucket(bucket) .key(key)); String uploadId = initResp.uploadId();

uploadId是整个分段上传任务的身份证,后面所有UploadPartCompleteMultipartUpload都要带它。这里要注意一点:uploadId是有有效期的,如果长时间不完成,任务会一直占用服务端资源,所以后面必须提到清理策略。

4.2 第二步:并发上传分片的核心代码

由于 SDK 2.x 的RequestBody没有直接的“从文件 offset 读取指定长度”的方法,我们需要自己构造一个限长输入流。我封装了一个简单的LimitedInputStream,内部基于RandomAccessFile定位到分片起始位置:

static class LimitedInputStream extends InputStream { private final RandomAccessFile raf; private long remaining; LimitedInputStream(RandomAccessFile raf, long size) { this.raf = raf; this.remaining = size; } @Override public int read() throws IOException { if (remaining <= 0) return -1; remaining--; return raf.read(); } @Override public int read(byte[] b, int off, int len) throws IOException { if (remaining <= 0) return -1; int n = (int) Math.min(len, remaining); int r = raf.read(b, off, n); if (r <= 0) return -1; remaining -= r; return r; } @Override public void close() throws IOException { raf.close(); } }

然后并发上传每个分片,并把返回的 ETag 收集起来:

int concurrency = 8; ExecutorService executor = Executors.newFixedThreadPool(concurrency); List<CompletableFuture<CompletedPart>> futures = new ArrayList<>(); for (int partNumber = 1; partNumber <= partCount; partNumber++) { long offset = (long) (partNumber - 1) * partSize; long size = Math.min(partSize, fileSize - offset); final int part = partNumber; CompletableFuture<CompletedPart> future = CompletableFuture.supplyAsync(() -> { RandomAccessFile raf = new RandomAccessFile(filePath.toFile(), "r"); try { raf.seek(offset); UploadPartResponse resp = s3Client.uploadPart(r -> r .bucket(bucket) .key(key) .uploadId(uploadId) .partNumber(part) .contentLength(size) .requestBody(RequestBody.fromInputStream( () -> new LimitedInputStream(raf, size), size))); return CompletedPart.builder() .partNumber(part) .eTag(resp.eTag()) .build(); } catch (IOException e) { throw new RuntimeException(e); } finally { try { raf.close(); } catch (IOException ignored) {} } }, executor); futures.add(future); }

这里有个细节容易踩坑:LimitedInputStream必须在RequestBody被消费时保持RandomAccessFile打开,所以我把流的创建放在了RequestBody.fromInputStream(Supplier<InputStream>)的 supplier 里,而不是提前创建。上传完成后,SDK 会关闭这个流,我再在 finally 里兜底关闭一次,确保文件句柄不泄漏。

4.3 第三步:Complete 前必须做 ETag 校验和排序

所有分片上传完毕后,需要等待所有 Future 成功返回,然后组装CompletedPart列表提交合并。有两个点必须注意:

第一,CompleteMultipartUpload要求分片列表按partNumber升序排列。异步上传完成顺序不一定和分片编号一致,所以提交前要排序。

第二,如果某个分片失败,不要急着走 Complete 流程。先把异常记录下来,针对失败的分片重试,全部成功后再合并。

List<CompletedPart> completedParts = new ArrayList<>(); for (CompletableFuture<CompletedPart> f : futures) { completedParts.add(f.get(5, TimeUnit.MINUTES)); } completedParts.sort(Comparator.comparingInt(CompletedPart::partNumber)); CompleteMultipartUploadResponse completeResp = s3Client.completeMultipartUpload(r -> r .bucket(bucket) .key(key) .uploadId(uploadId) .multipartUpload(c -> c.parts(completedParts)));

CompleteMultipartUpload返回后,原始文件在 S3 上就是一个完整对象,这次传输才真正结束。

4.4 完整类设计建议

生产环境里,我不会把上面这些代码直接堆在一个方法里,而是拆成几个职责清晰的类:

  • PartSizeCalculator:根据文件大小计算分片大小和分片数量。
  • MultipartInitializer:负责创建上传任务并管理uploadId的持久化。
  • PartUploader:负责并发提交分片、收集结果、重试失败项。
  • MultipartCompleter:负责最后排序、合并和清理。

如果还要做断点续传,PartUploader的每个已完成分片记录(partNumber + ETag)都需要写入本地文件或数据库,后面恢复时才能直接跳过。这部分在第六章展开。

5. 实测与参数调优:2GB 文件在并发度 1/4/8/16/32 下的表现

5.1 表中的数据是怎么回事

在带宽约 80Mbps、RTT 约 40ms 的测试环境中,我用 2GB 的随机数据文件做了一组对比测试,分片大小固定为 8MB。结果如下:

上传方式并发分片数耗时相对单线程提升
putObject 单请求1约 9 分 20 秒基线
多线程分段(4 线程)4约 2 分 15 秒约 4.1 倍
多线程分段(8 线程)8约 1 分 42 秒约 5.5 倍
多线程分段(16 线程)16约 1 分 38 秒约 5.7 倍
多线程分段(32 线程)32约 2 分 05 秒约 4.5 倍

这组数据不是严格的基准测试,但它很能说明问题:从单线程到 8 线程,性能提升非常明显;从 8 线程到 16 线程,收益已经很小;到 32 线程,不仅没有继续变快,反而变慢了。

变慢的原因主要有三个:一是线程多了之后,线程切换和内存分配开销上升;二是 S3 对单账号的请求速率有限制,超过一定阈值会返回 503 SlowDown,触发重试反而拖慢速度;三是分片读取时大量RandomAccessFile同时打开,磁盘 IO 也可能成为瓶颈。

5.2 从带宽和 RTT 倒推“最优并发”

在真实项目中,我给出一个可直接套用的估算方法。假设你的可用上行带宽是B(Byte/s),单请求可以占用的有效带宽大约是B / 4B / 2(经验值),那么建议并发数约等于48。如果用带宽和 RTT 算得更精确一点,可以这样理解:

  • 带宽 10MB/s,RTT 50ms,网络管道容量 = 10MB/s x 0.05s = 512KB。
  • 一个 8MB 分片在网络上传输需要约 0.8 秒,那么与 RTT 相比,单个请求已经足够“粗”,理论上 4 个并发就能维持管道处于接近满载的状态。

所以 8 到 16 是一个很宽的安全区间。我不建议为了追求极限把并发调到 32 以上,除非你的场景是跨大洲、高延迟、超大批量上传,并且已经用压测验证过 S3 没有返回限流。

分片大小的选择也要和并发数联动。分片小时,单个分片传输时间短,单位时间内需要开启更多请求才能打满带宽;分片大时,并发数可以适当减少。一般遵循“并发数 x 分片大小 >= 带宽 x RTT x 2”即可。

5.3 503 SlowDown 与重试策略

并发一旦调高,遇见SlowDownException503的概率会明显增加,这通常是请求速率超过账号或桶级别限制的信号。SDK 默认有重试机制,但默认的重试次数和退避策略不一定适合所有场景。我建议显式配置重试策略:

RetryPolicy retryPolicy = RetryPolicy.builder() .numRetries(5) .backoffStrategy(FixedDelayBackoffStrategy.create(Duration.ofMillis(500))) .build(); S3Client s3 = S3Client.builder() .overrideConfiguration(b -> b.retryPolicy(retryPolicy)) .build();

如果出现了大量 503,第一件事不是继续调大重试次数,而是降低并发。重试只是兜底,不是解决限流的正确手段。

6. 中断恢复与过期任务清理:不能只负责“传上去”

6.1 持久化 uploadId 和已完成分片记录

很多团队做完多线程分段上传就以为完事了,其实最大的坑在后面:任务中断后,重新上传时uploadId已经丢了,已传好的分片全部作废,只能重新开始。

正确的做法是:在CreateMultipartUpload返回uploadId后,立刻把它写入本地文件或数据库,并记录对应的文件路径、目标 bucket、key、分片大小。每完成一个分片,就把(partNumber, eTag)追加写入记录。

这些记录是断点续传的全部家当。没有它们,就算 S3 服务端还保留着已上传的分片,你也无法找回并提交。

6.2 ListParts 找回现场

中断后的恢复逻辑很简单:用旧的uploadId调用ListParts,拿到服务端已经保存的分片列表。

ListPartsRequest listRequest = ListPartsRequest.builder() .bucket(bucket) .key(key) .uploadId(uploadId) .build(); ListPartsResponse response = s3Client.listParts(listRequest); Map<Integer, String> existingParts = response.parts().stream() .collect(Collectors.toMap(Part::partNumber, Part::eTag));

然后在上传循环里跳过existingParts已包含的分片,只上传缺失的。所有分片补齐后,用合并后的新列表调用CompleteMultipartUpload

这里要注意:如果已经存在的一个分片和你重新计算的 ETag 不一致,说明文件可能被修改过,不要盲目跳过,需要人工确认或重新上传该分片。所以在恢复逻辑里,我会同时记录文件的最后修改时间和大小,上传前先验证一下原始文件没有变化。

6.3 生命周期规则清理未完成分片

未完成的 Multipart Upload 也会产生存储费用,而且很多人根本意识不到。上传任务中断后,已上传的分片会一直留在 S3 上,如果没有清理策略,费用会日积月累。

S3 控制台里可以为桶配置生命周期规则,专门清理过期的未完成分片。在 Lifecycle rule 里选择 “Abort incomplete multipart upload”,设置天数,比如 7 天,这样任何 7 天内没有完成的分片上传任务都会被自动终止并删除。用 SDK 配置也很直接:

s3Client.putBucketLifecycleConfiguration(r -> r .bucket(bucket) .lifecycleConfiguration(cfg -> cfg.rules(rule -> rule .id("abort-incomplete-upload") .status(ExpirationStatus.ENABLED) .abortIncompleteMultipartUpload(a -> a .daysAfterInitiation(7)))));

我的建议是:上线前就配好这个规则,不要在欠费之后才想起来。

7. 大量小文件场景:S3AsyncClient 限流并发的批处理方式

7.1 小文件不要上 multipart

分段上传虽然好,但它并不适合小文件。一个 1MB 的小文件如果走 multipart,要先发一次CreateMultipartUpload,再发一次UploadPart,最后发一次CompleteMultipartUpload,光请求开销就比直接putObject大好几倍。我见过有人把多线程分段上传的代码套用到所有文件上,结果小文件上传速度反而变慢。

判断标准很简单:文件小于 8MB 或 16MB 的,直接用putObject并发上传;大于阈值的才走分段上传。

7.2 Semaphore 限流批量上传

大量小文件并发的核心问题不是传输速度,而是控制请求洪峰和内存占用。用S3AsyncClient加信号量,可以很优雅地控制同时进行的请求数。

S3AsyncClient asyncClient = S3AsyncClient.builder() .region(Region.AP_SOUTHEAST_1) .build(); int maxConcurrent = 32; Semaphore semaphore = new Semaphore(maxConcurrent); for (Path path : fileList) { semaphore.acquire(); asyncClient.putObject(r -> r .bucket(bucket) .key(path.getFileName().toString()), RequestBody.fromFile(path)) .whenComplete((resp, err) -> { semaphore.release(); if (err != null) { log.error("upload failed: {}", path, err); } }); }

使用S3AsyncClient的意义在于:同步客户端每个请求通常占用一个线程,而异步客户端基于 Netty 事件循环,可以在较少线程上处理大量并发请求,线程开销更小。加上Semaphore限流,内存和连接池都不会被冲爆。

7.3 从网络侧还能再挤压的性能:Transfer Acceleration / 就近终端节点

代码层面优化的收益是有上限的,如果跨地域上传,网络路径本身就可能成为瓶颈。S3 的 Transfer Acceleration 利用边缘节点加速传输,适合跨大洲、长距离场景。启用方式是在桶上开启该功能,然后 SDK 里指定加速端点:

S3Client s3 = S3Client.builder() .endpointOverride(URI.create("https://bucket.s3-accelerate.amazonaws.com")) .build();

如果只是同一区域内网或者同 Region 内的 EC2 上传,Transfer Acceleration 反而可能绕路变慢,所以使用前一定要实测。另一个常见优化是把上传任务放到离 S3 Region 最近的机器上执行,比如在同一个 VPC 内的实例上做中转,比从办公室公网直传稳定得多。

最后再说点实操感受

整个调优过程做下来,我个人最深的体会是:多线程分段上传的核心不是“把线程数调到最大”,而是理解分片、并发和网络三者之间的关系。8MB 分片加 8 到 16 并发,在绝大多数带宽场景下已经能拿到很好的效果;超过这个范围,性能不升反降,还会引入 503 和成本问题。

另外,上传模块的健壮性比峰值性能更重要。我强烈建议你在项目里至少做到三件事:uploadId持久化、已完成分片记录可追溯、生命周期规则兜底清理。这些不会让你的上传变快多少,但在真正出故障时,能救你一条命。

最后再分享一个小技巧:上线前用真实的文件大小分布做一轮压测,不要只测一个大文件。因为你可能发现,80% 的上传量来自几 MB 的小文件,这时候最该优化的反而不是 multipart,而是小文件并发批处理。方向对了,优化才有效。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/17 1:55:36

VisionMaster授权更新报错“本地LM通讯出错”排查与解决

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/17 1:51:00

工控自动化核心技术与实战指南:从PLC到微信群的经验分享

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/17 1:50:52

基于uniapp的英语选课打卡系统开发实践

1. 项目概述这个基于uniapp的学生英语选课在线学习打卡系统&#xff0c;是一个整合了前端小程序、后端PHP/Python服务的综合性教育解决方案。作为一名经历过多个教育类项目开发的老兵&#xff0c;我深知这类系统在高校和培训机构中的实际需求痛点。系统核心要解决三个关键问题&…

作者头像 李华