返回 金丹・瑞吉厨域试炼

23从“这也能坏?”到“原来如此!”:套餐、购物车和订单的奇幻漂流

博主
大约 18 分钟

23从“这也能坏?”到“原来如此!”:套餐、购物车和订单的奇幻漂流

凌晨三点,我盯着屏幕上的购物车数据,发现一个用户把同一份水煮鱼加了50次。“这哥们是开餐馆的,还是把购物车当收藏夹用?” 直到看到订单表里出现了100份水煮鱼的外卖单,我才意识到:购物车逻辑写崩了。

image-20260202170248409

一、套餐管理:从“分开保存”到“原子操作”

1.1 我的第一个套餐新增实现

java

// 版本1:先保存套餐,再保存菜品
public void addSetmeal(Setmeal setmeal, List<SetmealDish> dishes) {
    // 保存套餐
    setmealMapper.insert(setmeal);
    Long setId = setmeal.getId();
    
    // 设置关联ID并保存菜品
    for (SetmealDish dish : dishes) {
        dish.setSetmealId(setId);
        setmealDishMapper.insert(dish);
    }
}

上线第一天就出问题了:

  1. 套餐保存成功,但菜品保存时数据库连接断了
  2. 结果:套餐存在,但里面是空的!用户付了钱买了个“空气套餐”
  3. 客服电话被打爆:“我买的398元豪华套餐,怎么就一张图片?”

1.2 事务拯救世界

image-20260202170325929

java

@Service
public class SetmealServiceImpl extends ServiceImpl<SetmealMapper, Setmeal> 
    implements SetmealService {
    
    @Autowired
    private SetmealDishService setmealDishService;
    
    @Transactional  // 加上这个注解,世界都安静了
    @Override
    public void saveWithDish(SetmealDto setmealDto) {
        // 保存套餐基本信息
        this.save(setmealDto);
        
        // 获取刚插入的套餐ID
        Long setmealId = setmealDto.getId();
        
        // 设置关联并保存菜品
        List<SetmealDish> dishes = setmealDto.getSetmealDishes();
        if (dishes != null && !dishes.isEmpty()) {
            dishes.forEach(dish -> dish.setSetmealId(setmealId));
            setmealDishService.saveBatch(dishes);  // 批量插入,性能更好
        }
    }
}

@Transactional 的奇幻漂流:

java

// 如果我想知道事务到底干了什么,可以这样调试
@Transactional
public void saveWithDish(SetmealDto setmealDto) {
    log.info("事务开始,当前状态:{}", TransactionSynchronizationManager.isActualTransactionActive());
    
    this.save(setmealDto);
    log.info("套餐保存成功,ID:{}", setmealDto.getId());
    
    // 模拟异常
    if (setmealDto.getName().contains("测试")) {
        throw new RuntimeException("测试异常,看看事务回滚不");
    }
    
    // ... 保存菜品
}

学习到的教训:

  1. 事务要么全成功,要么全失败——像极了爱情
  2. 默认只回滚RuntimeException——检查异常(Exception)不回滚
  3. 事务方法不能被同类的其他方法直接调用——AOP代理的坑

二、手机验证码登录:从“这也能忘?”到“自动搞定”

2.1 短信验证码的奇幻漂流

2.1.1 阿里云短信服务的第一次接触

java

// 我的第一次尝试:直接复制官网代码
public static void sendSms(String phone, String code) {
    // 这一堆配置是什么鬼?
    DefaultProfile profile = DefaultProfile.getProfile(
        "cn-hangzhou", 
        "你的AccessKeyId", 
        "你的AccessKeySecret"
    );
    
    // 这个Client又是什么?
    IAcsClient client = new DefaultAcsClient(profile);
    
    // 请求对象,参数一堆...
    SendSmsRequest request = new SendSmsRequest();
    request.setPhoneNumbers(phone);
    request.setSignName("瑞吉外卖");
    request.setTemplateCode("SMS_154950909");
    request.setTemplateParam("{\"code\":\"" + code + "\"}");
    
    try {
        SendSmsResponse response = client.getAcsResponse(request);
        log.info("短信发送成功:{}", response.getMessage());
    } catch (ClientException e) {
        log.error("短信发送失败:{}", e.getMessage());
        // 线上环境不能直接抛异常,用户会看到错误页面
        throw new BusinessException("短信发送失败,请稍后重试");
    }
}

