简介:微服务架构通过将单体应用拆分为一组小型、独立的服务,有效解决了系统扩展和维护的难题,成为构建现代分布式系统的核心技术范式。其核心原理在于服务自治、独立部署和轻量级通信,通过服务注册发现、API网关和配置中心等组件协同工作,实现了系统的松耦合与高可用。这种架构的技术价值在于能够支撑高并发访问、支持快速迭代和弹性伸缩,尤其适用于电商、社交等业务场景复杂的互联网应用。在电商平台等具体实践中,常需整合缓存、对象存储等中间件以应对实际挑战。例如,利用Redis作为内存数据库,通过其高性能的数据结构缓解数据库读压力,并借助分布式锁机制保障秒杀等高并发场景的数据一致性;同时,引入MinIO这类兼容S3协议的开源对象存储,以可控的成本实现海量图片、视频等非结构化文件的可靠存储与高效访问,从而构建出稳定、可扩展的技术骨架。

1. 项目概述:一个现代电商平台的技术骨架

最近刚带着团队做完一个电商平台的全栈项目,内部代号“尚品甄选”。这名字听起来挺高大上,但其实核心目标很明确:构建一个能支撑高并发、业务清晰、且易于维护和扩展的现代化电商系统。现在电商项目遍地开花,但很多还停留在单体应用的思维里,一旦流量上来或者业务线增多,维护和迭代就成了噩梦。我们这个项目,从立项之初就决定采用微服务架构,用Java 17和SpringCloud这套目前企业级开发的主流技术栈来搭台子。

简单来说,这个项目就是一个包含了完整前后台管理功能的电商平台。前台面向消费者,负责商品浏览、下单、支付;后台面向运营和管理人员,负责商品上架、订单处理、用户管理等。听起来是电商的标配,对吧?但难点在于如何让这些功能在流量洪峰下依然稳定,如何让海量的商品图片和文件存储既便宜又可靠,以及如何让开发、测试、上线这一套流程变得丝滑。这就是为什么我们引入了Redis做缓存扛住读压力,用MinIO做分布式文件存储替代昂贵的云存储,最后再用Docker把整个系统打包,实现一键部署和弹性伸缩。

如果你正在从传统的SSM或SpringBoot单体架构向微服务转型,或者想系统性地学习如何整合这些时下热门的技术组件来构建一个真正可用的系统,那么这个项目的拆解应该能给你不少实实在在的参考。它不是那种只讲概念的Demo,而是包含了从技术选型、模块拆分、到具体集成和部署上线的完整思考路径。

2. 技术选型与架构设计背后的逻辑

为什么是Java 17 + SpringCloud?这可能是很多人的第一个疑问。选型从来不是追新,而是权衡利弊后的最优解。

2.1 为什么选择Java 17作为基石?

项目启动时,Java的主流长期支持(LTS)版本是Java 11和Java 17。我们最终选择了Java 17,这并非盲目追求最新。首先,从性能上讲,Java 17在G1垃圾收集器上做了大量优化,特别是对于微服务这种可能频繁创建和销毁短生命周期对象的场景,其暂停时间(Stop-The-World)更可控,这直接关系到服务的响应延迟。其次,新特性带来了开发效率的提升。比如 Records (记录类),用它来定义DTO(数据传输对象)或配置类,代码简洁到令人发指,自动生成的 equals() hashCode() toString() 方法省去了大量模板代码。再比如 Sealed Classes (密封类),在定义领域模型时,可以更精确地控制类的继承关系,增强了模型的封装性。虽然这些特性在业务代码中未必处处用到,但它们代表了更现代、更安全的编程范式。最后,生态支持已经成熟,Spring Framework 6和Spring Boot 3直接基于Java 17构建,选择它意味着能紧跟核心框架的发展路线,享受最新的性能和安全更新。

注意 :从Java 8升级到Java 17,需要特别注意依赖库的兼容性。我们遇到过一些老版本的第三方工具包(如某些XML解析库)在模块化路径下找不到类的问题。解决方法是在启动命令中添加 --add-opens 等JVM参数来开放模块,但更根本的解决方式是寻找替代的、已更新支持Java 17的库。

