决定SEO收录哪一版页面的不是你的配置,是10分钟前路过的那个德国用户

决定SEO收录哪一版页面的不是你的配置,是10分钟前路过的那个德国用户
张文保 30 分钟阅读 2,723 阅读
本文目录
  1. 请求里带的那点东西,真能改变返回的页面吗?
  2. 这些分岔为什么在URL和canonical里一个字都看不见?
  3. 不写Vary,缓存到底会发生什么?
  4. 那把Vary写上不就完了?
  5. 现网到底写成了什么样?
  6. 上游一发Set-Cookie,nginx就再也不缓存了?
  7. 教程里那句proxy_ignore_headers,代价是什么?
  8. Googlebot在这套机制里站在哪个位置?
  9. 这笔账该怎么配才两头都不亏
  10. 常见问题解答
  11. 不写Vary,Google会不会因此判我cloaking?
  12. Vary里能不能写User-Agent?
  13. Googlebot不发Accept-Language,那我的多语言站它怎么抓?
  14. proxy_hide_header和proxy_ignore_headers到底差在哪?
  15. 只有50.4%的页面下发Cookie,是不是说另一半可以放心缓存?
  16. Client Hints对SEO有没有影响?
  17. 怎么最快确认自己的缓存有没有在串版本?
  18. 权威参考资料

摘要:在实验台上先用一个德语浏览器请求一次某个页面,之后不管来的是中文用户、英文用户、法语用户还是Googlebot,nginx全部返回HIT,给出的都是那份德语页面。整个过程没有任何一条配置写错,上游确实按语言返回了不同内容,缺的只是一个Vary响应头。而现网230个URL里,Vary里写了Accept-Language的只有3.5%,写了User-Agent的2.2%,下发了Cookie却没在Vary里声明Cookie的高达95.7%。更麻烦的是把Vary补上也不轻松:20个不同的Accept-Language取值只对应3种真实内容,缓存却老老实实存了20份副本。这篇拆的是同一个地址下“给谁哪一版”这个决定,最后到底是谁在做。

上一篇把噪声量出来之后,剩下的问题才好谈:在那136个连抓三次字节完全一致的URL上,改动请求里的东西,到底能让多少个页面吐出不一样的内容?

答案比传说中的小得多。但真正有意思的不是这个比例,而是这一小撮分岔,最后是由谁来仲裁的。

请求里带的那点东西,真能改变返回的页面吗?

口径先说清楚:只在三次基线指纹完全一致的稳定URL上做,噪声地板归零,测到的差异全部可归因。每个变体都是一个全新会话,只改一个变量。

改动样本响应不同占比最终URL变了html lang变了canonical变了
第二次请求带上服务器下发的Cookie13632.2%111
Accept-Language换成zh-CN1361511.0%676
Accept-Language换成de-DE1361410.3%797
完全不发Accept-Language13675.1%222
换成Googlebot的User-Agent1241411.3%111

三条读法。

第一,Cookie的效应小得出人意料。只有2.2%。原因不难理解:站点在首访下发的那批Cookie,绝大多数是给统计和风控用的会话标识,服务端拿到它并不会改变页面内容——真正会改内容的偏好(语言、币种、地区)通常要等用户主动选过一次才写进去。爬虫从不选,所以它永远看到的是那个“还没选过”的默认版本,而这个版本和一个第一次来的真人看到的完全一样。这一点上,无状态其实帮了忙。

第二,语言这一维的分岔最大,而且它是唯一一个连canonical都跟着变的。换成zh-CN,136个里有15个内容不同,其中6个连规范网址都指向了另一个地址。这意味着同一个URL在Google眼里可能归属两个不同的规范页——取决于抓它的那次请求头长什么样。

第三,Googlebot的UA本身就能撬动11.3%。这个数字要小心解读:绝大多数不是有意的cloaking,而是站点的设备判别、机器人防护、或者干脆是给爬虫准备的简化版页面。但从结果上看,八分之一的稳定页面,会因为你自称是谁而给出不同的字节。

这些分岔为什么在URL和canonical里一个字都看不见?

把上面三条合起来,会得到一个别扭的局面。

搜索引擎认的是URL。一个URL对应一个页面,这是整套索引体系的地基。同一个页面有11种URL写法都返回200那篇讲的是这条地基的一边——一份内容可能有一堆地址;这篇讲的是另一边——一个地址底下可能有一堆内容。

