高并发下如何防止重复下单？前言当你的用户疯狂点击提交按钮时，你的系统准备好迎接这场“连击风暴”了吗？在电商系统的实战中，我见过太多因重复提交导致的资损事故——用户一次点击，系统却创建了多个订单，导致库存错乱、用户重复支付、客服投诉爆棚。有些小伙伴在工作中可能遇到过这样的场景：大促期间，用户反馈“明明只点了一次，为什么扣了两次款？”开发同学查了半天日志，发现同一个用户请求在毫秒级内真的到达了服务器两次。今天这篇文章就跟大家聊聊高并发下防止重复提交订单，希望对你会有所帮助。01 为什么会重复提交？在深入解决方案前，我们必须搞清楚重复提交是如何发生的。常见的场景有：用户无意识重复点击：网络延迟时，用户心急多次点击提交按钮前端防抖失效：前端做了防抖处理，但被绕过或配置不当网络超时重试：请求超时后，客户端或网关自动重试恶意攻击：竞争对手或黑客故意重复提交后端处理超时：第一个请求处理慢，客户端以为失败又发一次来看一个典型的用户操作流程，以及其中可能发生重复的各个环节：从图中可以看到，从用户点击到订单落库，几乎每个环节都可能成为重复提交的“案发现场”。下面，我们就针对这些环节，层层布防。02 第一道防线：前端防抖与按钮控制这是最直观、成本最低的防护措施。原则是：在用户交互层面尽量减少无效请求。2.1 按钮状态控制// 前端防抖实现示例（Vue + Element UI） <el-button :loading="submitting" :disabled="submitting" @click="handleSubmitOrder" > {{ submitting ? '提交中...' : '提交订单' }} </el-button> 2.2 请求防抖与拦截// 使用axios拦截器实现请求防抖 import axios from 'axios' // 存储正在进行的请求 const pendingRequests = new Map() // 生成请求key const generateReqKey = (config) => { const { method, url, params, data } = config return [method, url, JSON.stringify(params), JSON.stringify(data)].join('&') } // 请求拦截器 axios.interceptors.request.use(config => { const key = generateReqKey(config) if (pendingRequests.has(key)) { // 请求已存在，取消当前请求 config.cancelToken = new axios.CancelToken(cancel => { cancel(`重复请求已被拦截: ${key}`) }) } else { // 新请求，添加到pending中 pendingRequests.set(key, config) } return config }) // 响应拦截器 axios.interceptors.response.use( response => { const key = generateReqKey(response.config) pendingRequests.delete(key) return response }, error => { if (axios.isCancel(error)) { console.log('请求被取消:', error.message) return Promise.reject(error) } // 错误处理完成后，也要从pending中移除 if (error.config) { const key = generateReqKey(error.config) pendingRequests.delete(key) } return Promise.reject(error) } )前端防护小结：优点：实现简单，能拦截大部分用户无意识的重复点击缺点：可被绕过（如直接调用API、禁用JS、使用Postman等工具）结论：前端防护是必要但不充分的措施，绝不能作为唯一防线03 第二道防线：后端接口幂等性设计幂等性是解决重复提交的核心理念。所谓幂等，就是同一个操作执行多次的结果与执行一次的结果相同。3.1 什么是幂等性？对于订单提交接口：幂等：无论调用1次还是N次，都只创建一个订单非幂等：调用N次可能创建N个订单3.2 基于Token的幂等实现这是最常用的幂等实现方案，流程如下：客户端在提交前，先向后端申请一个唯一Token提交订单时携带此Token服务端检查Token是否已使用过// 幂等性Token服务 @Service public class IdempotentTokenService { @Autowired private RedisTemplate<String, String> redisTemplate; private static final String IDEMPOTENT_PREFIX = "idempotent:token:"; private static final long TOKEN_EXPIRE_SECONDS = 300; // Token有效期5分钟 /** * 生成幂等性Token * */ public String generateToken(String userId) { String token = UUID.randomUUID().toString(); String redisKey = IDEMPOTENT_PREFIX + userId + ":" + token; // 存储Token，设置过期时间 redisTemplate.opsForValue().set( redisKey, "1", TOKEN_EXPIRE_SECONDS, TimeUnit.SECONDS ); return token; } /** * 检查并消费Token * @return true: Token有效且消费成功; false: Token无效或已消费 **/ public boolean checkAndConsumeToken(String userId, String token) { String redisKey = IDEMPOTENT_PREFIX + userId + ":" + token; // 使用Lua脚本保证原子性 String luaScript = """ if redis.call('get', KEYS[1]) == '1' then redis.call('del', KEYS[1]) return 1 else return 0 end """; DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>(); redisScript.setScriptText(luaScript); redisScript.setResultType(Long.class); Long result = redisTemplate.execute( redisScript, Collections.singletonList(redisKey) ); return result != null && result == 1L; } } // 使用AOP实现幂等性校验 @Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface Idempotent { String key() default ""; // 幂等键，支持SpEL表达式 long expireTime() default 300; // 过期时间，秒 } @Aspect @Component public class IdempotentAspect { @Autowired private RedisTemplate<String, String> redisTemplate; @Around("@annotation(idempotent)") public Object around(ProceedingJoinPoint joinPoint, Idempotent idempotent) throws Throwable { // 1. 获取方法参数 Object[] args = joinPoint.getArgs(); MethodSignature signature = (MethodSignature) joinPoint.getSignature(); Method method = signature.getMethod(); // 2. 解析幂等键（支持SpEL） String keyExpression = idempotent.key(); String redisKey = parseKey(keyExpression, method, args); // 3. 尝试获取分布式锁（防止并发请求同时通过检查） String lockKey = redisKey + ":lock"; boolean lockAcquired = false; try { // 尝试加锁 lockAcquired = redisTemplate.opsForValue() .setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS); if (!lockAcquired) { throw new BusinessException("系统繁忙，请稍后重试"); } // 4. 检查Token是否已使用 Boolean exists = redisTemplate.hasKey(redisKey); if (Boolean.TRUE.equals(exists)) { // Token已使用，直接返回之前的处理结果（这里需要根据实际业务调整） throw new BusinessException("请勿重复提交订单"); } // 5. 执行业务逻辑 Object result = joinPoint.proceed(); // 6. 标记Token已使用 redisTemplate.opsForValue().set( redisKey, "processed", idempotent.expireTime(), TimeUnit.SECONDS ); return result; } finally { // 释放锁 if (lockAcquired) { redisTemplate.delete(lockKey); } } } private String parseKey(String expression, Method method, Object[] args) { // 这里实现SpEL表达式解析，获取实际的幂等键 // 例如可以从参数中提取userId+orderToken return "parsed:key:from:expression"; } } // 在订单提交接口上使用 @RestController @RequestMapping("/order") public class OrderController { @PostMapping("/submit") @Idempotent(key = "#request.userId + ':' + #request.submitToken", expireTime = 300) public ApiResponse<OrderSubmitResult> submitOrder(@RequestBody OrderSubmitRequest request) { // 这里是真正的订单创建逻辑 OrderSubmitResult result = orderService.createOrder(request); return ApiResponse.success(result); } }3.3 基于唯一业务标识的幂等除了Token方案，还可以利用业务的自然唯一性实现幂等：@Service public class OrderService { @Autowired private OrderMapper orderMapper; @Transactional public OrderSubmitResult createOrder(OrderSubmitRequest request) { // 方法1：先查询是否存在 Order existingOrder = orderMapper.selectByUniqueKey( request.getUserId(), request.getProductId(), request.getSubmitTime() ); if (existingOrder != null) { // 订单已存在，直接返回 return convertToResult(existingOrder); } // 方法2：利用数据库唯一约束 try { Order newOrder = buildOrder(request); orderMapper.insert(newOrder); return convertToResult(newOrder); } catch (DuplicateKeyException e) { // 捕获唯一键冲突异常 log.warn("订单重复提交，uniqueKey={}", request.getUniqueKey()); // 查询已创建的订单并返回 Order createdOrder = orderMapper.selectByUniqueKey( request.getUserId(), request.getProductId(), request.getSubmitTime() ); if (createdOrder == null) { throw new BusinessException("订单处理异常，请稍后重试"); } return convertToResult(createdOrder); } } // 订单表可添加唯一索引 // ALTER TABLE t_order ADD UNIQUE KEY uk_user_product_time (user_id, product_id, submit_time); }幂等性设计小结：Token方案：通用性强，适合大多数场景业务标识方案：更自然，但依赖业务的天然唯一性关键点：所有幂等性检查必须在事务开始前完成，否则可能失效04 第三道防线：数据库层防护数据库是数据持久化的最后一道关卡，在这里设置防护至关重要。4.1 唯一约束与乐观锁-- 订单表设计示例 CREATE TABLE `t_order` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键', `order_no` varchar(32) NOT NULL COMMENT '订单号，业务唯一', `user_id` bigint(20) NOT NULL COMMENT '用户ID', `product_id` bigint(20) NOT NULL COMMENT '商品ID', `quantity` int(11) NOT NULL COMMENT '购买数量', `amount` decimal(10,2) NOT NULL COMMENT '订单金额', `status` tinyint(4) NOT NULL DEFAULT'1' COMMENT '订单状态：1-待支付，2-已支付', `submit_token` varchar(64) DEFAULT NULL COMMENT '提交Token，用于幂等', `version` int(11) NOT NULL DEFAULT'1' COMMENT '版本号，用于乐观锁', `create_time` datetime NOTNULLDEFAULTCURRENT_TIMESTAMP, `update_time` datetime NOTNULLDEFAULTCURRENT_TIMESTAMPONUPDATECURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), -- 订单号唯一 UNIQUE KEY `uk_user_submit_token` (`user_id`, `submit_token`), -- 提交Token唯一 UNIQUE KEY `uk_user_product_time` (`user_id`, `product_id`, `create_time`), -- 业务维度唯一 KEY `idx_user_id` (`user_id`), KEY `idx_create_time` (`create_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单表';4.2 数据库层面的幂等实现// 使用数据库事务+唯一约束保证最终一致性 @Service public class OrderServiceV2 { @Autowired private OrderMapper orderMapper; @Autowired private IdempotentTokenService tokenService; @Transactional(rollbackFor = Exception.class) public OrderSubmitResult submitOrderWithDBProtection(OrderSubmitRequest request) { String userId = request.getUserId(); String submitToken = request.getSubmitToken(); // 1. 检查幂等Token（在事务外先检查一次） if (!tokenService.checkAndConsumeToken(userId, submitToken)) { throw new BusinessException("请勿重复提交订单"); } try { // 2. 生成订单号（雪花算法等分布式ID生成器） String orderNo = generateOrderNo(); // 3. 创建订单对象 Order order = new Order(); order.setOrderNo(orderNo); order.setUserId(userId); order.setProductId(request.getProductId()); order.setQuantity(request.getQuantity()); order.setAmount(calculateAmount(request)); order.setSubmitToken(submitToken); // 4. 插入订单（这里依赖数据库唯一约束） orderMapper.insert(order); // 5. 更新库存等后续操作... updateProductStock(request.getProductId(), request.getQuantity()); return new OrderSubmitResult(orderNo, "订单创建成功"); } catch (DuplicateKeyException e) { // 6. 处理唯一约束冲突 log.warn("订单重复提交，userId={}, token={}", userId, submitToken); // 查询已创建的订单 Order existingOrder = orderMapper.selectBySubmitToken(userId, submitToken); if (existingOrder != null) { return new OrderSubmitResult( existingOrder.getOrderNo(), "订单已创建成功，请勿重复提交" ); } // 理论上不会走到这里，除非有极端情况 throw new BusinessException("订单处理异常，请稍后重试"); } } }05 第四道防线：分布式锁在分布式环境下，多个实例可能同时处理同一个请求，需要分布式锁来保证只有一个实例执行核心逻辑。5.1 基于Redis的分布式锁@Component public class DistributedLockService { @Autowired private RedissonClient redissonClient; /** * 尝试获取分布式锁 * @param lockKey 锁的key * @param waitTime 等待时间（毫秒） * @param leaseTime 持有时间（毫秒） * @return 锁对象，获取失败返回null **/ public RLock tryLock(String lockKey, long waitTime, long leaseTime) { RLock lock = redissonClient.getLock(lockKey); try { boolean acquired = lock.tryLock(waitTime, leaseTime, TimeUnit.MILLISECONDS); return acquired ? lock : null; } catch (InterruptedException e) { Thread.currentThread().interrupt(); return null; } } /** * 订单提交分布式锁 **/ public RLock lockForOrderSubmit(String userId, String submitToken) { String lockKey = String.format("order:submit:lock:%s:%s", userId, submitToken); return tryLock(lockKey, 100, 5000); // 等待100ms，锁持有5秒 } } // 在订单服务中使用分布式锁 @Service public class OrderServiceV3 { @Autowired private DistributedLockService lockService; @Autowired private OrderMapper orderMapper; public OrderSubmitResult submitOrderWithDistributedLock(OrderSubmitRequest request) { String userId = request.getUserId(); String submitToken = request.getSubmitToken(); // 1. 获取分布式锁 RLock lock = lockService.lockForOrderSubmit(userId, submitToken); if (lock == null) { throw new BusinessException("系统繁忙，请稍后重试"); } try { // 2. 检查是否已处理 Order existingOrder = orderMapper.selectBySubmitToken(userId, submitToken); if (existingOrder != null) { return new OrderSubmitResult( existingOrder.getOrderNo(), "订单已创建成功，请勿重复提交" ); } // 3. 执行业务逻辑 return doCreateOrder(request); } finally { // 4. 释放锁 if (lock.isHeldByCurrentThread()) { lock.unlock(); } } } private OrderSubmitResult doCreateOrder(OrderSubmitRequest request) { // 实际的订单创建逻辑 // 这里已经保证了同一时刻只有一个线程在处理同一个提交请求 // ... } }5.2 分布式锁的注意事项使用分布式锁时要注意：锁粒度：不要太粗（影响性能）也不要太细（增加复杂度）锁超时：必须设置合理的超时时间，防止死锁锁续期：对于长时间操作，需要实现锁续期机制可重入性：同一个线程可以重复获取锁容错性：Redis集群故障时要有降级方案06 第五道防线：异步处理与消息队列对于高并发场景，可以采用异步处理模式，将同步请求转为异步任务。实现代码示例：// 异步订单处理实现 @Component public class AsyncOrderService { @Autowired private RocketMQTemplate rocketMQTemplate; @Autowired private RedisTemplate<String, String> redisTemplate; /** * 异步提交订单 **/ public AsyncSubmitResult asyncSubmitOrder(OrderSubmitRequest request) { // 1. 生成唯一请求ID String requestId = generateRequestId(request.getUserId()); // 2. 快速验证（库存、用户状态等） quickValidate(request); // 3. 将请求ID与用户关联（用于查询结果） String pendingKey = "order:pending:" + request.getUserId() + ":" + requestId; redisTemplate.opsForValue().set(pendingKey, "processing", 10, TimeUnit.MINUTES); // 4. 发送到消息队列 OrderMessage message = new OrderMessage(); message.setRequestId(requestId); message.setRequest(request); message.setTimestamp(System.currentTimeMillis()); rocketMQTemplate.asyncSend( "ORDER_SUBMIT_TOPIC", message, new SendCallback() { @Override public void onSuccess(SendResult sendResult) { log.info("订单消息发送成功: {}", requestId); } @Override public void onException(Throwable throwable) { log.error("订单消息发送失败: {}", requestId, throwable); // 发送失败，更新状态 redisTemplate.opsForValue().set( pendingKey, "failed", 5, TimeUnit.MINUTES ); } } ); // 5. 立即返回，告知用户处理中 return new AsyncSubmitResult(requestId, "订单提交成功，正在处理中"); } } // 消息消费者 @Component @RocketMQMessageListener( topic = "ORDER_SUBMIT_TOPIC", consumerGroup = "order-submit-consumer-group" ) public class OrderSubmitConsumer implements RocketMQListener<OrderMessage> { @Autowired private OrderMapper orderMapper; @Override public void onMessage(OrderMessage message) { String requestId = message.getRequestId(); OrderSubmitRequest request = message.getRequest(); // 1. 幂等检查（基于requestId） Order existing = orderMapper.selectByRequestId(requestId); if (existing != null) { log.info("订单已处理，跳过: {}", requestId); return; } // 2. 创建订单 Order order = createOrder(request, requestId); try { orderMapper.insert(order); log.info("订单创建成功: {}", order.getOrderNo()); // 3. 更新处理状态 updateProcessingStatus(request.getUserId(), requestId, "success", order.getOrderNo()); } catch (DuplicateKeyException e) { log.warn("订单重复，requestId={}", requestId); // 查询已创建的订单 Order created = orderMapper.selectByRequestId(requestId); if (created != null) { updateProcessingStatus(request.getUserId(), requestId, "success", created.getOrderNo()); } } } }07 综合方案：多层次联合防护在实际生产环境中，我们通常会采用多层次、立体化的防护策略。以下是一个完整的综合方案流程图：这个多层次方案中，每一层都有其特定作用：前端层：用户体验优化，拦截大部分无意识重复网关层：安全防护，防刷、限流业务层：核心幂等逻辑，分布式锁保证并发安全数据层：最终保障，唯一约束防止数据不一致异步层：削峰填谷，提升系统吞吐量08 实战：不同场景下的方案选择不同的业务场景需要不同的防护策略，这里给出一些实践建议：8.1 普通电商订单// 普通电商订单推荐方案 @Service public class StandardOrderService { // 综合使用：前端防抖 + Token幂等 + 数据库唯一约束 public OrderSubmitResult submitStandardOrder(OrderSubmitRequest request) { // 1. 参数校验 validateRequest(request); // 2. 幂等Token检查（Redis） if (!idempotentCheck(request.getUserId(), request.getSubmitToken())) { return getExistingOrderResult(request.getUserId(), request.getSubmitToken()); } // 3. 分布式锁（防并发） RLock lock = acquireOrderLock(request.getUserId(), request.getProductId()); try { // 4. 库存检查等业务校验 checkInventory(request.getProductId(), request.getQuantity()); // 5. 创建订单（依赖数据库唯一约束） return createOrderInTransaction(request); } finally { lock.unlock(); } } }8.2 秒杀订单// 秒杀订单需要更极致的优化 @Service public class FlashSaleOrderService { // 秒杀方案：异步处理 + 库存预扣 + 最终一致性 public FlashSaleSubmitResult submitFlashSaleOrder(FlashSaleRequest request) { // 1. 验证用户资格和活动状态（缓存中检查） if (!checkUserQualification(request.getUserId(), request.getActivityId())) { throw new BusinessException("您不具备参与资格"); } // 2. 预扣库存（Redis原子操作） boolean stockDeducted = preDeductStock( request.getActivityId(), request.getProductId(), request.getUserId() ); if (!stockDeducted) { throw new BusinessException("库存不足"); } // 3. 生成唯一请求ID String requestId = generateRequestId(request.getUserId(), request.getActivityId()); // 4. 发送到消息队列（快速返回） sendToMQ(request, requestId); // 5. 立即返回 return new FlashSaleSubmitResult(requestId, "秒杀请求已接受，处理中"); } // 消费者异步创建订单 @Transactional public void processFlashSaleOrder(FlashSaleRequest request, String requestId) { // 这里只需要处理真正的订单创建 // 因为库存已在Redis中预扣，只需保证最终一致性 try { createOrder(request, requestId); // 同步库存到数据库 syncStockToDB(request.getProductId(), request.getActivityId()); } catch (Exception e) { // 失败时回滚Redis库存 rollbackStockInRedis(request.getActivityId(), request.getProductId(), request.getUserId()); throw e; } } }10 总结防止重复提交订单是一个系统工程，需要从前到后、多层次的防护。让我们回顾一下关键点：前端防护是体验，不是保障：按钮防抖、请求拦截能改善用户体验，但不能作为唯一防线。幂等性是核心理念：无论是Token方案还是业务唯一标识，都要保证同一操作执行多次的结果一致。分布式锁解决并发问题：在分布式环境下，防止多个实例同时处理同一请求。数据库是最后防线：唯一约束、乐观锁等机制能在应用层防护失效时保证数据一致性。异步处理提升吞吐：对于高并发场景，将同步请求转为异步处理，提高系统整体吞吐量。监控告警必不可少：没有监控的系统就像没有仪表的飞机，无法发现问题和优化性能。在实际架构设计中，我通常建议采用 "前端防抖 + 网关限流 + Token幂等 + 分布式锁 + 数据库唯一约束" 的综合方案，对于秒杀等极致场景再加入异步处理。有些小伙伴可能会觉得这些防护措施太复杂，影响开发效率。但请记住：预防的成本远低于修复的成本。一次重复提交导致的资损事故，可能就需要整个团队加班数周来修复数据和安抚用户。技术方案没有银弹，只有最适合业务场景的平衡。
接口幂等性问题，对于开发人员来说，是一个跟语言无关的公共问题。本文分享了一些解决这类问题非常实用的办法，绝大部分内容我在项目中实践过的，给有需要的小伙伴一个参考。不知道你有没有遇到过这些场景：有时我们在填写某些form表单时，保存按钮不小心快速点了两次，表中竟然产生了两条重复的数据，只是id不一样。我们在项目中为了解决接口超时问题，通常会引入了重试机制。第一次请求接口超时了，请求方没能及时获取返回结果（此时有可能已经成功了），为了避免返回错误的结果（这种情况不可能直接返回失败吧？），于是会对该请求重试几次，这样也会产生重复的数据。mq消费者在读取消息时，有时候会读取到重复消息（至于什么原因这里先不说，有兴趣的小伙伴，可以找我私聊），如果处理不好，也会产生重复的数据。没错，这些都是幂等性问题。接口幂等性是指用户对于同一操作发起的一次请求或者多次请求的结果是一致的，不会因为多次点击而产生了副作用。这类问题多发于接口的：insert操作，这种情况下多次请求，可能会产生重复数据。update操作，如果只是单纯的更新数据，比如：update user set status=1 where id=1，是没有问题的。如果还有计算，比如：update user set status=status+1 where id=1，这种情况下多次请求，可能会导致数据错误。那么我们要如何保证接口幂等性？本文将会告诉你答案。1. insert前先select通常情况下，在保存数据的接口中，我们为了防止产生重复数据，一般会在insert前，先根据name或code字段select一下数据。如果该数据已存在，则执行update操作，如果不存在，才执行 insert操作。该方案可能是我们平时在防止产生重复数据时，使用最多的方案。但是该方案不适用于并发场景，在并发场景中，要配合其他方案一起使用，否则同样会产生重复数据。我在这里提一下，是为了避免大家踩坑。2. 加悲观锁在支付场景中，用户A的账号余额有150元，想转出100元，正常情况下用户A的余额只剩50元。一般情况下，sql是这样的：update user amount = amount-100 where id=123;如果出现多次相同的请求，可能会导致用户A的余额变成负数。这种情况，用户A来可能要哭了。于此同时，系统开发人员可能也要哭了，因为这是很严重的系统bug。为了解决这个问题，可以加悲观锁，将用户A的那行数据锁住，在同一时刻只允许一个请求获得锁，更新数据，其他的请求则等待。通常情况下通过如下sql锁住单行数据：select * from user id=123 for update;具体流程如下：具体步骤：多个请求同时根据id查询用户信息。判断余额是否不足100，如果余额不足，则直接返回余额不足。如果余额充足，则通过for update再次查询用户信息，并且尝试获取锁。只有第一个请求能获取到行锁，其余没有获取锁的请求，则等待下一次获取锁的机会。第一个请求获取到锁之后，判断余额是否不足100，如果余额足够，则进行update操作。如果余额不足，说明是重复请求，则直接返回成功。需要特别注意的是：如果使用的是mysql数据库，存储引擎必须用innodb，因为它才支持事务。此外，这里id字段一定要是主键或者唯一索引，不然会锁住整张表。悲观锁需要在同一个事务操作过程中锁住一行数据，如果事务耗时比较长，会造成大量的请求等待，影响接口性能。此外，每次请求接口很难保证都有相同的返回值，所以不适合幂等性设计场景，但是在防重场景中是可以的使用的。在这里顺便说一下，防重设计 和 幂等设计，其实是有区别的。防重设计主要为了避免产生重复数据，对接口返回没有太多要求。而幂等设计除了避免产生重复数据之外，还要求每次请求都返回一样的结果。3. 加乐观锁既然悲观锁有性能问题，为了提升接口性能，我们可以使用乐观锁。需要在表中增加一个timestamp或者version字段，这里以version字段为例。在更新数据之前先查询一下数据：select id,amount,version from user id=123;如果数据存在，假设查到的version等于1，再使用id和version字段作为查询条件更新数据：update user set amount=amount+100,version=version+1where id=123 and version=1;更新数据的同时version+1，然后判断本次update操作的影响行数，如果大于0，则说明本次更新成功，如果等于0，则说明本次更新没有让数据变更。由于第一次请求version等于1是可以成功的，操作成功后version变成2了。这时如果并发的请求过来，再执行相同的sql： update user set amount=amount+100,version=version+1where id=123 and version=1;该update操作不会真正更新数据，最终sql的执行结果影响行数是0，因为version已经变成2了，where中的version=1肯定无法满足条件。但为了保证接口幂等性，接口可以直接返回成功，因为version值已经修改了，那么前面必定已经成功过一次，后面都是重复的请求。具体流程如下：具体步骤：先根据id查询用户信息，包含version字段根据id和version字段值作为where条件的参数，更新用户信息，同时version+1判断操作影响行数，如果影响1行，则说明是一次请求，可以做其他数据操作。如果影响0行，说明是重复请求，则直接返回成功。4. 加唯一索引绝大数情况下，为了防止重复数据的产生，我们都会在表中加唯一索引，这是一个非常简单，并且有效的方案。alter table `order` add UNIQUE KEY `un_code` (`code`);加了唯一索引之后，第一次请求数据可以插入成功。但后面的相同请求，插入数据时会报Duplicate entry '002' for key 'order.un_code异常，表示唯一索引有冲突。虽说抛异常对数据来说没有影响，不会造成错误数据。但是为了保证接口幂等性，我们需要对该异常进行捕获，然后返回成功。如果是java程序需要捕获：DuplicateKeyException异常，如果使用了spring框架还需要捕获：MySQLIntegrityConstraintViolationException异常。具体流程图如下：具体步骤：用户通过浏览器发起请求，服务端收集数据。将该数据插入mysql判断是否执行成功，如果成功，则操作其他数据（可能还有其他的业务逻辑）。如果执行失败，捕获唯一索引冲突异常，直接返回成功。5. 建防重表有时候表中并非所有的场景都不允许产生重复的数据，只有某些特定场景才不允许。这时候，直接在表中加唯一索引，显然是不太合适的。针对这种情况，我们可以通过建防重表来解决问题。该表可以只包含两个字段：id 和 唯一索引，唯一索引可以是多个字段比如：name、code等组合起来的唯一标识，例如：susan_0001。具体流程图如下：具体步骤：用户通过浏览器发起请求，服务端收集数据。将该数据插入mysql防重表判断是否执行成功，如果成功，则做mysql其他的数据操作（可能还有其他的业务逻辑）。如果执行失败，捕获唯一索引冲突异常，直接返回成功。需要特别注意的是：防重表和业务表必须在同一个数据库中，并且操作要在同一个事务中。6. 根据状态机很多时候业务表是有状态的，比如订单表中有：1-下单、2-已支付、3-完成、4-撤销等状态。如果这些状态的值是有规律的，按照业务节点正好是从小到大，我们就能通过它来保证接口的幂等性。假如id=123的订单状态是已支付，现在要变成完成状态。update `order` set status=3 where id=123 and status=2;第一次请求时，该订单的状态是已支付，值是2，所以该update语句可以正常更新数据，sql执行结果的影响行数是1，订单状态变成了3。后面有相同的请求过来，再执行相同的sql时，由于订单状态变成了3，再用status=2作为条件，无法查询出需要更新的数据，所以最终sql执行结果的影响行数是0，即不会真正的更新数据。但为了保证接口幂等性，影响行数是0时，接口也可以直接返回成功。具体流程图如下：具体步骤：用户通过浏览器发起请求，服务端收集数据。根据id和当前状态作为条件，更新成下一个状态判断操作影响行数，如果影响了1行，说明当前操作成功，可以进行其他数据操作。如果影响了0行，说明是重复请求，直接返回成功。主要特别注意的是，该方案仅限于要更新的表有状态字段，并且刚好要更新状态字段的这种特殊情况，并非所有场景都适用。7. 加分布式锁其实前面介绍过的加唯一索引或者加防重表，本质是使用了数据库的分布式锁，也属于分布式锁的一种。但由于数据库分布式锁的性能不太好，我们可以改用：redis或zookeeper。鉴于现在很多公司分布式配置中心改用apollo或nacos，已经很少用zookeeper了，我们以redis为例介绍分布式锁。目前主要有三种方式实现redis的分布式锁：setNx命令set命令Redission框架每种方案各有利弊，具体实现细节我就不说了，有兴趣的朋友可以加我微信找我私聊。具体流程图如下：具体步骤：用户通过浏览器发起请求，服务端会收集数据，并且生成订单号code作为唯一业务字段。使用redis的set命令，将该订单code设置到redis中，同时设置超时时间。判断是否设置成功，如果设置成功，说明是第一次请求，则进行数据操作。如果设置失败，说明是重复请求，则直接返回成功。需要特别注意的是：分布式锁一定要设置一个合理的过期时间，如果设置过短，无法有效的防止重复请求。如果设置过长，可能会浪费redis的存储空间，需要根据实际业务情况而定。8. 获取token除了上述方案之外，还有最后一种使用token的方案。该方案跟之前的所有方案都有点不一样，需要两次请求才能完成一次业务操作。第一次请求获取token第二次请求带着这个token，完成业务操作。具体流程图如下：第一步，先获取token。第二步，做具体业务操作。具体步骤：用户访问页面时，浏览器自动发起获取token请求。服务端生成token，保存到redis中，然后返回给浏览器。用户通过浏览器发起请求时，携带该token。在redis中查询该token是否存在，如果不存在，说明是第一次请求，做则后续的数据操作。如果存在，说明是重复请求，则直接返回成功。在redis中token会在过期时间之后，被自动删除。以上方案是针对幂等设计的。如果是防重设计，流程图要改改：需要特别注意的是：token必须是全局唯一的。
前言"快看我们的秒杀系统！库存显示-500了！"3年前的这个电话让我记忆犹新。当时某电商大促，我们自认为完美的分布式架构，在0点整瞬间被击穿。数据库连接池耗尽，库存表出现负数，客服电话被打爆...今天这篇文章跟大家一起聊聊商品超卖的问题，希望对你会有所帮助。1 为什么会发生超卖？首先我们一起看看为什么会发送超卖？1.1 数据库的"最后防线"漏洞我们用下面的列子，给大家介绍一下商品超卖是如何发生的。public boolean buy(int goodsId) { // 1. 查询库存 int stock = getStockFromDatabase(goodsId); if (stock > 0) { // 2. 扣减库存 updateStock(goodsId, stock - 1); return true; } return false; }在并发场景下可能变成下图这样的：请求1和请求2都将库存更新成9。根本原因：数据库的查询和更新操作，不是原子性校验，多个事务可能同时通过stock>0的条件检查。1.2 超卖的本质商品超卖的本质是：多个请求同时穿透缓存，同一时刻读取到相同库存值，最终在数据库层发生覆盖。就像100个人同时看上一件衣服，都去试衣间前看了眼牌子，出来时都觉得自己应该拿到那件衣服。2 防止超卖的方案2.1 数据库乐观锁数据库乐观锁的核心原理是通过版本号控制并发。例如下面这样的：UPDATE product SET stock = stock -1, version=version+1 WHERE id=123 AND version=#{currentVersion};Java的实现代码如下：@Transactional public boolean deductStock(Long productId) { Product product = productDao.selectForUpdate(productId); if (product.getStock() <= 0) return false; int affected = productDao.updateWithVersion( productId, product.getVersion(), product.getStock()-1 ); return affected > 0; }基于数据库乐观锁方案的架构图如下：优缺点分析：无需额外中间件高并发时DB压力大实现简单可能出现大量更新失败适用场景：日订单量1万以下的中小系统。2.2 Redis原子操作Redis原子操作的核心原理是使用：Redis + Lua脚本。核心代码如下：// Lua脚本保证原子性 String lua = "if redis.call('get', KEYS >= ARGV[1] then " + "return redis.call('decrby', KEYS[1], ARGV " + "else return -1 end"; public boolean preDeduct(String itemId, int count) { RedisScript<Long> script = new DefaultRedisScript<>(lua, Long.class); Long result = redisTemplate.execute(script, Collections.singletonList(itemId), count); return result != null && result >= 0; }该方案的架构图如下：性能对比：单节点QPS：数据库方案500 vs Redis方案8万响应时间：<1ms vs 50ms+2.3 分布式锁目前最常用的分布式锁的方案是Redisson。下面是Redisson的实现：RLock lock = redisson.getLock("stock_lock:"+productId); try { if (lock.tryLock(1, 10, TimeUnit.SECONDS)) { // 执行库存操作 } } finally { lock.unlock(); }注意事项1.锁粒度要细化到商品级别2.必须设置等待时间和自动释放3.配合异步队列使用效果更佳该方案的架构图如下：2.4 消息队列削峰可以使用 RocketMQ的事务消息。核心代码如下：// RocketMQ事务消息示例 TransactionMQProducer producer = new TransactionMQProducer("stock_group"); producer.setExecutor(new TransactionListener() { @Override public LocalTransactionState executeLocalTransaction(Message msg) { // 扣减数据库库存 return LocalTransactionState.COMMIT_MESSAGE; } });该方案的架构图如下：技术指标：削峰能力：10万QPS → 2万TPS订单处理延迟：<1秒（正常时段）2.5 预扣库存预扣库存是防止商品超卖的终极方案。核心算法如下：// Guava RateLimiter限流 RateLimiter limiter = RateLimiter.create(1000); // 每秒1000个令牌 public boolean preDeduct(Long itemId) { if (!limiter.tryAcquire()) return false; // 写入预扣库存表 preStockDao.insert(itemId, userId); return true; }该方案的架构图如下：性能数据：百万级并发支撑能力库存准确率99.999%订单处理耗时200ms内3 避坑指南3.1 缓存与数据库不一致某次大促因缓存未及时失效，导致超卖1.2万单。错误示例如下：// 错误示例：先删缓存再写库 redisTemplate.delete("stock:"+productId); productDao.updateStock(productId, newStock); // 存在并发写入窗口3.2 未考虑库存回滚秒杀取消后，忘记恢复库存，引发后续超卖。正确做法是使用事务补偿。例如下面这样的：@Transactional public void cancelOrder(Order order) { stockDao.restock(order.getItemId(), order.getCount()); orderDao.delete(order.getId()); }库存回滚和订单删除，在同一个事务中。3.3 锁粒度过大锁粒度过大，全局限流导致10%的请求被误杀。错误示例如下：// 错误示例：全局限锁 RLock globalLock = redisson.getLock("global_stock_lock");总结其实在很多大厂中，一般会将防止商品超卖的多种方案组合使用。架构图如下：通过组合使用：Redis做第一道防线（承受80%流量）分布式锁控制核心业务逻辑预扣库存+消息队列保证最终一致性实战经验：某电商在2023年双11中：Redis集群承载98%请求分布式锁拦截异常流量预扣库存保证最终准确性系统平稳支撑了每秒12万次秒杀请求，0超卖事故发生！记住：没有银弹方案，只有适合场景的组合拳！
前言很多时候，我们为了提升接口的性能，会把之前单线程同步执行的代码，改成多线程异步执行。比如：查询用户信息接口，需要返回用户基本信息、积分信息、成长值信息，而用户、积分和成长值，需要调用不同的接口获取数据。如果查询用户信息接口，同步调用三个接口获取数据，会非常耗时。这就非常有必要把三个接口调用，改成异步调用，最后汇总结果。再比如：注册用户接口，该接口主要包含：写用户表，分配权限，配置用户导航页，发通知消息等功能。该用户注册接口包含的业务逻辑比较多，如果在接口中同步执行这些代码，该接口响应时间会非常慢。这时就需要把业务逻辑梳理一下，划分：核心逻辑和非核心逻辑。这个例子中的核心逻辑是：写用户表和分配权限，非核心逻辑是：配置用户导航页和发通知消息。显然核心逻辑必须在接口中同步执行，而非核心逻辑可以多线程异步执行。等等。需要使用多线程的业务场景太多了，使用多线程异步执行的好处不言而喻。但我要说的是，如果多线程没有使用好，它也会给我们带来很多意想不到的问题，不信往后继续看。今天跟大家一起聊聊，代码改成多线程调用之后，带来的9大问题。1.获取不到返回值如果你通过直接继承Thread类，或者实现Runnable接口的方式去创建线程。那么，恭喜你，你将没法获取该线程方法的返回值。使用线程的场景有两种：不需要关注线程方法的返回值。需要关注线程方法的返回值。大部分业务场景是不需要关注线程方法返回值的，但如果我们有些业务需要关注线程方法的返回值该怎么处理呢？查询用户信息接口，需要返回用户基本信息、积分信息、成长值信息，而用户、积分和成长值，需要调用不同的接口获取数据。如下图所示：在Java8之前可以通过实现Callable接口，获取线程返回结果。Java8以后通过CompleteFuture类实现该功能。我们这里以CompleteFuture为例：public UserInfo getUserInfo(Long id) throws InterruptedException, ExecutionException { final UserInfo userInfo = new UserInfo(); CompletableFuture userFuture = CompletableFuture.supplyAsync(() -> { getRemoteUserAndFill(id, userInfo); return Boolean.TRUE; }, executor); CompletableFuture bonusFuture = CompletableFuture.supplyAsync(() -> { getRemoteBonusAndFill(id, userInfo); return Boolean.TRUE; }, executor); CompletableFuture growthFuture = CompletableFuture.supplyAsync(() -> { getRemoteGrowthAndFill(id, userInfo); return Boolean.TRUE; }, executor); CompletableFuture.allOf(userFuture, bonusFuture, growthFuture).join(); userFuture.get(); bonusFuture.get(); growthFuture.get(); return userInfo; }温馨提醒一下，这两种方式别忘了使用线程池。示例中我用到了executor，表示自定义的线程池，为了防止高并发场景下，出现线程过多的问题。此外，Fork/join框架也提供了执行任务并返回结果的能力。2.数据丢失我们还是以注册用户接口为例，该接口主要包含：写用户表，分配权限，配置用户导航页，发通知消息等功能。其中：写用户表和分配权限功能，需要在一个事务中同步执行。而剩余的配置用户导航页和发通知消息功能，使用多线程异步执行。表面上看起来没问题。但如果前面的写用户表和分配权限功能成功了，用户注册接口就直接返回成功了。但如果后面异步执行的配置用户导航页，或发通知消息功能失败了，怎么办？如下图所示：该接口前面明明已经提示用户成功了，但结果后面又有一部分功能在多线程异步执行中失败了。这时该如何处理呢？没错，你可以做失败重试。但如果重试了一定的次数，还是没有成功，这条请求数据该如何处理呢？如果不做任何处理，该数据是不是就丢掉了？为了防止数据丢失，可以用如下方案：使用mq异步处理。在分配权限之后，发送一条mq消息，到mq服务器，然后在mq的消费者中使用多线程，去配置用户导航页和发通知消息。如果mq消费者中处理失败了，可以自己重试。使用job异步处理。在分配权限之后，往任务表中写一条数据。然后有个job定时扫描该表，然后配置用户导航页和发通知消息。如果job处理某条数据失败了，可以在表中记录一个重试次数，然后不断重试。但该方案有个缺点，就是实时性可能不太高。3.顺序问题如果你使用了多线程，就必须接受一个非常现实的问题，即顺序问题。假如之前代码的执行顺序是：a,b,c，改成多线程执行之后，代码的执行顺序可能变成了：a,c,b。（这个跟cpu调度算法有关）例如：public static void main(String[] args) { Thread thread1 = new Thread(() -> System.out.println("a")); Thread thread2 = new Thread(() -> System.out.println("b")); Thread thread3 = new Thread(() -> System.out.println("c")); thread1.start(); thread2.start(); thread3.start(); }执行结果：a c b那么，来自灵魂的一问：如何保证线程的顺序呢？即线程启动的顺序是：a,b,c，执行的顺序也是：a,b,c。如下图所示：3.1 joinThread类的join方法它会让主线程等待子线程运行结束后，才能继续运行。列如：public static void main(String[] args) throws InterruptedException { Thread thread1 = new Thread(() -> System.out.println("a")); Thread thread2 = new Thread(() -> System.out.println("b")); Thread thread3 = new Thread(() -> System.out.println("c")); thread1.start(); thread1.join(); thread2.start(); thread2.join(); thread3.start(); }执行结果永远都是：a b c3.2 newSingleThreadExecutor我们可以使用JDK自带的Excutors类的newSingleThreadExecutor方法，创建一个单线程的线程池。例如： public static void main(String[] args) { ExecutorService executorService = Executors.newSingleThreadExecutor(); Thread thread1 = new Thread(() -> System.out.println("a")); Thread thread2 = new Thread(() -> System.out.println("b")); Thread thread3 = new Thread(() -> System.out.println("c")); executorService.submit(thread1); executorService.submit(thread2); executorService.submit(thread3); executorService.shutdown(); }执行结果永远都是：a b c使用Excutors类的newSingleThreadExecutor方法创建的单线程的线程池，使用了LinkedBlockingQueue作为队列，而此队列按 FIFO（先进先出）排序元素。添加到队列的顺序是a,b,c，则执行的顺序也是a,b,c。3.3 CountDownLatchCountDownLatch是一个同步工具类，它允许一个或多个线程一直等待，直到其他线程执行完后再执行。例如：public class ThreadTest { public static void main(String[] args) throws InterruptedException { CountDownLatch latch1 = new CountDownLatch(0); CountDownLatch latch2 = new CountDownLatch(1); CountDownLatch latch3 = new CountDownLatch(1); Thread thread1 = new Thread(new TestRunnable(latch1, latch2, "a")); Thread thread2 = new Thread(new TestRunnable(latch2, latch3, "b")); Thread thread3 = new Thread(new TestRunnable(latch3, latch3, "c")); thread1.start(); thread2.start(); thread3.start(); } } class TestRunnable implements Runnable { private CountDownLatch latch1; private CountDownLatch latch2; private String message; TestRunnable(CountDownLatch latch1, CountDownLatch latch2, String message) { this.latch1 = latch1; this.latch2 = latch2; this.message = message; } @Override public void run() { try { latch1.await(); System.out.println(message); } catch (InterruptedException e) { e.printStackTrace(); } latch2.countDown(); } }执行结果永远都是：a b c此外，使用CompletableFuture的thenRun方法，也能多线程的执行顺序，在这里就不一一介绍了。4.线程安全问题既然使用了线程，伴随而来的还会有线程安全问题。假如现在有这样一个需求：用多线程执行查询方法，然后把执行结果添加到一个list集合中。代码如下：List<User> list = Lists.newArrayList(); dataList.stream() .map(data -> CompletableFuture .supplyAsync(() -> query(list, data), asyncExecutor) )); CompletableFuture.allOf(futureArray).join();使用CompletableFuture异步多线程执行query方法：public void query(List<User> list, UserEntity condition) { User user = queryByCondition(condition); if(Objects.isNull(user)) { return; } list.add(user); UserExtend userExtend = queryByOther(condition); if(Objects.nonNull(userExtend)) { user.setExtend(userExtend.getInfo()); } }在query方法中，将获取的查询结果添加到list集合中。结果list会出现线程安全问题，有时候会少数据，当然也不一定是必现的。这是因为ArrayList是非线程安全的，没有使用synchronized等关键字修饰。如何解决这个问题呢？答：使用CopyOnWriteArrayList集合，代替普通的ArrayList集合，CopyOnWriteArrayList是一个线程安全的机会。只需一行小小的改动即可：List<User> list Lists.newCopyOnWriteArrayList();温馨的提醒一下，这里创建集合的方式，用了google的collect包。5.ThreadLocal获取数据异常我们都知道JDK为了解决线程安全问题，提供了一种用空间换时间的新思路：ThreadLocal。它的核心思想是：共享变量在每个线程都有一个副本，每个线程操作的都是自己的副本，对另外的线程没有影响。例如：@Service public class ThreadLocalService { private static final ThreadLocal<Integer> threadLocal = new ThreadLocal<>(); public void add() { threadLocal.set(1); doSamething(); Integer integer = threadLocal.get(); } }ThreadLocal在普通中线程中，的确能够获取正确的数据。但在真实的业务场景中，一般很少用单独的线程，绝大多数，都是用的线程池。那么，在线程池中如何获取ThreadLocal对象生成的数据呢？如果直接使用普通ThreadLocal，显然是获取不到正确数据的。我们先试试InheritableThreadLocal，具体代码如下：private static void fun1() { InheritableThreadLocal<Integer> threadLocal = new InheritableThreadLocal<>(); threadLocal.set(6); System.out.println("父线程获取数据：" + threadLocal.get()); ExecutorService executorService = Executors.newSingleThreadExecutor(); threadLocal.set(6); executorService.submit(() -> { System.out.println("第一次从线程池中获取数据：" + threadLocal.get()); }); threadLocal.set(7); executorService.submit(() -> { System.out.println("第二次从线程池中获取数据：" + threadLocal.get()); }); }执行结果：父线程获取数据：6 第一次从线程池中获取数据：6 第二次从线程池中获取数据：6由于这个例子中使用了单例线程池，固定线程数是1。第一次submit任务的时候，该线程池会自动创建一个线程。因为使用了InheritableThreadLocal，所以创建线程时，会调用它的init方法，将父线程中的inheritableThreadLocals数据复制到子线程中。所以我们看到，在主线程中将数据设置成6，第一次从线程池中获取了正确的数据6。之后，在主线程中又将数据改成7，但在第二次从线程池中获取数据却依然是6。因为第二次submit任务的时候，线程池中已经有一个线程了，就直接拿过来复用，不会再重新创建线程了。所以不会再调用线程的init方法，所以第二次其实没有获取到最新的数据7，还是获取的老数据6。那么，这该怎么办呢？答：使用TransmittableThreadLocal，它并非JDK自带的类，而是阿里巴巴开源jar包中的类。可以通过如下pom文件引入该jar包：<dependency> <groupId>com.alibaba</groupId> <artifactId>transmittable-thread-local</artifactId> <version>2.11.0</version> <scope>compile</scope> </dependency>代码调整如下：private static void fun2() throws Exception { TransmittableThreadLocal<Integer> threadLocal = new TransmittableThreadLocal<>(); threadLocal.set(6); System.out.println("父线程获取数据：" + threadLocal.get()); ExecutorService ttlExecutorService = TtlExecutors.getTtlExecutorService(Executors.newFixedThreadPool(1)); threadLocal.set(6); ttlExecutorService.submit(() -> { System.out.println("第一次从线程池中获取数据：" + threadLocal.get()); }); threadLocal.set(7); ttlExecutorService.submit(() -> { System.out.println("第二次从线程池中获取数据：" + threadLocal.get()); }); }执行结果：父线程获取数据：6 第一次从线程池中获取数据：6 第二次从线程池中获取数据：7我们看到，使用了TransmittableThreadLocal之后，第二次从线程中也能正确获取最新的数据7了。nice。如果你仔细观察这个例子，你可能会发现，代码中除了使用TransmittableThreadLocal类之外，还使用了TtlExecutors.getTtlExecutorService方法，去创建ExecutorService对象。这是非常重要的地方，如果没有这一步，TransmittableThreadLocal在线程池中共享数据将不会起作用。创建ExecutorService对象，底层的submit方法会TtlRunnable或TtlCallable对象。以TtlRunnable类为例，它实现了Runnable接口，同时还实现了它的run方法：public void run() { Map<TransmittableThreadLocal<?>, Object> copied = (Map)this.copiedRef.get(); if (copied != null && (!this.releaseTtlValueReferenceAfterRun || this.copiedRef.compareAndSet(copied, (Object)null))) { Map backup = TransmittableThreadLocal.backupAndSetToCopied(copied); try { this.runnable.run(); } finally { TransmittableThreadLocal.restoreBackup(backup); } } else { throw new IllegalStateException("TTL value reference is released after run!"); } }这段代码的主要逻辑如下：把当时的ThreadLocal做个备份，然后将父类的ThreadLocal拷贝过来。执行真正的run方法，可以获取到父类最新的ThreadLocal数据。从备份的数据中，恢复当时的ThreadLocal数据。如果你想进一步了解ThreadLocal的工作原理，可以看看我的另一篇文章《ThreadLocal夺命11连问》6.OOM问题众所周知，使用多线程可以提升代码执行效率，但也不是绝对的。对于一些耗时的操作，使用多线程，确实可以提升代码执行效率。但线程不是创建越多越好，如果线程创建多了，也可能会导致OOM异常。例如：Caused by: java.lang.OutOfMemoryError: unable to create new native thread在JVM中创建一个线程，默认需要占用1M的内存空间。如果创建了过多的线程，必然会导致内存空间不足，从而出现OOM异常。除此之外，如果使用线程池的话，特别是使用固定大小线程池，即使用Executors.newFixedThreadPool方法创建的线程池。该线程池的核心线程数和最大线程数是一样的，是一个固定值，而存放消息的队列是LinkedBlockingQueue。该队列的最大容量是Integer.MAX_VALUE，也就是说如果使用固定大小线程池，存放了太多的任务，有可能也会导致OOM异常。java.lang.OutOfMemeryError:Java heap space7.CPU使用率飙高不知道你有没有做过excel数据导入功能，需要将一批excel的数据导入到系统中。每条数据都有些业务逻辑，如果单线程导入所有的数据，导入效率会非常低。于是改成了多线程导入。如果excel中有大量的数据，很可能会出现CPU使用率飙高的问题。我们都知道，如果代码出现死循环，cpu使用率会飚的很多高。因为代码一直在某个线程中循环，没法切换到其他线程，cpu一直被占用着，所以会导致cpu使用率一直高居不下。而多线程导入大量的数据，虽说没有死循环代码，但由于多个线程一直在不停的处理数据，导致占用了cpu很长的时间。也会出现cpu使用率很高的问题。那么，如何解决这个问题呢？答：使用Thread.sleep休眠一下。在线程中处理完一条数据，休眠10毫秒。当然CPU使用率飙高的原因很多，多线程处理数据和死循环只是其中两种，还有比如：频繁GC、正则匹配、频繁序列化和反序列化等。后面我会写一篇介绍CPU使用率飙高的原因的专题文章，感兴趣的小伙伴，可以关注一下我后续的文章。8.事务问题在实际项目开发中，多线程的使用场景还是挺多的。如果spring事务用在多线程场景中，会有问题吗？例如：@Slf4j @Service public class UserService { @Autowired private UserMapper userMapper; @Autowired private RoleService roleService; @Transactional public void add(UserModel userModel) throws Exception { userMapper.insertUser(userModel); new Thread(() -> { roleService.doOtherThing(); }).start(); } } @Service public class RoleService { @Transactional public void doOtherThing() { System.out.println("保存role表数据"); } }从上面的例子中，我们可以看到事务方法add中，调用了事务方法doOtherThing，但是事务方法doOtherThing是在另外一个线程中调用的。这样会导致两个方法不在同一个线程中，获取到的数据库连接不一样，从而是两个不同的事务。如果想doOtherThing方法中抛了异常，add方法也回滚是不可能的。如果看过spring事务源码的朋友，可能会知道spring的事务是通过数据库连接来实现的。当前线程中保存了一个map，key是数据源，value是数据库连接。private static final ThreadLocal<Map<Object, Object>> resources = new NamedThreadLocal<>("Transactional resources");我们说的同一个事务，其实是指同一个数据库连接，只有拥有同一个数据库连接才能同时提交和回滚。如果在不同的线程，拿到的数据库连接肯定是不一样的，所以是不同的事务。所以不要在事务中开启另外的线程，去处理业务逻辑，这样会导致事务失效。9.导致服务挂掉使用多线程会导致服务挂掉，这不是危言耸听，而是确有其事。假设现在有这样一种业务场景：在mq的消费者中需要调用订单查询接口，查到数据之后，写入业务表中。本来是没啥问题的。突然有一天，mq生产者跑了一个批量数据处理的job，导致mq服务器上堆积了大量的消息。此时，mq消费者的处理速度，远远跟不上mq消息的生产速度，导致的结果是出现了大量的消息堆积，对用户有很大的影响。为了解决这个问题，mq消费者改成多线程处理，直接使用了线程池，并且最大线程数配置成了20。这样调整之后，消息堆积问题确实得到了解决。但带来了另外一个更严重的问题：订单查询接口并发量太大了，有点扛不住压力，导致部分节点的服务直接挂掉。为了解决问题，不得不临时加服务节点。在mq的消费者中使用多线程，调用接口时，一定要评估好接口能够承受的最大访问量，防止因为压力过大，而导致服务挂掉的问题。
跨域，对后端工程师来说，可谓既熟悉又陌生。这两个月我以架构师的角色参与一款教育产品的孵化，有了一段难忘的跨域之旅。写这篇文章，我想分享我在跨域这个知识点上的经历和思考，希望对大家有所启发。1 遇见跨域产品有多端：机构端，局方端 ，家长端等 。每端都有独立的域名，有的是在PC上访问，有的是通过微信公众号来访问，有的是扫码后H5展现。接入层调用的接口域名统一使用 api.training.com这个独立的域名，通过Nginx来配置请求转发。通常，我们提到的跨域指：CORS。CORS是一个W3C标准，全称是"跨域资源共享"（Cross-origin resource sharing）, 它需要浏览器和服务器同时支持他，允许浏览器向跨源服务器发送XMLHttpRequest请求，从而克服 AJAX 只能同源使用的限制。那么如何定义同源呢？我们先看下一个典型的网站的地址：同源是指：协议、域名、端口号完全相同。下表给出了与 URL http://www.training.com/dir/page.html 的源进行对比的示例:当用户通过浏览器访问应用（http://admin.training.com）时，调用接口的域名非同源域名(http://api.training.com)，这是显而易见的跨域场景。2 CORS详解跨域资源共享标准新增了一组 HTTP 首部字段，允许服务器声明哪些源站通过浏览器有权限访问哪些资源。规范要求，对那些可能对服务器数据产生副作用的 HTTP 请求方法（特别是 GET 以外的 HTTP 请求，或者搭配某些 MIME 类型的 POST 请求），浏览器必须首先使用 OPTIONS 方法发起一个预检请求（preflight request），从而获知服务端是否允许该跨域请求。服务器确认允许之后，才发起实际的 HTTP 请求。在预检请求的返回中，服务器端也可以通知客户端，是否需要携带身份凭证（包括 Cookies 和 HTTP 认证相关数据）。2.1 简单请求当请求同时满足如下条件时，CORS验证机制会使用简单请求， 否则CORS验证机制会使用预检请求。使用GET、POST、HEAD其中一种方法；只使用了如下的安全首部字段，不得人为设置其他首部字段；AcceptAccept-LanguageContent-LanguageContent-Type 仅限三种之一：text/plain，multipart/form-data，application/x-www-form-urlencoded：HTML头部 header field字段：DPR、Download、Save-Data、Viewport-Width、WIdth请求中的任意 XMLHttpRequestUpload 对象均没有注册任何事件监听器；XMLHttpRequestUpload 对象可以使用 XMLHttpRequest.upload 属性访问；请求中没有使用 ReadableStream 对象。简单请求模式，浏览器直接发送跨域请求，并在请求头中携带Origin的头，表明这是一个跨域的请求。服务器端接到请求后，会根据自己的跨域规则，通过Access-Control-Allow-Origin和Access-Control-Allow-Methods响应头，来返回验证结果。应答中携带了跨域头 Access-Control-Allow-Origin。使用 Origin 和 Access-Control-Allow-Origin 就能完成最简单的访问控制。本例中，服务端返回的 Access-Control-Allow-Origin: * 表明，该资源可以被任意外域访问。如果服务端仅允许来自 http://admin.training.com 的访问，该首部字段的内容如下：Access-Control-Allow-Origin: http://admin.training.com现在，除了 http://admin.training.com，其它外域均不能访问该资源。2.2 预检请求浏览器在发现页面发出的请求非简单请求，并不会立即执行对应的请求代码，而是会触发预先请求模式。预先请求模式会先发送preflight request（预先验证请求），preflight request是一个OPTION请求，用于询问要被跨域访问的服务器，是否允许当前域名下的页面发送跨域的请求。在得到服务器的跨域授权后才能发送真正的HTTP请求。OPTIONS请求头部中会包含以下头部：服务器收到OPTIONS请求后，设置头部与浏览器沟通来判断是否允许这个请求。如果preflight request验证通过，浏览器才会发送真正的跨域请求。3 后端配置后端配置我尝试过两种方式，经过两个月的测试，都能非常稳定的运行。MND推荐的Nginx配置；SpringBoot自带CorsFilter配置。▍MND推荐的Nginx配置Nginx配置相当于在请求转发层配置。location / { if ($request_method = 'OPTIONS') { add_header 'Access-Control-Allow-Origin' '*'; add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS'; # # Custom headers and headers various browsers *should* be OK with but aren't # add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range'; # # Tell client that this pre-flight info is valid for 20 days # add_header 'Access-Control-Max-Age' 1728000; add_header 'Content-Type' 'text/plain; charset=utf-8'; add_header 'Content-Length' 0; return 204; } if ($request_method = 'POST') { add_header 'Access-Control-Allow-Origin' '*' always; add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always; add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range' always; add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always; } if ($request_method = 'GET') { add_header 'Access-Control-Allow-Origin' '*' always; add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS' always; add_header 'Access-Control-Allow-Headers' 'DNT,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Range' always; add_header 'Access-Control-Expose-Headers' 'Content-Length,Content-Range' always; } }在配置Access-Control-Allow-Headers属性的时候，因为自定义的header包含签名和token，数量较多。为了简洁方便，我把Access-Control-Allow-Headers配置成 * 。在Chrome和firefox下没有任何异常，但在IE11下报了如下的错：Access-Control-Allow-Headers 列表中不存在请求标头 content-type。原来IE11要求预检请求返回的Access-Control-Allow-Headers的值必须以逗号分隔。▍SpringBoot自带CorsFilter首先基础框架里默认有如下跨域配置。public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOrigins("*") .allowedMethods("POST", "GET", "PUT", "OPTIONS", "DELETE") .allowCredentials(true) .allowedHeaders("*") .maxAge(3600); }可是部署完成，进入还是报CORS异常：从nginx和tomcat日志来看，仅仅收到一个OPTION请求，springboot应用里有一个拦截器ActionInterceptor，从header中获取token，调用用户服务查询用户信息，放入request中。当没有获取token数据时，会返回给前端JSON格式数据。但从现象来看CorsMapping并没有生效。为什么呢？实际上还是执行顺序的概念。下图展示了 过滤器，拦截器，控制器的执行顺序。DispatchServlet.doDispatch()方法是SpringMVC的核心入口方法。// Determine handler for the current request. mappedHandler = getHandler(processedRequest); if (!mappedHandler.applyPreHandle(processedRequest, response)) { return; } // Actually invoke the handler. mv = ha.handle(processedRequest, response, mappedHandler.getHandler());那么CorsMapping在哪里初始化的呢？经过调试，定位于AbstractHandlerMapping。protected HandlerExecutionChain getCorsHandlerExecutionChain(HttpServletRequest request, HandlerExecutionChain chain, CorsConfiguration config) { if (CorsUtils.isPreFlightRequest(request)) { HandlerInterceptor[] interceptors = chain.getInterceptors(); chain = new HandlerExecutionChain(new PreFlightHandler(config), interceptors); } else { chain.addInterceptor(new CorsInterceptor(config)); } return chain; }代码里有预检判断，通过 PreFlightHandler.handleRequest() 中处理，但是处于正常的业务拦截器之后。最终选择CorsFilter 主要基于两点原因：过滤器的执行顺序优先级最高；通过调试CorsFilter的源码，发现源码有很多细节的处理。private CorsConfiguration corsConfig() { CorsConfiguration corsConfiguration = new CorsConfiguration(); corsConfiguration.addAllowedOrigin("*"); corsConfiguration.addAllowedHeader("*"); corsConfiguration.addAllowedMethod("*"); corsConfiguration.setAllowCredentials(true); corsConfiguration.setMaxAge(3600L); return corsConfiguration; } @Bean public CorsFilter corsFilter() { UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/**", corsConfig()); return new CorsFilter(source); }下面的代码里，allowHeader是通配符 * 的时候，CorsFilter在设置 Access-Control-Allow-Headers 的时候，会将 Access-Control-Request-Headers 以逗号拼接起来，这样就可以避免IE11响应头的问题。public List<String> checkHeaders(@Nullable List<String> requestHeaders) { if (requestHeaders == null) { return null; } if (requestHeaders.isEmpty()) { return Collections.emptyList(); } if (ObjectUtils.isEmpty(this.allowedHeaders)) { return null; } boolean allowAnyHeader = this.allowedHeaders.contains(ALL); List<String> result = new ArrayList<>(requestHeaders.size()); for (String requestHeader : requestHeaders) { if (StringUtils.hasText(requestHeader)) { requestHeader = requestHeader.trim(); if (allowAnyHeader) { result.add(requestHeader); } else { for (String allowedHeader : this.allowedHeaders) { if (requestHeader.equalsIgnoreCase(allowedHeader)) { result.add(requestHeader); break; } } } } } return (result.isEmpty() ? null : result); }浏览器的执行效果如下:4 preflight响应码：200 vs 204后端配置完成之后，团队里的小伙伴问我：“勇哥，那预检请求返回的响应码到底是200还是204呀？”。这个问题真把我给问住了。我司的API网关的预检响应码是200，CorsFilter预检响应码也是200。MDN给的示例预检响应码全部是204。https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS我只能采取Google大法，赫然发现大名鼎鼎的API网关Kong的开发者也针对这个问题有一番讨论。MDN曾经推荐的preflight响应码是200 ，所以Kong也和MDN同步成200；The page was updated since then. See its contents on Sept 30th, 2018:https://web.archive.org/web/20180930031917/https://developer.mozilla.org/en-US/docs/Glossary/Preflight_request后来MDN将响应码修改204，于是Kong的开发者争论要不要和MDN保持同步。争论的核心点在于：有没有迫切的必要。200响应码运行得很好，似乎也将永远正常运行下去。而更换成204，不确定是否有隐藏问题。说到底，框架开发者还是依赖于浏览器的底层实现。在这个问题上，没有足够权威的资料能够支撑框架开发者，而各个知识点都散落在网络的各个角落，充斥着不完整的细节和部分解决方案，这些都让框架开发者非常困惑。最后，Kong的源码里预检响应码仍然是200，并没有和MDN保持同步。我仔细查看了各大主流网站，95%预检响应码是200。而经过两个多月的测试，Nginx配置预检响应码204，在主流的浏览器Chrome , Firefox , IE11 也没有出现任何问题。所以，200 works everywhere, 而204在当前主流的浏览器里也得到非常好的支持。5 Chrome: 非安全私有网络本以为跨域问题就这样解决了。没想到还是有一个小插曲。产品总监需要给客户做演示，我负责搞定演示环境。申请域名，准备阿里云服务器，应用打包，部署，一切都很顺利。可是在公司内网访问演示环境，有一个页面一直报CORS报错，报错内容类似下图：跨域的错误类型是：InsecurePrivateNetwork。这和原来遇到的跨域错误完全不一样，我心里一慌。马上Google , 原来这是chrome更新到94之后新的特性，可以手工关闭这个特性。打开 tab 页面 chrome://flags/#block-insecure-private-network-requests将其 Block insecure private network requests 设置为 Disabled, 然后重启就行了， 这样子就相当于把这个功能禁用掉。但这样是治标不治本呀。有点诡异的是，当我们不在公司内网访问演示环境的时候，演示环境完全正常，出错的页面也能正常访问。仔细看官方的文档，CORS-RFC1918 指出如下三种请求会受影响。公共网络访问私有网络；公共网络访问本地设备；私有网络访问本地设备。这样，我把问题定位在这个出错的第三方接口地址上。公司很多产品都依赖这个接口服务。当在公司内网访问的时候，该域名映射地址类似：172.16.xx.xx。而这个ip正好是rfc1918上规定的私有网络。10.0.0.0 - 10.255.255.255 (10/8 prefix) 172.16.0.0 - 172.31.255.255 (172.16/12 prefix) 192.168.0.0 - 192.168.255.255 (192.168/16 prefix)内网通过Chrome访问这个页面的时候，会触发非安全私有网络拦截。如何解决呢？官方给出的方案分两步走：私有网络只能通过Https来访问；未来，添加特定的预检头，比如说：Access-Control-Request-Private-Network等。当然还有一些临时方法：关闭Chrome该特性;换用其他浏览器比如Firefox;关闭网络内网开手机热点;修改本地host绑定外网ip。基于官方的方案 ，生产环境完全使用Https，公司内网访问就没有出现这样的跨域问题了。6 复盘美团Shepherd API网关的整体架构API网关非常适合当前产品的架构。架构设计之初，系统多端都会调用我司的API网关。API网关可以SAAS部署和私有化部署，有单独的域名，提供完善的签名算法。考虑到上线时间节点，团队成员对于API网关的熟悉程度以及多套环境部署投入时间成本，为了尽快交付，从架构层面，我做了一些平衡和妥协。接入层调用的接口域名统一使用 api.training.com这个独立的域名，通过Nginx来配置请求转发。同时，我和前端Leader统一了前后端协议，保持和我司API网关一致，为后续切回API网关做前置准备。API网关可以做鉴权，限流，灰度等，同时可以配置CORS。内部服务端不用特别关注跨域这个问题。腾讯API网关的配置界面同时，在解决跨域的问题过程中，我的心态也发生了变化。从最初的轻视，到逐渐沉下心来，一步步理解CORS的原理，分清楚不同解决方案的优缺点，事情也就慢慢顺遂起来。我也观察到：”有的项目组已经反馈过Chrome非安全私有网络问题，并给出了解决方案。对于技术管理者来讲，一定要重视项目中反馈的问题，做好梳理分析，整理预案。这样当同类问题出现时，也会条理有序“。
前言在实际工作中，我们经常需要在项目中调用第三方API接口，获取数据，或者上报数据，进行数据交换和通信。那么，调用第三方API接口会遇到哪些问题？如何解决这些问题呢？这篇文章就跟大家一起聊聊第三方API接口的话题，希望对你会有所帮助。1 域名访问不到一般我们在第一次对接第三方平台的API接口时，可能会先通过浏览器或者postman调用一下，该接口是否可以访问。有些人可能觉得多次一举。其实不然。有可能你调用第三方平台的API接口时，他们的接口真的挂了，他们还不知道。还有一种最重要的情况，就是你的工作网络，是否可以访问这个外网的接口。有些公司为了安全考虑，对内网的开发环境，是设置了防火墙的，或者有一些其他的限制，有些ip白名单，只能访问一些指定的外网接口。如果你发现你访问的域名，在开发环境访问不通，就要到运维同学给你添加ip白名单了。2 签名错误很多第三方API接口为了防止别人篡改数据，通常会增加数字签名(sign)的验证。sign = md5(多个参数拼接 + 密钥)在刚开始对接第三方平台接口时，会遇到参数错误，签名错误等问题。其中参数错误比较好解决，重点是签名错误这个问题。签名是由一些算法生成的。比如：将参数名和参数值用冒号拼接，如果有多个参数，则按首字母排序，然后再将多个参数一起拼接。然后加盐（即：密钥），再通过md5，生成一个签名。如果有多个参数，你是按首字母倒序的，则最后生成的签名会出问题。如果你开发环境的密钥，用的生产环境的，也可能会导致生产的签名出现问题。如果第三方平台要求最后3次md5生成签名，而你只用了1次，也可能会导致生产的签名出现问题。因此，接口签名在接口联调时是比较麻烦的事情。如果第三方平台有提供sdk生成签名是最好的，如果没有，就只能根据他们文档手写签名算法了。3 签名过期通过上面一步，我们将签名调通了，可以正常访问第三方平台获取数据了。但你可能会发现，同一个请求，15分钟之后，再获取数据，却返回失败了。第三方平台在设计接口时，在签名中增加了时间戳校验，同一个请求在15分钟之内，允许返回数据。如果超过了15分钟，则直接返回失败。这种设计是为了安全考虑。防止有人利用工具进行暴力破解，不停伪造签名，不停调用接口校验，如果一直穷举下去的话，总有一天可以校验通过的。sign = md5(多个参数拼接 + 密钥 + 时间戳)因此，有必要增加时间戳的校验。如果出现这种情况，不要慌，重新发起一次新的请求即可。​4 接口突然没返回数据如果你调用第三方平台的某个API接口查询数据，刚开始一直都有数据返回。但突然某一天没返回数据了。但是该API接口能够正常响应。不要感到意外，有可能是第三方平台将数据删除了。我对接完第三方平台的API接口后，部署到了测试环境，发现他们接口竟然没有返回数据，原因是他们有一天将测试环境的数据删完了。因此，在部署测试环境之前，要先跟对方沟通，要用哪些数据测试，不能删除。5 try...catch吗?我们通过调用第三方平台分页查询接口，同步数据时，如果接口查询了异常需要try/catch异常不？这是一个好问题。我们可能在一个循环中调用分页查询接口查数据，如果查询中间某一页的数据时，接口返回出现了异常。我们是直接终止程序，回滚前面写入表中的数据，还是try/catch忽略本次异常，直接查询下一页的数据呢？很显然第三方平台将API接口设置成分页查询，说明他们考虑过，该接口返回的数据太多了，如果一次返回可能会超时。如果我们的程序在同步某一页数据时出现了异常，而直接回滚前面已同步过数据，也可能会出现回滚超时的问题，显然非常不现实。我们需要在同步程序中，做幂等设计，支持重复发起请求。如果我们的程序在同步某一页数据时出现了异常，try/catch忽略本次异常，直接查询下一页的数据。对于异常数据，我们可以在后面的某个时间点，再跑一次同步程序即可。如果要更智能化一点，可以增加失败自动重试机制。6 token失效有些平台的API接口在请求之前，先要调用另外一个API接口获取token，然后再header中携带该token信息才能访问其他的业务API接口。在获取token的API接口中，我们需要传入账号、密码和密钥等信息。每个接口对接方，这些信息都不一样。我们在请求其他的API接口之前，每次都实时调用一次获取token的接口获取token？还是请求一次token，将其缓存到redis中，后面直接从redis获取数据呢？很显然我们更倾向于后者，因为如果每次请求其他的API接口之前，都实时调用一次获取token的接口获取token，这样每次都会请求两次接口，性能上会有一些影响。如果将请求的token，保存到redis，又会出现另外一个问题：token失效的问题。我们调用第三方平台获取token的接口获取到的token，一般都有个有效期，比如：1天，1个月等。在有效期内，该API接口能够正常访问。如果超过了token的有效期，则该API接口不允许访问。好办，我们把redis的失效时间设置成跟token的有效期一样不就OK了？想法是不错，但是有问题。你咋保证，你们系统的服务器时间，跟第三方平台的服务器时间一模一样？我之前遇到过某大厂，提供了获取token接口，在30天内发起请求，每次都返回相同的token值。如果超过了30天，则返回一个新的。有可能出现这种情况，你们系统的服务器时间要快一些，第三方平台的时间要慢一些。结果到了30天，你们系统调用第三方平台的获取token接口获取到了token还是老的token，更新到redis中了。过一段时间，token失效了，你们系统还是用老的token访问第三方平台的其他API接口，一直都返回失败。但获取新的token却要等30天，这个时间太漫长了。为了解决这个问题，需要捕获token失效的异常。如果在调用其他的API接口是发现token失效了，马上请求一次获取token接口，将新的token立刻更新到redis中。这样基本可以解决token失效问题，也能尽可能保证访问其他接口的稳定性和性能。7 接口超时系统上线之后，调用第三方API接口，最容易出现的问题，应该是接口超时问题了。系统到外部系统之间，有一条很复杂的链路，中间有很多环节出现问题，都可能影响API接口的相应时间。作为API接口的调用方，面对第三方API接口超时问题，除了给他们反馈问题，优化接口性能之外，我们更有效的方式，可能是增加接口调用的失败重试机制。例如：int retryCount=0; do { try { doPost(); break; } catch(Exception e) { log.warn("接口调用失败") retryCount++; } } where (retryCount <= 3)如果接口调用失败，则程序会立刻自动重试3次。如果重试之后成功了，则该API接口调用成功。如果重试3次之后还是失败，则该API接口调用失败。8 接口返回500调用第三方API接口，偶尔因为参数的不同，可能会出现500的问题。比如：有些API接口对于参数校验不到位，少部分必填字段，没有校验不能为空。刚好系统的有些请求，通过某个参数去调用该API接口时，没有传入那个参数，对方可能会出现NPE问题。而该接口的返回code，很可能是500。还有一种情况，就是该API接口的内部bug，传入不同的参数，走了不同的条件分支逻辑，在走某个分支时，接口逻辑出现异常，可能会导致接口返回500。这种情况做接口重试也没用，只能联系第三方API接口提供者，反馈相关问题，让他们排查具体原因。他们可能会通过修复bug，或者修复数据，来解决这个问题。9 接口返回404如果你在系统日志中发现调用的第三方API接口返回了404，这就非常坑了。如果第三方的API接口没有上线，很可能是他们把接口名改了，没有及时通知你。这种情况，可以锤他们了。还有一种情况是，如果第三方的API接口已经上线了，刚开始接口是能正常调用的。第三方也没有改过接口地址。后来，突然有一天发现调用第三方的API接口还是出现了404问题。这种情况很可能是他们网关出问题了，最新的配置没有生效，或者改了网关配置导致的问题。总之一个字：坑。10 接口少数据了之前我调过一个第三方的API接口分页查询数据，接入非常顺利，但后来上线之后，发现他们的接口少数据了。一查原因发现是该分页查询接口，返回的总页数不对，比实际情况少了。有些小伙伴可能会好奇，这么诡异的问题我是怎么发现？之前调用第三方API接口分页查询分类数据，保存到我们的第三方分类表中。突然有一天，产品反馈说，第三方有个分类在分类树中找不到。我确认之后，发现竟然是真的没有。从调用第三方API接口的响应日志中，也没有查到该分类的数据。这个API接口是分页查询接口，目前已经分了十几页查询数据，但还是没有查到我们想要的分类。之前的做法是先调用一次API接口查询第一页的数据，同时查出总页数。然后再根据总页数循环调用，查询其他页的数据。我当时猜测，可能是他们接口返回的总页数有问题。于是，可以将接口调用逻辑改成这样的：从第一页开始，后面每调用一次API接口查数据，页数就加1。然后判断接口返回的数据是否小于pageSize，如果不小于，则进行下一次调用。如果小于，则说明已经是最后一页了，可以停止后续调用了。验证之后发现这样果然可以获取那个分类的数据，只能说明第三方的分页查询接口返回的总页数比实际情况小了。11 偷偷改参数了我之前调用过某平台的API接口获取指标的状态，之前根据双方约定的状态有：正常和禁用 两种。然后将状态更新到我们的指标表中。后来，双方系统上线运行了好几个月。突然有一天，用户反馈说某一条数据明明删除了，为什么在页面上还是可以查到。此时，我查我们这边的指标表，发现状态是正常的。然后查看调用该平台的API接口日志，发现返回的该指标的状态是：下架。what？这是什么状态？跟该平台的开发人员沟通后，发现他们改了状态的枚举，增加了：上架、下架等多个值，而且没有通知我们。这就坑了。我们这边的代码中判断，如果状态非禁用状态，都认为是正常状态。而下架状态，自动被判断为正常状态。经过跟对方沟通后，他们确认下架状态，是非正常状态，不应该显示指标。他们改了数据，临时解决了该指标的问题。后来，他们按接口文档又改回了之前的状态枚举值。12 接口时好时坏不知道你在调用第三方接口时，有没有遇到过接口时好时坏的情况。5分钟前，该接口还能正常返回数据。5分钟后，该接口返回503不可用。又过了几分钟，该接口又能正常返回数据了。这种情况大概率是第三方平台在重启服务，在重启的过程中，可能会出现服务暂时不可用的情况。还有另外一种情况：第三方接口部署了多个服务节点，有一部分服务节点挂了。也会导致请求第三方接口时，返回值时好时坏的情况。此外还有一种情况：网关的配置没有及时更新，没有把已经下线的服务剔除掉。这样用户请求经过网关时，网关转发到了已经下线的服务，导致服务不可用。网关转发请求到正常的服务，该服务能够正常返回。如果遇到该问题，要尽快将问题反馈给第三方平台，然后增加接口失败重试机制。13 主键重复有一次，对接了某一个国企的分类查询接口，他们的系统非常古老，返回的id，值是：1、2、3、4、5之类的数字。这个时代，id不用雪花算法生成，还用数据库字段增长的主键，还是非常少见的。刚开始，我的想法是用数据库自增的id问题不大，只要能保证唯一性就行。但很快被无情的打脸了。我在查看数据时，发现id有重复的。后来跟他们沟通后发现，分类树的数据，他们是多张表拼接而成的。他们返回的分类数据上有个type字段，表示大类，一个大类一张表。我当时想不同的大类id可能会重复，但同一个大类id总会是唯一的。但不久又被打脸了。他们返回的末级分类，不是真正的分类，其实是清单项，是另外一张表。也就是说，同一个大类的数据中id，也可能会重复。此时，被一万点暴击。我们要把他们的数据保存到我们数据库，在页面中展示分类树，他们的id有重复，不就有问题？用户选择了某一个id的数据，可能会同时影响多条数据。如何解决这个问题呢？答：通过第三方平台分类树查询接口获取分类数据时，我们要根据雪花算法生成自己的主键id，他们的id只是用于页面展示，底层查询条件用我们自己的id。最关键的一步是parent_id，由于他们的id会重复，导致通过他们的parent_id查询出来的数据也有问题。我们需要根据分类树的层级关系，和用雪花算法生成的id，重新生成一个新的parent_id。14 文档和接口逻辑不一致之前还遇到一个第三方平台提供的API查询接口，接口文档中明确写明了有个dr字段表示删除状态。有了这个字段，我们在同步第三方平台的分类数据时，就能够知道有哪些数据是被删除的，后面可以及时调整我们这边的数据，将相关的数据也做删除处理。后来发现有些分类，他们那边已经删除了，但是我们这边却没删除。这是啥情况呢？代码逻辑很简单，我review了一下代码，也没有bug，为什么会出现这种情况呢？追查日志之后发现，调用第三方平台获取分类接口时，对方并没有把已删除的分类数据返回给我们。也就是说接口文档中的那个dr字段没有什么用，接口文档和接口逻辑不一致。这个问题估计好多小伙伴都遇到过。如果要解决这个问题，主要的方案有两种：第三方平台按文档修改接口逻辑，返回删除状态。我们系统在调用分类查询接口之后，根据分类code判断，如果数据库中有些分类的code不在接口返回值中，则删除这些分类。15 欠费了我们调用过百度的票据识别接口，可以自动识别发票信息，获取发票编号和金额等信息。之前是另外一个同事对接的接口，后来他离职了。发票识别功能上线，使用了很长一段时间，一直都没有出问题。后来，某一天，生产环境用户反馈发票识别不了了。我查询了相关服务的日志，没有发现异常，这就奇怪了。打开代码仔细看了一下，发现那位同事的代码中调用第三方的API接口，接收响应数据时，直接转换成了对象，没有打印当时返回的字符串。莫非，接口返回值有问题？后来，我增加了日志，打印出了该接口真正的返回内容值。原因一下查到了，原来是欠费了。如果出现该了异常，百度的API接口返回的数据结构，用之前那位同事的实体有些参数没法获取到。这是一个不小的坑。我们在接收第三方API接口返回数据时，尽可能先用字符串接收返回值，然后将字符串转换成相应实体类，一定要将该返回值在日志中打印出来，方便后面定位问题。不要直接用实体对象接收返回值，有些API接口，如果出现不同的异常，返回的数据结构差异比较大。有些异常结果可能是他们网关系统直接返回的，有些异常是他们业务系统返回的。如果看了文章有些收获的话，记得给我点赞喔
种1. 引言在上篇文章中，我们已经谈到了分布式中至关重要的两个理论：CAP 理论和 BASE 理论。今天将带大家探讨分布式事务里的“八奇技”，帮助大家在实际的分布式系统中更好地运用事务。2. 分布式事务常见的解决分案分布式事务是在分布式系统中，跨越多个计算机节点或数据存储系统进行的事务，在这种环境下保证事务的ACID（原子性、一致性、隔离性、持久性）属性是一大挑战。对此，业界有以下 8 种常见的解决方案，俗称 “八奇技”。奇技1）2PC二阶段提交协议（Two-phase commit protocol），简称 2PC。两阶段提交是一种强一致性事务协议，它分为准备阶段和提交阶段。在准备阶段，协调者节点询问所有参与者是否准备好提交事务，如果所有参与者都答应准备好了，那么在提交阶段，协调者会通知所有参与者提交事务。如果有任何一个参与者在准备阶段没有准备好，那么协调者会通知所有参与者回滚事务。有熟悉 MySQL 的同学可能马上想到了，MySQL 的事务提交就是通过几种日志来实现二阶段提交的。不了解MySQL执行流程的可以看我之前写的这篇文章：一张图看懂SQL执行流程优点原子性保证：2PC 协议可以保证所有参与者要么全部提交成功，要么全部失败回滚，从而实现跨多个分布式节点的事务的原子性。简单直观：2PC 的设计思路简单，逻辑清晰，容易理解，这使得它在很多传统的数据库和分布式系统中得到了广泛的应用，比如 MySQL 从 5.5 版本开始支持。缺点同步阻塞：在 2PC 的第一阶段，所有参与者在响应协调者的准备请求后，必须等待最终的提交或回滚指令。这期间，所有参与者都处于阻塞状态，无法进行其他操作，导致资源锁定时间较长，在高并发场景下很明显不太适用。单点故障：如果协调者在第二阶段崩溃，参与者可能会无限期地等待指令，因为它们不知道应该提交还是回滚。这使得整个系统容易受到单点故障的影响。数据不一致：如果在第二阶段中协调者向某些参与者发送了提交指令，而其他参与者因为网络问题没有收到指令，那么这些没有收到指令的参与者可能会选择回滚，导致数据不一致。复杂的恢复机制：当系统崩溃后，恢复过程非常复杂，所有参与者必须保持足够的信息以便在系统恢复后能够继续完成 2PC 协议。奇技2）3PC三阶段提交协议（Three-phase commit protocol），简称 3PC。三阶段提交（3PC）是两阶段提交（2PC）的改进版本，它旨在减少在协调者和参与者之间的阻塞时间，同时增加系统在某些故障情况下的容错能力，以下是 3PC 的三个阶段：CanCommit 阶段协调者行动: 发送 CanCommit 请求到所有参与者，并等待回应。参与者行动: 如果参与者可以提交事务，它就返回 Yes，并进入预备状态；如果不能提交，则返回 No。PreCommit 阶段协调者行动: 如果所有参与者回答 Yes，协调者发送 PreCommit 请求给所有参与者，并进入 Prepared 阶段；如果有任何参与者回答 No，或者等待超时，协调者发送 abort 请求。参与者行动: 在收到 PreCommit 请求后，参与者会执行事务操作，写入日志，但不提交，然后响应 ACK，并等待最终指令。如果参与者在这个阶段超时没有收到协调者的消息，它将中止事务。DoCommit 阶段协调者行动: 一旦协调者收到所有参与者的 ACK，它会进入 DoCommit 阶段，发送 commit 请求给所有参与者。参与者行动: 参与者在收到 commit 请求后，提交事务，释放所有事务锁定的资源，并向协调者发送完成消息。与 2PC 相比，3PC 在 PreCommit 阶段引入了超时机制，允许参与者在没有接收到协调者的最终指令时自行决定中止事务，这减少了协调者成为单点故障的可能性。实际业务场景3PC通常用于需要较高可靠性的分布式系统中，尤其是在那些不能接受长时间锁定资源的场景。例如：分布式数据库系统：分布式数据库可能使用 3PC 来确保跨多个数据中心的事务一致性。例如，一个全球性的银行可能需要在不同国家的分支机构之间处理账户转账，这时3PC可以减少在网络延迟或某个分支机构失去响应时的影响。电信网络：在电信运营商的计费系统中，可能会使用 3PC 来同步跨多个服务点的账单信息，这些系统通常要求高可用性和快速响应，因此不能长时间阻塞。大型分布式系统：对于需要跨多个服务和组件协调工作的大型分布式系统，比如云计算平台，3PC可以在保持事务一致性的同时，减少参与者等待协调者指令的时间。使用 3PC 的考虑因素虽然 3PC 提供了比 2PC 更好的容错性和减少了阻塞的时间，但它仍然有一些缺点：复杂性：3PC 比 2PC 更复杂，需要更多的消息交换和更多的状态管理。性能开销：3PC 引入了额外的阶段和网络通信，可能会导致更大的性能开销。极端情况：即使是 3PC，在某些极端的网络分区或多点故障情况下也可能无法保证事务的正确性。因此，在实际应用中，需要权衡 3PC 带来的好处与其复杂性和性能开销之间的关系，确保它适合特定的业务场景和系统需求。在某些情况下，其他的事务模型，如最大努力通知等最终一致性模型，可能会是更合适的选择。奇技3）TCCTCC（Try-Confirm-Cancel）是一种应用层的分布式事务解决方案，它将事务分为三个步骤：尝试（Try）、确认（Confirm）和取消（Cancel）：在 Try 阶段，会预留必要的业务资源；在 Confirm 阶段，如果所有相关的业务操作都成功了，则正式执行业务操作；如果有操作失败，则在 Cancel 阶段执行补偿操作，回滚之前的预留资源。假设我们买一张从深圳到北京的火车票，票价为 360 元，TCC 分为这三个步骤：Try：检查钱包的钱是否大于等于 360，并锁住资源（360 元和这张车票）；Cancel：如果有一个资源锁定失败，则进行 cancel 释放资源，这个过程中无论 cancel 还是其它操作失败都进行重试 cancel，所以需要保证幂等性；Confirm：如果资源锁定都成功，则进行 confirm，资源交换，这个过程中无论 confirm 还是其它操作失败都进行重试 confirm，都需保证幂等性。优势TCC 的出现解决二阶段提交的几个缺点：单点故障问题：引入了多个业务活动管理器，集群下高可用；数据不一致问题：引入超时补偿机制，由业务活动管理器来控制一致性；同步阻塞问题：引入超时补偿机制，不会锁定同步，将资源转换为业务逻辑形式，粒度更小。奇技4）分布式补偿事务（Saga）Saga 是一种长事务的解决方案，它将一个大的分布式事务拆分成多个较小的本地事务，这些本地事务通过异步消息传递串联起来。每个本地事务执行成功后，会发送消息触发下一个事务的执行。如果某个本地事务失败，Saga 会执行一系列补偿操作（回滚之前的操作）来保持数据的一致性。假设有一个旅游网站，用户可以通过它预订机票、酒店和租车服务。每个预订步骤都可以视为一个 Saga 中的小事务：用户预订机票。用户预订酒店。用户预订租车服务。如果用户成功完成了所有预订步骤，那么整个旅行预订就完成了。但如果在预订租车服务时失败了，那么 Saga 会开始执行补偿操作：取消酒店预订。取消机票预订。通过这种方式，Saga 确保了用户不会因为部分服务预订失败而损失金钱或留下未处理的预订。优点灵活性：Saga 允许每个小事务独立管理，提高了系统的灵活性。减少资源锁定：由于 Saga 不需要在事务执行过程中持续占用资源，因此可以减少长时间的资源锁定，提高系统的并发能力。容错性：Saga 通过定义补偿操作来处理失败，增强了系统的容错能力。适用于微服务架构：在微服务架构中，Saga 可以跨服务边界管理事务，每个服务独立处理自己的事务和补偿逻辑。缺点复杂性：实现 Saga 需要定义每个小事务的补偿操作，这可能会增加系统的复杂性。数据一致性：Saga 不能提供 2PC 那样的即时一致性保证，它只能保证最终一致性，这在某些业务场景中可能是不够的。补偿操作的难度：在某些情况下，补偿操作可能很难实现，尤其是当事务有副作用时（比如发送了一个不可撤销的通知）。测试和调试：由于 Saga 涉及多个服务和补偿逻辑，测试和调试可能会更加困难。在选择使用 Saga 模式时，需要仔细考虑业务场景是否适合最终一致性，以及是否能够有效地实现和管理补偿逻辑。对于那些需要高度一致性保证的场景，可能需要考虑其他事务管理机制。奇技5）分布式锁在某些情况下，可以使用分布式锁来确保多个分布式节点不会同时操作同一资源。这可以通过 Redis、ZooKeeper 等分布式协调服务来实现。不知道分布式锁或者不了解如何实现的，可以看这篇文章：说出来你可能不信，分布式锁竟然这么简单...项目/公司: 使用 Redis、ZooKeeper 等实现分布式锁的系统。实用场景: 在电商秒杀活动中，防止超卖现象，确保同一时间只有一个请求能够对库存数量进行修改。推荐场景: 当需要协调多个节点对共享资源进行访问控制时，分布式锁是一个有效的解决方案。奇技6）本地消息表本地消息表是一种确保分布式事务最终一致性的方法。它的工作原理是：在执行本地事务的同时，将需要异步执行的远程服务调用相关信息存储在同一个本地数据库的消息表中。本地事务和消息表的写入操作在同一个数据库事务中完成，这样可以保证要么都成功，要么都失败，从而保证了数据的一致性。本地事务提交后，一个独立的消息发布程序会定期扫描消息表，对于未处理的消息，发布到消息队列或直接调用远程服务。远程服务处理完成后，消息会被标记为已处理，从而确保每条消息只被处理一次。本地消息表是 ebay 公司提出的事务解决方案，它的核心原理是将需要分布式处理的任务通过消息日志的方式来异步执行。消息日志可以存储到本地文件、数据库或消息队列，再通过业务规则或人工发起重试。本地消息表基于 BASE 理论，实现数据的最终一致性，实现过程中需要注意幂等性原则。奇技7）可靠消息最终一致性通过可靠消息服务保证消息的可靠传输，并在消息消费者那里进行本地事务处理，从而实现最终一致性，所以又被称作消息事务。如果消息处理失败，可以重试或者进行人工干预。执行流程：发送 prepare 消息到消息中间件发送成功后，执行本地事务如果事务执行成功，则 commit，消息中间件将消息下发至消费端如果事务执行失败，则回滚，消息中间件将这条 prepare 消息删除消费端接收到消息进行消费，如果消费失败，则不断重试这种方案也是实现了「最终一致性」，和本地消息表类似，但是对比本地消息表实现方案，消息事务不需要再建消息表，而是将消息中间件的机制去做的，「不再依赖本地数据库事务」了。所以这种方案更适用于高并发的场景，目前市面上实现该方案的「只有阿里的 RocketMQ」。奇技8）最大努力通知原则最大努力通知也是一种基于消息的分布式事务解决方案，但它不保证 100% 的消息传递成功。它的工作原理是：在本地事务执行成功后，系统会尝试通知其他的参与者或服务。通知操作会尽最大努力去执行，但如果失败，系统不会无限重试。该方案通常结合人工干预，例如，如果通知失败，系统可能会记录日志、发送报警、或者提供管理界面供操作人员手动处理。本地消息表，或者通过 MQ 对事务进行通知都可以算作最大努力。本地消息表通过后台定时任务去异步保证数据的一致性，就是一种最大努力通知的思想：代表系统各模块之间已经最大程度地保证事务的最终一致性了。3. 小结在选择分布式事务解决方案时，需要根据业务需求、系统复杂度、性能要求等因素进行权衡。例如，对于业务场景要求数据的一致性非常高，且可以接受一定程度的性能损失时，2PC 或者 3PC 是很好的选择。对于复杂业务流程中的分布式事务，需要在业务层进行更细粒度控制时，TCC 是一个好的选择。比如，用户在电商平台下单购买商品，涉及到库存、账户余额、积分等多个服务的数据变更。而对于可容忍短时间内数据不一致的业务，则可以考虑最终一致性相关的解决方案，如：本地消息表、消息事务及最大努力通知方案等等。因此，当我们探讨分布式事务时，不仅要把握好用户痛点和实际需求，还要结合每个分布式事务解决方案的特点，才能把 “八奇技” 用到出神入化之境。
前言系统上线时，非常容易出问题。即使之前在测试环境，已经执行过SQL脚本了。但是有时候，在系统上线时，在生产环境执行相同的SQL脚本，还是有可能出现一些问题。有些小公司，SQL脚本是开发自己执行的，有很大的风险。有些大厂，有专业的DBA把关，但DBA也不是万能的，还是有可能会让一些错误的SQL脚本被生产环境执行了，比如：update语句的顺序不对。今天跟大家一起聊聊，系统上线时SQL脚本的9大坑，以便于大家吸取教训，能够防微杜渐，希望对你会有所帮助。1 漏脚本了我们上线时执行的SQL脚本，出现次数最多的问题，应该是漏脚本了。有时候少加了一个字段。有时候字段的注释没有及时修改。有时候有些新表没创建。有时候字段类型忘了修改。等等。我们的SQL脚本中漏脚本的情况有很多。那么，如何解决这个问题呢？答：将SQL脚本做成代码的一部分。在项目的代码中，创建一个专门的sql目录，在该目录下根据每个迭代创建一个子目录，比如：mv3.2.1，将SQL脚本存放到mv3.2.1下。我们在开发环境任何对表的相关操作，比如：增加字段、修改字段类型、修改注释、增加索引、创建表等等，都需要通过SQL语句操作，然后把该SQL语句，整理到SQL脚本中。最后提交到公司的GitLab上，我们在测试环境和生产环境发版时，去GitLab上找相关迭代版本的SQL脚本执行。通过该方式基本可以解决漏脚本的问题。2 脚本语法错误有些小伙伴看到这个标题可能有点懵，SQL脚本不是已经在测试环境执行过了吗？为什么还会出现语法错误？比如说有这样的场景：原本你的SQL脚本没问题的，但没有按照规范，给一张表的添加多个字段，你写了多条ALTER语句。例如：alter table t_user add column `work` varchar(30) DEFAULT NULL COMMENT '工作'; alter table t_user add column `provice` varchar(10) DEFAULT NULL COMMENT '籍贯';在上线时，你给DBA提SQL工单时，该工单被DBA审核拒绝打回来了。然后为了赶时间，你急急忙忙把多条ALTER语句改成一条ALTER语句。例如：alter table t_user add `work` varchar(30) DEFAULT NULL COMMENT '工作', add `provice` varchar(10) DEFAULT NULL COMMENT '籍贯';但在修改的过程中，有地方少了一个逗号，就可能会出现SQL语法错误。因此，不管是什么SQL语句，要养成好习惯，只要修改了一定要记得到开发环境的数据库中，先执行测试一下，切勿直接提到生产环境，即使你有很大的把握，也需要再更慎重一些。这样基本可以避免SQL语法错误的问题。3 脚本顺序不对有些时候，我们在上线系统时，DBA在执行SQL脚本的时候，没有报错，但最后的数据就是不对。有可能是脚本顺序不对导致的。比如有这样一种场景：你往某张表通过insert初始化了一条数据。例如：INSERT INTO `sue`.`t_user`(`id`, `code`, `age`, `name`, `height`, `address`, `work`, `provice`) VALUES (1, '101', 21, '周星驰', 173, '香港', NULL, NULL);另外一个人要基于你这条数据，通过update修改数据。例如：update t_user set age=25 where id=1;你们提了两条SQL脚本。另外一个人先提的，你后提的。DBA先把他的SQL工单审核通过了，先update数据，此时通过id是没法找到那条数据的，影响行数为0。然后DBA再审核你的SQL工单，审核通过了，插入了一条数据。由于SQL脚本的顺序不对，导致最终系统上线时的数据不对。那么这个问题要如何解决呢？双方要事先沟通好，把另外一个同事的SQL脚本加到你的初始化脚本中，你的脚本在初始化时，直接去修改数据即可。例如：INSERT INTO `sue`.`t_user`(`id`, `code`, `age`, `name`, `height`, `address`, `work`, `provice`) VALUES (1, '101', 25, '周星驰', 173, '香港', NULL, NULL);这样可以避免执行顺序问题。4 执行时机不对有些系统功能已经上线了，在后面的迭代中，为了尽量避免少影响线上功能，可以增加一个pre（即预生产环境）。该环境跟生产环境是差不多的，连接了相同的数据库，使用了相同的apollo配置。但唯一的区别是pre环境没有实际的用户流量，只能公司内部人员才能访问。一般在迭代版本上线之前，先要把系统功能发布到pre环境中，测试通过之后，才能发布到prod（即生产环境）。但有些SQL脚本，却没法再pre环境中执行，不然会影响生产环境。比如：修改了字段类型，int改成varchar了，或者初始化数据时，初始化了一条新加的枚举数据。由于pre环境是运行的最新代码，但prod环境还是运行的老代码。如果在发布pre环境时，直接执行SQL脚本，可能会导致prod环境的功能异常。因此要搞清楚SQL脚本的执行时机，哪些是要在pre环境执行的，哪些是要在prod环境执行的。我们在提SQL工单时，千万不要一股脑就提了，一定要区分时机。在发pre环境时，要么不提发prod环境的SQL脚本。要么，在工单的名称上做区分，比如增加prod_开头的标识。这样可以解决SQL脚本执行时机的问题。5 搞错数据库了有时候，我们的数据库做了分库分表，或者增加备份库。在执行SQL脚本的时候，由于我们自己的疏忽，提SQL工单时选错数据库了，或者DBA的疏忽，在执行SQL工单时搞错数据库了，就会出现问题。建议我们的SQL脚本增加库名，比如：alter table sue.t_user add `work` varchar(30) DEFAULT NULL COMMENT '工作';这里增加库名：sue。这样基本可以避免选错数据库的问题。6 脚本耗时太长有时候，我们的SQL脚本需要批量修改生产环境的一些数据，正常情况下一条update语句就能搞定。例如：update user set status=0 where status=1;但由于user表的数据量非常大，我们在执行该SQL脚本之前，没有预先评估该SQL脚本的耗时情况，而选择直接在生产环境的数据库中执行。假如该SQL脚本耗时非常长，比如要10分钟才能执行完，可能会导致user表长期锁表，影响正常的业务功能。在该SQL脚本执行的过程中，极有可能会出现业务功能操作，导致的死锁问题。因此，建议这种大批量的数据更新操作，要在用户较少的凌晨，分批多次执行。我们要尽可能少的影响线上用户的功能。此外，在生产环境增加字段，增加索引等操作，也能会导致长期锁表。也要避免在用户访问高峰期执行相关的SQL脚本。7 脚本无法回滚绝大多数系统上线是能够成功的，虽然过程中会遇到很多问题，但如果能够及时解决，也能够上线成功。但如果有些问题，没法再规定的时间内解决，很有可能会导致上线失败。如果上线失败，意味着代码和数据库的SQL脚本要回滚。如果只回滚了代码，不回滚数据库，可能会导致很多系统异常。因此，我们在准备SQL语句时，要留点心眼，顺便想想该SQL语句能否回滚。对于update语句可以加上修改时间：update t_user set age=25,time=now(3) where id=1;这样可以通过该时间追溯一次SQL操作修改的数据，方便后面做回滚。有些时候我们要update的数据，是要通过多条sql语句查询出来的，比如：需要使用的id。为了方便回滚我们可以增加临时表，保存这些id，后面就能追溯了。当然有些开源的数据库管理平台，比如：Archery，是有自带SQL审核和回滚的功能。8 忘了加索引我们在增加了字段之后，非常容易忽略的一件事是：加索引。特别是当前表数据量很大，而且增加的字段是另外一张表的id时，这种情况强烈建议增加索引。如果我们上线系统时，在SQL脚本中，忘了给该字段增加索引。如果该id字段被大批量访问，全部走的全表扫描，可能会导致数据库性能直线下降，出现大量的超时问题。所以建议我们在开发的时候，如果要增加字段的话，要养成良好习惯，想一想这个字段需不需要建索引。如果不确定数据量的话，可以先到生产环境查询一下真实的用户数据，不然后续可能会引起比较大的生产事故。9 字段改名对于生产环境的表字段，通常情况下，我们不允许修改名称。如果你在发布pre环境时，通过SQL脚本把某张表的某个字段名称修改了，pre环境代码使用了新的名称，系统没有问题。但prod环境还是使用老的名称，所有使用该名称的sql语句，在代码执行过程中都会报错。因此，禁止在生产环境通过SQL脚本修改字段名称。
前言在面试时，经常会被问一个问题：如何防止别人恶意刷接口？这是一个非常有意思的问题，防范措施挺多的。今天这篇文章专门跟大家一起聊聊，希望对你会有所帮助。1 防火墙防火墙是网络安全中最基本的安全设备之一，主要用于防止未经授权的网络访问和攻击。防火墙可以防止的攻击行为包括：无效数据包：防火墙可以识别和过滤掉无效的数据包，如错误的 IP 地址、伪造的数据包和无法识别的协议等。DOS 和 DDOS 攻击：防火墙可以使用不同的技术来检测和阻止 DOS 和 DDOS 攻击，如阻止大量 TCP/UDP 连接、IP 地址过滤和流量限制等。病毒和蠕虫攻击：防火墙可以使用特定的病毒和蠕虫检测技术，如签名检测、行为检测、模式识别等，来防止这些恶意软件的传播。网络钓鱼和欺骗攻击：防火墙可以检测和防止网络钓鱼和欺骗攻击，如防止虚假登录页面和欺骗的网站等。恶意流量攻击：防火墙可以检测和防止恶意流量攻击，如过滤掉带有恶意载荷的数据包和防止被黑客利用的端口。网络侦察攻击：防火墙可以使用一些技术来防止网络侦察攻击，如防止扫描、端口扫描和漏洞利用等。防火墙主要用于过滤和控制网络流量，以保护网络安全。2 验证码对于一些非常重要的接口，在做接口设计的时候，要考虑恶意用户刷接口的情况。最早的用户注册接口，是需要用图形验证码校验的，比如下面这样的：用户只需要输入：账号名称、密码和验证码即可，完成注册。其中账号名称作为用户的唯一标识。但有些图形验证码比较简单，很容易被一些暴力破解工具破解。由此，要给图形验证码增加难道，增加一些干扰项，增加暴力破解工具的难道。但有个问题是：如果图形验证码太复杂了，会对正常用户使用造成一点的困扰，增加了用户注册的成本，让用户注册功能的效果会大打折扣。因此，仅靠图形验证码，防止用户注册接口被刷，难道太大了。后来，又出现了一种移动滑块形式的图形验证方式，安全性更高。此外，使用验证码比较多的地方是发手机短信的功能。发手机短信的功能，一般是购买的云服务厂商的短信服务，按次收费，比如：发一条短信0.1元。如果发送短信的接口，不做限制，被用户恶意调用，可能会产生非常昂贵的费用。3 鉴权对于有些查看对外的API接口，需要用户登录之后，才能访问。这种情况就需要校验登录了。可以从当前用户上下文中获取用户信息，校验用户是否登录。如果用户登录了，当前用户上下文中该用户的信息不为空。否则，如果用户没登录，则当前用户上下文中该用户的信息为空。对于有些重要的接口，比如订单审核接口，只有拥有订单审核权限的运营账号，才有权限访问该接口。我们需要对该接口做功能权限控制。可以自定义一个权限注解，在注解上可以添加权限点。在网关层有个拦截器，会根据当前请求的用户的权限，去跟请求的接口的权限做匹配，只有匹配上次允许访问该接口。4 IP白名单对于有些非常重要的基础性的接口，比如：会员系统的开通会员接口，业务系统可能会调用该接口开通会员。会员系统为了安全性考虑，在设计开通会员接口的时候，可能会加一个ip白名单，对非法的服务器请求进行拦截。这个ip白名单前期可以做成一个Apollo配置，可以动态生效。如果后期ip数量多了的话，可以直接保存到数据库。只有ip在白名单中的那些服务器，才允许调用开通会员接口。这样即使开通会员接口地址和请求参数被泄露了，调用者的ip不在白名单上，请求开通会员接口会直接失败。除非调用者登录到了某一个白名单ip的对应的服务器，这种情况极少，因为一般运维会设置对访问器访问的防火墙。当然如果用了Fegin这种走内部域名的方式访问接口，可以不用设置ip白名单，内部域名只有在公司的内部服务器之间访问，外面的用户根本访问不了。但对于一些第三方平台的接口，他们更多的是通过设置ip白名单的方式保证接口的安全性。5 数据加密以前很多接口使用的是HTTP（HyperText Transport Protocol，即超文本传输协议）协议，它用于传输客户端和服务器端的数据。虽说HTTP使用很简单也很方便，但却存在以下3个致命问题：使用明文通讯，内容容易被窃听。不验证通讯方的真实身份，容易遭到伪装。无法证明报文的完整性，报文很容易被篡改。为了解决HTTP协议的这些问题，出现了HTTPS协议。HTTPS协议是在HTTP协议的基础上，添加了加密机制：SSL：它是Secure Socket Layer的缩写， 表示安全套接层。TLS：它是Transport Layer Security的缩写，表示传输层安全。HTTPS = HTTP + 加密 + 认证 + 完整性保护。为了安全性考虑，我们的接口如果能使用HTTPS协议，尽量少使用HTTP协议。如果你访问过一些大厂的网站，会发现他们提供的接口，都是使用的HTTPS协议。6 限流之前提到的发送短信接口，只校验验证码还不够，还需要对用户请求做限流。从页面上的验证码，只能限制当前页面的不能重复发短信，但如果用户刷新了页面，也可以重新发短信。因此非常有必要在服务端，即：发送短信接口做限制。我们可以增加一张短信发送表。该表包含：id、短信类型、短信内容、手机号、发送时间等字段。有用户发送短信请求过来时：先查询该手机号最近一次发送短信的记录 如果没有发送过，则发送短信。如果该手机号已经发送过短信，但发送时间跟当前时间比超过了60秒，则重新发送一条新的短信。如果发送时间跟当前时间比没超过60秒，则直接提示用户操作太频繁，请稍后重试。这样就能非常有效的防止恶意用户刷短信的行为。但还是有漏洞。比如：用户知道在60秒以内，是没法重复发短信的。他有个程序，刚好每隔60秒发一条短信。这样1个手机号在一天内可以发：60*24 = 1440 条短信。如果他有100个手机号，那么一天也可以刷你很多条短信。由此，还需要限制每天同一个手机号可以发的短信次数。其实可以用redis来做。用户发短信之后，在redis中保存一条记录，key是手机号，value是发短信的次数，过期时间是24小时。这样在发送短信之前，要先查询一下，当天发送短信的次数是否超过10次（假设同一个手机号一天最多允许发10条短信）。如果超过10次，则直接提示用户操作太频繁，请稍后重试。如果没超过10次，则发送短信，并且把redis中该手机号对应的value值加1。短信发送接口完整的校验流程如下：7 监控为了防止被别人恶意刷接口，对接口的调用情况进行监控，是非常有必要的。我们的程序中可以将用户的请求记录，打印到相关日志中。然后有专门的程序，统计用户接口的调用情况，如果发现有突增的流量，会自动发短信或者邮件提醒。有了监控之后，我们可以及时发现异常的用户请求。后面可以进行人工干预处理。8 网关为了保证我们接口的安全性，可以提供统一的API网关，它可以实现过滤、鉴权、限流等功能。用户请求我们的API接口时，需要先经过API网关，它转发请求到具体的API接口。有了API网关层，可以保护API接口。
前言前一段时间，公司同事的一个线上服务OOM的问题，我觉得挺有意思的，在这里跟大家一起分享一下。我当时其实也参与了一部分问题的定位。1 案发现场他们有个mq消费者服务，在某一天下午，出现OOM了，导致服务直接挂掉。当时我们收到了很多内存的报警邮件。发现问题之后，运维第一时间，帮他们dump了当时的内存快照，以便于开发人员好定位问题。之后，运维重启了该服务，系统暂时恢复了正常。大家都知道，如果出现了线上OOM问题，为了不影响用户的正常使用，最快的解决办法就是重启服务。但重启服务治标不治本，只能临时解决一下问题，如果不找到真正的原因，难免下次在某个不经意的时间点，又会出现OOM问题。所以，有必要定位一下具体原因。2 初步定位问题当时运维dump下来的内存快照文件有3G多，太大了，由于公司内网限制，没办法及时给到开发这边。没办法，只能先从日志文件下手了。在查日志之前，我们先查看了prometheus上的服务监控。查到了当时那个mq消费者服务的内存使用情况，该服务的内存使用率一直都比较平稳，从2022-09-26 14:16:29开始，出现了一个明显的内存飙升情况。根据以往经验总结出来的，在追查日志时，时间点是一个非常重要的过滤条件。所以，我们当时重点排查了2022-09-26 14:16:29前后5秒钟的日志。由于这个服务，并发量不大，在那段时间的日志量并不多。所以，我们很快就锁定了excel文件导入导出功能。该功能的流程图如下：用户通过浏览器上传excel，调用文件上传接口。该接口会上传excel到文件服务器。然后将文件url，通过mq消息，发送到mq服务器。mq消费者消费mq消息，从文件服务器中获取excel数据，做业务处理，然后把结果写入新的excel中。mq消费者将新excel文件上传到文件服务器，然后发websocket消息通知用户。用户收到通知结果，然后可以下载新的excel。经过日志分析，时间点刚好吻合，从excel文件导入之后，mq消费者服务的内存使用率一下子飙升。3. 打不开dump文件从上面分析我们得出初步的结论，线上mq消费者服务的OOM问题，是由于excel导入导出导致的。于是，我们查看了相关excel文件导入导出代码，并没有发现明显的异常。为了找到根本原因，我们不得不把内存快照解析出来。此时，运维把内存快照已经想办法发给了相关的开发人员（我的同事）。那位同事用电脑上安装的内存分析工具：MAT（Memory Analyzer Tool），准备打开那个内存快照文件。但由于该文件太大，占了3G多的内存，直接打开失败了。MemoryAnalyzer.ini文件默认支持打开的内存文件是1G，后来它将参数-xmx修改为4096m。修改之后，文件可以打开了，但打开的内容却有问题。猛然发现，原来是JDK版本不匹配导致的。他用的MAT工具是基于SunJDK，而我们生成环境用的OpenJDK，二者有些差异。SunJDK采用JRL协议发布，而OpenJDK则采用GPL V2协议发布。两个协议虽然都是开放源代码的，但是在使用上的不同，GPL V2允许在商业上使用，而JRL只允许个人研究使用。所以需要下载一个基于OpenJDK版本的MAT内存分析工具。4. 进一步分析刚好，另一个同事的电脑上下载过OpenJDK版本的MAT内存分析工具。把文件发给他帮忙分析了一下。最后发现org.apache.poi.xssf.usermodel.XSSFSheet类的对象占用的内存是最多的。目前excel的导入导出功能，大部分是基于apache的POI技术，而POI给我们提供了WorkBook接口。常用的WorkBook接口实现有三种：HSSFWorkbook：它是早期使用最多的工具，支持Excel2003以前的版本，Excel的扩展名是.xls。只能导出65535条数据，如果超过最大记录条数会报错，但不会出现内存溢出。XSSFWorkbook：它可以操作Excel2003-Excel2007之间的版本，Excel的扩展名是.xlsx。最多可以导出104w条数据，会创建大量的对象存放到内存中，可能会导致内存溢出。SXSSFWorkbook：它可以操作Excel2007之后的所有版本，Excel的扩展名是.xlsx。SXSSFWorkbook是streaming版本的XSSFWorkbook，它只会保存最新的rows在内存里供查看，以前的rows都会被写入到硬盘里。用磁盘空间换内存空间，不会导致内存溢出。看到了这个类，可以验证之前我们通过日志分析问题，得出excel导入导出功能引起OOM的结论，是正确的。那个引起OOM问题的功能，刚好使用了XSSFWorkbook处理excel，一次性创建了大量的对象。关键代码如下：XSSFWorkbook wb = new XSSFWorkbook(new FileInputStream(file)); XSSFSheet sheet = wb.getSheetAt(0);我们通过MAT内存分析工具，已经确定OOM问题的原因了。接下来，最关键的一点是：如何解决这个问题呢？5. 如何解决问题？根据我们上面的分析，既然XSSFWorkbook在导入导出大excel文件时，会导致内存溢出。那么，我们改成SXSSFWorkbook不就行了？关键代码改动如下：XSSFWorkbook wb = new XSSFWorkbook(new FileInputStream(file)); SXSSFWorkbook swb = new SXSSFWorkbook(wb,100); SXSSFSheet sheet = (SXSSFSheet) swb.createSheet("sheet1");使用SXSSFWorkbook将XSSFWorkbook封装了一层，其中100表示excel一次读入内存的最大记录条数，excel中其余的数据将会生成临时文件保存到磁盘上。这个参数，可以根据实际需要调整。还有一点非常重要：sheet.flushRows();需要在程序的结尾处加上上面的这段代码，不然生成的临时文件是空的。这样调整之后，问题被暂时解决了。此外，顺便说一句，在使用WorkBook接口的相关实现类时，用完之后，要记得调用close方法及时关闭喔，不然也可能会出现OOM问题。6. 后续思考其实，当时我建议过使用阿里开源的EasyExcel解决OOM的问题。但同事说，excel中有很多样式，在导出的新excel中要保留之前的样式，同时增加一列，返回导入的结果。如果使用EasyExcel不太好处理，使用原始的Workbook更好处理一些。但是使用mq异步导入excel文件这套方案，如果并发量大的话，任然可能会出现OOM问题，有安全隐患。因此，有必要调整一下mq消费者。后来，mq消费者的线程池，设置成4个线程消费，避免消费者同时处理过多的消息，读取大量的excel，导致内存占用过多的问题。当然线程个数参数，可以根据实际情况调整。此外，使用阿里的arthas也可以定位线上OOM问题，后面会有专门的文章介绍，感兴趣的小伙伴可以关注一下。