踩坑记录:

  1. 签名没审核通过——名字不能带"外卖",改成了"瑞吉美食"
  2. 模板没审核通过——不能有"验证码"三个字,改成了"您的登录码是${code}"
  3. 测试环境没充值——阿里云说"余额不足,请充值"

2.1.2 开发环境的"作弊"模式

java

@Value("${sms.enabled:true}")
private boolean smsEnabled;  // 配置开关

public void sendVerifyCode(String phone) {
    // 生成验证码
    String code = String.valueOf(new Random().nextInt(8999) + 1000);
    
    if (smsEnabled && !isDevEnvironment()) {
        // 生产环境:真的发短信
        SMSUtils.sendMessage("瑞吉美食", "SMS_154950909", phone, code);
    } else {
        // 开发环境:打印到日志,省短信费
        log.info("【开发环境】手机号 {} 的验证码是:{}", phone, code);
        // 甚至可以存到内存Map,方便测试
        devCodeMap.put(phone, code);
    }
    
    // 存入Session(或者Redis)
    HttpSession session = getCurrentSession();
    session.setAttribute(phone + "_code", code);
    session.setAttribute(phone + "_time", System.currentTimeMillis());
}

2.2 自动注册:用户体验的小心机

用户故事: 用户只想点个外卖,你却让他先注册?

java

@PostMapping("/login")
public R<User> login(@RequestBody Map<String, String> map, HttpSession session) {
    String phone = map.get("phone");
    String inputCode = map.get("code");
    
    // 1. 验证码校验
    String savedCode = (String) session.getAttribute(phone + "_code");
    if (savedCode == null || !savedCode.equals(inputCode)) {
        return R.error("验证码错误");
    }
    
    // 2. 验证码过期校验(5分钟)
    Long sendTime = (Long) session.getAttribute(phone + "_time");
    if (System.currentTimeMillis() - sendTime > 5 * 60 * 1000) {
        return R.error("验证码已过期");
    }
    
    // 3. 查询用户是否存在
    LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
    wrapper.eq(User::getPhone, phone);
    User user = userService.getOne(wrapper);
    
    // 4. 不存在?自动创建!
    if (user == null) {
        user = new User();
        user.setPhone(phone);
        user.setName("用户_" + phone.substring(7));  // 用手机尾号做默认名
        user.setStatus(1);
        userService.save(user);
        log.info("新用户自动注册:{}", phone);
    }
    
    // 5. 保存登录状态
    session.setAttribute("user", user.getId());
    BaseContext.setCurrentId(user.getId());  // ThreadLocal,后面会讲
    
    // 6. 清理验证码
    session.removeAttribute(phone + "_code");
    session.removeAttribute(phone + "_time");
    
    return R.success(user);
}

产品经理的点赞: "这个自动注册功能好!转化率提升了30%!"

三、ThreadLocal:线程的私人储物柜

image-20260202170635741

3.1 为什么需要ThreadLocal?

场景: 用户下单时,我需要知道是谁下的单。

错误做法:

java

// 每个方法都传userId
public void addToCart(Long userId, ShoppingCart cart) {
    cart.setUserId(userId);
    // ...
}

public void placeOrder(Long userId, Order order) {
    order.setUserId(userId);
    // ...
}

// Controller里每次都要取session
Long userId = (Long) session.getAttribute("user");
cartService.addToCart(userId, cart);

问题: 方法调用链一长,每个方法都要带这个参数,烦死了!

3.2 ThreadLocal的魔法

java

public class BaseContext {
    // 每个线程有自己的储物柜
    private static ThreadLocal<Long> threadLocal = new ThreadLocal<>();
    
    public static void setCurrentId(Long id) {
        threadLocal.set(id);
    }
    
    public static Long getCurrentId() {
        return threadLocal.get();
    }
    
    public static void removeCurrentId() {
        threadLocal.remove();  // 重要!不然会内存泄漏
    }
}

在过滤器中设置用户ID:

java

