client-go中watch机制的一些陷阱
Reference
- https://stackoverflow.com/questions/51399407/watch-in-k8s-golang-api-watches-and-get-events-but-after-sometime-doesnt-get-an
问题描述
最近在使用 client-go 的 watch 机制监听 k8s 中的 deployment 资源时,发现一个奇怪的现象
先看下代码:
- 服务启动时调用 watchDeployment 新建一个 watcher 监听对应的资源
- for 循环,select 处理 watcher.ResultChan 返回的事件
func WatchDeployment(ctx context.Context, namespace string, options metav1.ListOptions, handler EventHandler) error {watcher, err := KubeCli.AppsV1().Deployments(namespace).Watch(ctx, options)if err != nil {log.Errorf("watching deployments err: %+v", err)return err}defer watcher.Stop()// 处理事件for {select {case event, ok := <-watcher.ResultChan():if !ok {log.Errorf("Watcher channel closed")return nil}deployment, ok := event.Object.(*appsv1.Deployment)if !ok {log.Errorf("Error casting to Deployment")continue}switch event.Type {case watch.Added:if handler.OnAdd != nil {handler.OnAdd(ctx, deployment)}case watch.Modified:if handler.OnModify != nil {handler.OnModify(ctx, deployment)}case watch.Deleted:if handler.OnDelete != nil {handler.OnDelete(ctx, deployment)}}}}
}
在运行了一段时间后,watch 监听的通道会自动关闭,日志:“ERROR [trace-] Watcher channel closed”
检视完代码,唯一存在问题的就是 watcher.ResultCha( ) 如果出问题,则会直接 return 导致 for 循环退出了,所以我改了第二版的代码,将 return 替换为了 continue
if !ok {log.Errorf("Watcher channel closed")continue
}
再运行一段时间,日志疯狂报错 “ERROR [trace-] Watcher channel closed” ;
疑问:为什么错误已经continue了,为什么无法再继续监听了?
排查过程
具体debug看下 watch 机制的源码,只展示重要流程代码,细节忽略:
// watch 其实是可以设置 timeout 时间,具体用在哪,继续往下看看
func (c *deployments) Watch(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) {var timeout time.Durationif opts.TimeoutSeconds != nil {timeout = time.Duration(*opts.TimeoutSeconds) * time.Second}opts.Watch = truereturn c.client.Get().Namespace(c.ns).Resource("deployments").VersionedParams(&opts, scheme.ParameterCodec).Timeout(timeout).Watch(ctx)
}// Timeout makes the request use the given duration as an overall timeout for the
// request. Additionally, if set passes the value as "timeout" parameter in URL.
// 这里就将timeout 设置为了 rquest 请求的超时时间
func (r *Request) Timeout(d time.Duration) *Request {if r.err != nil {return r}r.timeout = dreturn r
}
从 watch 可以看到,client-go 提供的 watch 方法,就是使用 net/http 发起一个 http 请求 https://10.96.0.1:443/apis/apps/v1/namespaces/xxx/deployments?fieldSelector=metadata.name%3Dabc&watch=true
,并启用 watch 机制,成功后则返回一个 实现了 watch.Interface 这个接口的 StreamWatcher 的结构体
// Watch attempts to begin watching the requested location.
// Returns a watch.Interface, or an error.
func (r *Request) Watch(ctx context.Context) (watch.Interface, error) {// We specifically don't want to rate limit watches, so we// don't use r.rateLimiter here.if r.err != nil {return nil, r.err}client := r.c.Clientif client == nil {client = http.DefaultClient}isErrRetryableFunc := func(request *http.Request, err error) bool {// The watch stream mechanism handles many common partial data errors, so closed// connections can be retried in many cases.if net.IsProbableEOF(err) || net.IsTimeout(err) {return true}return false}retry := r.retryFn(r.maxRetries)url := r.URL().String()for {if err := retry.Before(ctx, r); err != nil {return nil, retry.WrapPreviousError(err)}req, err := r.newHTTPRequest(ctx)if err != nil {return nil, err}resp, err := client.Do(req)updateURLMetrics(ctx, r, resp, err)retry.After(ctx, r, resp, err)if err == nil && resp.StatusCode == http.StatusOK {return r.newStreamWatcher(resp)}// 重试机制...}
}
StreamWatcher 就是启了一个协程接受 wacth 中的事件变化,进行处理
func (r *Request) newStreamWatcher(resp *http.Response) (watch.Interface, error) {contentType := resp.Header.Get("Content-Type")mediaType, params, err := mime.ParseMediaType(contentType)if err != nil {klog.V(4).Infof("Unexpected content type from the server: %q: %v", contentType, err)}objectDecoder, streamingSerializer, framer, err := r.c.content.Negotiator.StreamDecoder(mediaType, params)if err != nil {return nil, err}handleWarnings(resp.Header, r.warningHandler)frameReader := framer.NewFrameReader(resp.Body)watchEventDecoder := streaming.NewDecoder(frameReader, streamingSerializer)return watch.NewStreamWatcher(restclientwatch.NewDecoder(watchEventDecoder, objectDecoder),// use 500 to indicate that the cause of the error is unknown - other error codes// are more specific to HTTP interactions, and set a reasonerrors.NewClientErrorReporter(http.StatusInternalServerError, r.verb, "ClientWatchDecoding"),), nil
}
// NewStreamWatcher creates a StreamWatcher from the given decoder.
func NewStreamWatcher(d Decoder, r Reporter) *StreamWatcher {sw := &StreamWatcher{source: d,reporter: r,// It's easy for a consumer to add buffering via an extra// goroutine/channel, but impossible for them to remove it,// so nonbuffered is better.result: make(chan Event),// If the watcher is externally stopped there is no receiver anymore// and the send operations on the result channel, especially the// error reporting might block forever.// Therefore a dedicated stop channel is used to resolve this blocking.done: make(chan struct{}),}go sw.receive()return sw
}// StreamWatcher turns any stream for which you can write a Decoder interface
// into a watch.Interface.
type StreamWatcher struct {sync.Mutexsource Decoderreporter Reporterresult chan Eventdone chan struct{}
}// Interface can be implemented by anything that knows how to watch and report changes.
type Interface interface {// Stop stops watching. Will close the channel returned by ResultChan(). Releases// any resources used by the watch.Stop()// ResultChan returns a chan which will receive all the events. If an error occurs// or Stop() is called, the implementation will close this channel and// release any resources used by the watch.ResultChan() <-chan Event
}
看下具体是怎么进行处理的
- 从 source 中解码得到k8s中监听到的事件变化的action(动作)
- 将结果写入 result 这个 channel 中
- result 这个channel 就是我们最开始 watch.ResultChan 函数的返回结果
// receive reads result from the decoder in a loop and sends down the result channel.
func (sw *StreamWatcher) receive() {defer utilruntime.HandleCrash()defer close(sw.result)defer sw.Stop()for {action, obj, err := sw.source.Decode()if err != nil {switch err {case io.EOF:// watch closed normallycase io.ErrUnexpectedEOF:klog.V(1).Infof("Unexpected EOF during watch stream event decoding: %v", err)default:if net.IsProbableEOF(err) || net.IsTimeout(err) {klog.V(5).Infof("Unable to decode an event from the watch stream: %v", err)} else {select {case <-sw.done:case sw.result <- Event{Type: Error,Object: sw.reporter.AsObject(fmt.Errorf("unable to decode an event from the watch stream: %v", err)),}:}}}return}select {case <-sw.done:returncase sw.result <- Event{Type: action,Object: obj,}:}}
}// ResultChan implements Interface.
func (sw *StreamWatcher) ResultChan() <-chan Event {return sw.result
}
回顾一下我们接受channel的写法,我们拿到channel后,从里面读取数据,会根据bool值来判断channel是否已经关闭,关闭则不处理;
ch := watcher.ResultChan()
event, ok := <-ch:
那为什么channel会关闭呢,猜测一下?
- 客户端超时断开了?但是我们没设置timeout,则默认为0,就是无限制时间,不会主动断开
- 服务端主动断开了?有可能
在watcher建立后,我们通过 lsof -p 查看对应进程打开的连接,可以看到与 k8s 建立的 https 的连接,就是对应的 watcher 发起的http请求建立的 tcp 长链接;net/http 发起的 http 请求,是使用了 transport 连接池进行管理的,所以会默认维持长链接,感兴趣可以看下这篇文章 [[net-http-transport]]
xxx-75df5b458c-hj6qr:45006->kubernetes.default.svc.cluster.local:https (ESTABLISHED)
知道了对端域名和端口后,就能通过 tcpkill -i eth0 host kubernetes.default.svc.cluster.local and port 443
命令,来手动中断这个连接,看下 streamWatch 是怎么处理的;
在 streamWatcher 的 receive 函数 select 中打断点调试,最后发现是在 net.IsProbableEOF 函数中命中了 “connection reset by peer”
// IsProbableEOF returns true if the given error resembles a connection termination
// scenario that would justify assuming that the watch is empty.
// These errors are what the Go http stack returns back to us which are general
// connection closure errors (strongly correlated) and callers that need to
// differentiate probable errors in connection behavior between normal "this is
// disconnected" should use the method.
func IsProbableEOF(err error) bool {if err == nil {return false}var uerr *url.Errorif errors.As(err, &uerr) {err = uerr.Err}msg := err.Error()switch {case err == io.EOF:return truecase err == io.ErrUnexpectedEOF:return truecase msg == "http: can't write HTTP request on broken connection":return truecase strings.Contains(msg, "http2: server sent GOAWAY and closed the connection"):return truecase strings.Contains(msg, "connection reset by peer"):return truecase strings.Contains(strings.ToLower(msg), "use of closed network connection"):return true}return false
}
并且我们将日志级别设置为0,就能直接打印对应的infof日志:
I1226 09:38:19.316831 16623 streamwatcher.go:114] Unable to decode an event from the watch stream: read tcp 10.244.1.96:33006->10.96.0.1:443: read: connection reset by peer
ok,连接被对端关闭了,然后按照代码逻,就会直接return,在返回之前,会执行 defer 进行一些操作,receive 在方法开始就定义了 defer 资源回收
- 明确声明了会关闭 sw.result 这个channel
- stop 中则是将 source 这个 streamDecoder 关闭,最后调用到 http.transportResponseBody 进行关闭,这也是 net-http 源码 transport 的设计,不过k8s-apiserver 貌似用的http2的协议;
defer close(sw.result)
defer sw.Stop()
// Stop implements Interface.
func (sw *StreamWatcher) Stop() {// Call Close() exactly once by locking and setting a flag.sw.Lock()defer sw.Unlock()// closing a closed channel always panics, therefore check before closingselect {case <-sw.done:default:close(sw.done)sw.source.Close()}
}// 最终的close函数,会把未读的数据都flush出来再关闭
func (b transportResponseBody) Close() error {cs := b.cscc := cs.cccs.bufPipe.BreakWithError(errClosedResponseBody)cs.abortStream(errClosedResponseBody)unread := cs.bufPipe.Len()if unread > 0 {cc.mu.Lock()// Return connection-level flow control.connAdd := cc.inflow.add(unread)cc.mu.Unlock()// TODO(dneil): Acquiring this mutex can block indefinitely.// Move flow control return to a goroutine?cc.wmu.Lock()// Return connection-level flow control.if connAdd > 0 {cc.fr.WriteWindowUpdate(0, uint32(connAdd))}cc.bw.Flush()cc.wmu.Unlock()}select {case <-cs.donec:case <-cs.ctx.Done():// See golang/go#49366: The net/http package can cancel the// request context after the response body is fully read.// Don't treat this as an error.return nilcase <-cs.reqCancel:return errRequestCanceled}return nil
}
再梳理一下整个流程:
- 我们通过client-go提供的方法创建一个watcher,监听对应的资源
- watcher 会先向 kube-apiserver 发起一个 http 请求,告知 apiserver 启用 watch 机制监听某类型的资源
- 服务与apiserver建立了连接后,就通过FD进行读写传输
- 最终变更的事件,是通过 channel 与我们的服务进行通信
- 当apiserver关闭了连接,streamwatcher就会return并进行资源回收,从而关闭 channel
问题原因
- apiserver 主动关闭了 TCP 连接,客户端 streamWatcher 将channel回收关闭了,所以,我们通过 watcher.ResultChan 获取到的 channel 永远都是关闭的
- apiserver 主动关闭连接有几个可能原因
- 监听的资源被删除了,尝试了手动删除,发现watcher还是存在不会关闭
- 长时间没有事件变更,TCP连接会自动断开(大概在30min左右)(事实证明就是这个原因)
- 其他xxx
解决办法
- 如果发现 channel 被关闭了,则重新建立一个 watcher 进行监听即可
改进后的代码:
func WatchDeployment(ctx context.Context, namespace string, options metav1.ListOptions, handler EventHandler) {log.Infof("start watch deployment: %+v", options)for {func() {defer func() {if r := recover(); r != nil {log.Warnf("The Kubernetes deployment watcher is attempting to restart for recovery. err: %v", r)}}()if err := runLoop(ctx, namespace, options, handler); err != nil {log.Errorf("Kubernetes deployment watcher has exited in runLoop: %v", err)}}()time.Sleep(5 * time.Second) // 等待一段时间后重试}
}func runLoop(ctx context.Context, namespace string, options metav1.ListOptions, handler EventHandler) error {watcher, err := KubeCli.AppsV1().Deployments(namespace).Watch(ctx, options)if err != nil {return err}ch := watcher.ResultChan()for {select {case event, ok := <-ch:if !ok {// channel 关闭,重启 watcherlog.Infof("Kubernetes hung up on us, restarting deployment watcher")return nil}deployment, ok := event.Object.(*appsv1.Deployment)if !ok {log.Errorf("Error casting to Deployment")continue}// 处理事件switch event.Type {case watch.Added:if handler.OnAdd != nil {handler.OnAdd(ctx, deployment)}case watch.Modified:if handler.OnModify != nil {handler.OnModify(ctx, deployment)}case watch.Deleted:if handler.OnDelete != nil {handler.OnDelete(ctx, deployment)}}case <-time.After(30 * time.Minute):// 超时,重启 watcherlog.Infof("Timeout, restarting deployment watcher")return nilcase <-ctx.Done():log.Info("Context done, stopping watch")return nil}}
}
其他疑问
1、为什么发起一个 http 请求,apiserver 就能与这个请求建立连接,进行 watch 并增量通知,apiserver 是怎么实现的?
- 推荐阅读:
- https://cloud.tencent.com/developer/article/1991054
- etcd教程(五)—watch机制原理分析
2、为什么 list-watch 机制不会每隔一段时间就关闭连接?(貌似有探活?)
3、StreamWatcher 中包装的 Decoder 是怎么与TCP连接的描述符关联上的,读写是怎么传输的?
相关文章:
client-go中watch机制的一些陷阱
Reference https://stackoverflow.com/questions/51399407/watch-in-k8s-golang-api-watches-and-get-events-but-after-sometime-doesnt-get-an 问题描述 最近在使用 client-go 的 watch 机制监听 k8s 中的 deployment 资源时,发现一个奇怪的现象 先看下代码&a…...

Chrome访问https页面显示ERR_CERT_INVALID,且无法跳过继续访问
在访问网页的时候,因为浏览器自身的安全设置问题, 对于https的网页访问会出现安全隐私的提示, 甚至无法访问对应的网站,尤其是chrome浏览器, 因此本文主要讲解如何设置chrome浏览器的设置,来解决该问题&…...

Jenkins pipeline 发送邮件及包含附件
Jenkins pipeline 发送邮件及包含附件 设置邮箱开启SMTP服务 此处适用163 邮箱 开启POP3/SMTP服务通过短信获取TOKEN (保存TOKEN, 后面Jenkins会用到) Jenkins 邮箱设置 安装 Build Timestamp插件 设置全局凭证 Dashboard -> Manage Jenkins …...

怎么把word试题转成excel?
在教育行业、学校管理以及在线学习平台中,试题库的高效管理是一项核心任务。许多教育工作者和系统开发人员常常面临将 Word 中的试题批量导入 Excel 的需求。本文将详细介绍如何快速将试题从 Word 转换为 Excel,帮助您轻松解决繁琐的数据整理问题&#x…...

【机器学习】量子机器学习:当量子计算遇上人工智能,颠覆即将来临?
我的个人主页 我的领域:人工智能篇,希望能帮助到大家!!!👍点赞 收藏❤ 在当今科技飞速发展的时代,量子计算与人工智能宛如两颗璀璨的星辰,各自在不同的苍穹闪耀,正以前…...

IDEA配置maven和git并如何使用maven打包和git推送到gitlab
首先找到设置 在里面输入maven然后找到点击 然后点击右边两个选项 路径选择下载的maven目录下的settings文件和新建的repository文件夹 点击apply应用 然后在搜索框里搜git点击进去 此路径为git的exe执行文件所在目录,选好之后点击test测试下方出现git版本号表…...

Supermaven 加入 Cursor:AI 编码新篇章
引言 2024 年 11 月 11 日,我们迎来了一个激动人心的时刻——Supermaven 正式加入 Cursor! 这一合作标志着 AI 编程工具进入了一个新的发展阶段,为开发者提供更智能、更高效的编码体验。本文将带您了解此次合并的背景、意义以及未来的发展方…...
【2024华为OD-E卷-100分-boss的收入】(题目+思路+JavaC++Python解析)
题目描述 题目:boss的收入 在一个公司中,有一个老板(boss)和若干名员工(employees)。老板和员工的收入信息存储在一个数组中,其中数组的每个元素表示一个人的收入。数组的第0个元素表示老板的…...

《Java8实战》汇总
参考书籍:《Java8 实战》 一、Lambda表达式 Lambda 是一个匿名函数。可以写出更简洁、更灵活的代码。作为一种更紧凑的代码风格,使Java的语言表达能力得到了提升。 1.1、Lambda表达式的关键:从匿名类到 Lambda 的转换 示例: <span style="background-color:#…...

Elasticsearch:搜索相关性
这里写目录标题 一、相关性的概述二、自定义评分策略1、TF-IDF算法2、BM25算法 三、自定义评分策略1、Index Boost:在索引层面修改相关性2、boosting:修改文档相关性3、negative_boost:降低相关性4、function_score:自定义评分5、…...

LeetCode 热题 100_二叉树展开为链表(46_114_中等_C++)(二叉树;先序遍历(递归+数组);先序遍历(递归))
LeetCode 热题 100_二叉树展开为链表(46_114) 题目描述:输入输出样例:题解:解题思路:思路一(先序遍历(递归数组)):思路二(先序遍历&am…...

uniapp实现在card卡片组件内为图片添加长按保存、识别二维码等功能
在原card组件的cover属性添加图片的话,无法在图片上面绑定 show-menu-by-longpress"true"属性,通过将图片自定义添加可使用该属性。 代码: <uni-card title"标题" padding"10px 0" :thumbnail"avata…...

最好用的图文识别OCR -- PaddleOCR(2) 提高推理效率(PPOCR模型转ONNX模型进行推理)
在实际推理过程中,使用 PaddleOCR 模型时效率较慢,经测试每张图片的检测与识别平均耗时超过 5 秒,这在需要大规模自动化处理的场景中无法满足需求。为此,我尝试将 PaddleOCR 模型转换为 ONNX 格式进行推理,以提升效率。…...

Redis--20--大Key问题解析
提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档 文章目录 大Key问题1.什么是 Redis 大 Key?在 Redis 中,大 Key 是指单个键值对的数据量非常大,可能包含大量数据。 2. Redis大Key的危害3.…...

新版2024AndroidStudio项目目录结构拆分
如题 下载了最新版的android studio 发现目录结构和以前不一样 自动帮你合并了 如何层层抽丝剥茧呢 按照一下步骤即可解决问题!...

STM32内置Flash
一、原理 利用flash存储用户数据需要注意查看,用户数据是否会覆盖芯片运行程序。 IAP(在程序中编程)利用程序修改程序本身,和OTA是一个原理。IAP在程序中编程支持任意一种通信下载。 ICP(在电路中编程,通…...

华为路由器、交换机、AC、新版本开局远程登录那些坑(Telnet、SSH/HTTP避坑指南)
关于华为设备远程登录配置开启的通用习惯1、HTTP/HTTPS相关服务 http secure-server enablehttp server enable 2、Telnet服务telnet server enable3、SSH服务stelnet server enablessh user admin authentication-type password 「模拟器、工具合集」复制整段内容 链接&…...

【Linux】深入理解进程信号机制:信号的产生、捕获与阻塞
🎬 个人主页:谁在夜里看海. 📖 个人专栏:《C系列》《Linux系列》《算法系列》 ⛰️ 时间不语,却回答了所有问题 目录 📚前言 📚一、信号的本质 📖1.异步通信 📖2.信…...

前端基础技术全解析:从HTML前端基础标签语言开始,逐步深入CSS样式修饰、JavaScript脚本控制、Ajax异步通信以及WebSocket持久通信
目录 前言: 1.前端技术html简单了解: 1.1HTML代码是由标签构成的。 1.2.HTML 文件基本结构 1.3.HTML 常见标签 标题标签: 段落标签: p 文本格式化标签 图片标签: 超链接标签: a 测试代码: 展示效果: 表单…...

Linux存储管理之核心秘密(The Core Secret of Linux Storage Management)
Linux存储管理之核心秘密 如果你来自Windows环境,那么Linux处理和管理存储设备的方式对你而言可能显得格外不同。我们知道,Linux的文件系统并不采用Windows那样的物理驱动器表示方式(如C:、D:或E:),而是构建了一个以&…...
零门槛NAS搭建:WinNAS如何让普通电脑秒变私有云?
一、核心优势:专为Windows用户设计的极简NAS WinNAS由深圳耘想存储科技开发,是一款收费低廉但功能全面的Windows NAS工具,主打“无学习成本部署” 。与其他NAS软件相比,其优势在于: 无需硬件改造:将任意W…...

Prompt Tuning、P-Tuning、Prefix Tuning的区别
一、Prompt Tuning、P-Tuning、Prefix Tuning的区别 1. Prompt Tuning(提示调优) 核心思想:固定预训练模型参数,仅学习额外的连续提示向量(通常是嵌入层的一部分)。实现方式:在输入文本前添加可训练的连续向量(软提示),模型只更新这些提示参数。优势:参数量少(仅提…...

中南大学无人机智能体的全面评估!BEDI:用于评估无人机上具身智能体的综合性基准测试
作者:Mingning Guo, Mengwei Wu, Jiarun He, Shaoxian Li, Haifeng Li, Chao Tao单位:中南大学地球科学与信息物理学院论文标题:BEDI: A Comprehensive Benchmark for Evaluating Embodied Agents on UAVs论文链接:https://arxiv.…...
【磁盘】每天掌握一个Linux命令 - iostat
目录 【磁盘】每天掌握一个Linux命令 - iostat工具概述安装方式核心功能基础用法进阶操作实战案例面试题场景生产场景 注意事项 【磁盘】每天掌握一个Linux命令 - iostat 工具概述 iostat(I/O Statistics)是Linux系统下用于监视系统输入输出设备和CPU使…...

Python实现prophet 理论及参数优化
文章目录 Prophet理论及模型参数介绍Python代码完整实现prophet 添加外部数据进行模型优化 之前初步学习prophet的时候,写过一篇简单实现,后期随着对该模型的深入研究,本次记录涉及到prophet 的公式以及参数调优,从公式可以更直观…...

跨链模式:多链互操作架构与性能扩展方案
跨链模式:多链互操作架构与性能扩展方案 ——构建下一代区块链互联网的技术基石 一、跨链架构的核心范式演进 1. 分层协议栈:模块化解耦设计 现代跨链系统采用分层协议栈实现灵活扩展(H2Cross架构): 适配层…...
CRMEB 框架中 PHP 上传扩展开发:涵盖本地上传及阿里云 OSS、腾讯云 COS、七牛云
目前已有本地上传、阿里云OSS上传、腾讯云COS上传、七牛云上传扩展 扩展入口文件 文件目录 crmeb\services\upload\Upload.php namespace crmeb\services\upload;use crmeb\basic\BaseManager; use think\facade\Config;/*** Class Upload* package crmeb\services\upload* …...
聊一聊接口测试的意义有哪些?
目录 一、隔离性 & 早期测试 二、保障系统集成质量 三、验证业务逻辑的核心层 四、提升测试效率与覆盖度 五、系统稳定性的守护者 六、驱动团队协作与契约管理 七、性能与扩展性的前置评估 八、持续交付的核心支撑 接口测试的意义可以从四个维度展开,首…...

【Oracle】分区表
个人主页:Guiat 归属专栏:Oracle 文章目录 1. 分区表基础概述1.1 分区表的概念与优势1.2 分区类型概览1.3 分区表的工作原理 2. 范围分区 (RANGE Partitioning)2.1 基础范围分区2.1.1 按日期范围分区2.1.2 按数值范围分区 2.2 间隔分区 (INTERVAL Partit…...

3-11单元格区域边界定位(End属性)学习笔记
返回一个Range 对象,只读。该对象代表包含源区域的区域上端下端左端右端的最后一个单元格。等同于按键 End 向上键(End(xlUp))、End向下键(End(xlDown))、End向左键(End(xlToLeft)End向右键(End(xlToRight)) 注意:它移动的位置必须是相连的有内容的单元格…...