Skip to content

这篇文章我们来聊一个经典但又极具挑战性的话题:“写多读少”的高并发系统设计

想象一下这个场景:成千上万的设备,像勤劳的蜜蜂一样,每时每刻都在向我们的系统汇报数据。物联网设备、实时日志、金融交易……它们共同的特点是写入请求如洪水猛兽,而读取查询却相对稀疏。如果我们用传统的方法去应对,数据库很快就会被这“写入的洪水”冲垮。

那么,如何驯服这头性能猛兽呢?我的核心设计心法是八个字:写入优先,异步为王;缓存精准,批量为皇

简单来说,就是把写入的优先级提到最高,用异步化的方式让它畅通无阻;同时,缓存只为那“一小撮”热数据服务,避免浪费;最后,通过批量处理,给数据库“减负”,让它能从容应对。

下面,我们就以一个非常具体的实战案例 —— 百万级智能电表数据上报,来庖丁解牛,看看这套组合拳是如何打的。

1. 实战沙盘:百万智能电表的数据洪流

让我们先把战场摆出来:

  • 业务画像:
    • 写操作(千军万马): 500万台智能电表,每秒都在上报读数,汇聚成约500万次/秒的写入请求(5000K TPS)。这可不是闹着玩的,相当于每秒钟都有500万个声音在对你的系统喊话。
    • 读操作(零星侦察): 90%的数据上报后就“沉睡”了,主要用于离线大数据分析。只有极少数的实时查询,而且用户最关心的永远是刚才的数据,也就是最近1分钟的读数。
  • 技术挑战:
    • 写入吞吐量: 必须顶住 >5000K TPS 的持续冲击。
    • 读取延迟: 虽然读的少,但一旦要读,就必须快,延迟需控制在 200ms 以内。
    • 数据时效性: 典型的“喜新厌旧”型数据,越新的数据,被访问的概率越高。

2. 亮剑时刻:核心架构与代码实现

Talk is cheap, show me the code. 让我们看看用Go + Redis + Kafka + MySQL这套组合,如何实现我们的“作战计划”。

go
// 这段Go代码,是应对“写多读少”场景的经典范式
package meter

import (
    "context"
    "encoding/json"
    "fmt"
    "time"

    "github.com/Shopify/sarama"
    "github.com/go-redis/redis/v8"
    "gorm.io/gorm"
)

// MeterData 电表上报的数据结构体
type MeterData struct {
    ID        int64     `json:"id"`
    MeterID   string    `json:"meter_id"`  // 设备ID
    Value     float64   `json:"value"`     // 电表读数
    Timestamp time.Time `json:"timestamp"` // 上报时间戳
}

// MeterService 核心服务,封装了所有操作
type MeterService struct {
    db            *gorm.DB            // 数据库连接,数据的最终归宿
    redis         *redis.Client       // Redis客户端,热数据的“高速公路”
    kafkaProducer sarama.SyncProducer // Kafka生产者,我们的“数据摆渡人”
}

// NewMeterService 创建服务实例的工厂方法
func NewMeterService(db *gorm.DB, redisClient *redis.Client, kafkaProducer sarama.SyncProducer) *MeterService {
    return &MeterService{
        db:            db,
        redis:         redisClient,
        kafkaProducer: kafkaProducer,
    }
}

// ReportData 处理数据上报的入口(写入路径)
// 它的职责非常单一:把数据又快又稳地扔进Kafka,然后告诉客户端“我收到了”,绝不恋战!
func (s *MeterService) ReportData(ctx context.Context, data MeterData) error {
    jsonData, err := json.Marshal(data)
    if err != nil {
        return err // 数据格式有问题,直接返回错误
    }

    // 核心操作:将数据推送到Kafka主题
    // 这是一个非常轻量的操作,网络开销极小,是实现高吞吐的关键
    msg := &sarama.ProducerMessage{
        Topic: "meter-data-topic",
        Value: sarama.ByteEncoder(jsonData),
    }

    _, _, err = s.kafkaProducer.SendMessage(msg)
    return err
}

