前言
在前面几章,介绍了influxdb的基本概念,经常的用法,以及怎么编译源码,以及服务启动部分,meta部分。
- influxdb概念详解1
- influxdb安装和使用
- influxdb概念详解2
- influxdb源码编译
- influxdb启动分析
- influxdb源码分析-meta部分
- 每章开始之前,都要回顾一下一张老图,源码分析的整体思路也是按照这张图的
meta已经分析完了,接下来是data部分。可能有人会有疑问,这张图的依据是什么?其实这张图是influxdb的存储结构。influxdb 默认的存储路径是**${PWD}/.influxdata**在这个目录下执行
tree -L 3
可以看到存储的的结构和我们上图的结构是一致的。本篇文章,从写入的角度,来分析一下整个写入链路的逻辑。
HTTP路由
influxdb启动之后,会监听8086端口,然后提供http服务。在influxdb/influxd/run/server.go中,cmd/influxd/run/server.go:378 open函数,表示了server启动之后,装配哪些service。其中有一个service是appendHTTPDService
s.appendHTTPDService(s.config.HTTPD)
s.appendRetentionPolicyService(s.config.Retention)
func (s *Server) appendHTTPDService(c httpd.Config) {
if !c.Enabled {
return
}
srv := httpd.NewService(c)
srv.Handler.MetaClient = s.MetaClient
authorizer := meta.NewQueryAuthorizer(s.MetaClient)
srv.Handler.QueryAuthorizer = authorizer
srv.Handler.WriteAuthorizer = meta.NewWriteAuthorizer(s.MetaClient)
srv.Handler.QueryExecutor = s.QueryExecutor
srv.Handler.Monitor = s.Monitor
srv.Handler.PointsWriter = s.PointsWriter
srv.Handler.Version = s.buildInfo.Version
srv.Handler.BuildType = "OSS"
ss := storage.NewStore(s.TSDBStore, s.MetaClient)
srv.Handler.Store = ss
if s.config.HTTPD.FluxEnabled {
srv.Handler.Controller = control.NewController(s.MetaClient, reads.NewReader(ss), authorizer, c.AuthEnabled, s.Logger)
}
s.Services = append(s.Services, srv)
}
在第三行,httpd.NewService,通过http.config 新建了一个http service,看一下NewService的逻辑
func NewService(c Config) *Service {
s := &Service{
addr: c.BindAddress,
https: c.HTTPSEnabled,
cert: c.HTTPSCertificate,
key: c.HTTPSPrivateKey,
limit: c.MaxConnectionLimit,
tlsConfig: c.TLS,
err: make(chan error),
unixSocket: c.UnixSocketEnabled,
unixSocketPerm: uint32(c.UnixSocketPermissions),
bindSocket: c.BindSocket,
Handler: NewHandler(c),
Logger: zap.NewNop(),
}
if s.tlsConfig == nil {
s.tlsConfig = new(tls.Config)
}
if s.key == "" {
s.key = s.cert
}
if c.UnixSocketGroup != nil {
s.unixSocketGroup = int(*c.UnixSocketGroup)
}
s.Handler.Logger = s.Logger
return s
}
是一些基本信息的装配,这里主要注意一下Handler字段的赋值,一般在web开发中,Handler都是用来处理用户的http请求的,这里也不例外。在NewHandler中,定义了路由参数:
h.AddRoutes([]Route{
Route{
"query-options", // Satisfy CORS checks.
"OPTIONS", "/query", false, true, h.serveOptions,
},
Route{
"query", // Query serving route.
"GET", "/query", true, true, h.serveQuery,
},
Route{
"query", // Query serving route.
"POST", "/query", true, true, h.serveQuery,
},
Route{
"write-options", // Satisfy CORS checks.
"OPTIONS", "/write", false, true, h.serveOptions,
},
Route{
"write", // Data-ingest route.
"POST", "/write", true, writeLogEnabled, h.serveWriteV1,
},
Route{
"write", // Data-ingest route.
"POST", "/api/v2/write", true, writeLogEnabled, h.serveWriteV2,
},
Route{
"prometheus-write", // Prometheus remote write
"POST", "/api/v1/prom/write", false, true, h.servePromWrite,
},
Route{
"prometheus-read", // Prometheus remote read
"POST", "/api/v1/prom/read", true, true, h.servePromRead,
},
Route{ // Ping
"ping",
"GET", "/ping", false, true, authWrapper(h.servePing),
},
Route{ // Ping
"ping-head",
"HEAD", "/ping", false, true, authWrapper(h.servePing),
},
Route{ // Ping w/ status
"status",
"GET", "/status", false, true, authWrapper(h.serveStatus),
},
Route{ // Ping w/ status
"status-head",
"HEAD", "/status", false, true, authWrapper(h.serveStatus),
},
Route{ // Ping
"ping",
"GET", "/health", false, true, authWrapper(h.serveHealth),
},
Route{
"prometheus-metrics",
"GET", "/metrics", false, true, authWrapper(promhttp.Handler().ServeHTTP),
},
}...)
这里就是我们通过http 写入和查询的入口所在,所有的请求都是从这里开始。找到起点,我们就可以开始后续的事情。
serveWriteV1
从上面找到了http的路由定义之后,先看一下写入的逻辑。
写入有两个版本,对应两个处理函数,这里我们首先研究第一个,也就是serveWriteV1
func (h *Handler) serveWriteV1(w http.ResponseWriter, r *http.Request, user meta.User) {
precision := r.URL.Query().Get("precision")
switch precision {
case "", "n", "ns", "u", "ms", "s", "m", "h":
// it's valid
default:
err := fmt.Sprintf("invalid precision %q (use n, u, ms, s, m or h)", precision)
h.httpError(w, err, http.StatusBadRequest)
}
db := r.URL.Query().Get("db")
rp := r.URL.Query().Get("rp")
h.serveWrite(db, rp, precision, w, r, user)
}
serviceWriteV1做了一下基本参数的校验,然后委托给了serveWrite
ServeWrite
serveWrite主要分为以下步骤
- 参数校验
- http payload 信息读取
- point的反序列化
- point写入到本地
- 写入结果处理
- 返回
这个函数有点长,所以一步一步的分析。
参数校验
atomic.AddInt64(&h.stats.WriteRequests, 1)
atomic.AddInt64(&h.stats.ActiveWriteRequests, 1)
defer func(start time.Time) {
atomic.AddInt64(&h.stats.ActiveWriteRequests, -1)
atomic.AddInt64(&h.stats.WriteRequestDuration, time.Since(start).Nanoseconds())
}(time.Now())
h.requestTracker.Add(r, user)
if database == "" {
h.httpError(w, "database is required", http.StatusBadRequest)
return
}
if di := h.MetaClient.Database(database); di == nil {
h.httpError(w, fmt.Sprintf("database not found: %q", database), http.StatusNotFound)
return
}
if h.Config.AuthEnabled {
if user == nil {
h.httpError(w, fmt.Sprintf("user is required to write to database %q", database), http.StatusForbidden)
return
}
if err := h.WriteAuthorizer.AuthorizeWrite(user.ID(), database); err != nil {
h.httpError(w, fmt.Sprintf("%q user is not authorized to write to database %q", user.ID(), database), http.StatusForbidden)
return
}
}
前面这部分都是参数校验相关,例如校验写入的数据库是不是存在,校验是不是需要开启鉴权等等。这部分就不再赘述。
数据读取
// Handle gzip decoding of the body
if r.Header.Get("Content-Encoding") == "gzip" {
b, err := gzip.NewReader(r.Body)
if err != nil {
h.httpError(w, err.Error(), http.StatusBadRequest)
return
}
defer b.Close()
body = b
}
var bs []byte
if r.ContentLength > 0 {
if h.Config.MaxBodySize > 0 && r.ContentLength > int64(h.Config.MaxBodySize) {
h.httpError(w, http.StatusText(http.StatusRequestEntityTooLarge), http.StatusRequestEntityTooLarge)
return
}
// This will just be an initial hint for the gzip reader, as the
// bytes.Buffer will grow as needed when ReadFrom is called
bs = make([]byte, 0, r.ContentLength)
}
buf := bytes.NewBuffer(bs)
_, err := buf.ReadFrom(body)
if err != nil {
if err == errTruncated {
h.httpError(w, http.StatusText(http.StatusRequestEntityTooLarge), http.StatusRequestEntityTooLarge)
return
}
if h.Config.WriteTracing {
h.Logger.Info("Write handler unable to read bytes from request body")
}
h.httpError(w, err.Error(), http.StatusBadRequest)
return
}
atomic.AddInt64(&h.stats.WriteRequestBytesReceived, int64(buf.Len()))
if h.Config.WriteTracing {
h.Logger.Info("Write body received by handler", zap.ByteString("body", buf.Bytes()))
}
在校验完参数之后,开始从读取http协议携带的内容。这里因为内容可能是压缩过的,所以首先判断一下header,是不是带有压缩信息。
12-25行,是直接从body里面把携带的信息读取出来。然后接下来对读取到的结果做校验,以及记录一些额外的信息。
读取到这里基本就结束了
数据反序列化
写入influxdb的数据都是要遵循influxdb的协议的,influxdb使用这个协议来对数据反序列化,这个协议也叫行协议(line protocol),再复习一下:
具体的协议内容:influxdb 行协议 这里举个例子:
weather,location=us-midwest,season=summer temperature=82 1465839830100400200
这里有一条数据,其中weather是measurement,location=us-midwest,season=summer 是tag,temperature=82是field, 1465839830100400200是timestamp,至于为啥这样分,行协议可以抽象为:
measurement[,tagk=tagv] fieldkey=fieldv[,fieldkey2=fieldv2] timestamp
这里很多人经常把field和tag搞混,其实很好分辨:
如果measurement后面没有逗号(,)那么这条数据就没有tag
如果有tag,tag直接需要用逗号隔开,如果发现第一个空格,那么空格后面就是field
field之间需要用逗号隔开
知道这几个原则,就能很简单的辨别tag和field。
复习到之后,其实反序列化,就是按照行协议,把byte数组给反序列出来。这部分逻辑在ParsePointsWithPrecision和parsePoint里面里面
func ParsePointsWithPrecision(buf []byte, defaultTime time.Time, precision string) ([]Point, error) {
points := make([]Point, 0, bytes.Count(buf, []byte{'\n'})+1)
var (
pos int
block []byte
failed []string
)
for pos < len(buf) {
pos, block = scanLine(buf, pos)
pos++
if len(block) == 0 {
continue
}
start := skipWhitespace(block, 0)
// If line is all whitespace, just skip it
if start >= len(block) {
continue
}
// lines which start with '#' are comments
if block[start] == '#' {
continue
}
// strip the newline if one is present
if block[len(block)-1] == '\n' {
block = block[:len(block)-1]
}
pt, err := parsePoint(block[start:], defaultTime, precision)
if err != nil {
failed = append(failed, fmt.Sprintf("unable to parse '%s': %v", string(block[start:]), err))
} else {
points = append(points, pt)
}
}
if len(failed) > 0 {
return points, fmt.Errorf("%s", strings.Join(failed, "\n"))
}
return points, nil
}
ParsePointsWithPrecision做的事情,比较简单,首先跳过所有的空白,找到第一个不是空白的位置。然后把解析任务委托给parsePoint
func parsePoint(buf []byte, defaultTime time.Time, precision string) (Point, error) {
// scan the first block which is measurement[,tag1=value1,tag2=value2...]
pos, key, err := scanKey(buf, 0)
if err != nil {
return nil, err
}
// measurement name is required
if len(key) == 0 {
return nil, fmt.Errorf("missing measurement")
}
if len(key) > MaxKeyLength {
return nil, fmt.Errorf("max key length exceeded: %v > %v", len(key), MaxKeyLength)
}
// scan the second block is which is field1=value1[,field2=value2,...]
pos, fields, err := scanFields(buf, pos)
if err != nil {
return nil, err
}
// at least one field is required
if len(fields) == 0 {
return nil, fmt.Errorf("missing fields")
}
var maxKeyErr error
err = walkFields(fields, func(k, v []byte) bool {
if sz := seriesKeySize(key, k); sz > MaxKeyLength {
maxKeyErr = fmt.Errorf("max key length exceeded: %v > %v", sz, MaxKeyLength)
return false
}
return true
})
if err != nil {
return nil, err
}
if maxKeyErr != nil {
return nil, maxKeyErr
}
// scan the last block which is an optional integer timestamp
pos, ts, err := scanTime(buf, pos)
if err != nil {
return nil, err
}
}
parsePoint 的逻辑比较长,这里截取部分的核心的逻辑。首先说一下这个parse的设计很好,为所有的字段都设计了一个parser,比如专门解析key的scanKey,解析field的scanFields这部分没看过的同学我建议仔细看看。说一个重要的点:scan操作是一个状态机,scan的过程会随着遇到的字符串流转,这个有点像编译原理的DFA之类的。
我看到这部分代码也是非常的赞赏,可能是没什么见识,发现这段代码写的很优雅。解析的这个我想并不困难,按照协议来解析这是件简单的事情,但是如何把代码写的优雅起来,是一件难事,在学习influxdb的时候,不仅仅是他的设计思想,代码本身写的也是非常值得学习的。
具体的解析逻辑就不再深入了,其实也没啥好说的。parse结束之后,会返回一个Point的slice,表示当前写入的point数组。需要说明的是,这里的parse其实是一种懒加载,这里看Point的定义就能看出来,随便举个例子:
type Point interface {
// Name return the measurement name for the point.
Name() []byte
}
Name字段,也就是measurement,是一个byte数组,这里并没有把它转成string,而是直接做了一个slice并且赋值。**这里也是一个优化!**因为influxdb是一种时序数据库,时序数据是写多读少的特点,这里就没有必要去理解具体的字段,可能需要到查询的时候,才需要理解具体含义。
数据写入
继续回到主线链路,parse结束之后,得到了一个point的slice,这个slice就是用来写入的数据。这部分逻辑很长,也是核心的逻辑。为了能够更好地看懂,我墙裂建议没看过meta部分源码解析的同学,去看看,否则后面你会发现你就看不懂了。
这里假设你已经精通了meta 部分的逻辑。那么接着往下看!先看一个核心的interface
type pointsWriterWithContext interface {
WritePointsWithContext(context.Context, string, string, models.ConsistencyLevel, meta.User, []models.Point) error
}
这个interface,定义了写入的函数WritePointsWithContext 解析完只有,定义了个writePoints函数,内部调用了pointsWriterWithContext某个实现的WritePointsWithContext方法完后对数据的写入。
writePoints := func() error {
switch pw := h.PointsWriter.(type) {
case pointsWriterWithContext:
var npoints, nvalues int64
ctx := context.WithValue(context.Background(), coordinator.StatPointsWritten, &npoints)
ctx = context.WithValue(ctx, coordinator.StatValuesWritten, &nvalues)
// for now, just store the number of values used.
err := pw.WritePointsWithContext(ctx, database, retentionPolicy, consistency, user, points)
atomic.AddInt64(&h.stats.ValuesWrittenOK, nvalues)
if err != nil {
return err
}
return nil
default:
return h.PointsWriter.WritePoints(database, retentionPolicy, consistency, user, points)
}
}
// Write points.
if err := writePoints(); influxdb.IsClientError(err) {
atomic.AddInt64(&h.stats.PointsWrittenFail, int64(len(points)))
h.httpError(w, err.Error(), http.StatusBadRequest)
return
}
这里可能有人看不懂了,尤其是多golang不是很熟悉,但是对influxdb的实现有很好奇的人。这里可以理解是一种闭包调用,writePoints就是函数内定义的函数,然后再函数内部自己调用。这里其实只是一种实现方式,我觉得这里的pointsWriterWithContext放在这里总感觉有点草率,明显是有更加合适的位置的,放在这里极有可能是开源版本删减导致的!但是这并不影响我们分析。还记得我们在服务启动里面介绍的coordinator模块吗influxdb服务启动里面介绍了PointsWriter这个接口,这个接口是Server的一个重要的组成部分,但是被放在了coordinator里面,我们当时的分析是,为了兼容其他的协议,比如opentsdb等等,其实确实是这样的。所以其实pointsWriterWithContext****是被PointsWriter实现的
PointWriter实现的WritePointsWithContext
找到pointWrite,看到具体的实现:
func (w *PointsWriter) WritePointsWithContext(ctx context.Context, database, retentionPolicy string, consistencyLevel models.ConsistencyLevel, user meta.User, points []models.Point) error {
return w.WritePointsPrivilegedWithContext(ctx, database, retentionPolicy, consistencyLevel, points)
}
这里委托给了WritePointsPrivilegedWithContext那就继续看看WritePointsPrivilegedWithContext的实现
到这里简单休息一下,总结一下我们现在的进度。
- 通过http写入的数据完成了解析,得到了一个point的数组
- 解析到的点通过pointsWriterWithContext的WritePointsWithContext完成写入
- pointsWriterWithContext是一个interface,真正的实现在PointsWriter
- PointsWriter是位于coordinator模块下面的一个结构,用来接受点的写入,有多种协议的实现。
- PointsWriter实现了WritePointsWithContext,并且委托给了WritePointsPrivilegedWithContext执行
这就是我们上半场的内容,WritePointsPrivilegedWithContext到这里可以说是万里长征第一步,其实后面还有很复杂的逻辑。所以如果你到这里看不懂了,那么没关系,把开头的文章都看一遍,对着源码翻翻,然后再来看,可能就会恍然大悟。这部分我看过的不下五遍
*----------------------------------------------上下半场分割线-------------------------------------------------------
如何你现在对上面的内容都有一个清楚的理解,那么继续写入的流程。
WritePointsPrivilegedWithContext
点的映射
WritePointsPrivilegedWithContext做的第一件事情,是把datapoint给映射到各个shard上去。这个过程大家一般也叫做shuffle。具体的逻辑在这里实现:
// 这里所有的datapoint 映射到shard上去
shardMappings, err := w.MapShards(&WritePointsRequest{Database: database, RetentionPolicy: retentionPolicy, Points: points})
if err != nil {
return err
}
MapShards输入的是WritePointsRequest,输出的是ShardMapping结构。ShardMapping的具体组成:
// ShardMapping contains a mapping of shards to points.
type ShardMapping struct {
n int
Points map[uint64][]models.Point // The points associated with a shard ID
Shards map[uint64]*meta.ShardInfo // The shards that have been mapped, keyed by shard ID
Dropped []models.Point // Points that were dropped
}
其中Points这个map的key是shardId,value是这个shard对应的要写入的点组成的slice。Shards这个map的key是shardId,value是shardInfo实例。所以这个函数的作用就是,对于输入的点,把它们映射到合适的Shard上去,并且把这个信息记录在ShardMapping里面。
那么接下来就是看一下映射的规则是什么,核心逻辑在:
mapping := NewShardMapping(len(wp.Points))
for _, p := range wp.Points {
sg := list.ShardGroupAt(p.Time())
if sg == nil {
// We didn't create a shard group because the point was outside the
// scope of the RP.
mapping.Dropped = append(mapping.Dropped, p)
atomic.AddInt64(&w.stats.WriteDropped, 1)
continue
}
sh := sg.ShardFor(p.HashID())
mapping.MapPoint(&sh, p)
}
这里首先构建了一个ShardMapping结构,然后遍历所有的点,寻找合适的shardgroup(如果这里你不清楚为啥要找shardgroup,那么说明你还是没读懂前面的,从头再读一遍)。找到shardgroup之后,调用shardgroup的ShardFor方法寻找一个shard。这个方法我们在meta部分提到过,就是简单的取模。那么当前点的hashid是怎么生成的呢?
对于一个点,生成这个点的hashId逻辑是如下的代码:
func (p *point) HashID() uint64 {
h := NewInlineFNV64a()
h.Write(p.key)
sum := h.Sum64()
return sum
}
func (s *InlineFNV64a) Write(data []byte) (int, error) {
hash := uint64(*s)
for _, c := range data {
hash ^= uint64(c)
hash *= prime64
}
*s = InlineFNV64a(hash)
return len(data), nil
}
这里可以看到,这个点的生成是基于FNV64算法,点的值取决于点的key(也就是measurement和tags),和field没啥关系。最后把这个值转化为一个int64类型的,就生成了hash值。
得到hash值之后,使用MapPoint来点的信息添加到这两个map里:
func (s *ShardMapping) MapPoint(shardInfo *meta.ShardInfo, p models.Point) {
if cap(s.Points[shardInfo.ID]) < s.n {
s.Points[shardInfo.ID] = make([]models.Point, 0, s.n)
}
// shardInfoId->
s.Points[shardInfo.ID] = append(s.Points[shardInfo.ID], p)
// shardInfoId->shardInfo的映射
s.Shards[shardInfo.ID] = shardInfo
}
这里就没啥好说的了,很简洁。
点的写入
拿到mapping信息之后,就知道每个点需要被写入到哪个shard。接着就是shard的写入逻辑。
ch := make(chan error, len(shardMappings.Points))
for shardID, points := range shardMappings.Points {
go func(ctx context.Context, shard *meta.ShardInfo, database, retentionPolicy string, points []models.Point) {
var numPoints, numValues int64
ctx = context.WithValue(ctx, tsdb.StatPointsWritten, &numPoints)
ctx = context.WithValue(ctx, tsdb.StatValuesWritten, &numValues)
err := w.writeToShardWithContext(ctx, shard, database, retentionPolicy, points)
if err == tsdb.ErrShardDeletion {
err = tsdb.PartialWriteError{Reason: fmt.Sprintf("shard %d is pending deletion", shard.ID), Dropped: len(points)}
}
if v, ok := ctx.Value(StatPointsWritten).(*int64); ok {
atomic.AddInt64(v, numPoints)
}
if v, ok := ctx.Value(StatValuesWritten).(*int64); ok {
atomic.AddInt64(v, numValues)
}
ch <- err
}(ctx, shardMappings.Shards[shardID], database, retentionPolicy, points)
}
写入的逻辑就是遍历上述得到的shardMapping,每个shard一个goroutine并发写入。然后把写入操作委托给writeToShardWithContext。 writeToShardWithContext的逻辑:
writeToShard := func() error {
type shardWriterWithContext interface {
WriteToShardWithContext(context.Context, uint64, []models.Point) error
}
switch sw := w.TSDBStore.(type) {
case shardWriterWithContext:
if err := sw.WriteToShardWithContext(ctx, shard.ID, points); err != nil {
return err
}
default:
if err := w.TSDBStore.WriteToShard(shard.ID, points); err != nil {
return err
}
}
return nil
}
// Except tsdb.ErrShardNotFound no error can be handled here
if err := writeToShard(); err == tsdb.ErrShardNotFound {
}
writeToShardWithContext定义了writeToShard接口,这个接口的实现在TSDBStore,注意这里!!! 这里的switch,就是用来选择存储引擎的。如果你想要自己实现一个存储引擎来存储数据,这里是一个很关键的拓展点。毫无疑问,这个条件分支走到了第一个case里面。
位于influxdb/tsdb/store.go的Store结构,实现了shardWriterWithContext接口的WriteToShardWithContext方法。这个方法就简单点。首先寻找具体的Shard结构。
sh := s.shards[shardID]
if sh == nil {
s.mu.RUnlock()
return ErrShardNotFound
}
找到Shard结构之后,把写入委托给了Shard的WritePointsWithContext方法。Shard的WritePointsWithContext有委托给了Engine的WritePointsWithContext方法。到这里,才算是真的开始写入了。逻辑也不是很复杂。
首先把point编码到Value结构里面,得到一个Value的s数组
values := make(map[string][]Value, len(points))
var (
keyBuf []byte
baseLen int
seriesErr error
npoints int64 // total points processed
nvalues int64 // total values (fields) processed
)
for _, p := range points {
// do encode 代码略
}
这里的逻辑比较有意思,但是篇幅优先,我们这里不再做仔细的分析,因为这篇文章已经很长了,太长了看着会晕。而且这段编码的逻辑也是influxdb到底是单值逻辑还是多值逻辑的重要证据!所以后面会单独分析。
编码完成之后,写入到Cache和WAL
// first try to write to the cache
if err := e.Cache.WriteMulti(values); err != nil {
return err
}
if e.WALEnabled {
if _, err := e.WAL.WriteMulti(values); err != nil {
return err
}
}
// if requested, store points written stats
if pointsWritten, ok := ctx.Value(tsdb.StatPointsWritten).(*int64); ok {
*pointsWritten = npoints
}
// if requested, store values written stats
if valuesWritten, ok := ctx.Value(tsdb.StatValuesWritten).(*int64); ok {
*valuesWritten = nvalues
}
到这里,写入链路算执行了80%,其实后面还有大量的扫尾工作,但是不是核心逻辑,就不再分析了。
总结
这篇文章花了很大的篇幅,梳理了influxdb的写入流程,虽然写了很多,但是其实还有很多细节。比如怎么编码的?怎么保证写入成功?为啥是先写Cache不是先写WAL等等,这些后面单独分析。
同时这里面也出现了大量的结构,比如Engine,Shard,Store,Series等,他们是啥关系?这部分我们在后面会详细分析。