@WebFilter("/*")
public class LoginCheckFilter implements Filter {
    
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) 
            throws IOException, ServletException {
        
        HttpServletRequest req = (HttpServletRequest) request;
        
        // 检查是否登录(检查session)
        Long userId = (Long) req.getSession().getAttribute("user");
        
        if (userId != null) {
            // 放到当前线程的储物柜里
            BaseContext.setCurrentId(userId);
            
            try {
                chain.doFilter(request, response);
            } finally {
                // 用完一定要清理!不然下一个请求会用错用户ID
                BaseContext.removeCurrentId();
            }
        } else {
            chain.doFilter(request, response);
        }
    }
}

现在Service层可以这样写:

java

@Service
public class CartServiceImpl implements CartService {
    
    @Override
    public void addToCart(ShoppingCart cart) {
        // 直接从ThreadLocal取,不用传参数了!
        Long userId = BaseContext.getCurrentId();
        cart.setUserId(userId);
        
        // ... 其他逻辑
    }
}

3.3 ThreadLocal的内存泄漏陷阱

故事时间: 线上服务运行一周后,内存爆了!

原因: Tomcat用了线程池,线程用完不是销毁,而是放回池子下次再用。如果ThreadLocal没清理,里面的数据就一直存在。

解决方案:

java

// 在过滤器的finally块中清理
try {
    if (userId != null) {
        BaseContext.setCurrentId(userId);
    }
    chain.doFilter(request, response);
} finally {
    // 无论如何都要清理
    BaseContext.removeCurrentId();
}

四、购物车:从"无限累加"到"智能合并"

4.1 那个加了50份水煮鱼的用户

image-20260202170816203

问题重现:

java

// 错误逻辑:每次点击都新增一条记录
@PostMapping("/add")
public R<ShoppingCart> add(@RequestBody ShoppingCart cart) {
    cart.setUserId(BaseContext.getCurrentId());
    cart.setCreateTime(new Date());
    cartService.save(cart);  // 每次都新增!
    return R.success(cart);
}

结果: 购物车表里有50条一模一样的水煮鱼记录。

4.2 正确的购物车逻辑

java

@Service
public class ShoppingCartServiceImpl implements ShoppingCartService {
    
    @Override
    public ShoppingCart addCart(ShoppingCart cart) {
        Long userId = BaseContext.getCurrentId();
        cart.setUserId(userId);
        
        // 查询是否已经存在相同的菜品/套餐
        LambdaQueryWrapper<ShoppingCart> wrapper = new LambdaQueryWrapper<>();
        wrapper.eq(ShoppingCart::getUserId, userId);
        
        if (cart.getDishId() != null) {
            // 添加的是菜品
            wrapper.eq(ShoppingCart::getDishId, cart.getDishId());
            // 口味也要匹配!辣的和不辣的是不同的商品
            wrapper.eq(ShoppingCart::getDishFlavor, cart.getDishFlavor());
        } else {
            // 添加的是套餐
            wrapper.eq(ShoppingCart::getSetmealId, cart.getSetmealId());
        }
        
        ShoppingCart existCart = this.getOne(wrapper);
        
        if (existCart != null) {
            // 已存在:数量+1
            existCart.setNumber(existCart.getNumber() + 1);
            this.updateById(existCart);
            return existCart;
        } else {
            // 不存在:新增,数量为1
            cart.setNumber(1);
            cart.setCreateTime(LocalDateTime.now());
            this.save(cart);
            return cart;
        }
    }
    
    @Override
    public ShoppingCart subCart(ShoppingCart cart) {
        Long userId = BaseContext.getCurrentId();
        
        LambdaQueryWrapper<ShoppingCart> wrapper = new LambdaQueryWrapper<>();
        wrapper.eq(ShoppingCart::getUserId, userId);
        
        if (cart.getDishId() != null) {
            wrapper.eq(ShoppingCart::getDishId, cart.getDishId());
            wrapper.eq(ShoppingCart::getDishFlavor, cart.getDishFlavor());
        } else {
            wrapper.eq(ShoppingCart::getSetmealId, cart.getSetmealId());
        }
        
        ShoppingCart existCart = this.getOne(wrapper);
        
        if (existCart != null) {
            if (existCart.getNumber() > 1) {
                // 数量>1:减1
                existCart.setNumber(existCart.getNumber() - 1);
                this.updateById(existCart);
            } else {
                // 数量=1:直接删除
                this.removeById(existCart.getId());
                existCart = null;
            }
        }
        
        return existCart;
    }
}