// BatchSave 消费者端的批量保存逻辑
// 这是躲在幕后的“劳模”,它从Kafka把数据一批批拉过来,整理好再存入数据库。
func (s *MeterService) BatchSave(messages []*sarama.ConsumerMessage) error {
    const batchSize = 500 // 攒够500条再“发车”,减少数据库IO
    var batch []MeterData

    for _, msg := range messages {
        var data MeterData
        // 反序列化消息
        if err := json.Unmarshal(msg.Value, &data); err != nil {
            // 某条数据有问题?跳过它,不能影响大部队
            fmt.Printf("反序列化消息失败: %v\n", err)
            continue
        }
        batch = append(batch, data)

        // 凑够一车,发往数据库
        if len(batch) >= batchSize {
            if err := s.db.Create(&batch).Error; err != nil {
                // 批量插入失败,需要有重试或告警机制
                return err
            }
            batch = batch[:0] // 清空切片,准备下一批
        }
    }

    // 处理掉队的数据(不足一个批次的)
    if len(batch) > 0 {
        return s.db.Create(&batch).Error
    }
    return nil
}

// QueryRealtime 查询实时数据(读取路径)
// 这是典型的“Cache-Aside”模式:先敲缓存的门,没人再去找数据库老大哥。
func (s *MeterService) QueryRealtime(ctx context.Context, meterID string) ([]MeterData, error) {
    cacheKey := "meter:rt:" + meterID

    // 1. 先去Redis查最近1分钟的热数据
    // 我们用Sorted Set,Score是时间戳,完美匹配时间窗口查询
    now := time.Now()
    oneMinuteAgo := now.Add(-1 * time.Minute)
    
    // ZRangeByScore 按分数(时间戳)范围查询
    zrangeResult, err := s.redis.ZRangeByScore(ctx, cacheKey, &redis.ZRangeBy{
        Min: fmt.Sprintf("%d", oneMinuteAgo.Unix()), // 使用Unix时间戳作为Score
        Max: fmt.Sprintf("%d", now.Unix()),
        Offset: 0,
        Count:  100, // 最多返回100条
    }).Result()

    // 如果缓存命中,并且有数据
    if err == nil && len(zrangeResult) > 0 {
        var result []MeterData
        for _, item := range zrangeResult {
            var meterData MeterData
            if json.Unmarshal([]byte(item), &meterData) == nil {
                result = append(result, meterData)
            }
        }
        // 缓存里有,直接返回,任务结束!
        return result, nil
    }

    // 2. 缓存未命中(或Redis出错了),回源到数据库
    var dbData []MeterData
    // 这里我们只查最近30秒的数据,作为一种权衡,避免给DB太大压力
    if err := s.db.Where("meter_id = ? AND timestamp >= ?",
        meterID, time.Now().Add(-30*time.Second)).
        Order("timestamp DESC").
        Find(&dbData).Error; err != nil {
        return nil, err
    }

    // 3. (重要!)缓存回填/预热:把从DB查到的数据“顺手”放回Redis,方便下次查询
    if len(dbData) > 0 {
        pipe := s.redis.Pipeline()
        for _, data := range dbData {
            jsonData, _ := json.Marshal(data)
            pipe.ZAdd(ctx, cacheKey, &redis.Z{
                Score:  float64(data.Timestamp.Unix()), // Score是时间戳
                Member: jsonData,
            })
        }
        // 给这个key设置一个5分钟的“保鲜期”,过期自动删除,防止冷数据堆积
        pipe.Expire(ctx, cacheKey, 5*time.Minute) 
        pipe.Exec(ctx) // 批量执行
    }

    return dbData, nil
}

3. 策略详解:拆解我们的组合拳

3.1 第一拳:写入链路 —— “泄洪”与“削峰”

写入是我们的主要矛盾,所以必须用最凌厉的招式。核心思想是:让数据库远离风暴中心

战术分层技术方案一句话解释
1. 前端解耦设备数据直接写入 Kafka就像开了一个巨大的蓄洪池,再大的流量先进来再说。
2. 异步削峰Kafka作为消息队列缓冲数据把瞬时的高峰流量,抚平成平稳的水流,慢慢喂给下游。
3. 批量合并Consumer从Kafka拉取,批量Insert到DB数据们别一个个打车了,凑一辆大巴(500条/批)再走,省钱省力。
4. 绕过缓存写入链路完全不碰Redis!99%的数据写进去就没人看,更新缓存纯属浪费资源和时间。

