一、为什么中大型项目要重视可扩展性?
很多做过中大型项目的开发者应该都有过这样的体会:项目刚上线时,几个服务就能搞定所有业务,跑起来顺得很;但过个半年一年,业务需求越来越多,比如电商项目要加会员体系、积分体系、多仓库存,教育项目要加直播、题库、学情分析,原来的服务慢慢就撑不住了——要么改代码改得牵一发动全身,要么加新功能得大动架构,甚至原来的功能还会跟着出bug。
可扩展性说白了,就是项目能“轻松加功能、轻松扛压力”的能力。对于中大型微服务项目来说,这种能力不是“加分项”,是“必选项”:一方面,业务发展快,新需求层出不穷,要是每次加功能都要重构,不仅开发效率低,还会耽误业务上线;另一方面,用户量可能突然爆发(比如电商大促、教育机构搞活动),要是架构扛不住,直接就是宕机、损失用户。
Kratos是国内团队开发的一套微服务框架,专门给中大型项目设计的,它的可扩展性设计不是空架子,是真的能解决实际问题的。接下来咱们就拆解开说。
二、Kratos可扩展性的核心设计思路
Kratos的可扩展性不是随便凑的功能,而是围绕“业务和底层解耦、模块能替换、资源能动态调”这三个核心思路来做的。简单说就是:业务代码不用管底层的事(比如怎么连数据库、怎么调别的服务),底层模块想换就换,资源不够能随时加。
2.1 业务与底层的解耦设计
解耦是可扩展性的基础,要是业务代码和底层技术绑死了,换个数据库都得改所有业务逻辑,那根本没法扩展。Kratos用的是“分层架构”,把整个项目分成三层:业务层、中间件层、基础设施层。
举个例子,咱们做一个电商的订单服务,业务层只需要写“创建订单要扣库存、减余额、生成订单号”这些业务逻辑,不用管“库存存在MySQL还是Redis”“余额存在支付宝还是微信”“怎么调用库存服务”这些事;中间件层负责把业务层的请求转成能被基础设施理解的格式;基础设施层才是真的连数据库、调服务的地方。
为了让大家更清楚,咱们写个具体的例子,先说明这个例子用的技术栈:Go语言(因为Kratos是Go写的,所有示例都用Go)。
首先是业务层的代码,只关心订单的核心逻辑:
// 业务层:订单服务的核心逻辑,只处理业务规则
package order
import (
"context"
"errors"
)
// 定义订单业务需要的接口(解耦的关键:只定义要什么,不定义怎么实现)
type OrderService interface {
CreateOrder(ctx context.Context, userID int64, productID int64, amount float64) error
}
// 具体的订单业务逻辑实现
type orderServiceImpl struct {
// 依赖的外部服务,用接口类型,不用具体实现
stockService StockService
payService PayService
}
// 新建订单业务实例
func NewOrderService(stock StockService, pay PayService) OrderService {
return &orderServiceImpl{
stockService: stock,
payService: pay,
}
}
// 创建订单的核心逻辑
func (o *orderServiceImpl) CreateOrder(ctx context.Context, userID int64, productID int64, amount float64) error {
// 第一步:扣库存,不用管库存服务怎么实现
if err := o.stockService.DeductStock(ctx, productID, 1); err != nil {
return errors.New("扣库存失败:" + err.Error())
}
// 第二步:扣余额,不用管支付服务怎么实现
if err := o.payService.DeductBalance(ctx, userID, amount); err != nil {
return errors.New("扣余额失败:" + err.Error())
}
// 第三步:生成订单号(这里简化,实际会更复杂)
orderID := generateOrderID()
_ = orderID // 这里可以存订单,先省略
return nil
}
// 生成订单号的工具函数
func generateOrderID() string {
return "ORD" + string(time.Now().UnixMilli())
}
然后是中间件层和基础设施层的代码,这两层可以随时换,不影响业务层:
// 中间件层:定义外部服务的接口,和业务层的接口对应
package middleware
// 库存服务接口,业务层依赖这个接口
type StockService interface {
DeductStock(ctx context.Context, productID int64, num int) error
}
// 支付服务接口,业务层依赖这个接口
type PayService interface {
DeductBalance(ctx context.Context, userID int64, amount float64) error
}
// 基础设施层:库存服务的具体实现(比如用MySQL)
package infra
import (
"context"
"errors"
"gorm.io/driver/mysql"
"gorm.io/gorm"
)
// MySQL库存服务实现
type MysqlStockService struct {
db *gorm.DB
}
// 新建MySQL库存服务
func NewMysqlStockService(dsn string) (*MysqlStockService, error) {
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
if err != nil {
return nil, err
}
return &MysqlStockService{db: db}, nil
}
// 实现扣库存的具体逻辑
func (m *MysqlStockService) DeductStock(ctx context.Context, productID int64, num int) error {
// 这里是具体的MySQL操作,业务层完全不用管
result := m.db.WithContext(ctx).Model(&Stock{}).Where("product_id = ?", productID).Update("stock", gorm.Expr("stock - ?", num))
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return errors.New("库存不足")
}
return nil
}
// 库存表的模型
type Stock struct {
ProductID int64 `gorm:"column:product_id"`
Stock int `gorm:"column:stock"`
}
要是哪天业务发展了,库存要换成Redis来存(比如秒杀场景,Redis的性能更高),只需要改基础设施层的代码,写一个Redis的库存服务,业务层的代码完全不用动:
// 基础设施层:Redis库存服务实现(新增的,不用改原来的代码)
package infra
import (
"context"
"errors"
"github.com/redis/go-redis/v9"
)
// Redis库存服务实现
type RedisStockService struct {
client *redis.Client
}
// 新建Redis库存服务
func NewRedisStockService(addr string) *RedisStockService {
client := redis.NewClient(&redis.Options{Addr: addr})
return &RedisStockService{client: client}
}
// 实现扣库存的具体逻辑,和MySQL的接口完全一样
func (r *RedisStockService) DeductStock(ctx context.Context, productID int64, num int) error {
// 这里是具体的Redis操作,业务层还是不用管
key := "stock:" + string(productID)
// 先判断库存够不够
stock, err := r.client.Get(ctx, key).Int()
if err != nil {
return errors.New("库存不存在")
}
if stock < num {
return errors.New("库存不足")
}
// 扣库存
_, err = r.client.DecrBy(ctx, key, int64(num)).Result()
return err
}
最后在主函数里,只需要把依赖的服务换成Redis的,业务层的CreateOrder逻辑完全不用改:
func main() {
// 原来用MySQL库存服务的写法
// stockService, err := infra.NewMysqlStockService("root:123456@tcp(localhost:3306)/order")
// if err != nil {
// panic(err)
// }
// 现在换成Redis库存服务,只改这一行
stockService := infra.NewRedisStockService("localhost:6379")
payService := infra.NewMysqlPayService("root:123456@tcp(localhost:3306)/order")
orderService := order.NewOrderService(stockService, payService)
// 调用创建订单的逻辑,完全不用改
ctx := context.Background()
err := orderService.CreateOrder(ctx, 1001, 2001, 99.9)
if err != nil {
panic(err)
}
}
这个例子就能看出来解耦的好处:业务层的核心逻辑稳定,底层技术想换就换,不用动业务代码,扩展起来特别轻松。
2.2 模块的可替换设计
Kratos的所有模块都是“插件化”的,就像手机的APP,不用的可以卸载,需要的可以安装。比如日志模块、监控模块、服务发现模块,都是独立的,想换哪个就换哪个。
比如原来用的是ELK做日志系统,后来换成了Loki,只需要把Kratos的日志插件换成Loki的,其他代码不用动。再比如服务发现,原来用的是Consul,后来换成了Nacos,也是只换插件就行。
Kratos的模块替换是有规范的,每个模块都要实现Kratos定义的接口,比如日志模块要实现Logger接口,服务发现模块要实现Discovery接口。只要符合接口规范,自己写的模块也能插进去。
举个服务发现的例子,Kratos默认的服务发现是Consul,要是想换成Nacos,只需要写一个实现Discovery接口的Nacos服务发现模块:
// 实现Kratos的服务发现接口
package discovery
import (
"context"
"github.com/go-kratos/kratos/v2/registry"
"github.com/nacos-group/nacos-sdk-go/clients"
"github.com/nacos-group/nacos-sdk-go/clients/naming_client"
"github.com/nacos-group/nacos-sdk-go/vo"
)
// Nacos服务发现模块
type NacosDiscovery struct {
client naming_client.INamingClient
}
// 新建Nacos服务发现实例
func NewNacosDiscovery(addr string) (*NacosDiscovery, error) {
client, err := clients.NewNamingClient(vo.NacosClientParam{
ClientConfig: &vo.ClientConfig{ServerAddr: addr},
})
if err != nil {
return nil, err
}
return &NacosDiscovery{client: client}, nil
}
// 实现Kratos要求的服务发现接口:获取服务实例
func (n *NacosDiscovery) GetService(ctx context.Context, serviceName string) ([]*registry.ServiceInstance, error) {
// 从Nacos获取服务实例
instances, err := n.client.SelectOneHealthyInstance(vo.SelectOneHealthyInstanceParam{
ServiceName: serviceName,
})
if err != nil {
return nil, err
}
// 转成Kratos要求的格式
return []*registry.ServiceInstance{
{
ID: instances.InstanceId,
Name: serviceName,
Endpoint: instances.Ip + ":" + string(instances.Port),
},
}, nil
}
// 实现Kratos要求的服务发现接口:监听服务变化
func (n *NacosDiscovery) Watch(ctx context.Context, serviceName string) (registry.Watcher, error) {
// 监听服务变化的逻辑,这里简化
return &nacosWatcher{}, nil
}
// 监听接口的实现
type nacosWatcher struct{}
func (w *nacosWatcher) Next() ([]*registry.ServiceInstance, error) {
// 具体的监听逻辑
return nil, nil
}
func (w *nacosWatcher) Stop() error {
return nil
}
然后在Kratos的主配置里,把服务发现换成Nacos的就行:
func main() {
// 原来的Consul服务发现
// reg := consul.New("localhost:8500")
// 现在换成Nacos服务发现
reg, err := discovery.NewNacosDiscovery("localhost:8848")
if err != nil {
panic(err)
}
// 启动Kratos服务,配置服务发现
srv := kratos.New(
kratos.Name("order-service"),
kratos.Registrar(reg),
kratos.Discovery(reg),
)
if err := srv.Run(); err != nil {
panic(err)
}
}
这个例子就能看出来,模块替换是完全不影响业务逻辑的,只需要改配置和插件,扩展起来特别灵活。
2.3 资源的动态扩展设计
可扩展性还有一个很重要的点,就是资源能动态调整,比如某个服务的压力大了,能随时加实例;压力小了,能随时减实例。Kratos的动态扩展是和容器编排(比如Kubernetes)结合的,服务本身是无状态的,所以能轻松伸缩。
无状态服务就是说,服务本身不存任何状态(比如用户的登录状态、订单的临时数据),所有状态都存在外部的存储里(比如Redis、MySQL)。这样的话,一个服务的多个实例是完全一样的,加新实例的时候,不用做任何额外的配置,直接启动就行。
比如电商的订单服务,要是大促的时候用户量突然涨了10倍,订单服务的压力大了,只需要在Kubernetes里把订单服务的副本数从1改成10,10个实例就能一起扛压力,完全不用改代码;大促结束后,再把副本数改回1就行。
Kratos的服务本身就是无状态的,它的配置是存在配置中心的,比如Nacos或者Consul,服务启动的时候从配置中心拉取配置,所以多个实例的配置是完全一样的,不用每个实例单独配置。
举个Kratos服务配置的例子,配置中心用Nacos,配置的内容是订单服务的数据库地址、端口、超时时间:
// Nacos里的配置内容(key是order-service/config)
{
"server": {
"addr": ":8080",
"timeout": "10s"
},
"database": {
"dsn": "root:123456@tcp(localhost:3306)/order",
"max_open_conns": 100,
"max_idle_conns": 10
}
}
然后Kratos服务启动的时候,从Nacos拉取配置:
func main() {
// 从Nacos拉取配置
config, err := config.NewNacosConfig("localhost:8848", "order-service/config")
if err != nil {
panic(err)
}
// 启动服务,配置的内容都是从Nacos拉取的,多个实例的配置完全一样
srv := kratos.New(
kratos.Name("order-service"),
kratos.Config(config),
)
if err := srv.Run(); err != nil {
panic(err)
}
}
这样的话,加新实例的时候,只需要启动一个新的Kratos服务,它会自动从Nacos拉取和原来实例一样的配置,不用做任何额外的工作,动态扩展特别轻松。
三、Kratos可扩展性的应用场景
Kratos的可扩展性设计,特别适合中大型微服务项目,具体的应用场景主要有这几个:
第一个是业务快速迭代的场景,比如互联网公司的电商、社交、教育项目,新需求层出不穷,需要频繁加功能、改逻辑。Kratos的解耦设计,能让业务逻辑和底层技术分开,改底层不影响业务,改业务也不影响底层,开发效率特别高。
第二个是用户量爆发的场景,比如电商大促、教育机构搞活动、社交平台的热点事件,用户量突然涨很多,需要架构能快速扛住压力。Kratos的动态扩展设计,能让服务轻松加实例,扛住压力。
第三个是技术栈升级的场景,比如原来用的技术栈比较老,需要换成新的技术栈,比如从MySQL换成Redis,从Consul换成Nacos,从ELK换成Loki。Kratos的模块可替换设计,能让技术栈升级的时候,不用改业务逻辑,只换模块就行,风险特别小。
第四个是多环境部署的场景,比如项目需要部署在不同的环境,比如开发环境、测试环境、生产环境,不同环境的配置不一样,甚至技术栈也不一样。Kratos的配置中心和模块可替换设计,能让不同环境的部署特别轻松,不用改代码。
四、Kratos可扩展性的优缺点
任何架构设计都有优缺点,Kratos的可扩展性设计也不例外,咱们得客观看待:
4.1 优点
第一个是解耦彻底,业务逻辑和底层技术分开,改底层不影响业务,改业务也不影响底层,开发效率高,维护成本低。比如原来的库存服务从MySQL换成Redis,只改基础设施层的代码,业务层完全不用动,不会影响原来的业务逻辑,风险特别小。
第二个是模块灵活,所有模块都是插件化的,想换哪个就换哪个,不用改核心代码。比如服务发现从Consul换成Nacos,只换插件就行,不用改业务逻辑,技术栈升级特别轻松。
第三个是动态扩展方便,服务是无状态的,能轻松加实例、减实例,扛住压力。比如大促的时候,订单服务的压力大了,只需要把副本数从1改成10,10个实例就能一起扛压力,完全不用改代码。
第四个是适合中大型项目,Kratos的可扩展性设计是专门为中大型项目设计的,能解决中大型项目的痛点,比如业务迭代快、用户量爆发、技术栈升级、多环境部署等。
4.2 缺点
第一个是学习成本高,Kratos的架构比较复杂,涉及到很多概念,比如分层架构、插件化、配置中心、服务发现、无状态服务等,新手需要花时间学习,才能掌握。
第二个是初期开发慢,因为要做解耦、要写接口、要配置模块,初期的开发量比普通的单体项目或者简单的微服务项目要大,比如写一个订单服务,要写业务层、中间件层、基础设施层,还要配置服务发现、配置中心,初期的开发时间会比普通项目长。
第三个是依赖多,Kratos的可扩展性设计依赖很多外部的技术,比如配置中心、服务发现、容器编排、数据库、缓存等,要是其中一个技术出问题,整个架构都会受影响,比如配置中心挂了,所有服务都没法启动。
第四个是不适合小型项目,Kratos的可扩展性设计是为中大型项目设计的,要是项目很小,比如只有几个服务,用户量也不大,用Kratos反而会增加复杂度,开发效率低,维护成本高。
五、使用Kratos可扩展性设计的注意事项
要是想用Kratos的可扩展性设计,得注意这几个问题:
第一个是不要过度设计,解耦和插件化是为了扩展,不是为了解耦而解耦,要是某个模块肯定不会换,就不用做太复杂的解耦,比如项目确定用MySQL做数据库,就不用写Redis的库存服务接口,增加开发量。
第二个是要规范接口,所有模块的接口都要符合Kratos的规范,比如日志模块要实现Logger接口,服务发现模块要实现Discovery接口,要是接口不规范,模块就没法替换,扩展起来特别麻烦。
第三个是要做好配置管理,配置中心的配置要规范,不同环境的配置要分开,比如开发环境的数据库地址和生产环境的数据库地址要分开,配置要加密,比如数据库的密码不能明文存在配置中心,要加密存储。
第四个是要做好监控,动态扩展的时候,要监控服务的压力,比如CPU使用率、内存使用率、请求量、响应时间等,要是服务的压力大了,及时加实例;要是服务的压力小了,及时减实例,避免浪费资源。
第五个是要测试,每次换模块或者加实例的时候,都要做测试,比如把库存服务从MySQL换成Redis,要测试扣库存的逻辑是不是正常,会不会有数据不一致的问题;加实例的时候,要测试多个实例是不是能正常工作,会不会有负载不均衡的问题。
六、总结
Kratos的可扩展性设计,是为中大型微服务项目量身定做的,核心思路是业务与底层解耦、模块可替换、资源动态调,能解决中大型项目的痛点,比如业务迭代快、用户量爆发、技术栈升级、多环境部署等。
它的优点是解耦彻底、模块灵活、动态扩展方便、适合中大型项目;缺点是学习成本高、初期开发慢、依赖多、不适合小型项目。
要是想用Kratos的可扩展性设计,得注意不要过度设计、规范接口、做好配置管理、做好监控、做好测试。
对于中大型微服务项目来说,Kratos的可扩展性设计是一个很好的选择,能让项目轻松加功能、轻松扛压力,支撑业务的快速发展。
Comments