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

一、套餐管理:从“分开保存”到“原子操作”
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);
}
}
上线第一天就出问题了:
- 套餐保存成功,但菜品保存时数据库连接断了
- 结果:套餐存在,但里面是空的!用户付了钱买了个“空气套餐”
- 客服电话被打爆:“我买的398元豪华套餐,怎么就一张图片?”
1.2 事务拯救世界

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("测试异常,看看事务回滚不");
}
// ... 保存菜品
}
学习到的教训:
- 事务要么全成功,要么全失败——像极了爱情
- 默认只回滚RuntimeException——检查异常(Exception)不回滚
- 事务方法不能被同类的其他方法直接调用——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("短信发送失败,请稍后重试");
}
}
踩坑记录:
- 签名没审核通过——名字不能带"外卖",改成了"瑞吉美食"
- 模板没审核通过——不能有"验证码"三个字,改成了"您的登录码是${code}"
- 测试环境没充值——阿里云说"余额不足,请充值"
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:线程的私人储物柜

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份水煮鱼的用户

问题重现:
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);
}
五、下单功能:多表操作的终极考验

5.1 下单的复杂性
一次下单涉及:
- 订单表(orders):订单基本信息
- 订单明细表(order_detail):买了什么,数量多少
- 地址表(address_book):送到哪里
- 购物车表(shopping_cart):下单后清空
- 用户表(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事务,而是理解了系统思维:
- 数据一致性:事务不是可选项,是必选项
- 用户体验:自动注册、购物车合并,都是为了让用户更爽
- 性能意识:N+1查询、批量操作,时刻想着数据库的感受
- 异常处理:不是所有异常都要抛给用户,有些可以静默处理
- 扩展性:雪花算法、乐观锁,为将来做准备
现在回头看那个加了50份水煮鱼的用户,我不再觉得他是个奇葩,而是感谢他帮我发现了系统的bug。
每一个奇葩用户,都是最好的测试工程师。 他们用你想不到的方式,帮你把系统打磨得更健壮。
与所有正在被"奇葩需求"折磨的程序员共勉:今天踩的每一个坑,都是明天架构师的垫脚石。
知识点测试
读完文章了?来测试一下你对知识点的掌握程度吧!
评论区
使用 GitHub 账号登录后即可发表评论,支持 Markdown 格式。
如果评论系统无法加载,请确保:
- 您的网络可以访问 GitHub
- giscus GitHub App 已安装到仓库
- 仓库已启用 Discussions 功能