RK3588边缘AI视觉零拷贝跨进程通信实践

发布时间:2026/9/4 10:42:09
RK3588边缘AI视觉零拷贝跨进程通信实践 几年前我第一次在RK3588上部署边缘AI视觉应用的时候遇到一个特别膈应的瓶颈视频流采集、预处理、模型推理、结果回传每个环节单独看都很快但串起来之后帧率就是上不去。查了很久才发现大量的时间不是花在计算上而是耗在数据在不同模块之间的搬运上。那时候就萌生了一个念头——要是数据在内存里能少搬几次或者干脆不搬整个流程的吞吐是不是就能有质的提升后来我把这条优化路径整个走了一遍底层用的核心手段就是零拷贝跨进程通信。这篇就围绕RK3588平台上的边缘AI视觉场景把零拷贝的原理、dma-buf的具体用法、以及如何把它串进一条完整的视频采集到AI推理链路里做一个比较系统的梳理。这篇文章更适合那些已经在RK3588上跑通基础AI推理想进一步压榨性能、提升系统并发能力的开发者。1. 性能瓶颈到底卡在哪一步动了内存就省下一大块时间先聊一个很实际的问题边缘AI视觉应用里数据是怎么流动的典型的流程长这样摄像头通过MIPI-CSI或者USB把RAW数据送进来ISP图像信号处理器处理后输出YUV或者RGB图然后这些图像数据被送到NPU做模型推理推理结果要么直接显示要么推送给上层应用去渲染或者分析。RK3588集成了6TOPS算力的NPU单纯论推理速度跑YOLOv8s这种模型batch1的情况下8ms到12ms就能完成一帧预处理加NPU推理。听起来很快对吧但实际系统的端到端延迟经常是60ms、80ms甚至更高。问题就出在内存拷贝上。以一张1080P的RGB图像为例一帧的数据量大概是1920×1080×3字节约6.2MB。如果按30fps来算一秒钟就是186MB的数据流量。如果这套数据要在视频采集模块、AI推理进程、显示进程之间复制两次额外消耗的内存带宽就是每秒接近400MB。RK3588的内存带宽虽然不低但架不住多个业务进程同时高频访问加上cache未命中、内存碎片等问题实际可用带宽会大打折扣。更要命的是数据量一大内存分配和释放就会频繁触发底层还要做页表映射、TLB刷新这些隐形成本几乎无法在性能剖析里看不到但却是实打实的延迟来源。我最早做的那个原型视频采集和AI推理是两个独立的进程通过本机TCP回环传输图像数据。表面上看代码写得很干净模块解耦也彻底但性能一测就傻眼1080P分辨率下TCP传输一帧图像花了将近12ms加上序列化和反序列化延迟和CPU占用双双暴涨。后来把TCP换成了共享内存传输消耗降是降下来了但架不住新的麻烦——共享内存的同步机制不好设计两个进程同时访问同一块内存要么加锁要么靠信号量手动管理生命周期动不动就踩内存越界。所以说边缘AI视觉这个场景里跨进程通信不是简单的数据搬运问题它直接决定了整个系统的实时性上限。零拷贝这个技术就是在少搬数据这件事上做文章核心思想是数据在内核态和用户态之间传递时避免不必要的数据复制数据在不同进程之间共享时让大家都操作同一块物理内存而不是各自copy一份。2. 不选共享内存和socket为什么非dma-buf不可有人可能会问跨进程通信方案那么多消息队列、共享内存、Unix Domain Socket哪一个不行为什么我在RK3588上偏偏选了dma-buf这其实是一个取舍问题。先快速对比一下常见的几种方案在边缘AI场景下的适用性。通信方式数据拷贝次数实时性适合场景主要痛点Unix Domain Socket2次以上中小数据量、控制指令大图传输延迟高CPU占用高System V / POSIX共享内存1次mmap后零拷贝高任意数据量同步机制难搞生命周期管理复杂无法直接和DMA硬件链打通dma-buf真正零拷贝高视频帧、GPU/NPU缓冲交换接口偏底层调试门槛高memfd seal1次中高适合纯软件数据共享无法和硬件DMA设备深度协作socket的问题是显而易见的数据从用户态到内核态再从内核态到用户态每一趟都要copy。共享内存虽然能解决进程间零拷贝的问题但它本质上是一个软件层面的共享内核的DMA引擎、NPU、ISP这些硬件设备并不能直接访问这块内存除非你额外做一层物理地址的映射和同步而这一层恰恰是最容易出问题的。dma-buf不一样。它本身就是Linux内核为DMA共享而设计的一套机制最初是用来在GPU、视频编解码器、ISP这些设备之间共享缓冲区的后来被扩展成了一套通用的buffer sharing框架。dma-buf背后的核心对象是struct dma_buf它通过一个文件描述符fd来引用一块物理内存。这个fd可以跨进程传递拿到fd的进程再通过mmap映射到自己的地址空间或者直接把它作为参数传给其他内核驱动比如NPU驱动、RGA驱动让硬件设备直接操作这块内存。换句话说dma-buf是唯一一个既能用户态零拷贝共享又能直接和硬件加速模块打通的方案。这在RK3588这种异构计算平台上优势尤其明显——NPU、RGARockchip Raster Graphic Acceleration图形加速单元、VPU视频编解码单元、ISP本质上都是DMA设备它们天然就支持dma-buf。既然硬件都支持我们没理由不用。顺带说一句RK3588上跑GStreamer或者PipeWire做视频流处理的时候底层也是在用dma-buf只是这些框架把底层细节封装掉了业务开发者不太感知得到。自己动手做零拷贝本质上就是把框架封装的那一层揭开来拿到底层的能力直接控制。3. dma-buf零拷贝链路的完整落地流程从分配内存到跨进程共享理论说了一堆现在进入真正能落地的环节。在RK3588平台上用dma-buf做零拷贝跨进程通信大概分四步分配dma-buf、传递fd、映射/导入、同步与回收。3.1 分配dma-buf用DMA-BUF Heaps还是ION早期Rockchip平台使用ION作为统一的内存分配器在旧内核版本上比较常见。/dev/ion节点打开后通过ioctl的ION_IOC_ALLOC命令分配内存拿到的fd本身就是dma-buf。但从内核5.x开始ION被逐步淘汰标准方案换成了DMA-BUF Heaps也就是/dev/dma_heap/下的一系列节点。RK3588默认的Debian/Ubuntu系统上可以看看有没有/dev/dma_heap/system-uncached这个节点。我建议新的项目不要走ION直接上DMA-BUF Heaps。原因有两个一是主流发行版的内核已经默认关闭ION即使强制打开也有一堆适配问题二是Rockchip提供的mppMedia Process Platform、rknpu2这些用户态库都已经支持从DMA-BUF Heaps拿buffer。分配内存的伪代码如下#include linux/dma-heap.h #include fcntl.h #include sys/ioctl.h #include sys/mman.h int alloc_dmabuf(size_t size) { int heap_fd open(/dev/dma_heap/system-uncached, O_RDWR); if (heap_fd 0) { perror(open dma_heap failed); return -1; } struct dma_heap_allocation_data data {0}; data.len size; data.fd_flags O_CLOEXEC | O_RDWR; data.heap_flags 0; // 申请为uncached内存 if (ioctl(heap_fd, DMA_HEAP_IOCTL_ALLOC, data) 0) { perror(dma_heap alloc failed); close(heap_fd); return -1; } close(heap_fd); return data.fd; // 这个fd就是dma-buf }注意分配的时候有一个uncached和cached的选项。用system-uncached节点的好处是CPU访问这块内存时绕过cache数据一致性由CPU和硬件设备通过显式屏障来保证。但实战中我发现如果是纯NPU或者RGA消费数据用uncached内存没毛病如果中途需要CPU做一点轻量级预处理比如抠图、缩放、归一化cached内存配合主动cache sync反而更快。这块后面在踩坑章节会重点展开。3.2 传递fd用SCM_RIGHTS实现在进程间空转buffer的所有权dma-buf分配完成后得到的是一块和某个进程关联的内存区域。要让另一个进程也能访问这块区域需要把这个fd本身传过去。Linux下跨进程传fd的标准姿势是Unix Domain Socket配合SCM_RIGHTS辅助消息。很多新手第一次接触SCM_RIGHTS会觉得很绕明明我传的是一张图的地址怎么代码里全是sendmsg和recvmsg。换个角度理解就通了fd在Linux里本质是一个整数它指向内核中的一个文件对象。SCM_RIGHTS不是传那个整数而是让内核帮你把那个文件对象在目标进程的文件表里也登记一份目标进程由此拿到一个新的fd指向同一个文件对象。对dma-buf来说就是这个buffer的内核对象引用计数加一两块物理内存还是同一块。传递fd的发送端代码void send_fd(int sock_fd, int fd_to_send) { struct msghdr msg {0}; char buf[CMSG_SPACE(sizeof(int))] {0}; msg.msg_control buf; msg.msg_controllen sizeof(buf); struct cmsghdr *cmsg CMSG_FIRSTHDR(msg); cmsg-cmsg_level SOL_SOCKET; cmsg-cmsg_type SCM_RIGHTS; cmsg-cmsg_len CMSG_LEN(sizeof(int)); memcpy(CMSG_DATA(cmsg), fd_to_send, sizeof(int)); // 可以顺带传一个空的普通消息头表示帧元信息比如宽、高、时间戳 struct iovec iov; struct frame_meta meta {0}; meta.width 1920; meta.height 1080; meta.format 0; iov.iov_base meta; iov.iov_len sizeof(meta); msg.msg_iov iov; msg.msg_iovlen 1; sendmsg(sock_fd, msg, 0); }接收端的代码int recv_fd(int sock_fd, struct frame_meta *meta) { struct msghdr msg {0}; char buf[CMSG_SPACE(sizeof(int))] {0}; struct iovec iov; iov.iov_base meta; iov.iov_len sizeof(struct frame_meta); msg.msg_iov iov; msg.msg_iovlen 1; msg.msg_control buf; msg.msg_controllen sizeof(buf); recvmsg(sock_fd, msg, 0); struct cmsghdr *cmsg CMSG_FIRSTHDR(msg); if (cmsg cmsg-cmsg_level SOL_SOCKET cmsg-cmsg_type SCM_RIGHTS) { int fd; memcpy(fd, CMSG_DATA(cmsg), sizeof(int)); return fd; } return -1; }这里有个实用细节传递fd的时候最好顺带把帧的宽、高、像素格式这些元数据一起发过去。因为dma-buf本身只是一块裸内存它不携带任何图像信息。你要是只传一个fd过去接收端其实不知道这块内存该按什么格式解释。3.3 接收端映射mmap之后才能让CPU直接读但这不是必须的接收端拿到fd之后有两种使用方式。第一种是让CPU去读这块内存的内容做法是mmap把dma-buf映射到进程的虚拟地址空间。这样CPU就能直接逐字节地操作图像数据。第二种是直接不经过CPU把fd直接喂给硬件设备。比如RGA驱动提供一个名为rga_buffer_import的接口它接受一个dma-buf的fd通过内核驱动把这个buffer的信息登记到RGA的硬件DMA描述符里然后RGA直接拿这块内存作为输入和输出的源地址/目标地址。NPU侧rknn的rknn_init、rknn_run接口也支持传入专门的外部buffer封装同样吃dma-buf fd。这种情况下你甚至不需要在当前进程中mmap这块内存。如果你想用CPU做一些预处理mmap的操作大概是void *map_dmabuf(int dmabuf_fd, size_t size) { void *addr mmap(NULL, size, PROT_READ | PROT_WRITE, MAP_SHARED, dmabuf_fd, 0); if (addr MAP_FAILED) { perror(mmap dma-buf failed); return NULL; } return addr; }注意这里必须用MAP_SHARED不能用MAP_PRIVATE否则底层内存就不共享了映射出去的只是一个写时拷贝的影子完全违背零拷贝的初衷。3.4 CPU和硬件的缓存同步容易忽略但必须做的关键一步dma-buf的零拷贝不等于不需要任何同步。当CPU和DMA设备访问同一块内存时典型的问题是这样的CPU写入数据时数据可能还留在cache里没有真正flush到物理内存硬件的DMA引擎直接去读物理内存读到的可能是老数据。反过来硬件DMA写入新数据后如果CPU侧的cache还残留着旧数据的副本CPU回读时可能命中cache看到一模一样的旧数据。Linux内核为dma-buf提供了专门的同步接口用户态可以通过ioctl调用DMA_BUF_IOCTL_SYNC告诉内核我接下来要CPU读这块buffer或者CPU写完了硬件可以读这块buffer了。这个操作的本质是执行cache的invalid和flush。void sync_dmabuf_cpu_read(int dmabuf_fd) { struct dma_buf_sync sync {0}; sync.flags DMA_BUF_SYNC_READ | DMA_BUF_SYNC_START; ioctl(dmabuf_fd, DMA_BUF_IOCTL_SYNC, sync); // 这里安全地读取buffer内容…… sync.flags DMA_BUF_SYNC_READ | DMA_BUF_SYNC_END; ioctl(dmabuf_fd, DMA_BUF_IOCTL_SYNC, sync); }如果忘了做cache sync现象往往是图像偶尔花屏或者上一次的帧和这一次的帧数据错位而且这种bug极难复现因为跟cache状态强相关换个运行负载表现就完全不一样。我在最开始自己撸这套代码的时候就因为在RGA和NPU之间共享了带cache属性的dma-buf而没做同步结果模型推理的准确率时好时坏折腾了整整一天才定位到这个环节。4. 从摄像头到NPU一路零拷贝的完整链路设计与实测对比搞定dma-buf共享的底层机制之后接下来就是把它串进一整条边缘AI视觉链路。这一节我分享一套自己在RK3588上搭的参考架构以及实测得到的数据。4.1 链路设计采集、调度、推理、回显四进程协同这一套架构我用了四个独立进程中间全部用dma-buf传帧不copy图像数据采集进程camera_producer通过RKMPP或者V4L2 零拷贝模式拿到ISP输出的帧把dma-buf fd发送到共享socket本身不承接任何AI逻辑。调度进程frame_scheduler负责任务分发从socket上收取fd和元数据维护一个轻量级的帧池把fd转发给推理端和回显端。推理进程ai_worker接收fd用RGA做预处理缩放、色域转换、归一化再作为外部输入喂给RKNN NPU推理完成后只把结果目标检测的坐标、类别、置信度通过共享内存或socket发回。回显进程display_worker接收fd直接把这一帧交给DRM/KMS显示或者另一路RGA做OSD叠加。链路里最值得说明的是推理进程中的RGA导入操作。RGA在Rockchip平台的官方用户态库是librga它提供了一个基于dma-buf的buffer handle结构体。一个典型的用法是#include rga/RgaApi.h int rga_process_frame(int dmabuf_fd, int src_width, int src_height, int dst_width, int dst_height) { rga_buffer_t src wrapbuffer_fd_t(dmabuf_fd, src_width, src_height, RK_FORMAT_YCbCr_420_SP); rga_buffer_t dst wrapbuffer_fd_t(dst_dmabuf_fd, dst_width, dst_height, RK_FORMAT_RGB_888); rga_set_rotate(0); int ret imresize(src, dst); if (ret) { printf(RGA resize failed: %d\n, ret); return -1; } return 0; }wrapbuffer_fd_t这个接口很关键它接收的是一个dma-buf fd底层直接让RGA硬件DMA去访问这块buffer不经过CPU也不发生任何内存拷贝。预处理完成后得到的dst_dmabuf_fd再传给rknn。Rockchip NPU用户态库rknn-toolkit2的C接口里可以用rknn_create_mem_from_fd这个API把dma-buf fd包装成rknn专用的tensor内存对象然后在rknn_set_io_mem接口中指定输入来自这块内存。rknn_tensor_mem *input_mem; rknn_create_mem_from_fd(ctx, dst_dmabuf_fd, input_mem, 0); ... rknn_set_io_mem(ctx, input_mem, input_attr); ret rknn_run(ctx, NULL);这里面的逻辑就是RGA输出直接落在一段dma-buf上NPU直接从同一段dma-buf取数据做卷积计算中间不需要任何进程来push或者copy像素数据。4.2 实测对比零拷贝链路 vs 传统socket链路我在这套四进程架构上用YOLOv8s模型做了一组对照实验统一输入1080P30fps的MIPI-CSI摄像头画面Rockchip NPU跑batch1推理。对比两种方案的端到端性能指标传统方案TCP socket 内存拷贝零拷贝方案dma-buf全链路一帧全链路延迟采集到结果回显约72ms约18ms推理进程CPU占用预处理阶段约55%约11%系统内存带宽占用约1.2GB/s约0.35GB/s持续运行1小时后帧率约14fps约28fps偶发花屏/丢帧次数每10分钟约2-3次基本为0延迟从72ms降到18ms这个提升幅度比我预想的更大。尤其是推理进程的CPU占用从55%降到11%说明原来大量的CPU时间不是花在NPU计算上而是花在反复搬图上。还有一个现象值得补充传统方案跑久了之后帧率会掉到14fps左右跟内存碎片化和cache颠簸有关系。零拷贝方案因为缓冲区的生命周期比较稳定运行一小时后帧率依然能稳定在28fps上下这个稳定性优势在长稳测试里特别值钱。5. 部署七天后才浮现的坑以及排查链路任何方案落地都不可能一帆风顺dma-buf跨进程通信尤其如此。这一节挑几个我自己踩过、且不太容易在网上找到答案的坑按从常见到诡异的顺序列出来。5.1 坑一dma-buf生命周期处理不当导致系统内存泄漏dma-buf的fd跨进程传递时内核层会为dma_buf对象维护引用计数这个计数随着fd的dup和close而变化。陷阱在于如果你用SCM_RIGHTS传fd时不注意接收端是否成功处理或者接收端进程崩溃前没有关闭fddma_buf引用计数就可能一直降不到零底层物理内存永远不会被释放。这种泄漏不会让进程的RSS常驻内存集马上飙起来因为分配工具看不到这块内存很难用常规手段查到。我踩到这个坑时系统跑了大概七个小时后/proc/meminfo里的MemFree一路降到了几十MB整个系统卡成幻灯片。定位手段比较原始反复执行dmesg看有没有分配失败的报错同时看/proc/slabinfo结果发现dma_buf的slab cache占用异常增大才锁定方向。解决方案也很简单直接在业务代码的边界层强制约定dma-buf fds只能由单一的owner调度进程统一管理其他进程用完后必须立即把fd返回给调度进程由调度进程统一close。用引用计数接管fd生命周期比让每个进程自己负责更可控。5.2 坑二uncached内存用CPU访问时性能极差改cached之后必须处理cache sync前文提到dma-buf Heaps有system和system-uncached两种节点。我一开始图省事全部用uncached内存结果RGA和NPU是爽了但有一次需要在推理前用CPU做一个很小的预处理——把图像从NV12转成BGR顺便缩放一下。这个操作在普通内存上跑大概2ms但在uncached dma-buf上跑了将近25ms性能差了十倍以上。根本原因在于uncached内存的CPU读写是真的一次一次访问物理内存没有cache缓存每次读一个字节都要走完整的DMA读事务延迟高得离谱。后来我把这块buffer改成从system节点cached分配然后在CPU写完之后显式调用DMA_BUF_IOCTL_SYNC把cache刷到内存再交给RGA。整个预处理降到3ms左右而且对RGA和NPU侧完全没有负面影响。这里要提醒一句cached的dma-buf做cache sync时必须按内存访问方向正确地设置DMA_BUF_SYNC_READ和DMA_BUF_SYNC_WRITE标志漏掉任何一个都可能导致脏数据。更隐蔽的是RGA有自己的MMU如果你同时发多个RGA任务操作同一块cached dmabuf不同任务之间也需要串行化否则RGA的MMU会看到不一致的页表。5.3 坑三NPU/RGA对内存对齐有硬性要求不满足就直接报错dma-buf虽然共享了内存但不是随便什么尺寸都能用的。RK3588的RGA和NPU对buffer的对齐和stride有很具体的要求。RGA要求宽度按16字节对齐高度按2字节对齐某些格式要求更严RKNPU要求在输入tensor传入前宽度、高度都按16对齐channel数按8或16对齐取决于模型结构分辨率不满足对齐要求时不能只改buffer大小还要同步调整stride即每行像素实际占用的字节数。dma-buf只保证内存连续不保证行与行之间是紧挨着的所以图像处理时必须显式传入stride信息。我在一个目标检测项目里把输入从640×640换成800×608只改了宽高参数没改stride结果NPU推理输出一堆乱码框。排查了很久最后是rknn的debug日志里看到了input stride mismatch字样才明白是对齐问题。5.4 坑四cache sync的粒度对性能影响很大不要整个buffer一把梭做cache sync时很多人图省事直接对整个buffer做invalid和flush。这在buffer很小的时候无所谓但在1080P或者4K视频帧上sync整个buffer要遍历大量的cache line耗时能达到2ms到4ms一帧白白浪费性能。合理的做法是尽量缩小sync的区间比如只sync实际改动的那一行范围或者利用DMA_BUF_IOCTL_SYNC接口的range参数。但是dma-buf sync接口在早期的内核版本上并不支持部分区间同步这意味着让整个buffer总是sync全量的情况可能没法避免。我在实际项目里用了一个妥协方案如果一整帧数据都是硬件生成的ISP输出、RGA输出那就在硬件生成完毕后做一个全量invalid不用flush因为CPU没写过如果只是CPU改了其中一个很小的ROI区域就锁在一个更小的临时dma-buf上做CPU写入再把这个小buffer和原帧做一次RGA blit合并用硬件拷贝代替大范围的cache清理。这个优化思路的原理很简单cache同步的开销和脏cache行数成正比而不是和buffer大小成正比所以尽可能减少缓存行的污染就能同时提升性能和稳定性。5.5 坑五scheduler进程帧同步可能引入隐藏延迟需要仔细设计dma-buf零拷贝解决了内存搬运的问题但没有解决帧同步的问题。在多进程架构下视频采集是30fpsAI推理可能只能跑20fps显示端又想要60fps三个进程的工作节奏不一致必然导致某个进程要等另一路的数据。最简单的方案是用带缓冲的队列采集进程只管push fd到队列推理进程按自己的节奏从队列里pop fd处理队列满时丢掉最旧的帧。这个方案实现简单但问题是它可能造成延迟累积——如果推理处理不过来队列里积压的帧会一直增加即使丢帧策略是丢最旧的依然可能让显示端看到的画面延迟持续升高。改进了两版之后我最终给调度进程加了一个最新帧优先策略调度进程一直持有最新一帧的fd每当新帧到达就把上一帧的fd丢回缓冲池复用只把最新fd广播给下游。实测中发现在推理负载较重时这个策略可以把端到端画面延迟稳定在一个非常低的水平同时不会丢帧。代价是推理进程可能会连续两次处理同一帧如果最新帧在两次调度之间没有更新但对大多数视觉应用来说重复处理旧帧比画面延迟增加更容易接受。6. 关于这套方案的可维护性和后续扩展方向零拷贝跨进程通信不是银弹它有一套自己的适用范围和成本。如果你的视觉应用是单进程内完成采集、推理和显示那完全可以用普通内存加memcpy没必要上dma-buf。一旦业务拆分成多个进程并且对实时性、CPU占用有明确指标要求dma-buf这条路才真正值得投入。从可维护性角度看我建议把dma-buf的分配、fd收发、sync封装成一个独立的C库接口设计得干净一点。项目里我是这样设计的typedef struct edge_buffer { int fd; size_t size; void *map_addr; uint32_t width, height, format; uint64_t timestamp; } edge_buffer_t; // 生产端申请一块dma-buf并映射 int edge_buffer_create(edge_buffer_t *buf, size_t size); // 生产端填充数据后用这个接口发出去 int edge_buffer_send(int sock_fd, edge_buffer_t *buf); // 消费端收fd并完成映射 int edge_buffer_recv(int sock_fd, edge_buffer_t *buf); // 消费端CPU读写之前调用begin/end int edge_buffer_sync_begin(edge_buffer_t *buf, int dir); int edge_buffer_sync_end(edge_buffer_t *buf, int dir);把这些原始操作隐藏成简单的API后业务进程的代码会干净很多。后续如果换平台比如RK3568或者未发布的下一代芯片只需要改底层这层封装的实现上层逻辑基本不用动。扩展方向上我目前打算做两件事。第一是把零拷贝链路接入GStreamer框架这样可以利用GStreamer插件生态做视频流的推流、存储和播放同时复用dma-buf的共享机制不需要为每一个GStreamer插件单独写buffer复制逻辑。第二是在调度进程里加一个统一的性能监控模块直接读dma_buf的引用计数、cache sync的次数、各进程的fd收发频率把这些指标透传到Prometheus或者自定义的仪表盘里方便做持续的性能回归测试。我个人的体会是零拷贝跨进程通信这类底层优化真正难的不是看懂概念而是把所有细节串起来之后、系统跑起来之后的那一堆意料之外。写这篇文的初衷也是想把这些链路细节和我踩过的坑尽可能完整地沉淀下来。如果后面大家按照这套思路做遇到一些我没预见到的状况也欢迎来一起讨论。