普通视图

发现新文章,点击刷新页面。
昨天 — 2025年11月27日掘金专栏-得物技术

Dragonboat统一存储LogDB实现分析|得物技术

作者 得物技术
2025年11月27日 13:56

一、项目概览

Dragonboat 是纯 Go 实现的(multi-group)Raft 库。

为应用屏蔽 Raft 复杂性,提供易于使用的 NodeHost 和状态机接口。该库(自称)有如下特点:

  • 高吞吐、流水线化、批处理;
  • 提供了内存/磁盘状态机多种实现;
  • 提供了 ReadIndex、成员变更、Leader转移等管理端API;
  • 默认使用 Pebble 作为 存储后端。

本次代码串讲以V3的稳定版本为基础,不包括GitHub上v4版本内容。

二、整体架构

三、LogDB 统一存储

LogDB 模块是 Dragonboat 的核心持久化存储层,虽然模块名字有Log,但是它囊括了所有和存储相关的API,负责管理 Raft 协议的所有持久化数据,包括:

Raft状态 (RaftState)

Raft内部状态变更的集合结构

包括但不限于:

  • ClusterID/NodeID: 节点ID
  • RaftState: Raft任期、投票情况、commit进度
  • EntriesToSave:Raft提案日志数据
  • Snapshot:快照元数据(包括快照文件路径,快照大小,快照对应的提案Index,快照对应的Raft任期等信息)
  • Messages:发给其他节点的Raft消息
  • ReadyToReads:ReadIndex就绪的请求

引导信息 (Bootstrap)

type Bootstrap struct {
    Addresses map[uint64]string // 初始集群成员
    Join      bool
    Type      StateMachineType
}

ILogDB的API如下:

type ILogDB interface {


    BinaryFormat() uint32 // 返回支持的二进制格式版本号


    ListNodeInfo() ([]NodeInfo, error) // 列出 LogDB 中所有可用的节点信息


    // 存储集群节点的初始化配置信息,包括是否加入集群、状态机类型等
    SaveBootstrapInfo(clusterID uint64, nodeID uint64, bootstrap pb.Bootstrap) error


    // 获取保存的引导信息
    GetBootstrapInfo(clusterID uint64, nodeID uint64) (pb.Bootstrap, error)


    // 原子性保存 Raft 状态、日志条目和快照元数据
    SaveRaftState(updates []pb.Update, shardID uint64) error


    // 迭代读取指定范围内的连续日志条目
    IterateEntries(ents []pb.Entry, size uint64, clusterID uint64, nodeID uint64, 
                   low uint64, high uint64, maxSize uint64) ([]pb.Entry, uint64, error)


    // 读取持久化的 Raft 状态
    ReadRaftState(clusterID uint64, nodeID uint64, lastIndex uint64) (RaftState, error)


    // 删除指定索引之前的所有条目, 日志压缩、快照后清理旧日志
    RemoveEntriesTo(clusterID uint64, nodeID uint64, index uint64) error


    // 回收指定索引之前条目占用的存储空间
    CompactEntriesTo(clusterID uint64, nodeID uint64, index uint64) (<-chan struct{}, error)


    // 保存所有快照元数据
    SaveSnapshots([]pb.Update) error


    // 删除指定的快照元数据 清理过时或无效的快照
    DeleteSnapshot(clusterID uint64, nodeID uint64, index uint64) error


    // 列出指定索引范围内的可用快照
    ListSnapshots(clusterID uint64, nodeID uint64, index uint64) ([]pb.Snapshot, error)


    // 删除节点的所有相关数据
    RemoveNodeData(clusterID uint64, nodeID uint64) error


    // 导入快照并创建所有必需的元数据
    ImportSnapshot(snapshot pb.Snapshot, nodeID uint64) error
}

3.1索引键

存储的底层本质是一个KVDB (pebble or rocksdb),由于业务的复杂性,要统一各类业务key的设计方法,而且要降低空间使用,所以有了如下的key设计方案。

龙舟中key分为3类:

其中,2字节的header用于区分各类不同业务的key空间。

entryKeyHeader       = [2]byte{0x10x1}  // 普通日志条目
persistentStateKey   = [2]byte{0x20x2}  // Raft状态
maxIndexKey          = [2]byte{0x30x3}  // 最大索引记录
nodeInfoKey          = [2]byte{0x40x4}  // 节点元数据
bootstrapKey         = [2]byte{0x50x5}  // 启动配置
snapshotKey          = [2]byte{0x60x6}  // 快照索引
entryBatchKey        = [2]byte{0x70x7}  // 批量日志

在key的生成中,采用了useAsXXXKey和SetXXXKey的方式,复用了data这个二进制变量,减少GC。

type Key struct {
    data []byte  // 底层字节数组复用池
    key  []byte  // 有效数据切片
    pool *sync.Pool // 似乎并没有什么用
}




func (k *Key) useAsEntryKey() {
    k.key = k.data
}


type IReusableKey interface {
    SetEntryBatchKey(clusterID uint64, nodeID uint64, index uint64)
    // SetEntryKey sets the key to be an entry key for the specified Raft node
    // with the specified entry index.
    SetEntryKey(clusterID uint64, nodeID uint64, index uint64)
    // SetStateKey sets the key to be an persistent state key suitable
    // for the specified Raft cluster node.
    SetStateKey(clusterID uint64, nodeID uint64)
    // SetMaxIndexKey sets the key to be the max possible index key for the
    // specified Raft cluster node.
    SetMaxIndexKey(clusterID uint64, nodeID uint64)
    // Key returns the underlying byte slice of the key.
    Key() []byte
    // Release releases the key instance so it can be reused in the future.
    Release()
}


func (k *Key) useAsEntryKey() {
    k.key = k.data
}


// SetEntryKey sets the key value to the specified entry key.
func (k *Key) SetEntryKey(clusterID uint64, nodeID uint64, index uint64) {
    k.useAsEntryKey()
    k.key[0] = entryKeyHeader[0]
    k.key[1] = entryKeyHeader[1]
    k.key[2]0
    k.key[3]0
    binary.BigEndian.PutUint64(k.key[4:], clusterID)
    // the 8 bytes node ID is actually not required in the key. it is stored as
    // an extra safenet - we don't know what we don't know, it is used as extra
    // protection between different node instances when things get ugly.
    // the wasted 8 bytes per entry is not a big deal - storing the index is
    // wasteful as well.
    binary.BigEndian.PutUint64(k.key[12:], nodeID)
    binary.BigEndian.PutUint64(k.key[20:], index)
}

3.2变量复用IContext

IContext的核心设计目的是实现并发安全的内存复用机制。在高并发场景下,频繁的内存分配和释放会造成较大的GC压力,通过IContext可以实现:

  • 键对象复用:通过GetKey()获取可重用的IReusableKey
  • 缓冲区复用:通过GetValueBuffer()获取可重用的字节缓冲区
  • 批量操作对象复用:EntryBatch和WriteBatch的复用