2.2 SpringCloud微服务组件选型与职责划分

SpringCloud不是一个具体框架,而是一套微服务生态的集成工具箱。我们的选型基于“稳定、社区活跃、与SpringBoot无缝集成”的原则。

  1. 服务注册与发现:Nacos 早期我们考虑过Eureka,但Nacos后来居上,因为它不仅提供了服务注册发现,还集成了动态配置管理功能,一举两得。在电商场景中,商品服务、订单服务、用户服务等数十个实例需要相互感知和调用,Nacos充当了“电话簿”的角色。每个服务启动时向Nacos注册自己的IP和端口,调用方需要时去Nacos查询,实现了服务的动态扩缩容。

  2. 服务调用与负载均衡:OpenFeign + LoadBalancer RestTemplate太原始,Dubbo生态略重。OpenFeign通过声明式的接口定义服务调用,用起来就像调用本地方法一样简单,底层自动集成了Ribbon(现由SpringCloud LoadBalancer替代)做负载均衡。例如,订单服务需要调用用户服务查询收货地址,我们只需要在订单服务中定义一个 UserClient 接口,用 @FeignClient 注解指明服务名,框架就会自动处理HTTP请求的拼装、发送以及从多个用户服务实例中按策略(如轮询、随机)选取一个进行调用。

  3. 服务容错与降级:Sentinel 电商大促时,某个服务(如秒杀服务)崩溃,不能让它产生的雪崩效应拖垮整个系统(如支付服务)。Sentinel负责设置“保险丝”。我们为关键接口配置了 流控规则 (如QPS超过1000则快速失败)、 降级规则 (如调用用户服务异常比例超过50%,则5秒内所有对该服务的调用直接返回一个预设的兜底地址信息)。它的控制台能实时监控流量,效果比早期的Hystrix更直观、更强大。

  4. API网关:SpringCloud Gateway 所有前端请求的统一入口。它做了三件关键事: 路由 (将 /api/product/** 的请求转发到商品服务集群)、 过滤 (在请求前后进行逻辑处理,如鉴权、日志记录)、 限流 (全局层面的流量控制)。网关的存在,让内部微服务的网络地址和端口不必暴露给外界,提升了安全性,也使得后续进行灰度发布、跨域处理等变得集中和方便。

  5. 配置中心:Nacos Config 将数据库连接、Redis地址、开关配置等从各个服务的 application.yml 中抽离出来,集中管理在Nacos。修改一个日志级别,无需重启数十个服务,在Nacos控制台更新后,相关服务能自动感知并刷新配置。这对管理多环境(开发、测试、生产)的配置差异尤其有用。

2.3 存储层选型:Redis与MinIO的互补之道

数据库(我们用了MySQL和一点MongoDB做评论这类非结构化数据)是“硬盘”,保证数据的持久化和强一致性。但电商中大量操作是“读”,比如查商品详情、查用户信息。所有请求都打到数据库上,它肯定吃不消。

Redis 在这里扮演了“内存高速缓存”的角色。它的价值在于:

  • 缓解数据库压力 :将热点数据(如首页轮播图信息、热门商品信息、用户会话Token)存放在内存中,读请求命中缓存,直接返回,比查数据库快1-2个数量级。
  • 实现分布式锁 :在秒杀扣库存、防止重复下单等场景,我们利用Redis的 SETNX 命令实现一个简单的分布式锁,确保在高并发下,关键操作串行执行,避免超卖。
  • 支持复杂数据结构 :例如用 Sorted Set (有序集合)实现商品销量排行榜,实时更新,效率极高。

MinIO 解决的是另一个痛点: 海量非结构化文件的存储 。用户上传的头像、商品详情图、宣传视频,如果都存到业务数据库里,数据库会变得异常臃肿且备份困难。使用传统FTP或云存储(如OSS)又有成本或管理上的顾虑。

MinIO是一个兼容Amazon S3协议的开源对象存储系统。我们自建MinIO集群,它带来的好处是:

  • 成本可控 :使用普通的服务器磁盘即可搭建,存储成本远低于商业云存储。
  • 性能优异 :读写速度快,特别适合图片、视频这类文件的存取。
  • 高可用 :通过纠删码(Erasure Code)技术,即使部分硬盘损坏,数据也不会丢失,实现了类似RAID的效果但更灵活。
  • 与云原生生态融合好 :它的S3协议是事实上的对象存储标准,方便未来迁移或与其它工具集成。

在架构中,MinIO和Redis是互补的。用户上传一张商品图,文件流通过后端接口存到MinIO,MinIO返回一个可访问的URL地址(如 http://minio.example.com/bucket/image.jpg ),后端再将这个URL字符串作为商品的一个属性,保存到数据库中。当需要展示图片时,前端直接通过这个URL从MinIO获取,这个链路不经过后端应用服务器,实现了动静分离,极大减轻了应用服务器的负载。

3. 核心模块设计与业务拆解

微服务拆分的核心原则是“高内聚、低耦合”,围绕业务领域进行。我们将“尚品甄选”拆分为以下几个核心微服务,每个服务独立开发、部署、数据库。

3.1 用户服务:不仅仅是登录注册

用户服务( user-service )负责所有与用户身份相关的业务。它的核心表包括用户基本信息表、收货地址表、会员等级表等。

  • 核心接口
    • POST /register :注册。这里有个关键点,密码必须加盐(Salt)哈希后存储,绝对禁止明文。我们采用BCrypt算法。
    • POST /login :登录。验证成功后,生成一个全局唯一的Token(如JWT),将 userId 等信息封装其中,返回给前端。同时,将 Token:userId 的键值对存入Redis,并设置合理的过期时间(如2小时)。后续的接口鉴权,网关或拦截器只需解析Token并从Redis验证是否存在即可。
    • GET /addresses :获取收货地址列表。这里会用到缓存,将 userId 作为Key,将地址列表JSON序列化后存入Redis,设置较短的过期时间(如5分钟),避免用户频繁修改地址时看到旧数据。
  • 数据库设计心得 :用户表里不要存太多冗余信息。像用户积分、成长值这些频繁变动的数据,可以考虑拆到单独的“用户资产表”或直接存到Redis中,避免更新用户主表时造成行锁竞争。

3.2 商品服务:品类、属性与搜索的基石

商品服务( product-service )是电商的核心,也是最复杂的服务之一。它管理着类目(Category)、品牌(Brand)、商品SPU(Standard Product Unit,标准化产品单元,如“iPhone 15”)、商品SKU(Stock Keeping Unit,库存量单位,如“iPhone 15 256GB 黑色”)。

  • 核心难点:SKU与属性 。一款手机有颜色、内存、版本等多个属性。我们采用经典的“属性键值对”模型。设计三张核心表:
    1. spu 表:存商品标题、主图、详情等不变信息。
    2. sku 表:存具体规格的价格、库存、条形码等。
    3. sku_attr_value 表:关联表,存储每个SKU对应的属性名和属性值(如“颜色:黑色”,“内存:256GB”)。
  • 搜索集成 :商品搜索不能全靠数据库 LIKE 。我们集成了Elasticsearch。当后台新增或修改一个SPU时,通过消息队列(如RabbitMQ)发送一个事件,由专门的搜索服务消费,将商品数据构建索引存入Elasticsearch。前台搜索时,请求直接走搜索服务,查询Elasticsearch,实现毫秒级响应。
  • 缓存策略 :商品详情是绝对的热点。我们采用“多级缓存”策略。首先,在Redis中使用 hash 结构缓存SPU和SKU的详细信息,Key设计为 product:spu:{spuId} product:sku:{skuId} 。其次,对于首页热销商品列表,使用Redis的 list sorted set 进行缓存。这里要注意 缓存穿透 (查询不存在的商品ID)和 缓存雪崩 (大量缓存同时过期)问题。对于不存在的ID,也缓存一个空值(设置短过期时间);对于过期时间,采用基础时间加随机偏移量的方式。

3.3 订单服务:事务与一致性的挑战

订单服务( order-service )是交易的核心,涉及钱和库存,对一致性和事务性要求最高。

  • 下单流程(核心中的核心)
    1. 校验 :接收前端传来的商品SKU ID和数量,调用商品服务接口校验库存是否充足(这里用到了Redis分布式锁,防止超卖)。
    2. 创建订单 :生成唯一的订单号(通常用雪花算法),在本地数据库事务中插入订单主表( order )和订单明细表( order_item ),状态为“待支付”。
    3. 预扣库存 :调用商品服务的“预扣库存”接口。注意,这不是真实扣减,而是将库存从“可售库存”移到“锁定库存”。如果后续支付超时,这些库存会被释放回可售库存。
    4. 返回结果 :将生成的订单ID返回给前端,引导用户去支付。
  • 分布式事务问题 :创建订单(本地数据库)和预扣库存(调用商品服务)分属不同服务、不同数据库,如何保证要么都成功,要么都失败?我们采用了 最终一致性 方案,而非强一致的2PC。具体是:在下单事务提交后,向消息队列发送一个“扣减真实库存”的消息。商品服务监听该消息,进行真实库存扣减。如果扣减失败(如库存不足),则触发补偿机制,例如取消订单并释放预扣库存。虽然中间有短暂的不一致(订单已创建,真实库存未扣),但通过后续的异步消息和补偿,最终能达到一致状态,性能远比同步的分布式事务高。
  • 订单状态机 :订单状态(待支付、已支付、待发货、已发货、已完成、已取消等)的流转必须清晰、严谨。我们使用状态模式(State Pattern)来设计,每个状态是一个类,明确规定了可以从当前状态切换到哪些下一个状态,避免了复杂的 if-else 判断。

3.4 购物车、支付与后台管理

  • 购物车服务 :我们选择将购物车数据存在Redis中,以 userId 为Key,Value是一个 hash 结构,存储 skuId count 。因为购物车数据读写频繁,且对实时性要求高,但丢失了影响相对较小(用户重新添加即可)。Redis的高性能完美匹配这个场景。
  • 支付服务 :对接微信支付、支付宝等第三方渠道。它的核心是处理支付回调。支付成功后,第三方会异步通知我们的回调接口。这个接口必须做好 幂等性 处理(防止重复通知导致重复更新订单状态),验证签名,然后更新订单状态为“已支付”,并触发后续的发货流程。支付状态最好也同步到Redis,供前端轮询查询。
  • 后台管理系统 :这是一个独立的Web应用(通常用Vue.js+Element UI构建),通过网关调用上述各个微服务的后台接口。它需要强大的权限控制(RBAC模型),不同角色的运营人员(商品管理员、订单审核员、超级管理员)能看到和操作的菜单、数据不同。

4. 关键集成点:Redis与MinIO的实战配置

理论说再多,不如一行配置。下面看看这两个核心组件如何集成到SpringBoot应用中。

4.1 SpringBoot集成Redis:缓存与分布式锁

首先在项目的 pom.xml 中引入依赖:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
</dependency>

application.yml 中配置连接:

spring:
  redis:
    host: your-redis-host
    port: 6379
    password: your-password # 生产环境一定要设密码
    database: 0 # 通常按业务分库,0-15
    lettuce:
      pool:
        max-active: 8 # 连接池最大连接数,根据压力调整
        max-idle: 8
        min-idle: 0

然后就可以注入 RedisTemplate StringRedisTemplate 来操作了。但更优雅的方式是使用Spring的缓存抽象。

1. 声明式缓存: 在启动类加 @EnableCaching 注解。在Service方法上使用注解:

@Service
public class ProductServiceImpl implements ProductService {
    @Cacheable(value = "product", key = "#spuId") // 缓存,key为product::123
    public SpuDTO getSpuById(Long spuId) {
        // 模拟从数据库查询
        return spuMapper.selectById(spuId);
    }

    @CachePut(value = "product", key = "#spuDTO.id") // 更新缓存
    public SpuDTO updateSpu(SpuDTO spuDTO) {
        spuMapper.updateById(spuDTO);
        return spuDTO;
    }

    @CacheEvict(value = "product", key = "#spuId") // 删除缓存
    public void deleteSpu(Long spuId) {
        spuMapper.deleteById(spuId);
    }
}

2. 手动操作与分布式锁: 对于更复杂的操作,需要手动控制。

@Component
public class RedisLockUtil {
    @Autowired
    private StringRedisTemplate redisTemplate;

    private static final String LOCK_PREFIX = "lock:";

    /**
     * 尝试获取分布式锁
     * @param lockKey 锁的key
     * @param requestId 请求标识(可用UUID),用于安全释放锁
     * @param expireTime 锁的过期时间(秒)
     * @return 是否获取成功
     */
    public boolean tryLock(String lockKey, String requestId, long expireTime) {
        // 使用SET命令,NX表示不存在才设置,EX设置过期时间
        Boolean success = redisTemplate.opsForValue()
                .setIfAbsent(LOCK_PREFIX + lockKey, requestId, Duration.ofSeconds(expireTime));
        return Boolean.TRUE.equals(success);
    }

    /**
     * 释放分布式锁(Lua脚本保证原子性)
     */
    public boolean releaseLock(String lockKey, String requestId) {
        String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
        DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
        redisScript.setScriptText(luaScript);
        redisScript.setResultType(Long.class);
        Long result = redisTemplate.execute(redisScript, Collections.singletonList(LOCK_PREFIX + lockKey), requestId);
        return result != null && result == 1L;
    }
}