而后一种情况比前一种更难发现,原因很简单:决定给你哪一版的那几个变量,一个都不在地址栏里。

  • Accept-Language是请求头,不进URL;
  • Cookie是请求头,不进URL;
  • User-Agent是请求头,不进URL;
  • 出口IP连头都不是,它在TCP那一层。

于是你把这个URL复制给同事,同事打开看到的可能是另一个版本,而你俩都会以为对方看错了。按IP自动跳转语言版本那类做法至少还诚实——它跳走了,地址栏会变,你能看见。而按请求头分内容不跳转的做法,从地址上完全看不出发生过任何事。

HTTP协议对这件事是有安排的,就是Vary响应头。它的作用是告诉所有中间缓存:“这个响应的内容取决于请求里的这几个字段,你存它的时候得把这几个字段一起当成键。”RFC 9111把这条写成了强制要求:

"When a cache receives a request that can be satisfied by a stored response and that stored response contains a Vary header field, the cache MUST NOT use that stored response without revalidation unless all the presented request header fields nominated by that Vary field value match those fields in the original request."

注意这句话的结构:缓存的义务是由响应里那个Vary字段触发的。没有Vary,就没有这条义务,缓存有权把存下来的那一份发给任何人。

不写Vary,缓存到底会发生什么?

这个问题在文档里能读到答案,但读到和看到是两回事。我在服务器上另起了一套nginx,配置极其普通:一个上游按Accept-Language返回三种语言的内容,前面挂一层proxy_cache,用的是教程里最常见的那三行。上游不发Vary。

然后按顺序请求:

顺序请求的Accept-LanguageX-Cache拿到的版本
1de-DEMISS<html lang="de">
2zh-CNHIT<html lang="de">
3en-USHIT<html lang="de">
4fr-FRHIT<html lang="de">

第一个德国用户来过之后,这个URL在边缘上就变成德语的了。中文用户、英文用户、法国用户拿到的全是德语页面,而且每一次都是HIT——响应快得很,只是内容是别人的。

把上游改成显式发一个Vary: Accept-Language,同一套缓存配置,结果立刻变对:

请求的Accept-LanguageX-Cache拿到的版本
zh-CNMISS<html lang="zh">
en-USMISS<html lang="en">
de-DEHIT<html lang="de">

一个响应头的差别,从“所有人拿德语版”变成“各拿各的”。这里最值得记的一点是:缺Vary这件事,在源站上完全测不出来。你直连上游,每种语言都正确;只有在边缘那一层、而且必须有另一个语言的人先你一步到达,问题才会显形。这跟Nginx配置每次reload都通过、Googlebot每8次抓取却有1次撞在301上是同一个家族的毛病——配置本身没错,错的是它在多个人共用的那一层上的行为。

那把Vary写上不就完了?

如果这么简单,就不会有95.7%的站没写了。把Vary写上,账单会立刻寄过来。

我把Accept-Language放进proxy_cache_key(效果与Vary等价,且更容易观察缓存对象数),然后拿20个真实浏览器会发出的Accept-Language取值各请求一次:

指标数值
发出的不同Accept-Language取值20
实际返回的内容种类3(en / zh / de)
缓存目录里生成的对象数20

20份副本,3种内容,命中率0。而这20个取值不是我瞎编的,全是真实浏览器会发的东西:

en-US,en;q=0.9en-GB,en;q=0.9en;q=0.9en-US,en;q=0.8en-US,en;q=0.9,zh-CN;q=0.8zh-CN,zh;q=0.9zh-TW,zh;q=0.9de-DE,de;q=0.9de-AT,de;q=0.9de-CH,de;q=0.9……

de-DEde-ATde-CH三个取值返回的是完全相同的德语页面,缓存却认认真真存了三份。用户装了几个语言、装的顺序、系统区域设置的细微差别,都会造出一个新的键。

这就是Vary这条规则的真实成本:它按字符串精确匹配,而不是按语义匹配。RFC 9111连“字段不存在”这种情况都规定得很死:

"If (after any normalization that might take place) a header field is absent from a request, it can only match another request if it is also absent there."

也就是说,一个不发Accept-Language的客户端,会在缓存里占据一个独立的、谁也共用不了的分片。这个细节等一下讲Googlebot的时候还会用上。

