Preamble
We will show you a go-zero microservice example in detail through a series of articles. The whole series is divided into ten articles. The directory structure is as follows:
- Environment construction
- service split
- User service
- product service
- Order service
- payment service
- RPC Service Auth Authentication
- service monitoring
- link tracking
- Distributed Transactions (this article)
I hope that through this series, you can quickly develop a mall system using go-zero in the Docker environment on the local machine, so that you can quickly get started with microservices.
Complete example code: https://github.com/nivin-studio/go-zero-mall
First, let's take a look at the overall service split diagram:
10.1 DTM
Introduction
DTM is a distributed transaction manager developed by golang
, which solves the problem of consistency of updating data across databases, services, and language stacks.
Most of the transactions of the order system will cross services, so there is a need to update data consistency, and the architecture can be greatly simplified through DTM to form an elegant solution.
Moreover, DTM has cooperated deeply and natively supports distributed transactions in go-zero. Let's explain in detail how to use DTM to help our order system solve the consistency problem.
10.2 go-zero
use DTM
First, let's review the order service in Chapter 5 order rpc
service Create
interface processing logic. The method determines the legitimacy of users and products, and whether the product inventory is sufficient, and finally creates a new order through OrderModel
, and calls product rpc
service Update
The interface updates the product's inventory.
func (l *CreateLogic) Create(in *order.CreateRequest) (*order.CreateResponse, error) {
// 查询用户是否存在
_, err := l.svcCtx.UserRpc.UserInfo(l.ctx, &user.UserInfoRequest{
Id: in.Uid,
})
if err != nil {
return nil, err
}
// 查询产品是否存在
productRes, err := l.svcCtx.ProductRpc.Detail(l.ctx, &product.DetailRequest{
Id: in.Pid,
})
if err != nil {
return nil, err
}
// 判断产品库存是否充足
if productRes.Stock <= 0 {
return nil, status.Error(500, "产品库存不足")
}
newOrder := model.Order{
Uid: in.Uid,
Pid: in.Pid,
Amount: in.Amount,
Status: 0,
}
res, err := l.svcCtx.OrderModel.Insert(&newOrder)
if err != nil {
return nil, status.Error(500, err.Error())
}
newOrder.Id, err = res.LastInsertId()
if err != nil {
return nil, status.Error(500, err.Error())
}
_, err = l.svcCtx.ProductRpc.Update(l.ctx, &product.UpdateRequest{
Id: productRes.Id,
Name: productRes.Name,
Desc: productRes.Desc,
Stock: productRes.Stock - 1,
Amount: productRes.Amount,
Status: productRes.Status,
})
if err != nil {
return nil, err
}
return &order.CreateResponse{
Id: newOrder.Id,
}, nil
}
As we said before, there is a data consistency problem in the processing logic here. It is possible that the order was created successfully, but it may fail when updating the product inventory. At this time, the order creation is successful and the product inventory is not reduced.
Because the product inventory update here is operated across services, and there is no way to use local transactions to handle it, we need to use distributed transactions to handle it. Here we need to use the DTM
SAGA
protocol of ---c8c2a5bb72d506742572b3c504f4aeb4--- to realize the cross-service distributed transaction operation of order creation and product inventory update.
You can move to the document DTM
and then take over the SAGA transaction mode .
10.2.1 Add DTM
service configuration
See Chapter 1 Environment Construction , modify the dtm->config.yml
configuration file. As long as we modify MicroService
in Target
, EndPoint
configuration to the dtm
registered to etcd
in.
# ......
# 微服务
MicroService:
Driver: 'dtm-driver-gozero' # 要处理注册/发现的驱动程序的名称
Target: 'etcd://etcd:2379/dtmservice' # 注册 dtm 服务的 etcd 地址
EndPoint: 'dtm:36790'
# ......
10.2.2 Added dtm_barrier
Datasheet
Microservices are a distributed system, so various exceptions may occur, such as repeated requests caused by network jitter, which can make business processing extremely complicated. In DTM
, the sub-transaction barrier technology was pioneered. Using this technology, abnormal problems can be solved very conveniently, which greatly reduces the threshold for the use of distributed transactions.
Using the sub-transaction barrier technology provided by DTM
requires the creation of sub-transaction barrier-related tables in the business database. The table creation statement is as follows:
create database if not exists dtm_barrier
/*!40100 DEFAULT CHARACTER SET utf8mb4 */
;
drop table if exists dtm_barrier.barrier;
create table if not exists dtm_barrier.barrier(
id bigint(22) PRIMARY KEY AUTO_INCREMENT,
trans_type varchar(45) default '',
gid varchar(128) default '',
branch_id varchar(128) default '',
op varchar(45) default '',
barrier_id varchar(45) default '',
reason varchar(45) default '' comment 'the branch type who insert this record',
create_time datetime DEFAULT now(),
update_time datetime DEFAULT now(),
key(create_time),
key(update_time),
UNIQUE key(gid, branch_id, op, barrier_id)
);
Note: Do not modify the library name and table name. If you customize the table name, please call dtmcli.SetBarrierTableName
before use.
10.2.3 Modification of OrderModel
and ProductModel
In each sub-transaction, a lot of operation logic needs to use local transactions, so we add some sub-transaction barriers model
method compatible DTM
$ vim mall/service/order/model/ordermodel.go
package model
......
type (
OrderModel interface {
TxInsert(tx *sql.Tx, data *Order) (sql.Result, error)
TxUpdate(tx *sql.Tx, data *Order) error
FindOneByUid(uid int64) (*Order, error)
}
)
......
func (m *defaultOrderModel) TxInsert(tx *sql.Tx, data *Order) (sql.Result, error) {
query := fmt.Sprintf("insert into %s (%s) values (?, ?, ?, ?)", m.table, orderRowsExpectAutoSet)
ret, err := tx.Exec(query, data.Uid, data.Pid, data.Amount, data.Status)
return ret, err
}
func (m *defaultOrderModel) TxUpdate(tx *sql.Tx, data *Order) error {
productIdKey := fmt.Sprintf("%s%v", cacheOrderIdPrefix, data.Id)
_, err := m.Exec(func(conn sqlx.SqlConn) (result sql.Result, err error) {
query := fmt.Sprintf("update %s set %s where `id` = ?", m.table, orderRowsWithPlaceHolder)
return tx.Exec(query, data.Uid, data.Pid, data.Amount, data.Status, data.Id)
}, productIdKey)
return err
}
func (m *defaultOrderModel) FindOneByUid(uid int64) (*Order, error) {
var resp Order
query := fmt.Sprintf("select %s from %s where `uid` = ? order by create_time desc limit 1", orderRows, m.table)
err := m.QueryRowNoCache(&resp, query, uid)
switch err {
case nil:
return &resp, nil
case sqlc.ErrNotFound:
return nil, ErrNotFound
default:
return nil, err
}
}
$ vim mall/service/product/model/productmodel.go
package model
......
type (
ProductModel interface {
TxAdjustStock(tx *sql.Tx, id int64, delta int) (sql.Result, error)
}
)
......
func (m *defaultProductModel) TxAdjustStock(tx *sql.Tx, id int64, delta int) (sql.Result, error) {
productIdKey := fmt.Sprintf("%s%v", cacheProductIdPrefix, id)
return m.Exec(func(conn sqlx.SqlConn) (result sql.Result, err error) {
query := fmt.Sprintf("update %s set stock=stock+? where stock >= -? and id=?", m.table)
return tx.Exec(query, delta, delta, id)
}, productIdKey)
}
10.2.4 Modification product rpc
Service
Added
DecrStock
,DecrStockRevert
interface methodWe need to add
DecrStock
andDecrStockRevert
two interface methods for theproduct rpc
service, which are used for product inventory update and product inventory update compensation.
$ vim mall/service/product/rpc/product.proto
syntax = "proto3";
package productclient;
option go_package = "product";
......
// 减产品库存
message DecrStockRequest {
int64 id = 1;
int64 num = 2;
}
message DecrStockResponse {
}
// 减产品库存
service Product {
......
rpc DecrStock(DecrStockRequest) returns(DecrStockResponse);
rpc DecrStockRevert(DecrStockRequest) returns(DecrStockResponse);
}
Tip: Use the goctl tool to regenerate the code after modification.
Implement
DecrStock
interface methodHere only when the inventory is insufficient, we do not need to retry and roll back directly.
$ vim mall/service/product/rpc/internal/logic/decrstocklogic.go
package logic
import (
"context"
"database/sql"
"mall/service/product/rpc/internal/svc"
"mall/service/product/rpc/product"
"github.com/dtm-labs/dtmcli"
"github.com/dtm-labs/dtmgrpc"
"github.com/tal-tech/go-zero/core/logx"
"github.com/tal-tech/go-zero/core/stores/sqlx"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type DecrStockLogic struct {
ctx context.Context
svcCtx *svc.ServiceContext
logx.Logger
}
func NewDecrStockLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DecrStockLogic {
return &DecrStockLogic{
ctx: ctx,
svcCtx: svcCtx,
Logger: logx.WithContext(ctx),
}
}
func (l *DecrStockLogic) DecrStock(in *product.DecrStockRequest) (*product.DecrStockResponse, error) {
// 获取 RawDB
db, err := sqlx.NewMysql(l.svcCtx.Config.Mysql.DataSource).RawDB()
if err != nil {
return nil, status.Error(500, err.Error())
}
// 获取子事务屏障对象
barrier, err := dtmgrpc.BarrierFromGrpc(l.ctx)
if err != nil {
return nil, status.Error(500, err.Error())
}
// 开启子事务屏障
err = barrier.CallWithDB(db, func(tx *sql.Tx) error {
// 更新产品库存
result, err := l.svcCtx.ProductModel.TxAdjustStock(tx, in.Id, -1)
if err != nil {
return err
}
affected, err := result.RowsAffected()
// 库存不足,返回子事务失败
if err == nil && affected == 0 {
return dtmcli.ErrFailure
}
return err
})
// 这种情况是库存不足,不再重试,走回滚
if err == dtmcli.ErrFailure {
return nil, status.Error(codes.Aborted, dtmcli.ResultFailure)
}
if err != nil {
return nil, err
}
return &product.DecrStockResponse{}, nil
}
Implement
DecrStockRevert
interface methodIn the
DecrStock
interface method, the product inventory is minus the specified quantity, and here we add it back. In this way, the product inventory is returned to the previous quantity in theDecrStock
interface method minus the previous quantity.
$ vim mall/service/product/rpc/internal/logic/decrstockrevertlogic.go
package logic
import (
"context"
"database/sql"
"mall/service/product/rpc/internal/svc"
"mall/service/product/rpc/product"
"github.com/dtm-labs/dtmcli"
"github.com/dtm-labs/dtmgrpc"
"github.com/tal-tech/go-zero/core/logx"
"github.com/tal-tech/go-zero/core/stores/sqlx"
"google.golang.org/grpc/status"
)
type DecrStockRevertLogic struct {
ctx context.Context
svcCtx *svc.ServiceContext
logx.Logger
}
func NewDecrStockRevertLogic(ctx context.Context, svcCtx *svc.ServiceContext) *DecrStockRevertLogic {
return &DecrStockRevertLogic{
ctx: ctx,
svcCtx: svcCtx,
Logger: logx.WithContext(ctx),
}
}
func (l *DecrStockRevertLogic) DecrStockRevert(in *product.DecrStockRequest) (*product.DecrStockResponse, error) {
// 获取 RawDB
db, err := sqlx.NewMysql(l.svcCtx.Config.Mysql.DataSource).RawDB()
if err != nil {
return nil, status.Error(500, err.Error())
}
// 获取子事务屏障对象
barrier, err := dtmgrpc.BarrierFromGrpc(l.ctx)
if err != nil {
return nil, status.Error(500, err.Error())
}
// 开启子事务屏障
err = barrier.CallWithDB(db, func(tx *sql.Tx) error {
// 更新产品库存
_, err := l.svcCtx.ProductModel.TxAdjustStock(tx, in.Id, 1)
return err
})
if err != nil {
return nil, err
}
return &product.DecrStockResponse{}, nil
}
10.2.5 Modify order rpc
Service
Add
CreateRevert
interface methodorder rpc
service already hasCreate
interface method, we need to create its compensation interface methodCreateRevert
.
$ vim mall/service/order/rpc/order.proto
syntax = "proto3";
package orderclient;
option go_package = "order";
......
service Order {
rpc Create(CreateRequest) returns(CreateResponse);
rpc CreateRevert(CreateRequest) returns(CreateResponse);
......
}
Tip: Use the goctl tool to regenerate the code after modification.
Modify
Create
interface methodThe original
Create
interface method in the product inventory judgment and update operations, we have already implemented in theproduct rpc
DecrStock
interface method, so we only need to create an order here. Can.
$ vim mall/service/order/rpc/internal/logic/createlogic.go
package logic
import (
"context"
"database/sql"
"fmt"
"mall/service/order/model"
"mall/service/order/rpc/internal/svc"
"mall/service/order/rpc/order"
"mall/service/user/rpc/user"
"github.com/dtm-labs/dtmgrpc"
"github.com/tal-tech/go-zero/core/logx"
"github.com/tal-tech/go-zero/core/stores/sqlx"
"google.golang.org/grpc/status"
)
type CreateLogic struct {
ctx context.Context
svcCtx *svc.ServiceContext
logx.Logger
}
func NewCreateLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateLogic {
return &CreateLogic{
ctx: ctx,
svcCtx: svcCtx,
Logger: logx.WithContext(ctx),
}
}
func (l *CreateLogic) Create(in *order.CreateRequest) (*order.CreateResponse, error) {
// 获取 RawDB
db, err := sqlx.NewMysql(l.svcCtx.Config.Mysql.DataSource).RawDB()
if err != nil {
return nil, status.Error(500, err.Error())
}
// 获取子事务屏障对象
barrier, err := dtmgrpc.BarrierFromGrpc(l.ctx)
if err != nil {
return nil, status.Error(500, err.Error())
}
// 开启子事务屏障
if err := barrier.CallWithDB(db, func(tx *sql.Tx) error {
// 查询用户是否存在
_, err := l.svcCtx.UserRpc.UserInfo(l.ctx, &user.UserInfoRequest{
Id: in.Uid,
})
if err != nil {
return fmt.Errorf("用户不存在")
}
newOrder := model.Order{
Uid: in.Uid,
Pid: in.Pid,
Amount: in.Amount,
Status: 0,
}
// 创建订单
_, err = l.svcCtx.OrderModel.TxInsert(tx, &newOrder)
if err != nil {
return fmt.Errorf("订单创建失败")
}
return nil
}); err != nil {
return nil, status.Error(500, err.Error())
}
return &order.CreateResponse{}, nil
}
Implement
CreateRevert
interface methodIn this interface, we query the order just created by the user and change the status of the order to
9(无效状态)
.
$ vim mall/service/order/rpc/internal/logic/createrevertlogic.go
package logic
import (
"context"
"database/sql"
"fmt"
"mall/service/order/rpc/internal/svc"
"mall/service/order/rpc/order"
"mall/service/user/rpc/user"
"github.com/dtm-labs/dtmgrpc"
"github.com/tal-tech/go-zero/core/logx"
"github.com/tal-tech/go-zero/core/stores/sqlx"
"google.golang.org/grpc/status"
)
type CreateRevertLogic struct {
ctx context.Context
svcCtx *svc.ServiceContext
logx.Logger
}
func NewCreateRevertLogic(ctx context.Context, svcCtx *svc.ServiceContext) *CreateRevertLogic {
return &CreateRevertLogic{
ctx: ctx,
svcCtx: svcCtx,
Logger: logx.WithContext(ctx),
}
}
func (l *CreateRevertLogic) CreateRevert(in *order.CreateRequest) (*order.CreateResponse, error) {
// 获取 RawDB
db, err := sqlx.NewMysql(l.svcCtx.Config.Mysql.DataSource).RawDB()
if err != nil {
return nil, status.Error(500, err.Error())
}
// 获取子事务屏障对象
barrier, err := dtmgrpc.BarrierFromGrpc(l.ctx)
if err != nil {
return nil, status.Error(500, err.Error())
}
// 开启子事务屏障
if err := barrier.CallWithDB(db, func(tx *sql.Tx) error {
// 查询用户是否存在
_, err := l.svcCtx.UserRpc.UserInfo(l.ctx, &user.UserInfoRequest{
Id: in.Uid,
})
if err != nil {
return fmt.Errorf("用户不存在")
}
// 查询用户最新创建的订单
resOrder, err := l.svcCtx.OrderModel.FindOneByUid(in.Uid)
if err != nil {
return fmt.Errorf("订单不存在")
}
// 修改订单状态9,标识订单已失效,并更新订单
resOrder.Status = 9
err = l.svcCtx.OrderModel.TxUpdate(tx, resOrder)
if err != nil {
return fmt.Errorf("订单更新失败")
}
return nil
}); err != nil {
return nil, status.Error(500, err.Error())
}
return &order.CreateResponse{}, nil
}
10.2.6 Modification order api
Service
We order rpc
Services Create
, CreateRevert
interface methods, product rpc
Services DecrStock
, DecrStockRevert
order api
DecrStockRevert
a distributed transaction operation with SAGA事务模式
in the service.
Add
pproduct rpc
dependency configuration$ vim mall/service/order/api/etc/order.yaml
Name: Order
Host: 0.0.0.0
Port: 8002
......
OrderRpc:
Etcd:
Hosts:
- etcd:2379
Key: order.rpc
ProductRpc:
Etcd:
Hosts:
- etcd:2379
Key: product.rpc
Added
pproduct rpc
Instantiation of service configuration$ vim mall/service/order/api/internal/config/config.go
package config
import (
"github.com/tal-tech/go-zero/rest"
"github.com/tal-tech/go-zero/zrpc"
)
type Config struct {
rest.RestConf
Auth struct {
AccessSecret string
AccessExpire int64
}
OrderRpc zrpc.RpcClientConf
ProductRpc zrpc.RpcClientConf
}
Dependency of registration service context
pproduct rpc
$ vim mall/service/order/api/internal/svc/servicecontext.go
package svc
import (
"mall/service/order/api/internal/config"
"mall/service/order/rpc/orderclient"
"mall/service/product/rpc/productclient"
"github.com/tal-tech/go-zero/zrpc"
)
type ServiceContext struct {
Config config.Config
OrderRpc orderclient.Order
ProductRpc productclient.Product
}
func NewServiceContext(c config.Config) *ServiceContext {
return &ServiceContext{
Config: c,
OrderRpc: orderclient.NewOrder(zrpc.MustNewClient(c.OrderRpc)),
ProductRpc: productclient.NewProduct(zrpc.MustNewClient(c.ProductRpc)),
}
}
Add import
gozero
dtm
driver$ vim mall/service/order/api/order.go
package main
import (
......
_ "github.com/dtm-labs/driver-gozero" // 添加导入 `gozero` 的 `dtm` 驱动
)
var configFile = flag.String("f", "etc/order.yaml", "the config file")
func main() {
......
}
Modify
order api
Create
interface method$ vim mall/service/order/api/internal/logic/createlogic.go
package logic
import (
"context"
"mall/service/order/api/internal/svc"
"mall/service/order/api/internal/types"
"mall/service/order/rpc/order"
"mall/service/product/rpc/product"
"github.com/dtm-labs/dtmgrpc"
"github.com/tal-tech/go-zero/core/logx"
"google.golang.org/grpc/status"
)
type CreateLogic struct {
logx.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewCreateLogic(ctx context.Context, svcCtx *svc.ServiceContext) CreateLogic {
return CreateLogic{
Logger: logx.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *CreateLogic) Create(req types.CreateRequest) (resp *types.CreateResponse, err error) {
// 获取 OrderRpc BuildTarget
orderRpcBusiServer, err := l.svcCtx.Config.OrderRpc.BuildTarget()
if err != nil {
return nil, status.Error(100, "订单创建异常")
}
// 获取 ProductRpc BuildTarget
productRpcBusiServer, err := l.svcCtx.Config.ProductRpc.BuildTarget()
if err != nil {
return nil, status.Error(100, "订单创建异常")
}
// dtm 服务的 etcd 注册地址
var dtmServer = "etcd://etcd:2379/dtmservice"
// 创建一个gid
gid := dtmgrpc.MustGenGid(dtmServer)
// 创建一个saga协议的事务
saga := dtmgrpc.NewSagaGrpc(dtmServer, gid).
Add(orderRpcBusiServer+"/orderclient.Order/Create", orderRpcBusiServer+"/orderclient.Order/CreateRevert", &order.CreateRequest{
Uid: req.Uid,
Pid: req.Pid,
Amount: req.Amount,
Status: 0,
}).
Add(productRpcBusiServer+"/productclient.Product/DecrStock", productRpcBusiServer+"/productclient.Product/DecrStockRevert", &product.DecrStockRequest{
Id: req.Pid,
Num: 1,
})
// 事务提交
err = saga.Submit()
if err != nil {
return nil, status.Error(500, err.Error())
}
return &types.CreateResponse{}, nil
}
Tip:SagaGrpc.Add
The first parameter of the methodaction
is the method path accessed by thegrpc
. This method path needs to be found in the following files.
mall/service/order/rpc/order/order.pb.go
mall/service/product/rpc/product/product.pb.go
Search by keywordInvoke
to find it.
10.3 Test go-zero
+ DTM
10.3.1 Test the normal flow of distributed transactions
- Use the
postman
--- interface to call/api/product/create
interface to create a product that inventorystock
as1
.
- Use the
postman
--- interface to call/api/order/create
interface to create an order with product IDpid
as1
.
- We can see that the product inventory has changed from the original
1
to0
.
- Let's look at the data in the sub-transaction barrier table
barrier
, we can see that the operations of the two services have been completed.
10.3.2 Test Distributed Transaction Failure Process 1
- Then the above test results, the product ID at this time is
1
the inventory is already0
, usepostman
to call/api/order/create
interface, 2 Create an order.
- Let's see that there is a data in the order data table whose ID is
2
the product ID is1
, and its order data status is9
.
- Let's look at the data in the sub-transaction barrier table
barrier
, we can see that(gid = fqYS8CbYbK8GkL8SCuTRUF)
the first service(branch_id = 01)
the sub-transaction barrier operation is normal, Service(branch_id = 02)
barrier operation failed, compensation required. So both services have a compensating operation record.
The operation flow of this distributed transaction
- First
DTM
service will be transferredorder rpc
Create
interface to create order processing. - After creating the order is completed
DTM
service and then transferredproduct rpc
DecrStock
interface that's bypid
update product inventory, due to lack of inventory , throws transaction failure. -
DTM
service initiates the compensation mechanism, and adjusts theorder rpc
CreateRevert
interface for order compensation processing. -
DTM
service initiates the compensation mechanism and adjusts theproduct rpc
DecrStockRevert
interface to perform compensation processing for product inventory updates. However, because within the sub-transaction barrier of theproduct rpc
DecrStock
interface, the business processing did not succeed. So in theDecrStockRevert
interface, the business logic in the sub-transaction barrier will not be executed.
- First
10.3.3 Test Distributed Transaction Failure Process 2
- We manually change the product ID in the database to
1
the inventory to 100, and then artificially create an exception outside the sub-transaction barrier in theproduct rpc
DecrStock
interface method. .
- Use the
postman
--- interface to call/api/order/create
interface, and create an order with the product IDpid
as1
.
- Let's look at the order data table and the product data table respectively. The order data table ID is
3
, and its order data status is9
. The product data sheet ID is1
, its inventory is still100
and the data update time has also changed.
- Let's look at the data in the sub-transaction barrier table
barrier
, we can see that(gid = ZbjYHv2jNra7RMwyWjB5Lc)
the first service(branch_id = 01)
the sub-transaction barrier operation is normal, the second Service(branch_id = 02)
barrier operation is also normal. Because outside the neutron transaction barrier of theproduct rpc
DecrStock
interface method, we artificially fail to create an exception, so the two services have a compensation operation record.
You can compare the difference between testing distributed transaction failure process 1 and testing distributed transaction failure process 2, can you find and appreciate the power of this sub-transaction barrier technology DTM
.
The sub-transaction barrier will automatically identify whether the forward operation has been executed. The failed process 1 does not execute the business operation, so when it is compensated, it will not execute the compensated business operation; the failed process 2 executes the business operation, so the compensation will also be executed. business operations.
project address
https://github.com/zeromicro/go-zero
Welcome go-zero
and star support us!
WeChat exchange group
Follow the official account of " Microservice Practice " and click on the exchange group to get the QR code of the community group.
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。