在秒杀扣库存时使用:

public boolean seckill(Long skuId) {
    String lockKey = "seckill:stock:" + skuId;
    String requestId = UUID.randomUUID().toString();
    try {
        // 尝试获取锁,最多等待100毫秒
        if (redisLockUtil.tryLock(lockKey, requestId, 5)) {
            // 1. 查询库存(这里可从Redis或DB查)
            Integer stock = getStockFromRedis(skuId);
            if (stock > 0) {
                // 2. 扣减库存
                decrementStock(skuId);
                // 3. 创建订单(异步)
                createOrderAsync(skuId);
                return true;
            }
        }
        return false;
    } finally {
        // 确保释放锁
        redisLockUtil.releaseLock(lockKey, requestId);
    }
}

4.2 SpringBoot集成MinIO:文件上传与管理

MinIO的集成同样简单。引入官方Java SDK:

<dependency>
    <groupId>io.minio</groupId>
    <artifactId>minio</artifactId>
    <version>8.5.7</version> <!-- 使用最新稳定版 -->
</dependency>

配置类:

@Configuration
public class MinioConfig {
    @Value("${minio.endpoint}")
    private String endpoint;
    @Value("${minio.accessKey}")
    private String accessKey;
    @Value("${minio.secretKey}")
    private String secretKey;