所以现实里的选择是三选一,没有一个是白拿的:

做法内容正确性缓存命中率适用场景
按请求头分内容,不写Vary,随机串版本没有,这是纯粹的bug
按请求头分内容,写Vary,按取值数分片取值空间很小的头,比如Accept-Encoding
每个版本一个独立URL,不按请求头分多语言站的标准答案
在边缘做归一化,把高熵头压成少数几档较高边缘计算能力的站

第三行才是多语言站该走的路,也是hreflang那一整套机制存在的前提——每个语言版本得有自己独立、稳定、能直接链的URL,搜索引擎才有东西可收。按请求头在同一个URL下换内容,本质上是让搜索引擎去索引一个薛定谔的页面。

第四行是个折中,值得多说一句:在CDN边缘把Accept-Language解析出主语言、压成en/zh/de三档,再拿这三档去做缓存键。20个取值就收敛成3个分片,命中率回来了,内容也对。代价是你得在边缘上写一小段逻辑,而且缓存键的设计从此变成需要维护的东西。

现网到底写成了什么样?

230个URL的Vary头统计如下:

Vary里出现的字段URL数占比
accept-encoding19383.9%
rsc(Next.js的React Server Components)3917.0%
next-router-state-tree3716.1%
next-router-prefetch3716.1%
cookie2510.9%
accept146.1%
accept-language83.5%
origin73.0%
user-agent52.2%
content-language20.9%
完全没有Vary头198.3%

这张表读下来有三件事。

一是accept-encoding一枝独秀。83.9%的站写了它,因为不写就会出压缩事故——把gzip的字节发给不支持gzip的客户端,页面直接是乱码。内容编码协商那一维之所以人人都写对,是因为写错了会立刻挨骂。

二是排在第二三四位的全是框架自动加的。rscnext-router-state-tree这几个是Next.js自己发的,开发者没手写过一个字。Vary这件事,现网的绝大多数正确案例都不是人做对的,是框架和中间件替人做的。

三是需要人自己判断的那几维,覆盖率都在个位数。accept-language3.5%、user-agent2.2%。而前面测出来,语言这一维真的能撬动11.0%的内容变化,UA能撬动11.3%。会分岔的比例,是声明了分岔的比例的三到五倍。

保哥把这条总结成一句话:凡是写错了会立刻在用户屏幕上炸出来的,现网覆盖率就高;凡是写错了只会静静地让一部分人拿到别人的内容的,覆盖率就低。压缩协商属于前者,语言协商属于后者。这跟工程师认不认真没关系,跟反馈回路的长短有关系——响应头这一层的SEO机制之所以年年都要重讲,也是因为它整体上属于后者。

Cookie这一维的缺口最夸张。230个URL里116个首访就下发了Cookie,而这116个里111个的Vary没写Cookie,占95.7%。好在前面测出Cookie真正改变内容的只有2.2%,所以这个缺口大部分是无害的——但这属于运气好,不属于做对了。

上游一发Set-Cookie,nginx就再也不缓存了?

这一节是整篇里最反直觉的一块。

先看普查数字:230个URL里116个在首访就下发了Cookie,占50.4%(首页比例更高)。每站Cookie条数中位2条、p90是6条,最多的是sephora.com的20条。这些Cookie后续每次请求都要回传,回传字节中位209、p90是1090,最多2967字节。

Cookie属性方面顺带记一下现状,因为这几个数很少有人量过:HttpOnly出现在56.9%、SameSite=None在42.2%、SameSite=Lax在32.8%、SameSite=Strict只有4.3%。而Partitioned(也就是分区Cookie)、__Host-前缀、__Secure-前缀,三个各自都只出现在2个URL上,占1.7%。业界讨论了好几年的这几样东西,现网首页上基本等于没有。SameSite那一整套倒是铺开了,毕竟浏览器直接改了默认值,不跟就出事故。

现在关键问题来了:这一半会下发Cookie的页面,放在nginx的proxy_cache后面会怎么样?

实验台上打三次,结果是三次全部MISS。不是命中率低,是一次都不缓存。

nginx官方文档把这条写得干干净净:

"If the header includes the "Set-Cookie" field, such a response will not be cached."