Controller配合:

java

@PostMapping("/add")
public R<ShoppingCart> add(@RequestBody ShoppingCart cart) {
    ShoppingCart savedCart = cartService.addCart(cart);
    return R.success(savedCart);
}

@PostMapping("/sub")
public R<ShoppingCart> sub(@RequestBody ShoppingCart cart) {
    ShoppingCart updatedCart = cartService.subCart(cart);
    return R.success(updatedCart);
}

五、下单功能:多表操作的终极考验

image-20260202170902773

5.1 下单的复杂性

一次下单涉及:

  1. 订单表(orders):订单基本信息
  2. 订单明细表(order_detail):买了什么,数量多少
  3. 地址表(address_book):送到哪里
  4. 购物车表(shopping_cart):下单后清空
  5. 用户表(user):谁下的单

5.2 我的第一次下单实现(灾难版)

java

public void submitOrder(Order order) {
    // 1. 保存订单
    orderMapper.insert(order);
    
    // 2. 保存订单明细
    List<OrderDetail> details = order.getOrderDetails();
    for (OrderDetail detail : details) {
        detail.setOrderId(order.getId());
        orderDetailMapper.insert(detail);
    }
    
    // 3. 清空购物车
    cartMapper.deleteByUserId(order.getUserId());
    
    // 4. 扣减库存(如果有库存管理)
    // stockMapper.reduceStock(...);
    
    // 问题:如果第3步失败,订单已经创建了,但购物车没清空
    // 用户会重复下单!
}

5.3 事务控制的下单

java

@Service
public class OrderServiceImpl implements OrderService {
    
    @Autowired
    private OrderMapper orderMapper;
    
    @Autowired
    private OrderDetailService orderDetailService;
    
    @Autowired
    private ShoppingCartService cartService;
    
    @Autowired
    private UserService userService;
    
    @Autowired
    private AddressBookService addressBookService;
    
    @Transactional(rollbackFor = Exception.class)  // 所有异常都回滚
    @Override
    public void submit(Orders order) {
        // 获取当前用户
        Long userId = BaseContext.getCurrentId();
        
        // 1. 查询当前用户的购物车
        LambdaQueryWrapper<ShoppingCart> cartWrapper = new LambdaQueryWrapper<>();
        cartWrapper.eq(ShoppingCart::getUserId, userId);
        List<ShoppingCart> carts = cartService.list(cartWrapper);
        
        if (carts == null || carts.isEmpty()) {
            throw new BusinessException("购物车为空,不能下单");
        }
        
        // 2. 查询用户信息
        User user = userService.getById(userId);
        
        // 3. 查询地址信息
        AddressBook address = addressBookService.getById(order.getAddressBookId());
        if (address == null) {
            throw new BusinessException("地址信息有误,不能下单");
        }
        
        // 4. 生成订单号(雪花算法)
        long orderId = IdWorker.getId();
        order.setId(orderId);
        order.setNumber(String.valueOf(orderId));
        order.setUserId(userId);
        order.setUserName(user.getName());
        order.setOrderTime(LocalDateTime.now());
        order.setCheckoutTime(LocalDateTime.now());
        order.setStatus(2);  // 待派送
        
        // 5. 组装地址
        order.setConsignee(address.getConsignee());
        order.setPhone(address.getPhone());
        order.setAddress(
            (address.getProvinceName() == null ? "" : address.getProvinceName()) +
            (address.getCityName() == null ? "" : address.getCityName()) +
            (address.getDistrictName() == null ? "" : address.getDistrictName()) +
            (address.getDetail() == null ? "" : address.getDetail())
        );
        
        // 6. 计算总金额,组装订单明细
        BigDecimal totalAmount = BigDecimal.ZERO;
        List<OrderDetail> orderDetails = new ArrayList<>();
        
        for (ShoppingCart cart : carts) {
            OrderDetail detail = new OrderDetail();
            detail.setOrderId(orderId);
            detail.setName(cart.getName());
            detail.setImage(cart.getImage());
            detail.setDishId(cart.getDishId());
            detail.setSetmealId(cart.getSetmealId());
            detail.setDishFlavor(cart.getDishFlavor());
            detail.setNumber(cart.getNumber());
            detail.setAmount(cart.getAmount());
            
            // 累加金额
            BigDecimal itemTotal = cart.getAmount().multiply(new BigDecimal(cart.getNumber()));
            totalAmount = totalAmount.add(itemTotal);
            
            orderDetails.add(detail);
        }
        
        order.setAmount(totalAmount);
        
        // 7. 保存订单
        orderMapper.insert(order);
        
        // 8. 保存订单明细(批量插入)
        orderDetailService.saveBatch(orderDetails);
        
        // 9. 清空购物车
        cartService.remove(cartWrapper);
        
        // 10. 发送消息通知(异步,不影响事务)
        sendOrderNotification(order, user, address);
    }
    