// IContext is the per thread context used in the logdb module.
// IContext is expected to contain a list of reusable keys and byte
// slices that are owned per thread so they can be safely reused by the
// same thread when accessing ILogDB.
type IContext interface {
    // Destroy destroys the IContext instance.
    Destroy()
    // Reset resets the IContext instance, all previous returned keys and
    // buffers will be put back to the IContext instance and be ready to
    // be used for the next iteration.
    Reset()
    // GetKey returns a reusable key.
    GetKey() IReusableKey // 这就是上文中的key接口
    // GetValueBuffer returns a byte buffer with at least sz bytes in length.
    GetValueBuffer(sz uint64) []byte
    // GetWriteBatch returns a write batch or transaction instance.
    GetWriteBatch() interface{}
    // SetWriteBatch adds the write batch to the IContext instance.
    SetWriteBatch(wb interface{})
    // GetEntryBatch returns an entry batch instance.
    GetEntryBatch() pb.EntryBatch
    // GetLastEntryBatch returns an entry batch instance.
    GetLastEntryBatch() pb.EntryBatch
}








type context struct {
    size    uint64
    maxSize uint64
    eb      pb.EntryBatch
    lb      pb.EntryBatch
    key     *Key
    val     []byte
    wb      kv.IWriteBatch
}


func (c *context) GetKey() IReusableKey {
    return c.key
}


func (c *context) GetValueBuffer(sz uint64) []byte {
    if sz <= c.size {
        return c.val
    }
    val := make([]byte, sz)
    if sz < c.maxSize {
        c.size = sz
        c.val = val
    }
    return val
}


func (c *context) GetEntryBatch() pb.EntryBatch {
    return c.eb
}


func (c *context) GetLastEntryBatch() pb.EntryBatch {
    return c.lb
}


func (c *context) GetWriteBatch() interface{} {
    return c.wb
}


func (c *context) SetWriteBatch(wb interface{}) {
    c.wb = wb.(kv.IWriteBatch)
}

3.3存储引擎封装IKVStore

IKVStore 是 Dragonboat 日志存储系统的抽象接口,它定义了底层键值存储引擎需要实现的所有基本操作。这个接口让 Dragonboat 能够支持不同的存储后端(如 Pebble、RocksDB 等),实现了存储引擎的可插拔性。

type IKVStore interface {
    // Name is the IKVStore name.
    Name() string
    // Close closes the underlying Key-Value store.
    Close() error


    // 范围扫描 - 支持前缀遍历的迭代器
    IterateValue(fk []byte,
            lk []byte, inc bool, op func(key []byte, data []byte) (bool, error)) error
    
    // 查询操作 - 基于回调的内存高效查询模式
    GetValue(key []byte, op func([]byte) error) error
    
    // 写入操作 - 单条记录的原子写入
    SaveValue(key []byte, value []byte) error


    // 删除操作 - 单条记录的精确删除
    DeleteValue(key []byte) error
    
    // 获取批量写入器
    GetWriteBatch() IWriteBatch
    
    // 原子提交批量操作
    CommitWriteBatch(wb IWriteBatch) error
    
    // 批量删除一个范围的键值对
    BulkRemoveEntries(firstKey []byte, lastKey []byte) error
    
    // 压缩指定范围的存储空间
    CompactEntries(firstKey []byte, lastKey []byte) error
    
    // 全量压缩整个数据库
    FullCompaction() error
}


type IWriteBatch interface {
    Destroy()                 // 清理资源,防止内存泄漏
    Put(key, value []byte)    // 添加写入操作
    Delete(key []byte)        // 添加删除操作
    Clear()                   // 清空批处理中的所有操作
    Count() int               // 获取当前批处理中的操作数量
}

openPebbleDB是Dragonboat 中 Pebble 存储引擎的初始化入口,负责根据配置创建一个完整可用的键值存储实例。

// KV is a pebble based IKVStore type.
type KV struct {
    db       *pebble.DB
    dbSet    chan struct{}
    opts     *pebble.Options
    ro       *pebble.IterOptions
    wo       *pebble.WriteOptions
    event    *eventListener
    callback kv.LogDBCallback
    config   config.LogDBConfig
}


var _ kv.IKVStore = (*KV)(nil)