收益: 吞吐量从DB直连的几万TPS飙升到5000K+ TPS,数据库负载降低**90%**以上,再也不用担心被写爆了。

3.2 第二拳:读取链路——“快、准、狠”的精准打击

读取请求虽然少,但都是VIP,必须伺候好。我们的策略是:只把最好的资源(缓存)留给最需要的人(热数据)

策略实现方式一句话解释
1. 时间窗口缓存Redis Sorted Set,用时间戳做Score天然的时间序列索引,查询“最近1分钟”就像切蛋糕一样简单。
2. 冷热分离只有热数据(最近1分钟)才进缓存内存是寸土寸金的,只给“热点人物”留座位。
3. 被动预热缓存未命中时,从DB查出数据后回填缓存“这次让你多等了,下次保证快”,提升用户连续查询体验。
4. 短TTL缓存Key设置5分钟自动过期及时“打扫战场”,防止过时数据占着茅坑不拉屎。

收益: 缓存内存占用从可能需要的上千GB,骤降到不足50GB,用极低的成本换来了95%以上的读请求命中率。

4. 居安思危:为极端情况上好“保险”

一个稳固的架构,不仅要能打顺风局,更要能扛逆风局。

挑战我们的“锦囊妙计”
Kafka也扛不住了?动态扩容Consumer实例;开启Kafka背压(Backpressure)机制。
缓存穿透攻击?用**布隆过滤器(BloomFilter)**拦截不存在的设备ID。1亿设备ID仅需约120MB内存,性价比极高。
某个设备成热点?在应用层增加**本地缓存(如Caffeine/BigCache/GroupCache)**作为二级缓存,挡在Redis前面,进一步减少网络IO。

5. 架构师的黄金法则与工具箱

最后,沉淀出几条可以放之四海而皆准的法则。

5.1 缓存使用黄金法则

这是一个简单但有效的经验公式:

  • 如果 写入频率 / 读取频率 > 10果断放弃写时更新缓存。直接写库,让读操作自己去填充缓存,就像我们这个案例。
  • 如果 写读比 < 3:可以考虑**写穿透(Write-Through)写回(Write-Back)**策略,即写入时同步更新缓存。

5.2 数据库选型参考

场景特征推荐方案
纯时序数据,分析需求强InfluxDB / TDEngine (专业时序数据库)
关系型写多读少,有事务需求MySQL/PostgreSQL + 分库分表
写入要求突破天际(>100K TPS)Apache Cassandra / ScyllaDB (LSM-Tree架构)

5.3 降级与熔断机制

  • 降级:当Kafka积压超过警戒线时,临时将数据写入本地磁盘队列,等恢复后再重新投递,保证数据不丢失。
  • 熔断:当消费者处理失败时,将问题消息投递到死信队列(Dead Letter Queue),由人工介入分析,避免一颗老鼠屎坏了一锅汤。

6. 结论:大道至简,架构的“绕行”智慧

面对“写多读少”这种看似无解的流量难题,硬碰硬往往不是最佳选择。我们的核心策略其实是一种“绕行”的智慧:

  1. 写入路径,走“高速公路”Kafka异步削峰 + 消费者批量入库,让数据丝滑地流入最终存储,全程不打扰数据库。
  2. 读取路径,走“VIP通道”精准定义热数据边界(如时间窗口),只为这部分数据提供昂贵的缓存服务,并用短TTL机制实现自动化的新陈代谢。
  3. 防御工事,常备“工具箱”布隆过滤器防穿透,本地缓存抗热点,死信队列保平安。

这套架构在真实的工业物联网场景中得到了充分验证,不仅成功支撑了百万级设备的并发写入,还将数据库负载降低了90%,同时保障了核心查询在200ms内闪电响应。更重要的是,资源成本(特别是昂贵的内存)得到了极大的节约,实现了技术与商业的完美平衡。