这是个非常合理的默认值——Set-Cookie意味着这个响应里有专属于某一个人的东西,缓存下来发给别人显然是灾难。但把它和50.4%这个普查数字放在一起,结论就相当刺眼了:

现网有一半的页面,在标准的nginx反向代理缓存配置下,一次都缓存不了。而站点自己往往并不知道——你配了proxy_cachenginx -t通过了,重载成功了,日志里也没有任何异常,只是命中率永远是0。这跟PHP在响应头里替你写了一行1981年的日期是同一类故障:缓存没生效,而不生效这件事本身不产生任何错误。

顺带说,这条对fastcgi_cache那一套同样成立,PHP只要调了session_start()就会发Set-Cookie,整页缓存当场失效。很多人装完全页缓存插件发现“怎么没快多少”,根子在这儿。

教程里那句proxy_ignore_headers,代价是什么?

发现命中率为0之后,搜索引擎会很快把你引向一条广为流传的解法。nginx确实提供了这个开关,文档里列得明明白白:

"Disables processing of certain response header fields from the proxied server. The following fields can be ignored: "X-Accel-Redirect", "X-Accel-Expires", "X-Accel-Limit-Rate" (1.1.6), "X-Accel-Buffering" (1.1.6), "X-Accel-Charset" (1.1.6), "Expires", "Cache-Control", "Set-Cookie" (0.8.44), and "Vary" (1.7.7)."

加上proxy_ignore_headers Set-Cookie;,命中率立刻上来了。我在实验台上打三次,第一次MISS,第二三次HIT。皆大欢喜。

然后我看了一眼这三次响应里的Set-Cookie值:

第几次X-Cache响应里的Set-Cookie
1MISSd86pref=4c030e5c9ac8c1c5; path=/; HttpOnly
2HITd86pref=4c030e5c9ac8c1c5; path=/; HttpOnly
3HITd86pref=4c030e5c9ac8c1c5; path=/; HttpOnly

三个不同的访客,拿到了同一个Cookie值。

作为对照,默认配置下同样打三次,三个人拿到的是961f8a95…b29783ae…81f55adf…三个不同的值——那才是正常的。

proxy_ignore_headers Set-Cookie做的事情,是让nginx忽略这个头对缓存决策的影响,但并不把这个头从响应里删掉。于是第一个访客的那份响应连同他的Set-Cookie一起被存进了缓存,之后每一个HIT都把这个头原样发出去。如果这个Cookie恰好是会话标识,你就得到了一个所有人共用同一个会话的站点。

更要命的是那个开关名字里还能加Varyproxy_ignore_headers Vary;这行的意思是“上游让我按语言分片,我不听”——把本文第三节那个德语版事故,从“忘了配”升级成“主动配出来的”。

正确的做法不是忽略,是分情况:

  • 如果这个Cookie对页面内容没影响(统计、埋点、A/B分桶标识),用proxy_hide_header Set-Cookie把它从缓存的那一份里摘掉,而不是无视它;
  • 如果这个Cookie会影响内容(登录态、地区、币种),那这个页面本来就不该进共享缓存,该走proxy_no_cache按Cookie存在与否绕开;
  • 如果只是想给匿名访客提速,判别条件应该写在请求侧——请求里没有登录Cookie才走缓存,有就直接回源。

保哥见过的最惨一次,是某个站为了提升命中率把这行加在了全站location /上,上线三小时后客服收到用户反馈说“我登进去看到的是别人的订单”。这类事故的可怕之处在于,它在压测里测不出来——压测工具不带Cookie,也不看别人的Cookie。

Googlebot在这套机制里站在哪个位置?

把前面几节的机制拼起来,Googlebot的处境就清楚了。它有三个特征,每一个都有官方出处。

第一,它不带Cookie。Google在A/B测试的官方指南里写:

"Googlebot generally doesn't support cookies. This means it will only see the content version that's accessible to users with browsers that don't accept cookies."

第二,它不发Accept-Language。这条写在本地化自适应页面的文档里:

"the crawler sends HTTP requests without setting Accept-Language in the request header."

第三,它的出口IP不固定在一个国家。同一份文档里:

"Googlebot crawls with IP addresses based outside the USA, in addition to the US-based IP addresses."

把这三条和缓存机制叠起来,会得到几个不太舒服的推论。

