在云原生社区近日主办的 Service Mesh Summit 2022 服务网格峰会上,网易数帆云原生技术专家方志恒分享了轻舟服务网格无侵入增强 Istio 的经验,本文据此次分享整理,介绍了对无侵入和实现的思考,轻舟服务网格演进过程中的扩展增强,以及这些扩展增强和无侵入的关系。这里“无侵入”强调的是对服务网格基础设施本身的无侵入,而不是只有对业务的无侵入,后者是服务网格本身的定位所要考虑的内容。
服务网格维护中的无侵入
关于无侵入,我们从各自的实践经验也知道,做无侵入的增强是非常困难的。原因有很多,比如说我们可能要做业务的适配,快速落地,定制的一些需求等,业务以及项目周期的压力,迫使我们不得不做出一些有侵入的选择。但是我们为什么还要去强调无侵入呢?因为对于我们维护团队,或者说对于我们自己这个技术方案的维护方来说,每一分侵入都会有每一分的成本,它会在后续会体现出来,比如说在做长期维护,做版本演进,去做社区的一些功能和新版本的对齐,我们都需要去解决我们的改动和社区的主干分支之间的冲突。
因此,我们需要在我们的研发流程里面去贯彻这样一个目标或者理念:这个方案是不是做到无侵入了,如果没有做到的话,那做到的程度是怎么样的?这样可能才得到一个“求上得中”的效果,就是坚持无侵入,我们才可能做到比较低的侵入。
在使用社区的方案去做定制开发、去演进的过程中,我们认为,有一些比较合适的用来去做维护的思路,最合适的方式,是直接使用社区原生的 API 提供的扩展点去做一些无侵入的扩展,这是最理想的情况。当然,社区的一些扩展的 API 可能无法完全满足我们的需求,这个时候我们可以在上层去做一个封装,业界的一些落地的实践案例也体现出这样的理念。这里引用一句名言:计算机科学领域的任何问题,都可以通过增加一个中间层来解决。
即使是这样,我们出于性能的考虑,或者出于特性的考虑,很多时候还是会面临一些不得不去做修改的情况。我们有第二个原则,就是把扩展增强的内容去做一些封装在一个单独的库里面,这样可以做到最小的修改和替换。这也是一个比较朴素的工程经验。
第三点,如果我们确实要做比较大的一个修改,这个时候我们可以尽量去贯彻一个理念,就是要对社区原生的一些设计思路和特性做一致性的对齐。这里我分享一个“撸猫原则”:如果我们非要去撸一只猫的话,最好顺着它的毛去撸,否则我们可能会把它的毛搞得很乱,甚至它还会反过来咬我们一口。
服务和配置的扩展
首先介绍我们对 Istio 的服务和配置的扩展。
配置
Istio 社区已经提供支持多 configSource,并给出了一个协议叫 MCP-over-xds,通过这种方式我们可以从不同的数据源去拿到所需的配置。
configSources:
- address: xds://mesh-registry.istio-system.svc:16010?type=serviceentry&type=sidecar
- address: k8s://
这里给出一个配置样例,是通过 xDS 协议去向某一个服务去获取它的配置,同时也可以去 Kubernetes 去获取那些标准的 CR,这是它的一个扩展的方式。
我们的 URL 跟官方的稍微有点不一样,是在这基础上稍微做了一点改进,实现了一个叫做 Istio-MCP 的库平替社区原生的 adsc,这就是前面说的第二个原则。在这个库里面,我们实现了 xDS 的增量推送,更增强了一个 revision 的机制,Istio 支持按照 revision 去获取自己感兴趣的配置,我们对此做了增强。我们还做了一个更灵活的分派,允许在 configSource 里面去指定一个类型,相当于从不同的 configSource 去获取不同类型的配置,这个很多时候都是实践的需要。
服务
服务这部分,在网易数帆的场景里面,比较广泛地应用到了 ServiceEntry
。Istio 对 Kubernetes 服务的支持很好,大部分情况下无需做额外的扩展,但是因为它的定位,Istio 把对非 Kubernetes 服务的支持几乎都留给了它的一个扩展点,就是 ServiceEntry
这个API,以及前面所说的配置扩展的方式。通过这种方式,Istio 允许第三方来作为配置和服务的提供员,来提供其他的服务模型。当然在这个过程中,第三方需要自己去完成服务模型的转换,因为 ServiceEntry
几乎就是 Istio 内部服务模型的 API 版本,你可以认为它是 Istio Service 模型。
我们也有一个单独的组件,叫做 mesh-registry,实现了 MCP-over-xds 的协议,作为一个MCP server。在这个组件内部,我们支持了不同的服务模型的转换,包括将 Dubbo/ZK、Eureka、Nacos 去转换成标准的 ServiceEntry,然后下发。
这是在服务这一块的扩展。从目前来说,以上两部分的扩展方式都可以说是无侵入的,是基于社区原生接口和协议的扩展。
插件的扩展
第二部分是插件的扩展。Istio 的功能确实很丰富,这导致它在市场上成为主流,使用的人很多。但很多人使用同时也意味着,即使 Istio 的能力再丰富,它也无法覆盖所有用户的场景,就会需要这种扩展机制。
EnvoyFilter
Istio 社区的扩展方式是一个比较典型的 EnvoyFilter 的方式。这种方式,configPatches 进去的内容是 Envoy 的一个一个 filter 的配置,Envoy 具体的内容我们可以先忽略,先看一下 applyTo、context、match、routeConfiguration、vhost 这一堆东西。
举个例子,我们要做一个限流的功能,因为它是一个业务功能,作为使用者,我们要知道限流 API 的业务语义,首先需要去看 Envoy 的限流插件它的 API 是怎么样的,跟上层对限流的业务需求是不是对得上,再确定我应该怎么样去写一个限流的插件的配置。到这一步还只是完成了要 Patch 进去的内容,要写出这个 EnvoyFilter 的时候,我们还需要去了解更多的东西,比如这里的 applyTo、HTTP_ROUTE 以及 vhost 等。如果是其他的类型的 Patch,可能还有其他的概念。
apiVersion: networking.istio.io/v1alpha3
kind: EnvoyFilter
metadata:
name: bookinfo-gateway-sampling
namespace: istio-system
spec:
configPatches:
- applyTo: HTTP_ROUTE
match:
context: GATEWAY
routeConfiguration:
portNumber: 80
vhost:
name: "*:80"
patch:
operation: MERGE
value:
这里面有一个本质的问题,filter 是 Istio 提供的一个几乎是纯数据结构级别的 Patch 机制,它直接操作 Istio 下发给 Envoy 的 xDS 配置,它的数据结构的描述、定义和类型都是 Envoy 侧的一些概念,比如 vhost,这就意味着 Istio 的使用者需要深入了解 Envoy 侧的概念。同时还有一些的灰色地带,举个例子,如果我们要给一个 vhost 去 Patch 一些东西,就要知道 vhost name,而 vhost name 是 Istio 自己的一个实现,纯粹实现层面的东西,相当于说上层的使用者还要知道某一个版本的 Istio,它的 vhost name 是通过什么规则拼起来的。我们会认为对使用者来说负担会比较多,一个比较理想的做法是,他既然是 Istio 的使用者,那么他接触到的应该尽量是 Istio 层面的一些语义。我们对它做增强的思路就是这样的。
下面是轻舟服务网格做的一个比较浅的封装,但是在我们内部用得很多,所以我们认为它解决了一些实际问题。这个字段描述我们这个插件要去作用于网关,作用于某一个 host,作用于某一条路由,也就是说我们会尽量用 Istio 层面的语义来做这种类似的封装,帮用户转成下层的 Envoy 语义。
apiVersion: microservice.slime.io/v1alpha1
kind: EnvoyPlugin
metadata:
name: reviews-ep
namespace: istio-samples
spec:
workloadSelector:
labels:
app: reviews
gateway:
- gateway-system/prod-gateway
host:
- reviews.istio-samples.svc.cluster.local
route:
- ratings.istio-samples.svc.cluster.local:80/default
- prefix-route1
plugins:
- name: envoy.filters.network.ratelimit
enable: true
inline:
settings:
{{plugin_settings}} # plugin settings
基于同样的思路,我们还做了一个限流的模块。但这里不是为了讲限流,而是说我们怎么去做上层的业务语义描述。限流这个功能有点特别,可以做得很复杂,所以 Envoy 提供了一个非常灵活的 API,这带的一个问题是,别说 Istio 的用户,就是 Istio 的维护者自己要把 Envoy 限流 API 看明白,都需要付出较多的时间和精力。所以,我们希望把它做得简化一点,更接近业务语义描述,这也是一个复杂度的消化——Envoy 做得非常灵活,它什么都可以做,但复杂度不会凭空消失,中间需要肯定有一层实现业务语义到底层的灵活能力的映射。
apiVersion: microservice.slime.io/v1alpha2
kind: SmartLimiter
metadata:
name: review
namespace: default
spec:
sets:
v1:
descriptor:
- action:
fill_interval:
seconds: 1
quota: "10"
strategy: "single"
condition: "{{.v1.cpu.sum}}>10"
target:
port: 9080
Rider 插件扩展
插件扩展的第二大类是我们的 Rider 的插件,Rider 比较像 Envoy 版本的 OpenResty,Envoy 本身有支持 Lua 的插件,但是它的支持比较简单,里面的 API 比较少,熟悉 OpenResty 的同学应该知道,我们写 Lua 和 OpenResty 是完全不一样的,因为 OpenResty 提供了很丰富的,跟网络操作、跟 Nginx 内部 API 做交互的 API,让我们很容易去做实际业务功能的开发,你无法想象我们纯粹用 Lua 去开发一个 HTTP_SERVER。基于这个背景,我们对 Lua 做了一个增强,在 Rider 插件提供了比较丰富的 Lua 交互的 API,让用户可以相对容易地在里面去实现一个上层的业务和治理的功能。另外,我们也对原生的 Lua 插件实现做了一些性能优化,相比来说 Rider 是有一定的性能优势的。
Rider 和原生的 Envoy Lua 插件的对比,是我们可以支持插件的配置。这里的配置是指类似于 Envoy 的 WASM 或者 Lua 插件,都是分成两部分,一部分是下发一个可执行的内容,无论是 Lua 脚本还是 WASM 二进制,都是一个插件实际执行的逻辑,我们还可以给它一份配置,这个配置跟执行内容两相结合,形成最终的业务行为。
社区的 Lua 不支持如路由级别的插件配置,这导致它的行为比较死,比较 hardcode,我们支持它的插件配置,支持更多的 API,性能也会更好一点。
现在 WASM 是一个很火的概念,我们也跟 WASM 做了一个对比,第一点是 Lua 跟 WASM 的对比,作为脚本语言,Lua 更加可见即可得,虽然 WASM 也可以从脚本编译而得,但如果用脚本转成 WASM 再做下发,WASM 编译语言性能损耗更小的优势就没有了。第二点是便于分发,因为没有编译的过程。第三点也是支持更多的API,原生的 Envoy WASM API 确实会少一些。最后一点是更好的性能,我们原以为即使考虑上 LuaJIT,Rider 的性能也不会更好,但实际上 Envoy WASM API 的实现导致 Envoy WASM 跟 Envoy 内部交互的成本略高,所以测出来它的性能反而比 Rider 要更差一点。
扩展方案 | 是否支持插件配置 | 是否支持动态扩展 | 性能 | 支持语言 | 开发复杂度 |
---|---|---|---|---|---|
原生 C++ | 是 | 否 | 最优 | C++ | 复杂 |
社区 Lua | 否 | 是 | 较差 | Lua | 简单 |
社区 WASM | 是 | 是 | 差 | C++/Rust/Go等 | 中等 |
这是我们实际上测出来的一个对比,我们分别模拟三种场景下,一个很简单的,一个中等复杂度的,还有一个稍微高一点复杂度的,不同方案的性能差别,从图中可以都可以看到,三种场景下 Rider 插件的性能都是好于 WASM C++ 的,尤其是在复杂场景,大概有10%左右的提升,当然这三者相比于原生插件都有不小的差别。
这里给出了一个我们提供的 API 的列表,可以看到风格还是非常的 Resty 的。
我们做的 RiderPlugin,这个东西比较有意思,下面是一个 WASM 的样例,大家可以看到它的 API 的数据结构,完全是一个 WASM 的 API,但实际上做的事情,是通过镜像的方式把我们的 Rider 插件给下发了,再分发到数据面,效果是将 HTTP 请求的那个 response 的 body 改成了 C++ is awesome
。所以说,使用上不能说差不多,简直是一模一样。
# wasm_plugin.yaml
apiVersion: extensions.istio.io/v1alpha1
kind: WasmPlugin
metadata:
name: test1.rider
spec:
imagePullPolicy: IfNotPresent
imagePullSecret: qingzhou-secret
pluginConfig:
destination: Body
message: C++ is awesome!!
source: Static
selector:
matchLabels:
app: reviews
sha256: nil
url: oci://slimeio/rider_plugin:0.1.0
我当时看到 Istio 的 WasmPlugin 这个 API 的时候,是有点惊讶的,第一是不知道它为什么要做一个 WasmPlugin,而不是做一个通用的插件分发机制,有点过于耦合了;第二是我发现它字段的设计就是一个通用的插件分发,但是它的名字就叫做 WasmPlugin,当然它在实现的时候也是这样的。所以,我们完全可以用这个 API 来实现我们的 Rider 插件的分发,这个是我们目前已有的一个特性。当然我们还另外设计了一个 RiderPlugin CRD,主要是考虑到后续 Rider 可能提供更丰富的功能,会有更多的字段。我们在实现这个支持的时候,不能说没有侵入,但真的是只改了一点点——它的那些插件分发,包括 Pilot agent 里面,怎么从镜像里面去提取 WASM 文件,我们几乎按同样的规范去定义 Rider 的镜像,去在里面放我们的 Rider 的插件的配置,Lua 的文件,几乎是完全一样的。这就是前面提到的另一个原则,如果我们非要去做一些新的功能,可以做得跟原生的比较像,这样的话无论是我们自己还是用户,都很容易上手。
我们最终下发给 Envoy 的一个数据结构,和 WASM 是有一些差异的,这个差异是在实现里面去做了一个屏蔽,本质上说,我们只是在最后生成下发给 Envoy 的数据的时候对内容做了一些修改,让它是一个 Rider 的格式。
route_config:
name: local_route
virtual_hosts:
- name: local_service
domains:
- "*"
# Plugin config here applies to the VirtualHost
#
# typed_per_filter_config:
# proxy.filters.http.rider:
# "@type": type.googleapis.com/proxy.filters.http.rider.v3alpha1.RouteFilterConfig
# plugins:
routes:
- match:
prefix: "/static-to-header"
route:
cluster: web_service
# Plugin config here applies to the Route
#
# plugins is a list of plugin route configs. Each entry has a name and its config.
# The filter will look up the list by order given a plugin name, and use the first match entry.
typed_per_filter_config:
proxy.filters.http.rider:
"@type": type.googleapis.com/proxy.filters.http.rider.v3alpha1.RouteFilterConfig
plugins:
- name: echo
config:
message: "Lua is awesome!"
source: Static
destination: Header
header_name: x-echo-foo
http_filters:
- name: proxy.filters.http.rider
typed_config:
"@type": type.googleapis.com/proxy.filters.http.rider.v3alpha1.FilterConfig
plugin:
vm_config:
package_path: "/usr/local/lib/rider/?/init.lua;/usr/local/lib/rider/?.lua;"
code:
local:
filename: /usr/local/lib/rider/examples/echo/echo.lua
name: echo
config:
message: "C++ is awesome!"
source: Static
destination: Body
- name: envoy.filters.http.router
typed_config: {}
基于此我们还做了前面提到的一个 EnvoyPlugin 的插件,对插件分发做了一个比较浅的上层封装,也加入了对 WASM、Rider 的支持,将会在近期 release。
Dubbo 协议扩展
接下来介绍协议的扩展。我们第一个做的是 Dubbo 协议扩展,因为 Dubbo 在国内确实使用很广泛,同时我们还有一些特殊的考量,稍后再详解。
- 数据面
首先看数据面的部分。第一,我们做了比较丰富的七层的 dubbo filters,Istio + Envoy 这套体系的大部分治理功能都是通过七层插件来实现的,HTTP 的比较丰富的治理功能有很多七层插件,所以我们也实现了很多 Dubbo 的插件。
"typed_per_filter_config": {
"proxy.filters.dubbo.locallimit": {
"@type": "type.googleapis.com/udpa.type.v1.TypedStruct",
"type_url": "type.googleapis.com/proxy.filters.dubbo.local_limit.v2.\
ProtoCommonConfig",
"value": {
第二,我们实现了一个 DRDS,也就是 Dubbo RDS,因为 Envoy RDS 几乎等同于 HTTP RDS,只定义了 HTTP 协议相关的路由的数据结构,我们要实现一个比较灵活的路由,以及性能比较好的路由分发,就需要定义一个单独的 xDS 资源类型。
第三,我们还做了一个 Dubbo 协议嗅探,这是有一些特殊的场景,Istio 社区对协议嗅探有一部分的的支持,它的本意不是为了实现一个很丰富的基于协议嗅探的治理,而是为了解决一部分场景需求,相当于我们引入一个代理之后,会有一些场景需要通过特殊的技术手段去解决,而我们在实际生产中会面临 HTTP 的、TCP 的、Dubbo 的协议,也需要用同样的机制去解决它,所以我们也支持 Dubbo 的协议嗅探。其中技术细节非常多,这里就不展开。
- 控制面
控制面的改动更多,用户会更可感知。我们的支持比较特殊,因为我们实现了跟 HTTP 几乎完全同等语义的 Istio API,体现为 VS/DR,就是基本的治理。还有比较丰富的治理,是那些七层的 filter,还有 Istio 这一层所抽象出来的认证鉴权的策略是只作用于 HTTP 的,我们也是对它做了 Dubbo 的支持。还有一个比较特殊的就是 Sidecar,Istio 有一个资源是用来描述应用或者服务之间的依赖关系的,这样就可以实现按需下发,像配置瘦身、推送范围的影响,都可以得到一个比较好的优化,我们对此也做了 Dubbo 的支持,相当于用标准的 API 去支持 Dubbo 的类似于服务依赖描述和按需下发。还有一个是 EnvoyFilter,我们也支持了用 EnvoyFilter 的标准的 API 去做 Dubbo 插件的分发。
- applyTo: DUBBO_FILTER
match:
context: SIDECAR_OUTBOUND
listener:
filterChain:
filter:
name: envoy.filters.network.dubbo_proxy
subFilter:
name: envoy.filters.dubbo.router
patch:
operation: INSERT_BEFORE
value:
config:
'@type': type.googleapis.com/udpa.type.v1.TypedStruct
type_url: type.googleapis.com/proxy.filters.dubbo.traffic_mark.v2.ProtoCommonConfig
- 通用七层扩展框架
我们也做了通用的七层扩展框架的支持,我们在 Envoy 社区的 Maintainer 也和国内同行沟通过,共同努力推进,目前已经合入 Envoy 社区版本,也就是说在数据面是有通用七层扩展框架的支持的,后续 Istio 社区相关的支持,我觉得也是可以期待的。
这里简要展开一下我个人对通用七层扩展框架的理解。我们服务网格多协议适配以及长期维护的成本很高,每接入一个新的协议,都需要去做一个额外的适配。在 Envoy 视角来说,支持一个新的协议需要做的事情,首先是对协议基本的编解码和协议流程的支持,这是协议内部的东西,肯定要做的;除此之外,还涉及到它要跟 Envoy 原有流程的对接,比如 Cluster 是不是可以用,路由是怎么生效的,流量的分派从 listener 进来怎么走到协议解析器或者是四层的 filter,也就是说,一个新的协议的支持要做很多重复性的事情,比如跟原有的机制相结合,这部分重复的工作量我们是可以省去的。
从服务代理和服务治理的视角来说,很多时候我们不关心它是什么协议,服务治理可以理解为一个基于特征的流量分配或者是流量处理,也就是说通常我们关心的是它的特征能否用一种比较通用的方式去描述,就好比我们在做协议设计的时候,可能都会塞进去一个字段,叫做 metadata,HTTP 的协议里面的 header 其实就是一种 metadata,如果能用一个比较简单的通用模型去描述我们所有协议的特征,我们就可以基于这个模型去做服务治理,剩下的事情就是把已有的协议来转化成这个通用模型。当然,所有的这种加一个简单的中间层去做一个复杂度的屏蔽,都会有一个问题,就是无法感知我们抽象出来的共性以外的东西。
举个例子,如果我们要支持 HTTP2,想做一些相对下层的治理,可能会比较困难,因为感知不到 stream、frame 等等,能感知到的就只有它这个抽象。这就是我觉得所有类似的技术方案都有同样的问题。但是一个技术方案的价值,肯定是在它带来的收益减去副作用之后的,如果我们觉得还不错,就可以继续去使用它。
我们目前在用这个通用框架去实现 Dubbo。以前 Dubbo 之所以没有进 Istio 社区,是因为 Istio 社区并不想维护一个特定的协议,即使该协议国内用户比较多,而且因为年代的原因,Dubbo 是不那么云原生的。但是如果我们这里引入的不是 Dubbo,而是一个通用的七层框架,那应该比较乐观一些。所以我们后续会替换原有的 Dubbo Proxy,就是数据面这一块,同时也会尝试跟相关方去推动控制面的接入,看能不能进入 Istio 社区。
Slime 开源项目的集成
上述的很多扩展增强,都已经沉淀在我们开源的 Slime 项目(github.com/slime-io/slime )里面了。这个项目已经进入 Istio 生态,我们对 Istio 的增强,或者是说魔改也好,或者是说生态丰富也好,都会放进 Slime 项目里面,它是一个 group,里面包含比较多的子项目。这里简单介绍 Slime 最近的一些进展。首先在架构层面,我们做了比较彻底的模块化设计,可以快速地去对 Slime 做上层功能的扩充。
我们定义了一个比较明确的框架层来管理上层的模块,同时也提供一些基础能力给上层,包含定义的一些 metric 框架,让上层可以用很少的代码去获取到一些像 Kubernetes 乃至更多类型的 metric 信息,并且对它做一些处理。同时我们也做了多集群的支持——很多时候我们之所以做一些东西,是因为 Istio 的多个增强功能都需要去做相关的支持,我们就会把它放到框架层——在这个框架层我们直接做了与 Istio 原生比较一致的多集群感知,社区叫做 multi cluster discovery。
我们还支持了一个统一的服务模型数据,支持了 Kubernetes Services 和 ServiceEntry,两个消化完以后处理为内部的一个数据类型。我们之前有了解到,一些同行的朋友在调研技术方案的时候,发现我们只支持 Kubernetes 就没有继续了。比如懒加载的方案,我们现在已经做了一个比较彻底的支持。
还有一个模块聚合和管理的能力,最终的形态大概是这样,只要手动写几行代码,把每一个模块直接放进来就可以了。
func main() {
module.Main("bundle", []module.Module{
&limitermod.Module{},
&pluginmod.Module{},
&pilotadminmod.Module{},
&sidecarmgrmod.Module{},
&tracetiomod.Module{},
&meshregistrymod.Module{},
})
}
我们在最近一年多做了很多的模块,比如前面提到的 meshregistry、pilotadmin、sidecarmgr、tracetio,其中里面有一部分已经开源出去了,还有一部分因为耦合了一些业务逻辑,可能会晚一点开源。
稍微重点说一下,我们有一个子项目叫做 i9s(github.com/slime-io/i9s ),是从另一个开源项目 K9s fork 过来的。熟悉 Kubernetes 的同学可能会用过 K9s ,它提供一种交互式的方式,我们觉得它很好用。同时我们也想到,对于 Istio 的运维管理,有很多地方也可以用这种方式来实现,使用起来会更方便一些。i9s 本质上是用 K9s 这种交互式的视图去展示 Istio 的各种内部信息,比如可以去查看 Istio 上面连接哪些 Sidecar,每个 Sidecar 的运行状态,下发的配置,它的配置是否和应有的配置一致,我们可以去 watch 推送的频率,以及推送的时延,等等。
Slime 项目后续会开放更多服务网格管理的能力,期待大家共建社区。谢谢!
延伸阅读
- 视频回放:https://www.bilibili.com/vide...
- IstioCon 回顾 | 网易数帆的 Istio 推送性能优化经验
- Slime 2022 展望:把 Istio 的复杂性塞入智能的黑盒
- Slime 项目:https://github.com/slime-io/s...
- i9s 子项目:https://github.com/slime-io/i9s
- Rider 插件框架:https://github.com/hango-io/r...
作者简介: 方志恒,网易数帆云原生技术专家,负责轻舟 Service Mesh,先后参与多家科技公司 Service Mesh 建设及相关产品演进。从事多年基础架构、中间件研发,有较丰富的 Istio 管理维护、功能拓展和性能优化经验。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。