    // 异步发送通知,失败不影响主流程
    @Async
    public void sendOrderNotification(Orders order, User user, AddressBook address) {
        try {
            // 发送短信通知
            String message = String.format("尊敬的%s,您的订单%s已提交,我们将尽快为您配送!", 
                user.getName(), order.getNumber());
            SMSUtils.sendMessage(user.getPhone(), "订单提交成功", message);
            
            // 也可以发送站内通知、推送等
            log.info("订单通知发送成功:{}", order.getNumber());
        } catch (Exception e) {
            log.error("订单通知发送失败:{}", e.getMessage());
            // 通知失败不影响订单创建,只记录日志
        }
    }
}

5.4 雪花算法:分布式ID生成器

为什么不用自增ID?

  • 分库分表时会有冲突
  • 容易被人猜出订单量
  • 安全问题:知道一个订单号,可以尝试其他ID

雪花算法实现:

java

@Component
public class IdWorker {
    // 开始时间戳(2023-01-01)
    private final long twepoch = 1672502400000L;
    
    // 机器ID所占位数
    private final long workerIdBits = 5L;
    
    // 数据中心ID所占位数
    private final long datacenterIdBits = 5L;
    
    // 支持的最大机器ID
    private final long maxWorkerId = -1L ^ (-1L << workerIdBits);
    
    // 支持的最大数据中心ID
    private final long maxDatacenterId = -1L ^ (-1L << datacenterIdBits);
    
    // 序列在ID中占的位数
    private final long sequenceBits = 12L;
    
    // 机器ID向左移12位
    private final long workerIdShift = sequenceBits;
    
    // 数据中心ID向左移17位
    private final long datacenterIdShift = sequenceBits + workerIdBits;
    
    // 时间戳向左移22位
    private final long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits;
    
    // 生成序列的掩码
    private final long sequenceMask = -1L ^ (-1L << sequenceBits);
    
    private long workerId;
    private long datacenterId;
    private long sequence = 0L;
    private long lastTimestamp = -1L;
    
    public IdWorker(long workerId, long datacenterId) {
        if (workerId > maxWorkerId || workerId < 0) {
            throw new IllegalArgumentException("workerId不合法");
        }
        if (datacenterId > maxDatacenterId || datacenterId < 0) {
            throw new IllegalArgumentException("datacenterId不合法");
        }
        this.workerId = workerId;
        this.datacenterId = datacenterId;
    }
    
    public synchronized long nextId() {
        long timestamp = timeGen();
        
        if (timestamp < lastTimestamp) {
            throw new RuntimeException("时钟回拨,拒绝生成ID");
        }
        
        if (timestamp == lastTimestamp) {
            sequence = (sequence + 1) & sequenceMask;
            if (sequence == 0) {
                timestamp = tilNextMillis(lastTimestamp);
            }
        } else {
            sequence = 0L;
        }
        
        lastTimestamp = timestamp;
        
        return ((timestamp - twepoch) << timestampLeftShift)
                | (datacenterId << datacenterIdShift)
                | (workerId << workerIdShift)
                | sequence;
    }
    
    protected long tilNextMillis(long lastTimestamp) {
        long timestamp = timeGen();
        while (timestamp <= lastTimestamp) {
            timestamp = timeGen();
        }
        return timestamp;
    }
    