    @Bean
    public MinioClient minioClient() {
        return MinioClient.builder()
                .endpoint(endpoint)
                .credentials(accessKey, secretKey)
                .build();
    }
}

application.yml 中配置:

minio:
  endpoint: http://192.168.1.100:9000 # MinIO服务器地址
  accessKey: your-access-key # 在MinIO控制台创建
  secretKey: your-secret-key
  bucket-name: mall-images # 默认使用的桶

编写文件上传工具类:

@Service
public class MinioService {
    @Autowired
    private MinioClient minioClient;
    @Value("${minio.bucket-name}")
    private String bucketName;

    /**
     * 上传文件
     * @param file 文件
     * @param objectName 对象名(可包含路径,如 "avatar/2024/user1.jpg")
     * @return 可访问的URL
     */
    public String uploadFile(MultipartFile file, String objectName) throws Exception {
        // 1. 判断桶是否存在,不存在则创建
        boolean found = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
        if (!found) {
            minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build());
            // 设置桶策略为公开读(根据实际情况调整)
            String policy = """
                    {
                      "Version": "2012-10-17",
                      "Statement": [
                        {
                          "Effect": "Allow",
                          "Principal": "*",
                          "Action": ["s3:GetObject"],
                          "Resource": ["arn:aws:s3:::%s/*"]
                        }
                      ]
                    }
                    """.formatted(bucketName);
            minioClient.setBucketPolicy(SetBucketPolicyArgs.builder().bucket(bucketName).config(policy).build());
        }