// openPebbleDB
// =============
// 将 Dragonboat 的 LogDBConfig → Pebble 引擎实例
func openPebbleDB(
        cfg  config.LogDBConfig,
        cb   kv.LogDBCallback,   // => busy通知:busy(true/false)
        dir  string,             // 主数据目录
        wal  string,             // WAL 独立目录(可空)
        fs   vfs.IFS,            // 文件系统抽象(磁盘/memfs)
) (kv.IKVStore, error) {
    
    //--------------------------------------------------
    // 2️⃣ << 核心调优参数读入
    //--------------------------------------------------
    blockSz      := int(cfg.KVBlockSize)                    // 数据块(4K/8K…)
    writeBufSz   := int(cfg.KVWriteBufferSize)              // 写缓冲
    bufCnt       := int(cfg.KVMaxWriteBufferNumber)         // MemTable数量
    l0Compact    := int(cfg.KVLevel0FileNumCompactionTrigger) // L0 层文件数量触发压缩的阈值
    l0StopWrites := int(cfg.KVLevel0StopWritesTrigger)
    baseBytes    := int64(cfg.KVMaxBytesForLevelBase)
    fileBaseSz   := int64(cfg.KVTargetFileSizeBase)
    cacheSz      := int64(cfg.KVLRUCacheSize)
    levelMult    := int64(cfg.KVTargetFileSizeMultiplier)  // 每层文件大小倍数
    numLevels    := int64(cfg.KVNumOfLevels)
    
    
    //--------------------------------------------------
    // 4️⃣ 构建 LSM-tree 层级选项 (每层无压缩)
    //--------------------------------------------------
    levelOpts := []pebble.LevelOptions{}
    sz := fileBaseSz
    for lvl := 0; lvl < int(numLevels); lvl++ {
        levelOpts = append(levelOpts, pebble.LevelOptions{
            Compression:    pebble.NoCompression, // 写性能优先
            BlockSize:      blockSz,
            TargetFileSize: sz,                 // L0 < L1 < … 呈指数增长
        })
        sz *= levelMult
    }
    
    //--------------------------------------------------
    // 5️⃣ 初始化依赖:LRU Cache + 读写选项
    //--------------------------------------------------
    cache := pebble.NewCache(cacheSz)    // block缓存
    ro    := &pebble.IterOptions{}       // 迭代器默认配置
    wo    := &pebble.WriteOptions{Sync: true// ❗fsync强制刷盘
    
    opts := &pebble.Options{
        Levels:                      levelOpts,
        Cache:                       cache,
        MemTableSize:                writeBufSz,
        MemTableStopWritesThreshold: bufCnt,
        LBaseMaxBytes:               baseBytes,
        L0CompactionThreshold:       l0Compact,
        L0StopWritesThreshold:       l0StopWrites,
        Logger:                      PebbleLogger,
        FS:                          vfs.NewPebbleFS(fs),
        MaxManifestFileSize:         128 * 1024 * 1024,
        // WAL 目录稍后条件注入
    }
    
    kv := &KV{
        dbSet:    make(chan struct{}),          // 关闭->初始化完成信号
        callback: cb,                           // 上层 raft engine 回调
        config:   cfg,
        opts:     opts,
        ro:       ro,
        wo:       wo,
    }
    
    event := &eventListener{
        kv:      kv,
        stopper: syncutil.NewStopper(),
    }
    
    // => 关键事件触发
    opts.EventListener = pebble.EventListener{
        WALCreated:    event.onWALCreated,
        FlushEnd:      event.onFlushEnd,
        CompactionEnd: event.onCompactionEnd,
    }
    
    //--------------------------------------------------
    // 7️⃣ 目录准备
    //--------------------------------------------------
    if wal != "" {
        fs.MkdirAll(wal)        // 📁 为 WAL 单独磁盘预留
        opts.WALDir = wal
    }
    fs.MkdirAll(dir)            // 📁 主数据目录
    
    //--------------------------------------------------
    // 8️⃣ 真正的数据库实例化
    //--------------------------------------------------
    pdb, err := pebble.Open(dir, opts)
    if err != nil { return nil, err }
    
    //--------------------------------------------------
    // 9️⃣ 🧹 资源整理 & 启动事件
    //--------------------------------------------------
    cache.Unref()               // 去除多余引用,防止泄露
    kv.db = pdb
    
    // 🔔 手动触发一次 WALCreated 确保反压逻辑进入首次轮询
    kv.setEventListener(event)  // 内部 close(kv.dbSet)
    
    return kv, nil
}

其中eventListener是对pebble 内存繁忙的回调,繁忙判断的条件有两个:

  • 内存表大小超过阈值(95%)
  • L0 层文件数量超过阈值(L0写入最大文件数量-1)


func (l *eventListener) notify() {
    l.stopper.RunWorker(func() {
        select {
        case <-l.kv.dbSet:
            if l.kv.callback != nil {
                memSizeThreshold := l.kv.config.KVWriteBufferSize *
                    l.kv.config.KVMaxWriteBufferNumber * 19 / 20
                l0FileNumThreshold := l.kv.config.KVLevel0StopWritesTrigger - 1
                m := l.kv.db.Metrics()
                busy := m.MemTable.Size >= memSizeThreshold ||
                    uint64(m.Levels[0].NumFiles) >= l0FileNumThreshold
                l.kv.callback(busy)
            }
        default:
        }
    })
}

3.4日志条目存储DB

db结构体是Dragonboat日志数据库的核心管理器,提供Raft日志、快照、状态等数据的持久化存储接口。是桥接了业务和pebble存储的中间层。

// db is the struct used to manage log DB.
type db struct {
    cs      *cache       // 节点信息、Raft状态信息缓存
    keys    *keyPool     // Raft日志索引键变量池
    kvs     kv.IKVStore  // pebble的封装
    entries entryManager // 日志条目读写封装
}


// 这里面的信息不会过期,叫寄存更合适
type cache struct {
    nodeInfo       map[raftio.NodeInfo]struct{}
    ps             map[raftio.NodeInfo]pb.State
    lastEntryBatch map[raftio.NodeInfo]pb.EntryBatch
    maxIndex       map[raftio.NodeInfo]uint64
    mu             sync.Mutex
}
  • 获取一个批量写容器

实现:

func (r *db) getWriteBatch(ctx IContext) kv.IWriteBatch {
    if ctx != nil {
        wb := ctx.GetWriteBatch()
        if wb == nil {
            wb = r.kvs.GetWriteBatch()
            ctx.SetWriteBatch(wb)
        }
        return wb.(kv.IWriteBatch)
    }
    return r.kvs.GetWriteBatch()
}

降低GC压力

  • 获取所有节点信息

实现:

func (r *db) listNodeInfo() ([]raftio.NodeInfo, error) {
    fk := newKey(bootstrapKeySize, nil)
    lk := newKey(bootstrapKeySize, nil)
    fk.setBootstrapKey(00)
    lk.setBootstrapKey(math.MaxUint64, math.MaxUint64)
    ni := make([]raftio.NodeInfo, 0)
    op := func(key []byte, data []byte) (boolerror) {
        cid, nid := parseNodeInfoKey(key)
        ni = append(ni, raftio.GetNodeInfo(cid, nid))
        return truenil
    }
    if err := r.kvs.IterateValue(fk.Key(), lk.Key(), true, op); err != nil {
        return []raftio.NodeInfo{}, err
    }
    return ni, nil
}
  • 保存集群状态

实现:

type Update struct {
    ClusterID uint64  // 集群ID,标识节点所属的Raft集群
    NodeID    uint64  // 节点ID,标识集群中的具体节点


    State  // 包含当前任期(Term)、投票节点(Vote)、提交索引(Commit)三个关键持久化状态


    EntriesToSave []Entry    // 需要持久化到稳定存储的日志条目
    CommittedEntries []Entry // 已提交位apply的日志条目
    MoreCommittedEntries bool  // 指示是否还有更多已提交条目等待处理


    Snapshot Snapshot  // 快照元数据,当需要应用快照时设置


    ReadyToReads []ReadyToRead  // ReadIndex机制实现的线性一致读


    Messages []Message  // 需要发送给其他节点的Raft消息


    UpdateCommit struct {
        Processed         uint64  // 已推送给RSM处理的最后索引
        LastApplied       uint64  // RSM确认已执行的最后索引
        StableLogTo       uint64  // 已稳定存储的日志到哪个索引
        StableLogTerm     uint64  // 已稳定存储的日志任期
        StableSnapshotTo  uint64  // 已稳定存储的快照到哪个索引
        ReadyToRead       uint64  // 已准备好读的ReadIndex请求索引
    }
}




func (r *db) saveRaftState(updates []pb.Update, ctx IContext) error {
      // 步骤1:获取写入批次对象,用于批量操作提高性能
      // 优先从上下文中获取已存在的批次,避免重复创建
      wb := r.getWriteBatch(ctx)
      
      // 步骤2:遍历所有更新,处理每个节点的状态和快照
      for _, ud := range updates {
          // 保存 Raft 的硬状态(Term、Vote、Commit)
          // 使用缓存机制避免重复保存相同状态
          r.saveState(ud.ClusterID, ud.NodeID, ud.State, wb, ctx)
          
          // 检查是否有快照需要保存
          if !pb.IsEmptySnapshot(ud.Snapshot) {
              // 快照索引一致性检查:确保快照索引不超过最后一个日志条目的索引
              // 这是 Raft 协议的重要约束,防止状态不一致
              if len(ud.EntriesToSave) > 0 {
                  lastIndex := ud.EntriesToSave[len(ud.EntriesToSave)-1].Index
                  if ud.Snapshot.Index > lastIndex {
                      plog.Panicf("max index not handled, %d, %d",
                          ud.Snapshot.Index, lastIndex)
                  }
              }
              
              // 保存快照元数据到数据库
              r.saveSnapshot(wb, ud)
              
              // 更新节点的最大日志索引为快照索引
              r.setMaxIndex(wb, ud, ud.Snapshot.Index, ctx)
          }
      }
      
      // 步骤3:批量保存所有日志条目
      // 这里会调用 entryManager 接口的 record 方法,根据配置选择批量或单独存储策略
      r.saveEntries(updates, wb, ctx)
      
      // 步骤4:提交写入批次到磁盘
      // 只有在批次中有实际操作时才提交,避免不必要的磁盘 I/O
      if wb.Count() > 0 {
          return r.kvs.CommitWriteBatch(wb)
      }
      return nil
  }
  
  
  • 保存引导信息

实现:

func (r *db) saveBootstrapInfo(clusterID uint64,
    nodeID uint64, bs pb.Bootstrap) error {
    wb := r.getWriteBatch(nil)
    r.saveBootstrap(wb, clusterID, nodeID, bs)
    return r.kvs.CommitWriteBatch(wb) // 提交至Pebble
}


func (r *db) saveBootstrap(wb kv.IWriteBatch,
    clusterID uint64, nodeID uint64, bs pb.Bootstrap) {
    k := newKey(maxKeySize, nil)
    k.setBootstrapKey(clusterID, nodeID) // 序列化集群节点信息
    data, err := bs.Marshal()
    if err != nil {
        panic(err)
    }
    wb.Put(k.Key(), data)
}
  • 获取Raft状态

实现:

func (r *db) getState(clusterID uint64, nodeID uint64) (pb.State, error) {
    k := r.keys.get()
    defer k.Release()
    k.SetStateKey(clusterID, nodeID)
    hs := pb.State{}
    if err := r.kvs.GetValue(k.Key(), func(data []byte) error {
        if len(data) == 0 {
            return raftio.ErrNoSavedLog
        }
        if err := hs.Unmarshal(data); err != nil {
            panic(err)
        }
        return nil
    }); err != nil {
            return pb.State{}, err
    }
    return hs, nil
}

3.5对外存储API实现

龙舟对ILogDB提供了实现:ShardedDB,一个管理了多个pebble bucket的存储单元。

var _ raftio.ILogDB = (*ShardedDB)(nil)
// ShardedDB is a LogDB implementation using sharded pebble instances.
type ShardedDB struct {
    completedCompactions uint64             // 原子计数器:已完成压缩操作数
    config               config.LogDBConfig // 日志存储配置
    ctxs                 []IContext         // 分片上下文池,减少GC压力
    shards               []*db              // 核心:Pebble实例数组
    partitioner          server.IPartitioner // 智能分片策略器
    compactionCh         chan struct{}      // 压缩任务信号通道
    compactions          *compactions       // 压缩任务管理器
    stopper              *syncutil.Stopper  // 优雅关闭管理器
}
  • 初始化过程

实现:

// 入口函数:创建并初始化分片日志数据库
OpenShardedDB(config, cb, dirs, lldirs, batched, check, fs, kvf):


    // ===阶段1:安全验证===
    if 配置为空 then panic
    if check和batched同时为true then panic


    // ===阶段2:预分配资源管理器===
    shards := 空数组
    closeAll := func(all []*db) { //出错清理工具
        for s in all {
            s.close()
        }
    }


    // ===阶段3:逐个创建分片===
    loop i := 0 → 分片总数:
        datadir := pathJoin(dirs[i], "logdb-"+i)  //数据目录
        snapdir := ""                           //快照目录(可选)
        if lldirs非空 {
            snapdir = pathJoin(lldirs[i], "logdb-"+i)
        }


        shardCb := {shard:i, callback:cb}      //监控回调
        db, err := openRDB(...)                //创建实际数据库实例
        if err != nil {                        //创建失败
            closeAll(shards)                   //清理已创建的
            return nil, err
        }
        shards = append(shards, db)


    // ===阶段5:核心组件初始化===
    partitioner := 新建分区器(execShards数量, logdbShards数量)
    instance := &ShardedDB{
        shards:      shards,
        partitioner: partitioner,
        compactions: 新建压缩管理器(),
        compactionCh: 通道缓冲1,
        ctxs:       make([]IContext, 执行分片数),
        stopper:    新建停止器()
    }


    // ===阶段6:预分配上下文&启动后台===
    for j := 0 → 执行分片数:
        instance.ctxs[j] = 新建Context(saveBufferSize)


    instance.stopper.RunWorker(func() {        //后台压缩协程
        instance.compactionWorkerMain()
    })


    return instance, nil                      //构造完成
    

  • 保存集群状态

实现:

func (s *ShardedDB) SaveRaftState(updates []pb.Update, shardID uint64error {
    if shardID-1 >= uint64(len(s.ctxs)) {
        plog.Panicf("invalid shardID %d, len(s.ctxs): %d", shardID, len(s.ctxs))
    }
    ctx := s.ctxs[shardID-1]
    ctx.Reset()
    return s.SaveRaftStateCtx(updates, ctx)
}


func (s *ShardedDB) SaveRaftStateCtx(updates []pb.Update, ctx IContext) error {
    if len(updates) == 0 {
        return nil
    }
    pid := s.getParititionID(updates)
    return s.shards[pid].saveRaftState(updates, ctx)
}

以sylas为例子,我们每个分片都是单一cluster,所以logdb只使用了一个分片,龙舟设计初衷是为了解放多cluster的吞吐,我们暂时用不上,tindb可以考虑

四、总结

LogDB是Dragonboat重要的存储层实现,作者将Pebble引擎包装为一组通用简洁的API,极大方便了上层应用与存储引擎的交互成本。

其中包含了很多Go语言的技巧,例如大量的内存变量复用设计,展示了这个库对高性能的极致追求,是一个十分值得学习的优秀工程案例。

往期回顾

1. 从数字到版面:得物数据产品里数字格式化的那些事

2. 一文解析得物自建 Redis 最新技术演进

3. Golang HTTP请求超时与重试:构建高可靠网络请求|得物技术

4. RN与hawk碰撞的火花之C++异常捕获|得物技术

5. 得物TiDB升级实践

文 /酒米

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

昨天以前掘金专栏-得物技术

从数字到版面:得物数据产品里数字格式化的那些事

作者 得物技术
2025年11月25日 17:16

一、前 言

做数据前端,你会很快建立一个共识:

怎样把枯燥的数字用合适的方式展示出来,是我们的第一要务,但这只是起点。

如果说规范的数字排版是中后台系统的“地基” ,保证了信息的准确传达;那么可视化图表就是地基之上的“建筑” 。地基稳固,建筑才能发挥其功能——让用户从微观的读数中解放出来,更快速地识别趋势、定位异常,从而真正从数据中获取规律。

但这篇主要想聊的,不是那座“建筑”,而是这块往往被忽视,却决定了整个系统专业度的“地基”——数字格式化。

请看得物各业务线里的这些日常场景:

  • 商品详情页:券后价、折扣、分期单价等;
  • 智珠(得物社区运营平台)、得数(自助取数平台)、智能运营(得物交易运营平台)里的 GMV、转化率、留存率、PVCTR等;
  • 社区里帖子阅读量、点赞数、粉丝数。

这些数字表面上只是 number,一旦出现在屏幕上,就自动变成版面的一部分:

对齐方式、位数长短、小数几位、有没有“万 / 亿”、单位怎么放——都会影响到整个页面的节奏和专业感。

排版本质上是在管理信息和秩序:层级、节奏、对齐、留白。而数字不是排版的“附属品”,恰恰是这些原则最密集的载体。

本文不发散去谈数据可视化,只专注于“数字格式化”这一件事:

  1. 什么是数字格式化?它背后有哪些鲜为人知的文化差异和技术细节?
  2. 如果没有统一方案,失控的数字会给产品决策、UI 排版和工程维护带来什么麻烦?
  3. 得物数据前端在得数 / 智珠 / 智能运营里,是怎么把这件事从“工具函数”做成“基础设施”的?
  4. 我们基于Intl.NumberFormat和@dfx/number-format 的方案,架构是怎样的,带来了哪些实际收益?

二、什么是“数字格式化”?不只是toFixed(2)

一提到提到数字格式化,第一反应是:toFixed(2)、拼个%、加个¥就完事了或者通过正则来拼接千分符。但在真实世界里,“同一个数长成什么样”远比想象复杂。

2.1 数字写法本身就是“文化差异”

zh.wikipedia.org/wiki/小数点

小数分隔符的符号演变史

据Florian Cajori 1928年的著作《数学符号史》记载,小数分隔符(旧称 separatrix)的演变经历了一个漫长的标准化过程。

在早期数学文献中,不同地区对小数的处理方式各异。以数值 34.65 为例,中世纪文献常采用在整数与小数间加横线或在个位数字上方加注标记的方式。英国早期曾采用竖线“|”作为分隔,后在印刷中逐渐简化为逗号或点,这与当时阿拉伯数学家主要使用逗号的习惯相呼应。

“点”与“逗号”分流的历史成因

17 世纪末至 18 世纪初是符号标准化的关键时期,也是英美体系与欧洲大陆体系产生分歧的起点。这一差异在很大程度上受到了微积分发明者及其符号体系的影响:

  1. 欧洲大陆(莱布尼茨的影响) :德国数学家莱布尼茨提议使用“点”作为乘法符号。这一提议经由克里斯蒂安·沃尔夫等学者的推广,在欧洲大陆教科书中广泛普及。为了避免符号含义的冲突,欧洲大陆数学家普遍采用了“逗号”作为小数分隔符。
  2. 英国(牛顿体系的延续) :英国数学界未采纳莱布尼茨的乘法符号,而是沿用“X”表示乘法。因此,“点”在英国并未被乘法占用,得以继续作为小数分隔符使用。据统计,18 世纪初的英国教科书中,约 60% 使用点,40% 使用逗号;而到了 18 世纪末,点已成为英国的绝对主流。

标准的确立

尽管比利时和意大利等国曾长期坚持使用点作为小数分隔符,但最终均向欧洲大陆的主流标准靠拢,改用了逗号。至此,英美使用“小数点”、欧洲大陆使用“小数逗号”的格局基本定型。

值得注意的是,直至20世纪初,符号的统一仍未完全完成,各类文献中仍可见等非标准写法。

  • 英语系 / 中国常见写法:1,234,567.89
  • 很多欧洲国家:1.234.567,89(点是千分位,逗号是小数)
  • 瑞士习惯:1'234.56或1'234,56,用撇号 ' 做分组。

这些规则都已经被整理进Unicode CLDR和ICU数据库,现代浏览器的Intl.NumberFormat就是建立在这套数据之上,能根据 locale 自适应这些写法。

所以,一个简单的1,000:

  • 在美国人或中国人眼里是“ one thousand / 一千”;
  • 在某些欧洲语境下可能被读成“保留三位小数的一点零”。

一旦你做电商、跨境、数据产品,这种“写法”的差异,就不再是小问题,而是直接影响决策和合规的东西。

2.2 数字不只是“大小”还有语义和语气

在 UI 里,我们其实经常在表达“数字的语气”:

  • +12.3%:不是纯数学“加号”,而是一种“上涨”的信号,排版上常常配合红/绿颜色;
  • 1.2M / 120 万:为了节省空间和降低认知负担,用缩写表示量级;
  • < 0.01:极小数值,让用户知道“接近 0”,而不是盯着一串 0.000032 的数字尾巴发呆;
  • — /N/A:告诉用户“这里是没数据 / 异常”,而不是“就是 0”。

这些都属于“数字的表达”而非“运算结果”。

如果我们只用toFixed和拼字符串,很难让这些语气在整个系统内保持一致。

2.3浏览器和 Node 已经给了我们一个“引擎”

ECMAScript 402标准中提供的 Intl.NumberFormat,是一个专门做本地化数字格式化的构造器。

它支持:

  • 根据 locale 切换小数点、分组规则、数字符号;
  • 货币格式:style: "currency" + currency: "CNY" | "JPY" | ...;
  • 百分比:style: "percent",自动乘 100 并加 %;
  • 紧凑表示:notation: "compact",输出 9.9M、9.9亿等缩写;
  • formatToParts():把数字拆成整数、小数点、小数、货币符号等片段,方便做更精细的排版。

所以,数字格式化的“引擎问题”其实已经有人帮我们解决了——真正难的是:

  • 怎么结合业务语义;
  • 怎么结合排版规范;
  • 怎么在多个系统之间做到一致;
  • 怎么治理“不乱写”的工程实践。

这就回到我们自己的故事。

三、如果没有统一方案:三个数据产品的日常

下面这几个场景,相信你在得物或类似电商/数据平台里一定见过。

想象一张典型后台页面:

  • 顶部是活动看板 KPI 卡片;
  • 中间是按品类/渠道拆开的表格;
  • 下方是创作者表现列表。

3.1 价格:同一张订单,不同系统“长得不同”

那么,以前我们是怎么做的?

在没有统一规范的“蛮荒时代”,面对一个数字,我们的第一反应往往是:“这还不简单?拼个字符串不就完事了?”

这种代码,你一定写过,或者在项目的某个角落实实在在地见过:

场景一:简单粗暴的拼接一把梭

// "我管你什么场景,先拼上去再说"
function formatPrice(value: number) {
  // 隐患埋雷:这里是全角¥还是半角¥?null 会变成 "¥null" 吗?
  return '¥' + value.toFixed(2);
}

场景二:为了千分位,手写正则“炫技”

// "网上一搜一大把的正则,看着能跑就行"
export const formatNumberWithCommas = (number: number | string) => {
  const parts = number.toString().split('.');
  
  // 经典的正则替换,但这真的覆盖了所有负数、极大值场景吗?
  parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
  
  // 手动补零逻辑,不仅难维护,还容易在边界情况(如 undefined)下报错
  if (parts.length > 1 && parts[1]?.length === 1) {
    parts[1] = ${parts[1]}0;
  }
  return parts.join('.');
};

这看起来似乎“能用”,并且用起来貌似也没啥问题。但当业务复杂度稍微上来一点或者需要做国际化的时候,那接手这个需求的同学只能发出一句『哦吼』。

  1. 视觉上的“各自为政”
    1. 符号打架: 商品卡片用半角 ⁠¥,详情页用全角 ⁠¥,甚至有的地方混用了 ⁠CNY。
    2. 精度随心: 有的开发觉得 ⁠toFixed(2) 严谨,有的觉得 ⁠。00 太多余直接砍掉,导致同一个页面里,导致同一个页面里,数字像锯齿一样参差不齐。
  2. 排版上的“秩序崩塌”
    1. 试想一个表格列:⁠1299、⁠1,299.00、⁠1299.0 混在一起。
    2. 对齐基准线完全错乱,尤其在表格里,就和『狗啃过一样』,犬牙交错,参差不齐,用户的眼睛需要在不同的小数位之间来回跳跃,阅读体验极差。
  3. 国际化上的“死胡同”
    1. 这种硬编码逻辑(Hardcode),完全堵死了国际化的路。
    2. 一旦业务要支持 USD(美元)、JPY(日元,默认无小数)、EUR(欧元,部分国家用逗号做小数点)。
    3. 比如 ⁠1,234,567.89(英美)和 ⁠1.234.567,89(德意),这完全不是改个符号就能解决的,而是整个数字书写逻辑的根本差异。

我们原本想省事写的小函数,最终变成了阻碍系统演进的技术债务。

3.2看似智能的“万/亿”缩写,其实是硬编码的陷阱

在创作者中心或内容社区,我们希望阅读量(PV)能符合用户的直觉认知:

  • 小数值:⁠< 10,000 时,显示完整数字,精确到个位;
  • 中量级:⁠≥ 10,000 时,缩写为 ⁠X.X 万;
  • 海量级:≥ 100,000,000 时,缩写为 ⁠X.X 亿。

如果是海外版,需求又变成了:⁠1.2k/⁠3.4M/5.6B。

于是,我们写出了那段经典的⁠formatPv

function formatPv(count: number) {
  if (count < 10000) return String(count);
  if (count < 100000000) return (count / 10000).toFixed(1) + '万';
  return (count / 100000000).toFixed(1) + '亿';
}

这段代码逻辑清晰,看似解决了问题。但在真实场景中,它却是一个“Bug 制造机”:

1.临界点的“视觉突变”

  • 9999 下一秒变成 ⁠1.0 万?产品会立刻找你:“这个数展示是不是不大对,能不能9999之后显示 1.0w 而不是 1.0 万?”
  • 99950 四舍五入变成 ⁠10.0 万,要不要显示成更直观的 ⁠10 万?这些微小的细节,需要堆砌大量的 ⁠if-else 来修补。

2.维护上的“复制粘贴地狱”

  • 中文版写一套,英文版 ⁠formatPvEn 再写一套,等咱们的业务做往世界各地的时候,那得要多少 formatPv(xx)呢?
  • A 项目拷一份,B 项目拷一份。等哪天产品说“万”后面不留小数了,你得去 N 个仓库里把这行代码找出来改一遍。最终结果就是:全站的缩写策略处于一种“相似但不一致”的薛定谔状态。

3.文化适配上的“盲区”

  • 这套逻辑是典型的“简体中文中心主义”。
  • 印度用户看到⁠100k 是没感觉的,他们习惯用 ⁠Lakh (10 万) 和 ⁠Crore (1000 万);
  • 阿拉伯语区可能需要用东阿拉伯数字。

这不仅仅是把“万”翻译成“Wan”的问题,而是数字分级逻辑在不同文化中完全不同,在前面针对符号系统我们也提到过。

我们在 UI 上硬塞了一套“只能在简体中文里自洽”的规则,这在国际化产品中是行不通的。

3.3 被“抹平”的语义—0、空值与极小值

在得数(自助取数平台)、智珠(得物社区运营平台)、智能运营(得物交易运营平台)这类重度数据看板中,我们充斥着各种比率型指标

  • 风控类:鉴别通过率、拦截率;
  • 履约类:超时率、投诉成功率;
  • 增长类:活动转化率、复购率。

面对这些指标,以前最常见的处理方式是写一个通用的 formatPercent:

function formatPercent(value: number | null | undefined) {
  // "没数据就当 0 算呗,省得报错"
  return ((value || 0) * 100).toFixed(2) + '%';
}

这行代码虽然只有一句话,却在业务层面犯了三个严重的错误:

1. 混淆了“事实”与“缺失”

  • ⁠null 代表数据未产出或链路异常(就是没数),而 ⁠0 代表业务结果确实为零。
  • 代码粗暴地将 ⁠null 转为 ⁠0.00%,会让用户误以为“今天没人投诉”或“转化率为 0”,从而掩盖了背后的系统故障或数据延迟。

2. “抹杀”了长尾数据的价值

  • 对于 AB 或算法模型来说,⁠0.000032 是一个具备统计意义的概率值。
  • 被这行代码强行截断为 ⁠0.00% 后,业务同学会困惑:“为什么 P 值还能是 0 的嘛?”这直接损害了数据的公信力,严重点来说,都会影响业务决策。

3. 阻断了精细化表达的可能

当你想把极小值优化为 ⁠< 0.01% 这种更科学的表达时,你通过 vscode 一搜代码,500+ 文件,直接就两眼一黑。

从排版设计的角度看,这三者本应拥有完全不同的视觉层级:

  • 空值(No Data) :使用 ⁠— 或灰色占位符,表示“此处无信息”,降低视觉干扰;
  • 异常值(Error) :使用 ⁠N/A 或警示色,提示用户“数据有问题”;
  • 极小值(Tiny Value) :使用 ⁠< 0.01% 或者≈ 0,保留数据的存在感,同时传达“接近于零”的准确语义。

得数DataHub在治理展示层时发现,大量“同一个指标在不同页面长得不一样”,根源就在于这些各自为政、缺乏语义区分的格式化规则。

四、从“写工具函数”到“定义展示语义”

回顾上述场景,透过那些混乱的代码片段,可以发现三个共性的系统性难题:

  1. 逻辑熵增:每个业务线、甚至每个页面都在重复造轮子。⁠formatPrice、⁠formatPercent 遍地开花,前后端逻辑割裂,维护成本随着业务扩张呈指数级上升。
  2. 无法治理:想把全站的“万 / 亿”阈值统一?或者把某种费率的精度从两位改成四位?这几乎是不可能的任务。
  3. 体验失控:设计规范虽然写着“空值用 —”,但落实到代码里全看开发心情。结果就是用户在不同系统间切换时,看到的是一种“似是而非”的统一,严重影响了产品的专业感。

为了解决这些问题,在数据域产品中,我们对“格式化”这件事进行了重新定义:

它不只是前端的 UI 渲染逻辑,而是指标定义的一部分。

我们不仅要定义“指标的计算口径”,更要定义“指标的展示语义”。

在 Galaxy(指标管理平台)定义好“数是怎么算出来的”之后,得数 DataHub 承担起了定义“数该怎么被看见”的职责。我们将这层逻辑抽象为 “展示语义”(Visualization Semantics)

  • 定类型(Type) :它是金额(Currency)、比率(Ratio),还是计数(Integer)?
  • 定单位(Unit) :默认是元 / 万元,还是 %、‰ (千分比) 或 bp (基点)?
  • 定精度(Precision) :小数位是固定保留两位,还是根据数值大小动态截断?
  • 定状态(State) :遇到 Null(空值)、Error(异常)或 Epsilon(极小值),展示层该如何兜底?
  • 定场景(Context) : 在空间局促的 KPI 卡片里(追求简洁),和需要财务核对的 明细表格里(追求精确),是否应用不同的渲染策略?

这是一种架构上的升维:

这些关于“长什么样”的逻辑,从此不再散落在业务代码的 ⁠if-else 里,而是被统一收拢到元数据系统中进行管理。

从这一刻起,数字格式化不再是前端模板里的一个小工具,而是成为了得数体系的一项基础设施——一层独立、可配置、可治理的数据领域服务

五、开始造轮子:站在Intl.NumberFormat 肩膀上

在着手开发之前,首先确立了一个原则:底层能力不造轮子,拥抱 Web 标准。

ECMAScript 402 标准中的 ⁠Intl.NumberFormat 已经为我们提供了一个极其强大的本地化格式化引擎。它的能力远超大多数手写的正则替换:

const n = 123456.789;


// 德国:点号分组、逗号小数
new Intl.NumberFormat('de-DE').format(n);
// "123.456,789"


// 印度:lakh/crore 分组
new Intl.NumberFormat('en-IN').format(n);
// "1,23,456.789"


// 日元货币:默认 0 位小数
new Intl.NumberFormat('ja-JP', {
  style'currency',
  currency'JPY',
}).format(n);
// "¥123,457"

它完美解决了那些最让前端头疼的国际化底层问题

  • 文化差异:全球各地的千分位、小数点、数字符号习惯;
  • 货币规则:不同币种的标准小数位(如日元 0 位,美元 2 位)和符号位置;
  • 多态支持:内置了百分比、紧凑缩写(Compact Notation)、科学计数法等模式;
  • 排版能力:通过 ⁠formatToParts() 将数字拆解为数组(整数部分、小数部分、符号等),为精细化排版提供了可能,比如在小数或百分比符号比整数小 2 个字号。

但是,它天然“不懂”业务:

  • 它不懂中文的习惯:无法直接实现“兆/京/垓”这种中文超大数缩写逻辑(标准最多支持到亿);
  • 它不懂得数的规范:不知道 ⁠*_rate 类型的指标在空值时要显示 ⁠"-",在极小值时要显示 ⁠< 0.01%;
  • 它不懂业务的上下文:不知道 GMV 在 KPI 卡片里要按“万元”展示,而在财务报表里必须精确到“厘”。

因此,我们的最终的架构策略是:

以 ⁠Intl.NumberFormat 为底层的“渲染引擎”;

在其之上搭建一层“数字领域层”(Domain Layer);

专门用于转译得物的业务规则和排版语义。

六、@dfx/number-format:构建数字的“领域层”

在得物数据前端的大仓里,我们把这层能力实现为一个独立包:@dfx/number-format。专门服务得数、智珠、智能运营等内部系统。

可以把它理解为三件事的组合:

  • 一个统一封装了Intl.NumberFormat的核心引擎(含缓存、解析);
  • 一套可以被配置的业务规则 / 预设 (preset)和插件系统
  • 一组面向 React 和 Node 环境的接口(组件 + Hook + FP 函数)。

6.1 声明式开发:把“数字长什么样”抽象成规则

在业务开发侧,最终期望的目标是:让开发者只关心“这是什么指标”,而不关心“它该怎么展示”。

场景一:基础格式化

我们可以直接使用组件,以声明式的方式调用:

import { NumberFormat } from '@dfx/number-format';
// 无论在哪个页面,价格都只需这样写
<NumberFormat
  value={price}
  options={{ style: 'currency', currency: 'CNY' }}
/>

场景二:基于语义的自动格式化

更进阶的用法是,在系统层面定义好“规则集”,业务组件只需传入指标名称:

import { NumberFormatProviderAutoMetricNumber } from '@dfx/number-format';
// 1. 定义规则:所有 "price.cny" 类型的指标,都遵循人民币格式
const rules = [
  { name'price.cny'options: { style'currency'currency'CNY' } },
];
// 2. 注入上下文
<NumberFormatProvider options={{ rules }}>
  {/* 3. 业务使用:完全解耦,只传语义 */}
  <AutoMetricNumber name="price.cny" value={price} />
</NumberFormatProvider>

收益显而易见:

  • 自动化:CNY 自动带两位小数,切到 JPY 自动变 0 位小数,逻辑完全由底层接管。
  • 一致性:全站所有 ⁠price.cny 的地方,千分位、符号位置严格统一,版面节奏自然对齐。

场景三:批量匹配比率指标

对于成百上千个转化率指标,我们不需要逐一定义,只需一条正则规则:

const rules = [
  // 匹配所有以 _rate 结尾的指标,自动转为百分比,保留2位小数
  { pattern/_rate$/ioptions: { style'percent'maximumFractionDigits2 } },
];


// 页面代码极其干净
<AutoMetricNumber name="conversion_rate" value={conversionRate} />

空值与异常值如何展示、极小值是否显示 < 0.01%、两位小数从哪里来,全部在规则 + 插件里处理。

6.2插件系统:收口那些“奇怪但常见”的需求

得物的业务场景中,存在大量 ⁠Intl 标准无法直接覆盖的边缘需求。我们将这些需求统一建模为插件(Format Plugin) ,介入格式化的生命周期:

  • 千分比 / 基点 (bps) :需在 ⁠pre-process 阶段将数值乘 1000 或 10000;
  • 中文大写金额:会计与合同场景的特殊转换;
  • 极小值兜底:设定阈值,当数值小于 ⁠0.0001 时,⁠post-process 阶段输出 ⁠< 0.01%;
  • 会计格式:负数使用括号 ⁠(1,234.56) 而非负号;
  • 动态精度策略:根据数值大小动态决定保留几位小数。

这套插件机制的意义在于“治理”:

它让“在某个业务仓库里偷偷写一个特殊正则”成为过去式。任何新的格式化需求,都必须以插件形式接入,由数据前端统一评审、沉淀,最终复用到全站。

6.3 全链路打通:从Galaxy元数据到UI渲染

最后,将这套系统与得数的中台能力打通(也是目前我们正在做的),形成了一条完整的渲染链路。

在Galaxy和得数的元数据里,指标本身已经有code、label、type 等字段。我们只需要再加一点约定:

{
  "metricCode": "gmv",
  "label": "GMV",
  "format": "currency", // 基础类型
  "meta": {
    "formatConfig": {"style": "currency", "maximumFractionDigits": 2}, 
    "category": ["交易指标"],
    "displayConfig": { "table": "precise", "card": "compact" }
  }
}

前端渲染时

  • 配置下发:页面加载时,获取指标的 ⁠Code 及元信息,转换为前端的 ⁠MetricFormatRule[]。
  • 上下文注入:在应用根节点通过 ⁠NumberFormatProvider 注入规则集。
  • 傻瓜式使用:表格、卡片、图表组件只需消费指标 ID:

这样一来就实现了真正的『数据驱动 UI』:

  • “指标长什么样”只在得数元数据管理中定义一次。
  • 修改展示规则(如调整精度),只需改配置,无需批量修改前端代码,更无需重新发版;
  • 前台业务与中台报表看到的同一个指标,格式永远是物理上的一致。

七、统一之后:重塑设计、工程与业务的价值

从我们的视角出发,这套统一数字格式方案的落地,带来的收益远不止“代码整洁”那么简单,它在三个层面产生了深远的影响。

7.1 对设计与排版:版面终于可控了

曾经,产品需要在每个页面的验收中反复纠结数字的对齐和精度。现在,设计规范只需在文档中定义一次:

  • 金额类:统一保留两位小数,强制右对齐,货币符号半角化;
  • 比率类:空值兜底为 ⁠—,异常值显示 ⁠N/A,极小值转译为 ⁠< 0.01%;
  • 缩写策略:全站统一遵循“中文万/亿、英文 k/M/B”的梯度逻辑。

开发同学不再是『古法手搓』,而是直接接入统一的 Preset 和插件。

无论是看板、详情页,还是导出的 Excel 报表,数字风格保持了像素级的一致。从视觉角度看,我们相当于给数字建立了一套Design Token:精度、单位、占位符都有了标准索引,让整个平台呈现出高度统一的专业感和节奏感

7.2 对工程质量与效率:从“搜索toFixed”到“改一处生效全站”

所有数字格式逻辑集中在一个领域层和配置中心。

一类指标的规则变更(精度、单位、空值策略)可以配置化调整。

规约可以进入 Lint(数字格式化限制) / Code Review:

  • 禁止直接在业务代码中new Intl.NumberFormat、toFixed拼字符串;
  • 鼓励所有数字展示全部走@dfx/number-format与 DataHub 规则。

这就是工程治理层面的价值:它将“依赖开发者自觉”的软约束,变成了 “可配置、可维护、可迭代”的系统能力

7.3 对业务和数据信任:从“怀疑这数有问题”到“愿意用来决策”

统一数字格式还有一个最隐性、却最核心的价值:重构数据信任。

  • 消除歧义:前台商品价格与中台报表金额完全一致,运营不再因为“显示精度不同”而去质疑数据准确性;
  • 精准语义:空值(Null)和零(Zero)被清晰区分,避免了因格式问题导致的错误决策。

在得物,严谨是需要刻在基因里的关键词

作为数据前端,我们深知:懂数据,并能用最专业的方式呈现数据,是这一岗位的核心素养。 当我们不仅能交付代码,还能通过精准的数字展示消除歧义、传递信任时,技术与业务之间就建立起了一种深层的默契。

这种对数字细节的极致追求,不仅是对用户体验的尊重,更是对得物“正品感”品牌心智在数字世界的延伸。

八、数字,是版面的『最后一公里』

回头看,得物数据前端在得数、智珠智能运营这条线做的,其实有两件事:

  • 给予数字应有的“设计尊严”

我们不再将数字视为模板字符串里一个随时可替换的“黑洞”,而是将其视作与字体、色彩、间距同等重要的排版元素,纳入统一的设计语言系统。

  • 为数字构建专属的“基础设施”

我们以 Web 标准⁠Intl.NumberFormat为引擎,以 ⁠@dfx/number-format为领域层,以得数 Schema 为配置中枢,将“数字如何展示”这一命题,从硬编码的泥潭中解放出来,转变为一种可配置、可治理、可演进的系统能力。

当你再回头看公司产品的各个页面——

  • 前台商品详情页的价格和券后价;
  • 社区里的阅读和点赞数字;
  • 智珠的策略看板;
  • 得数的自助分析报表。

你会发现它们拥有了一个共同的特征:

这些数字不再是各说各话的噪点,而是共同操着同一种精准、优雅的“排版语言”。

这就是我们做“数字格式化”的初衷:用技术的确定性,换取业务的专注力。

此时此刻,彼时彼刻,作为前端开发,你可以坚定地说出:我这展示的没问题,是数出问题了~

九、走出数据域:从内部门户到全业务

前面的篇幅,更多围绕着得数、智珠、智能运营等中后台系统展开。在这些场景下,数字格式化的核心用户是运营、分析师和算法同学——帮助他们透过数据看清业务走势。

但这套能力并不应局限于“后台”。它完全具备走出数据域的潜力,成为得物全业务线共享的一层关键基础设施

9.1 跨业务线:从“运营看板”走向“交易链路”

目前@dfx/number-format 虽然生长于数据土壤,但其解决的问题是通用的。未来,它可以自然地渗透到更广阔的业务场景:

  • 交易域: 统一商品详情页、结算页的价格、分期费率、税费展示逻辑;
  • 社区域: 标准化社区详情页中的风险分、阈值、命中率精度;
  • 客服域: 规范赔付金额、工单时长的展示口径。

这在工程上意味着一次“升维”:

  • 依赖升级:将 ⁠@dfx/number-format 从数据域私有包提升为公司级的基础依赖;
  • 开发范式:新业务在处理数字展示时,默认动作不再是“手写一个 format 函数”,而是查阅现有的 Preset 和插件;

最终带来的体验质变是:

用户无论是在得物 App的前台页面,还是在内部的各类管理系统中,看到的数字都拥有同一套呼吸感和视觉习惯。这种跨端的一致性,是品牌专业感最直接的体现。

9.2 跨地区与汇率:构建独立的“全球化价格能力”

随着得物业务的出海,多地区、多币种是绕不开的挑战。此时,数字领域层不仅要负责“长什么样”,更要承担起“展示策略”的职责。

我们可以设想一个**「全球化价格组件」**:

<MultiRegionPrice
  skuId="123456"
  basePriceCny={price}
  regions={[
    { locale: 'zh-CN', currency: 'CNY' },
    { locale: 'en-US', currency: 'USD' },
    { locale: 'ja-JP', currency: 'JPY' },
  ]}
/>

这个组件将负责两层逻辑的解耦:

  • 计算层: 负责实时汇率换算、多币种价格计算。
  • 展示策略层
    • 对照展示: 是否显示“原币种 + 本地参考价”(如 ⁠JP¥ 20,000 (≈ $135.00));
    • 文化适配: 是否遵循当地的“心理价位”策略(如 ⁠199.99vs199.99 vs ⁠200);
    • 格式渲染: 最终调用 ⁠@dfx/number-format,确保日元无小数、美元有小数、分节号正确。

从工程视角看,这避免了“汇率算对了、数字排版全乱了”的尴尬;从产品视角看,这正是得物沉淀“出海技术套件”的重要一步(比如国际智能运营)。

9.3 时间与日期:用同样的思路,去做第二条“格式化主线”

数字是一类挑战,“时间”则是另一类。跨时区转换、相对时间(此刻)、不同地区的日期写法(⁠DD/MM vs ⁠MM/DD),其复杂度丝毫不亚于数字。

既然浏览器提供了Intl.DateTimeFormat,我们完全可以复刻数字领域层的成功路径,再赢一次:

  • 基础设施:构建 ⁠@dfx/date-format,统一封装时间格式化与相对时间逻辑;
  • 预设管理:定义标准 Preset(如“运营报表用 ⁠YYYY-MM-DD”、“C 端动态用 ⁠今天 12:30”);
  • 组件化:提供 ⁠ 和 ⁠ 组件;
  • 元数据打通:在得数的元数据中,针对时间型的维度也同样进行配置。

这样,数据前端的展示层就拥有了两条清晰的主线:

  • 数值主线:⁠Intl.NumberFormat → ⁠@dfx/number-format → 数字展示规范
  • 时间主线:⁠Intl.DateTimeFormat → ⁠@dfx/date-format → 时间展示规范

未来,我们甚至可以抽象出更上层的 ⁠@dfx/data-format,让“任何字段该怎么展示”,完全由 Schema 配置 + 领域层规则共同决定。

十、最后:把“展示”这件事做到极致

如果只看代码,我们做的事情似乎很简单:

把 ⁠Intl 标准用到极致,封装了一层领域库,并接通了元数据配置,写了个工具库。

但如果从产品体验和工程演进的角度看,我们其实完成了一次基础设施的升级

把前端开发中最琐碎、最容易被忽视的“数字与时间展示”,从“到处粘贴的小工具”,升级成了“有统一规范、有可观测性、有迭代空间的系统能力”。

现在,这套能力已经让得数、智珠、智能运营的部分模块长出了统一的“数字气质”。

未来,期望它能够走出数据平台,支撑得物更广泛的业务场景,让同一件商品,在不同地区、不同语言、不同终端上,既算得对,又看得顺。

参考资料:

往期回顾

  1. 一文解析得物自建 Redis 最新技术演进

  2. Golang HTTP请求超时与重试:构建高可靠网络请求|得物技术

  3. RN与hawk碰撞的火花之C++异常捕获|得物技术

  4. 得物TiDB升级实践

  5. 得物管理类目配置线上化:从业务痛点到技术实现

文 /柏锐

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

❌
❌