推论一:在写了Vary: Accept-Language的站上,Googlebot独占一个缓存分片。因为RFC规定“字段不存在只能匹配字段也不存在”,而Googlebot从不发这个头。这个分片没有任何真人会命中,所以它多半是冷的——Googlebot每次抓取都在为自己单独回源一次。好的一面是它拿到的内容至少是干净的默认版。

推论二:在没写Vary的站上,Googlebot拿到的是别人的版本。我在实验台上验了这一条:先让德语用户灌一次缓存,再用Googlebot的UA去抓,结果是HIT,内容<html lang="de">。而同一个Googlebot直连上游(绕过缓存),拿到的是默认的<html lang="en">

同一个爬虫,走缓存拿德语、直连拿英语。哪一版进索引,取决于它那一次请求落在哪个边缘节点、以及那个节点上碰巧是谁先到的。

再叠上第三条——它的IP分布在多个国家——事情就更热闹了:一个做了地理分发的CDN,Googlebot从法兰克福节点抓和从弗吉尼亚节点抓,命中的是两个完全独立的缓存池,里面躺着的可能是两批不同人灌进去的内容。

保哥拿这条去解释过一个一直查不明白的现象:某个客户的德语站,Search Console里偶尔会冒出几个页面的索引内容是英文的,重新提交之后又好了,过一阵子再犯。查源站查不出问题,查hreflang也全对。后来发现根子在边缘——那批URL的Vary里没有Accept-Language,而德语站的默认语言恰好是英文。Googlebot从哪个节点来、那个节点上前一个访客是谁,决定了这次抓到的是德语还是英文。这种问题没法靠“重新提交”修,因为它不是内容错了,是内容不确定。

推论三:这类问题在日志里几乎无法归因。你的源站日志里只会看到Googlebot偶尔来了一次,看不到它在边缘拿到了什么。日志分析能告诉你抓了多少次、抓了哪些URL,告诉不了你每一次抓到的是哪一版。要查这个,只能在边缘那一层打日志,或者用模拟Googlebot的UA从多个地理位置各测一遍——而这又回到上一篇那件事:测之前得先知道这个页面自己抖不抖,否则你分不清“两个节点给的不一样”和“这个页面本来就每次都不一样”。

最后补一句关于Client Hints的。有一套新机制叫客户端提示,服务器发一个Accept-CH响应头,浏览器在后续请求里就会带上屏幕宽度、设备型号这类信息。230个URL里发Accept-CH的有12个(5.2%),发Critical-CH的只有2个(0.9%)——ebay、walmart、gap、nike、paypal、moz、vercel这几家。

这套机制对爬虫天然失效,原因和Cookie一模一样:它要求客户端有“后续请求”这个概念,而爬虫每一次抓取都是第一次。服务器发出去的那句邀请,从来没被回过话。所以任何依赖高熵Client Hints来决定页面内容的逻辑,对搜索引擎而言就是一段死代码——它永远走的是那个“什么提示都没拿到”的分支。

这笔账该怎么配才两头都不亏

把整篇的实测收成一张可执行的表。

如果你的站……该做什么为什么
Accept-Language在同一个URL下换语言改成每个语言一个独立URL,配hreflang;短期内至少先把Vary: Accept-Language补上不补Vary会串版本;补了会把缓存打成20份。独立URL是唯一两头都对的解
按UA给爬虫和用户不同内容先确认是有意还是无意;无意的(设备判别、防护页)尽快统一,有意的对照cloaking的判定口径自查实测11.3%的稳定页面会因UA分岔,多数站自己不知道
首访就下发Cookie,且配了proxy_cache查一下命中率是不是0。是的话,用proxy_hide_header把无关Cookie摘掉,别用proxy_ignore_headersnginx默认见Set-Cookie就不缓存;忽略它会把第一个人的Cookie发给所有人
用了CDN且有多地区流量在边缘把高熵请求头归一化成少数几档,再做缓存键20个取值只对应3种内容,归一化能把命中率救回来
什么都没按请求头分确认Vary里只有Accept-Encoding,别让框架偷偷加东西多一个Vary字段就多一层分片,白白掉命中率
想验证自己有没有问题同一个URL用3种Accept-Language、带与不带Cookie、浏览器与Googlebot两种UA各测一遍,比指纹10分钟能测完,但要先量过基线抖动,否则结论不可信