    protected long timeGen() {
        return System.currentTimeMillis();
    }
    
    // 静态方法,方便使用
    private static IdWorker instance = new IdWorker(1, 1);
    
    public static long getId() {
        return instance.nextId();
    }
}

使用方式:

java

// 生成订单ID
long orderId = IdWorker.getId();
order.setId(orderId);
order.setNumber(String.valueOf(orderId));

六、那些让我成长的坑

6.1 N+1查询问题

场景: 查询订单列表,每个订单要显示商品明细。

错误做法:

java

List<Orders> orders = orderMapper.selectList(null);
for (Orders order : orders) {
    // 为每个订单查询一次明细
    LambdaQueryWrapper<OrderDetail> wrapper = new LambdaQueryWrapper<>();
    wrapper.eq(OrderDetail::getOrderId, order.getId());
    List<OrderDetail> details = orderDetailMapper.selectList(wrapper);
    order.setOrderDetails(details);
}

问题: 如果有100个订单,要查1次订单 + 100次明细 = 101次查询!

解决方案:批量查询

java

// 1. 先查所有订单
List<Orders> orders = orderMapper.selectList(null);
List<Long> orderIds = orders.stream()
    .map(Orders::getId)
    .collect(Collectors.toList());

// 2. 一次查询所有明细
LambdaQueryWrapper<OrderDetail> wrapper = new LambdaQueryWrapper<>();
wrapper.in(OrderDetail::getOrderId, orderIds);
List<OrderDetail> allDetails = orderDetailMapper.selectList(wrapper);

// 3. 按订单ID分组
Map<Long, List<OrderDetail>> detailsMap = allDetails.stream()
    .collect(Collectors.groupingBy(OrderDetail::getOrderId));

// 4. 设置到订单中
for (Orders order : orders) {
    order.setOrderDetails(detailsMap.get(order.getId()));
}

6.2 并发下单问题

场景: 商品只剩最后1份,两个用户同时下单。

问题: 都检查库存充足,都下单成功,超卖了!

解决方案:乐观锁

java

// 商品表加version字段
public class Dish {
    private Long id;
    private String name;
    private Integer stock;  // 库存
    private Integer version; // 版本号
}

// 更新时带版本号
@Transactional
public boolean reduceStock(Long dishId, Integer quantity) {
    // 先查当前库存和版本
    Dish dish = dishMapper.selectById(dishId);
    if (dish.getStock() < quantity) {
        throw new BusinessException("库存不足");
    }
    
    // 更新库存,版本号+1
    int rows = dishMapper.updateStock(dishId, quantity, dish.getVersion());
    
    // rows=0表示更新失败(版本号不匹配)
    if (rows == 0) {
        // 重试或者抛异常
        throw new BusinessException("库存更新失败,请重试");
    }
    
    return true;
}

SQL:

sql

UPDATE dish 
SET stock = stock - #{quantity}, 
    version = version + 1
WHERE id = #{id} 
  AND version = #{version}
  AND stock >= #{quantity}

结语:从功能实现到系统思维

写完这一整套系统,我最大的收获不是学会了MyBatis-Plus或者Spring事务,而是理解了系统思维

  1. 数据一致性:事务不是可选项,是必选项
  2. 用户体验:自动注册、购物车合并,都是为了让用户更爽
  3. 性能意识:N+1查询、批量操作,时刻想着数据库的感受
  4. 异常处理:不是所有异常都要抛给用户,有些可以静默处理
  5. 扩展性:雪花算法、乐观锁,为将来做准备

现在回头看那个加了50份水煮鱼的用户,我不再觉得他是个奇葩,而是感谢他帮我发现了系统的bug。

每一个奇葩用户,都是最好的测试工程师。 他们用你想不到的方式,帮你把系统打磨得更健壮。

与所有正在被"奇葩需求"折磨的程序员共勉:今天踩的每一个坑,都是明天架构师的垫脚石。

知识点测试

读完文章了?来测试一下你对知识点的掌握程度吧!

评论区

使用 GitHub 账号登录后即可发表评论,支持 Markdown 格式。

如果评论系统无法加载,请确保:

  • 您的网络可以访问 GitHub
  • giscus GitHub App 已安装到仓库
  • 仓库已启用 Discussions 功能