        // 2. 上传
        minioClient.putObject(
                PutObjectArgs.builder()
                        .bucket(bucketName)
                        .object(objectName)
                        .stream(file.getInputStream(), file.getSize(), -1)
                        .contentType(file.getContentType())
                        .build()
        );

        // 3. 返回URL(如果是公开桶)
        return String.format("%s/%s/%s", minioClient.getEndpoint(), bucketName, objectName);
    }

    /**
     * 生成一个预签名的上传URL(用于前端直传,减轻服务器压力)
     * @param objectName 对象名
     * @param expiryMinutes 过期时间(分钟)
     * @return 预签名URL
     */
    public String getPresignedUploadUrl(String objectName, int expiryMinutes) throws Exception {
        return minioClient.getPresignedObjectUrl(
                GetPresignedObjectUrlArgs.builder()
                        .method(Method.PUT)
                        .bucket(bucketName)
                        .object(objectName)
                        .expiry(expiryMinutes * 60, TimeUnit.SECONDS)
                        .build()
        );
    }
}

在Controller中使用:

@RestController
@RequestMapping("/api/file")
public class FileController {
    @Autowired
    private MinioService minioService;

    @PostMapping("/upload")
    public Result<String> upload(@RequestParam("file") MultipartFile file) {
        if (file.isEmpty()) {
            return Result.fail("文件不能为空");
        }
        try {
            // 生成唯一文件名,防止覆盖
            String originalFilename = file.getOriginalFilename();
            String suffix = originalFilename.substring(originalFilename.lastIndexOf("."));
            String objectName = "product/" + UUID.randomUUID() + suffix;

            String url = minioService.uploadFile(file, objectName);
            return Result.success(url);
        } catch (Exception e) {
            log.error("文件上传失败", e);
            return Result.fail("上传失败");
        }
    }
}