我拿自己这个站跑了一遍:首页在en-USzh-CNde-DE三种Accept-Language下,以及换成Googlebot的UA,四次拿到的都是同一枚指纹b9711033b7b9Vary里只有Accept-Encoding;没有Set-Cookie。这不是优化出来的,是“什么都没按请求头分”的自然结果。

一个页面只有一个版本,这件事在今天已经算是一种奢侈了——但它换来的是:任何人、任何缓存、任何爬虫,在任何时候看到的都是同一份东西。做技术SEO时最难排的那类问题,几乎全部来自这个前提不成立。

常见问题解答

不写Vary,Google会不会因此判我cloaking?

不会。cloaking的定义是有意针对爬虫身份返回不同内容,而缺Vary造成的串版本对所有人一视同仁——德国用户先到,中文用户和Googlebot拿到的是同一份德语页。它不是作弊,是缓存事故。真正的危害在别处:Googlebot可能把一个语言版本的内容记在了另一个URL名下,导致索引里的内容和落地页对不上,以及规范网址判定不稳定。

Vary里能不能写User-Agent?

技术上可以,实际上基本不该。UA的取值空间是所有请求头里最大的之一,浏览器版本号每几周就滚一次,按它分片等于让缓存彻底失效。现网也印证了这一点:230个URL里只有5个(2.2%)在Vary里写了UA。如果你确实按UA分内容(比如给移动端一套模板),正确的做法是要么响应式一套HTML,要么移动端用独立URL,而不是靠Vary撑着。

Googlebot不发Accept-Language,那我的多语言站它怎么抓?

它抓到的是你的默认语言版本。这正是Google反复建议“每个语言版本要有独立URL”的原因——只有独立URL,爬虫才有办法访问到非默认的那些版本。如果你的德语内容只存在于“发了de-DE请求头才出现”的那个状态里,那它在索引里就是不存在的。这一点和按IP自动跳转是同一个坑的两种表现。

proxy_hide_header和proxy_ignore_headers到底差在哪?

差在动作对象。proxy_ignore_headers Set-Cookie是让nginx在做缓存决策时不理会这个头,但这个头仍然留在响应里,会跟着缓存副本一起发给后面每一个人。proxy_hide_header Set-Cookie是把这个头从发给客户端的响应里删掉。想安全地缓存一个带无关Cookie的页面,两个通常要一起用:ignore让它能进缓存,hide保证进去的那份不带别人的身份。

只有50.4%的页面下发Cookie,是不是说另一半可以放心缓存?

下发Cookie只是最容易被发现的那道障碍。另一半还得看Cache-Control里有没有no-storeprivate,PHP有没有自动写no-cache,以及有没有Vary把分片打碎。判断一个页面能不能被共享缓存住,得把这几样一起看。最省事的验证办法是直接看命中率,连打三次看X-Cache,比逐条读配置快得多。

Client Hints对SEO有没有影响?

目前基本没有正向影响,只有负向风险。因为爬虫没有“后续请求”,服务器发出去的Accept-CH永远等不到回话,爬虫拿到的一定是“什么提示都没有”那个分支的内容。如果你的页面在这个分支上退化得很厉害(比如图片全部按最小尺寸出、组件不渲染),那搜索引擎看到的就是那个退化版本。现网发Accept-CH的只有5.2%,暂时不是普遍问题,但用了的站值得单独验一遍。

怎么最快确认自己的缓存有没有在串版本?

三步。第一步,直连源站,用两种Accept-Language各请求一次,看内容是否不同——如果相同,说明你压根没按语言分内容,后面不用查了。第二步,如果不同,看响应头里有没有Vary: Accept-Language,没有就是确定的隐患。第三步,通过CDN请求,先用一种语言灌一次,再换另一种语言请求,看X-Cache是不是HIT、内容是不是变成了第一种语言的。第三步能复现,就是实锤。

权威参考资料

分享到
标签
版权声明

本文标题:《决定SEO收录哪一版页面的不是你的配置,是10分钟前路过的那个德国用户》

本文链接:https://zhangwenbao.com/vary-header-cache-fragmentation-googlebot-version-seo.html

版权声明:本文原创,转载与引用请注明作者与原文链接。许可协议: CC BY 4.0

继续阅读
发表评论
分享到微信 或在下方手动填写
支持 Ctrl + Enter 提交