重要提示 :生产环境中,强烈建议使用 预签名URL 的方式让前端直接上传到MinIO。流程是:前端请求后端获取一个临时的、有时效性的上传URL,然后前端直接用这个URL将文件PUT到MinIO。这样做的好处是文件流不经过应用服务器,节省了服务器的带宽和IO资源,上传速度也更快。后端服务只负责生成和验证这个URL。

5. Docker容器化部署:从开发到生产的一致环境

微服务多了,部署就成了问题。每个服务依赖的环境(JDK版本、配置文件、端口)都可能不同。Docker通过容器化解决了“在我机器上能跑”的噩梦。

5.1 为每个服务编写Dockerfile

一个标准的SpringBoot应用的 Dockerfile 如下:

# 第一阶段:构建
FROM maven:3.8.7-eclipse-temurin-17 AS builder
WORKDIR /app
COPY pom.xml .
# 利用Maven层缓存,如果pom没变,则不会重复下载依赖
RUN mvn dependency:go-offline -B
COPY src ./src
RUN mvn clean package -DskipTests

# 第二阶段:运行
FROM eclipse-temurin:17-jre-jammy
WORKDIR /app
# 从构建阶段拷贝jar包
COPY --from=builder /app/target/*.jar app.jar
# 设置时区
RUN ln -sf /usr/share/zoneinfo/Asia/Shanghai /etc/localtime
# 暴露端口(与application.yml中server.port一致)
EXPOSE 8080
# 启动命令,这里使用外部化配置,方便通过环境变量覆盖
ENTRYPOINT ["java", "-jar", "-Dspring.profiles.active=${SPRING_PROFILES_ACTIVE:-prod}", "/app/app.jar"]

这个Dockerfile使用了多阶段构建,最终镜像只包含运行所需的JRE,体积比包含Maven和JDK的构建环境小得多。

5.2 使用Docker Compose编排所有服务

在项目根目录创建 docker-compose.yml ,一键启动所有依赖的中间件和微服务。

version: '3.8'
services:
  # MySQL数据库
  mysql:
    image: mysql:8.0
    container_name: mall-mysql
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: root123456
      MYSQL_DATABASE: mall
    ports:
      - "3306:3306"
    volumes:
      - ./data/mysql:/var/lib/mysql
      - ./config/mysql/init.sql:/docker-entrypoint-initdb.d/init.sql # 初始化脚本
    networks:
      - mall-network

  # Redis缓存
  redis:
    image: redis:7-alpine
    container_name: mall-redis
    restart: always
    command: redis-server --requirepass your_redis_password # 设置密码
    ports:
      - "6379:6379"
    volumes:
      - ./data/redis:/data
    networks:
      - mall-network

  # Nacos注册配置中心
  nacos:
    image: nacos/nacos-server:v2.2.3
    container_name: mall-nacos
    restart: always
    environment:
      - MODE=standalone # 单机模式,生产用集群
      - JVM_XMS=512m
      - JVM_XMX=512m
    ports:
      - "8848:8848"
    volumes:
      - ./data/nacos/logs:/home/nacos/logs
    networks:
      - mall-network

  # MinIO对象存储
  minio:
    image: minio/minio:latest
    container_name: mall-minio
    restart: always
    command: server /data --console-address ":9001"
    environment:
      MINIO_ROOT_USER: admin
      MINIO_ROOT_PASSWORD: admin123456
    ports:
      - "9000:9000" # API端口
      - "9001:9001" # 控制台端口
    volumes:
      - ./data/minio:/data
    networks:
      - mall-network

  # 用户服务(示例)
  user-service:
    build: ./user-service # Dockerfile所在目录
    container_name: mall-user-service
    restart: always
    depends_on:
      - mysql
      - redis
      - nacos
    environment:
      SPRING_PROFILES_ACTIVE: prod
      SPRING_CLOUD_NACOS_SERVER-ADDR: nacos:8848
      SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/mall_user?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
      SPRING_REDIS_HOST: redis
    ports:
      - "8081:8080" # 宿主机8081映射到容器8080
    networks:
      - mall-network

  # 商品服务、订单服务等定义类似...
  # gateway-service 网关服务,需要暴露给外网
  gateway-service:
    build: ./gateway-service
    container_name: mall-gateway
    restart: always
    depends_on:
      - nacos
    ports:
      - "80:8080" # 将网关的80端口映射到宿主机80
    networks:
      - mall-network

# 定义自定义网络,方便服务间通过容器名通信
networks:
  mall-network:
    driver: bridge

在这个编排文件中,所有服务都加入了同一个自定义网络 mall-network 。在这个网络里,服务之间可以使用 容器名 直接通信(如 user-service 访问 mysql:3306 ),这比使用IP地址稳定得多。

5.3 生产环境部署考量

本地 docker-compose up 一键启动很适合开发测试。但上生产环境,需要考虑更多:

  1. 镜像仓库 :使用私有Docker Registry(如Harbor)或云厂商的容器镜像服务来存储我们构建好的镜像。
  2. 容器编排 :使用Kubernetes(K8s)替代Docker Compose。K8s能管理成百上千个容器的生命周期,实现自动扩缩容、滚动更新、服务自愈。每个微服务对应一个K8s的Deployment,服务发现可以通过K8s的Service机制集成,或者依然使用Nacos。
  3. 配置管理 :生产环境的数据库密码、Redis密码、MinIO密钥等敏感信息,绝不能写在 docker-compose.yml 或代码里。应使用K8s的Secret对象或者专门的配置中心(如Nacos Config)来管理。
  4. 日志与监控 :所有容器的日志需要集中收集(如EFK栈:Elasticsearch, Fluentd, Kibana)。同时需要监控各服务的JVM状态、接口响应时间、错误率等(如Prometheus + Grafana)。

6. 开发与部署中的常见陷阱与解决方案

在实际开发和部署这个项目的过程中,我们踩过不少坑,这里总结几个典型的。

6.1 微服务链路追踪与日志排查

当用户反馈“下单失败”时,问题可能出现在网关、订单服务、用户服务、商品服务中的任何一个。没有链路追踪,排查就像大海捞针。

解决方案 :集成 SkyWalking Zipkin 。我们在每个微服务的 pom.xml 中引入SkyWalking的Java Agent依赖,并在启动参数中指定Agent。这样,一个请求从前端到网关,再到各个微服务,会生成一个唯一的 traceId 贯穿始终。在日志文件中打印出这个 traceId ,就可以在SkyWalking的UI上直观地看到整个调用链的耗时、成功与否,快速定位瓶颈或错误节点。

6.2 Redis缓存一致性难题

这是老生常谈但至关重要的问题。更新了数据库,如何同步或失效缓存?

我们的策略

  • 读操作 :先读缓存,命中则返回;未命中则读数据库,写入缓存。
  • 写操作(更新/删除) :采用 “先更新数据库,再删除缓存” 的策略。为什么不是先删缓存再更新数据库?因为后者在并发下也可能导致脏数据。虽然“先更新数据库,再删除缓存”也不是百分百完美(存在一个极小时间窗口的脏读可能),但概率极低,且实现简单。我们通过将缓存删除操作放入消息队列,失败重试,来保证最终一致性。
    @Transactional
    public void updateProduct(Product product) {
        // 1. 更新数据库
        productMapper.updateById(product);
        // 2. 异步删除缓存
        redisTemplate.delete("product:" + product.getId());
        // 或者发送MQ消息,由专门的消费者删除,解耦并支持重试
    }
    

6.3 MinIO文件上传的权限与跨域问题

前端直接通过预签名URL上传文件到MinIO时,常遇到两个问题:

  1. AccessDenied :可能是预签名URL生成时使用的密钥不对,或者桶策略(Bucket Policy)没有配置相应的 PutObject 权限。
  2. CORS错误 :前端浏览器因为跨域请求被阻止。

解决方案

  • 权限 :确保生成预签名URL的服务使用的 accessKey secretKey 具有该桶的 PutObject 权限。可以在MinIO控制台为这个用户创建自定义策略。
  • 跨域 :在MinIO服务器上为对应的桶配置CORS规则。可以通过MinIO控制台或使用 mc 命令行工具配置,允许前端所在域名的 PUT POST GET 等请求。

6.4 Docker容器内服务连接失败

docker-compose.yml 中,商品服务配置了 SPRING_DATASOURCE_URL: jdbc:mysql://mysql:3306/mall_product ,但启动后报错“无法连接到mysql:3306”。

可能原因与解决

  1. 依赖顺序 :确保服务在 depends_on 里声明了所依赖的服务(如 mysql , redis )。但这只保证容器启动顺序,不保证服务已就绪。更健壮的做法是在应用启动脚本中加入健康检查,等待依赖服务端口真正可连接后再启动SpringBoot应用。
  2. 网络问题 :所有相关服务必须处于同一个自定义网络下(如上述的 mall-network )。检查 docker-compose.yml 中网络配置是否正确。
  3. 容器内DNS解析 :在容器内执行 ping mysql 看是否能解析出IP。使用 docker network inspect mall-network 查看网络详情和容器IP。

6.5 SpringCloud组件版本兼容性

这是最大的坑之一。SpringCloud、SpringBoot、SpringCloud Alibaba(Nacos, Sentinel)等版本之间有严格的兼容关系。随意组合版本会导致各种奇怪的 ClassNotFoundException NoSuchMethodError

黄金法则 :始终去Spring官方或SpringCloud Alibaba官方查看 版本兼容性矩阵 。我们项目使用的稳定组合是: Spring Boot 2.7.x + Spring Cloud 2021.0.x + Spring Cloud Alibaba 2021.0.5.0 。对于Java 17,确保所有依赖的第三方库(如MyBatis, Druid连接池)都有兼容的版本。

整个项目从零到一的搭建过程,就像在组装一个精密的乐高模型。每个技术组件(Java 17, SpringCloud, Redis, MinIO, Docker)都是一块独特的积木,选择哪一块、如何拼接,直接决定了最终系统的稳定性、性能和可维护性。这个“尚品甄选”项目所实践的技术栈和架构思路,对于想要构建中大型分布式系统的团队来说,是一个经过验证的、可行的参考方案。记住,没有最好的架构,只有最适合当前业务和团队情况的架构。在理解原理的基础上灵活运用,才是工程师的价值所在。

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

更多推荐