Redis中6种缓存更新策略

Redis作为一款高性能的内存数据库,已经成为缓存层的首选解决方案。然而,使用缓存时最大的挑战在于保证缓存数据与底层数据源的一致性。缓存更新策略直接影响系统的性能、可靠性和数据一致性,选择合适的策略至关重要。

本文将介绍Redis中6种缓存更新策略。

策略一:Cache-Aside(旁路缓存)策略

工作原理

Cache-Aside是最常用的缓存模式,由应用层负责缓存和数据库的交互逻辑:

  1. 读取数据:先查询缓存,命中则直接返回;未命中则查询数据库,将结果写入缓存并返回
  2. 更新数据:先更新数据库,再删除缓存(或更新缓存)

代码示例

复制代码
@Service
public class UserServiceCacheAside {
    
    @Autowired
    private RedisTemplate<String, User> redisTemplate;
    
    @Autowired
    private UserRepository userRepository;
    
    private static final String CACHE_KEY_PREFIX = "user:";
    private static final long CACHE_EXPIRATION = 30; // 缓存过期时间(分钟)
    
    public User getUserById(Long userId) {
        String cacheKey = CACHE_KEY_PREFIX + userId;
        
        // 1. 查询缓存
        User user = redisTemplate.opsForValue().get(cacheKey);
        
        // 2. 缓存命中,直接返回
        if (user != null) {
            return user;
        }
        
        // 3. 缓存未命中,查询数据库
        user = userRepository.findById(userId).orElse(null);
        
        // 4. 将数据库结果写入缓存(设置过期时间)
        if (user != null) {
            redisTemplate.opsForValue().set(cacheKey, user, CACHE_EXPIRATION, TimeUnit.MINUTES);
        }
        
        return user;
    }
    
    public void updateUser(User user) {
        // 1. 先更新数据库
        userRepository.save(user);
        
        // 2. 再删除缓存
        String cacheKey = CACHE_KEY_PREFIX + user.getId();
        redisTemplate.delete(cacheKey);
        
        // 或者选择更新缓存
        // redisTemplate.opsForValue().set(cacheKey, user, CACHE_EXPIRATION, TimeUnit.MINUTES);
    }
}

优缺点分析

优点

  • 实现简单,控制灵活
  • 适合读多写少的业务场景
  • 只缓存必要的数据,节省内存空间

缺点

  • 首次访问会有一定延迟(缓存未命中)
  • 存在并发问题:如果先删除缓存后更新数据库,可能导致数据不一致
  • 需要应用代码维护缓存一致性,增加了开发复杂度

适用场景

  • 读多写少的业务场景
  • 对数据一致性要求不是特别高的应用
  • 分布式系统中需要灵活控制缓存策略的场景

策略二:Read-Through(读穿透)策略

工作原理

Read-Through策略将缓存作为主要数据源的代理,由缓存层负责数据加载:

  1. 应用程序只与缓存层交互
  2. 当缓存未命中时,由缓存管理器负责从数据库加载数据并存入缓存
  3. 应用程序无需关心缓存是否存在,缓存层自动处理加载逻辑

代码示例

首先定义缓存加载器接口:

复制代码
public interface CacheLoader<K, V> {
    V load(K key);
}

实现Read-Through缓存管理器:

复制代码
@Component
public class ReadThroughCacheManager<K, V> {
    
    @Autowired
    private RedisTemplate<String, V> redisTemplate;
    
    private final ConcurrentHashMap<String, CacheLoader<K, V>> loaders = new ConcurrentHashMap<>();
    
    public void registerLoader(String cachePrefix, CacheLoader<K, V> loader) {
        loaders.put(cachePrefix, loader);
    }
    
    public V get(String cachePrefix, K key, long expiration, TimeUnit timeUnit) {
        String cacheKey = cachePrefix + key;
        
        // 1. 查询缓存
        V value = redisTemplate.opsForValue().get(cacheKey);
        
        // 2. 缓存命中,直接返回
        if (value != null) {
            return value;
        }
        
        // 3. 缓存未命中,通过加载器获取数据
        CacheLoader<K, V> loader = loaders.get(cachePrefix);
        if (loader == null) {
            throw new IllegalStateException("No cache loader registered for prefix: " + cachePrefix);
        }
        
        // 使用加载器从数据源加载数据
        value = loader.load(key);
        
        // 4. 将加载的数据存入缓存
        if (value != null) {
            redisTemplate.opsForValue().set(cacheKey, value, expiration, timeUnit);
        }
        
        return value;
    }
}

使用示例:

复制代码
@Service
public class UserServiceReadThrough {
    
    private static final String CACHE_PREFIX = "user:";
    private static final long CACHE_EXPIRATION = 30;
    
    @Autowired
    private ReadThroughCacheManager<Long, User> cacheManager;
    
    @Autowired
    private UserRepository userRepository;
    
    @PostConstruct
    public void init() {
        // 注册用户数据加载器
        cacheManager.registerLoader(CACHE_PREFIX, this::loadUserFromDb);
    }
    
    private User loadUserFromDb(Long userId) {
        return userRepository.findById(userId).orElse(null);
    }
    
    public User getUserById(Long userId) {
        // 直接通过缓存管理器获取数据,缓存逻辑由管理器处理
        return cacheManager.get(CACHE_PREFIX, userId, CACHE_EXPIRATION, TimeUnit.MINUTES);
    }
}

优缺点分析

优点

  • 封装性好,应用代码无需关心缓存逻辑
  • 集中处理缓存加载,减少冗余代码
  • 适合只读或读多写少的数据

缺点

  • 缓存未命中时引发数据库请求,可能导致数据库负载增加
  • 无法直接处理写操作,需要与其他策略结合使用
  • 需要额外维护一个缓存管理层

适用场景

  • 读操作频繁的业务系统
  • 需要集中管理缓存加载逻辑的应用
  • 复杂的缓存预热和加载场景

策略三:Write-Through(写穿透)策略

工作原理

Write-Through策略由缓存层同步更新底层数据源:

  1. 应用程序更新数据时先写入缓存
  2. 然后由缓存层负责同步写入数据库
  3. 只有当数据成功写入数据库后才视为更新成功

代码示例

首先定义写入接口:

复制代码
public interface CacheWriter<K, V> {
    void write(K key, V value);
}

实现Write-Through缓存管理器:

复制代码
@Component
public class WriteThroughCacheManager<K, V> {
    
    @Autowired
    private RedisTemplate<String, V> redisTemplate;
    
    private final ConcurrentHashMap<String, CacheWriter<K, V>> writers = new ConcurrentHashMap<>();
    
    public void registerWriter(String cachePrefix, CacheWriter<K, V> writer) {
        writers.put(cachePrefix, writer);
    }
    
    public void put(String cachePrefix, K key, V value, long expiration, TimeUnit timeUnit) {
        String cacheKey = cachePrefix + key;
        
        // 1. 获取对应的缓存写入器
        CacheWriter<K, V> writer = writers.get(cachePrefix);
        if (writer == null) {
            throw new IllegalStateException("No cache writer registered for prefix: " + cachePrefix);
        }
        
        // 2. 同步写入数据库
        writer.write(key, value);
        
        // 3. 更新缓存
        redisTemplate.opsForValue().set(cacheKey, value, expiration, timeUnit);
    }
}

使用示例:

复制代码
@Service
public class UserServiceWriteThrough {
    
    private static final String CACHE_PREFIX = "user:";
    private static final long CACHE_EXPIRATION = 30;
    
    @Autowired
    private WriteThroughCacheManager<Long, User> cacheManager;
    
    @Autowired
    private UserRepository userRepository;
    
    @PostConstruct
    public void init() {
        // 注册用户数据写入器
        cacheManager.registerWriter(CACHE_PREFIX, this::saveUserToDb);
    }
    
    private void saveUserToDb(Long userId, User user) {
        userRepository.save(user);
    }
    
    public void updateUser(User user) {
        // 通过缓存管理器更新数据,会同步更新数据库和缓存
        cacheManager.put(CACHE_PREFIX, user.getId(), user, CACHE_EXPIRATION, TimeUnit.MINUTES);
    }
}

优缺点分析

优点

  • 保证数据库与缓存的强一致性
  • 将缓存更新逻辑封装在缓存层,简化应用代码
  • 读取缓存时命中率高,无需回源到数据库

缺点

  • 实时写入数据库增加了写操作延迟
  • 增加系统复杂度,需要处理事务一致性
  • 对数据库写入压力大的场景可能成为性能瓶颈

适用场景

  • 对数据一致性要求高的系统
  • 写操作不是性能瓶颈的应用
  • 需要保证缓存与数据库实时同步的场景

策略四:Write-Behind(写回)策略

工作原理

Write-Behind策略将写操作异步化处理:

  1. 应用程序更新数据时只更新缓存
  2. 缓存维护一个写入队列,将更新异步批量写入数据库
  3. 通过批量操作减轻数据库压力

代码示例

实现异步写入队列和处理器:

复制代码
@Component
public class WriteBehindCacheManager<K, V> {
    
    @Autowired
    private RedisTemplate<String, V> redisTemplate;
    
    private final BlockingQueue<CacheUpdate<K, V>> updateQueue = new LinkedBlockingQueue<>();
    private final ConcurrentHashMap<String, CacheWriter<K, V>> writers = new ConcurrentHashMap<>();
    
    public void registerWriter(String cachePrefix, CacheWriter<K, V> writer) {
        writers.put(cachePrefix, writer);
    }
    
    @PostConstruct
    public void init() {
        // 启动异步写入线程
        Thread writerThread = new Thread(this::processWriteBehindQueue);
        writerThread.setDaemon(true);
        writerThread.start();
    }
    
    public void put(String cachePrefix, K key, V value, long expiration, TimeUnit timeUnit) {
        String cacheKey = cachePrefix + key;
        
        // 1. 更新缓存
        redisTemplate.opsForValue().set(cacheKey, value, expiration, timeUnit);
        
        // 2. 将更新放入队列,等待异步写入数据库
        updateQueue.offer(new CacheUpdate<>(cachePrefix, key, value));
    }
    
    private void processWriteBehindQueue() {
        List<CacheUpdate<K, V>> batch = new ArrayList<>(100);
        
        while (true) {
            try {
                // 获取队列中的更新,最多等待100ms
                CacheUpdate<K, V> update = updateQueue.poll(100, TimeUnit.MILLISECONDS);
                
                if (update != null) {
                    batch.add(update);
                }
                
                // 继续收集队列中可用的更新,最多收集100个或等待200ms
                updateQueue.drainTo(batch, 100 - batch.size());
                
                if (!batch.isEmpty()) {
                    // 按缓存前缀分组批量处理
                    Map<String, List<CacheUpdate<K, V>>> groupedUpdates = batch.stream()
                            .collect(Collectors.groupingBy(CacheUpdate::getCachePrefix));
                    
                    for (Map.Entry<String, List<CacheUpdate<K, V>>> entry : groupedUpdates.entrySet()) {
                        String cachePrefix = entry.getKey();
                        List<CacheUpdate<K, V>> updates = entry.getValue();
                        
                        CacheWriter<K, V> writer = writers.get(cachePrefix);
                        if (writer != null) {
                            // 批量写入数据库
                            for (CacheUpdate<K, V> u : updates) {
                                try {
                                    writer.write(u.getKey(), u.getValue());
                                } catch (Exception e) {
                                    // 处理异常,可以重试或记录日志
                                    log.error("Failed to write-behind for key {}: {}", u.getKey(), e.getMessage());
                                }
                            }
                        }
                    }
                    
                    batch.clear();
                }
                
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                break;
            } catch (Exception e) {
                log.error("Error in write-behind process", e);
            }
        }
    }
    
    @Data
    @AllArgsConstructor
    private static class CacheUpdate<K, V> {
        private String cachePrefix;
        private K key;
        private V value;
    }
}

使用示例:

复制代码
@Service
public class UserServiceWriteBehind {
    
    private static final String CACHE_PREFIX = "user:";
    private static final long CACHE_EXPIRATION = 30;
    
    @Autowired
    private WriteBehindCacheManager<Long, User> cacheManager;
    
    @Autowired
    private UserRepository userRepository;
    
    @PostConstruct
    public void init() {
        // 注册用户数据写入器
        cacheManager.registerWriter(CACHE_PREFIX, this::saveUserToDb);
    }
    
    private void saveUserToDb(Long userId, User user) {
        userRepository.save(user);
    }
    
    public void updateUser(User user) {
        // 更新仅写入缓存,异步写入数据库
        cacheManager.put(CACHE_PREFIX, user.getId(), user, CACHE_EXPIRATION, TimeUnit.MINUTES);
    }
}

优缺点分析

优点

  • 显著提高写操作性能,减少响应延迟
  • 通过批量操作减轻数据库压力
  • 平滑处理写入峰值,提高系统吞吐量

缺点

  • 存在数据一致性窗口期,不适合强一致性要求的场景
  • 系统崩溃可能导致未写入的数据丢失
  • 实现复杂,需要处理失败重试和冲突解决

适用场景

  • 高并发写入场景,如日志记录、统计数据
  • 对写操作延迟敏感但对一致性要求不高的应用
  • 数据库写入是系统瓶颈的场景

策略五:刷新过期(Refresh-Ahead)策略

工作原理

Refresh-Ahead策略预测性地在缓存过期前进行更新:

  1. 缓存设置正常的过期时间
  2. 当访问接近过期的缓存项时,触发异步刷新
  3. 用户始终访问的是已缓存的数据,避免直接查询数据库的延迟

代码示例

复制代码
@Component
public class RefreshAheadCacheManager<K, V> {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    @Autowired
    private ThreadPoolTaskExecutor refreshExecutor;
    
    private final ConcurrentHashMap<String, CacheLoader<K, V>> loaders = new ConcurrentHashMap<>();
    
    // 刷新阈值,当过期时间剩余不足阈值比例时触发刷新
    private final double refreshThreshold = 0.75; // 75%
    
    public void registerLoader(String cachePrefix, CacheLoader<K, V> loader) {
        loaders.put(cachePrefix, loader);
    }
    
    @SuppressWarnings("unchecked")
    public V get(String cachePrefix, K key, long expiration, TimeUnit timeUnit) {
        String cacheKey = cachePrefix + key;
        
        // 1. 获取缓存项和其TTL
        V value = (V) redisTemplate.opsForValue().get(cacheKey);
        Long ttl = redisTemplate.getExpire(cacheKey, TimeUnit.MILLISECONDS);
        
        if (value != null) {
            // 2. 如果缓存存在但接近过期,触发异步刷新
            if (ttl != null && ttl > 0) {
                long expirationMs = timeUnit.toMillis(expiration);
                if (ttl < expirationMs * (1 - refreshThreshold)) {
                    refreshAsync(cachePrefix, key, cacheKey, expiration, timeUnit);
                }
            }
            return value;
        }
        
        // 3. 缓存不存在,同步加载
        return loadAndCache(cachePrefix, key, cacheKey, expiration, timeUnit);
    }
    
    private void refreshAsync(String cachePrefix, K key, String cacheKey, long expiration, TimeUnit timeUnit) {
        refreshExecutor.execute(() -> {
            try {
                loadAndCache(cachePrefix, key, cacheKey, expiration, timeUnit);
            } catch (Exception e) {
                // 异步刷新失败,记录日志但不影响当前请求
                log.error("Failed to refresh cache for key {}: {}", cacheKey, e.getMessage());
            }
        });
    }
    
    private V loadAndCache(String cachePrefix, K key, String cacheKey, long expiration, TimeUnit timeUnit) {
        CacheLoader<K, V> loader = loaders.get(cachePrefix);
        if (loader == null) {
            throw new IllegalStateException("No cache loader registered for prefix: " + cachePrefix);
        }
        
        // 从数据源加载
        V value = loader.load(key);
        
        // 更新缓存
        if (value != null) {
            redisTemplate.opsForValue().set(cacheKey, value, expiration, timeUnit);
        }
        
        return value;
    }
}

使用示例:

复制代码
@Service
public class ProductServiceRefreshAhead {
    
    private static final String CACHE_PREFIX = "product:";
    private static final long CACHE_EXPIRATION = 60; // 1小时
    
    @Autowired
    private RefreshAheadCacheManager<String, Product> cacheManager;
    
    @Autowired
    private ProductRepository productRepository;
    
    @PostConstruct
    public void init() {
        // 注册产品数据加载器
        cacheManager.registerLoader(CACHE_PREFIX, this::loadProductFromDb);
    }
    
    private Product loadProductFromDb(String productId) {
        return productRepository.findById(productId).orElse(null);
    }
    
    public Product getProduct(String productId) {
        return cacheManager.get(CACHE_PREFIX, productId, CACHE_EXPIRATION, TimeUnit.MINUTES);
    }
}

线程池配置

复制代码
@Configuration
public class ThreadPoolConfig {
    
    @Bean
    public ThreadPoolTaskExecutor refreshExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(5);
        executor.setMaxPoolSize(20);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("cache-refresh-");
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        executor.initialize();
        return executor;
    }
}

优缺点分析

优点

  • 用户始终访问缓存数据,避免因缓存过期导致的延迟
  • 异步刷新减轻了数据库负载峰值
  • 缓存命中率高,用户体验更好

缺点

  • 实现复杂度高,需要额外的线程池管理
  • 预测算法可能不准确,导致不必要的刷新
  • 对于很少访问的数据,刷新可能是浪费

适用场景

  • 对响应时间要求苛刻的高流量系统
  • 数据更新频率可预测的场景
  • 数据库资源有限但缓存容量充足的系统

策略六:最终一致性(Eventual Consistency)策略

工作原理

最终一致性策略基于分布式事件系统实现数据同步:

  1. 数据变更时发布事件到消息队列
  2. 缓存服务订阅相关事件并更新缓存
  3. 即使某些操作暂时失败,最终系统也会达到一致状态

代码示例

首先定义数据变更事件:

复制代码
@Data
@AllArgsConstructor
public class DataChangeEvent {
    private String entityType;
    private String entityId;
    private String operation; // CREATE, UPDATE, DELETE
    private String payload;   // JSON格式的实体数据
}

实现事件发布者:

复制代码
@Component
public class DataChangePublisher {
    
    @Autowired
    private KafkaTemplate<String, DataChangeEvent> kafkaTemplate;
    
    private static final String TOPIC = "data-changes";
    
    public void publishChange(String entityType, String entityId, String operation, Object entity) {
        try {
            // 将实体序列化为JSON
            String payload = new ObjectMapper().writeValueAsString(entity);
            
            // 创建事件
            DataChangeEvent event = new DataChangeEvent(entityType, entityId, operation, payload);
            
            // 发布到Kafka
            kafkaTemplate.send(TOPIC, entityId, event);
        } catch (Exception e) {
            log.error("Failed to publish data change event", e);
            throw new RuntimeException("Failed to publish event", e);
        }
    }
}

实现事件消费者更新缓存:

复制代码
@Component
@Slf4j
public class CacheUpdateConsumer {
    
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    private static final long CACHE_EXPIRATION = 30;
    
    @KafkaListener(topics = "data-changes")
    public void handleDataChangeEvent(DataChangeEvent event) {
        try {
            String cacheKey = buildCacheKey(event.getEntityType(), event.getEntityId());
            
            switch (event.getOperation()) {
                case "CREATE":
                case "UPDATE":
                    // 解析JSON数据
                    Object entity = parseEntity(event.getPayload(), event.getEntityType());
                    // 更新缓存
                    redisTemplate.opsForValue().set(
                            cacheKey, entity, CACHE_EXPIRATION, TimeUnit.MINUTES);
                    log.info("Updated cache for {}: {}", cacheKey, event.getOperation());
                    break;
                    
                case "DELETE":
                    // 删除缓存
                    redisTemplate.delete(cacheKey);
                    log.info("Deleted cache for {}", cacheKey);
                    break;
                    
                default:
                    log.warn("Unknown operation: {}", event.getOperation());
            }
        } catch (Exception e) {
            log.error("Error handling data change event: {}", e.getMessage(), e);
            // 失败处理:可以将失败事件放入死信队列等
        }
    }
    
    private String buildCacheKey(String entityType, String entityId) {
        return entityType.toLowerCase() + ":" + entityId;
    }
    
    private Object parseEntity(String payload, String entityType) throws JsonProcessingException {
        // 根据实体类型选择反序列化目标类
        Class<?> targetClass = getClassForEntityType(entityType);
        return new ObjectMapper().readValue(payload, targetClass);
    }
    
    private Class<?> getClassForEntityType(String entityType) {
        switch (entityType) {
            case "User": return User.class;
            case "Product": return Product.class;
            // 其他实体类型
            default: throw new IllegalArgumentException("Unknown entity type: " + entityType);
        }
    }
}

使用示例:

复制代码
@Service
@Transactional
public class UserServiceEventDriven {
    
    @Autowired
    private UserRepository userRepository;
    
    @Autowired
    private DataChangePublisher publisher;
    
    public User createUser(User user) {
        // 1. 保存用户到数据库
        User savedUser = userRepository.save(user);
        
        // 2. 发布创建事件
        publisher.publishChange("User", savedUser.getId().toString(), "CREATE", savedUser);
        
        return savedUser;
    }
    
    public User updateUser(User user) {
        // 1. 更新用户到数据库
        User updatedUser = userRepository.save(user);
        
        // 2. 发布更新事件
        publisher.publishChange("User", updatedUser.getId().toString(), "UPDATE", updatedUser);
        
        return updatedUser;
    }
    
    public void deleteUser(Long userId) {
        // 1. 从数据库删除用户
        userRepository.deleteById(userId);
        
        // 2. 发布删除事件
        publisher.publishChange("User", userId.toString(), "DELETE", null);
    }
}

优缺点分析

优点

  • 支持分布式系统中的数据一致性
  • 削峰填谷,减轻系统负载峰值
  • 服务解耦,提高系统弹性和可扩展性

缺点

  • 一致性延迟,只能保证最终一致性
  • 实现和维护更复杂,需要消息队列基础设施
  • 可能需要处理消息重复和乱序问题

适用场景

  • 大型分布式系统
  • 可以接受短暂不一致的业务场景
  • 需要解耦数据源和缓存更新逻辑的系统

缓存更新策略选择指南

选择合适的缓存更新策略需要考虑以下因素:

1. 业务特性考量

业务特征 推荐策略
读多写少 Cache-Aside 或 Read-Through
写密集型 Write-Behind
高一致性需求 Write-Through
响应时间敏感 Refresh-Ahead
分布式系统 最终一致性

2. 资源限制考量

资源约束 推荐策略
内存限制 Cache-Aside(按需缓存)
数据库负载高 Write-Behind(减轻写压力)
网络带宽受限 Write-Behind 或 Refresh-Ahead

3. 开发复杂度考量

复杂度要求 推荐策略
简单实现 Cache-Aside
中等复杂度 Read-Through 或 Write-Through
高复杂度但高性能 Write-Behind 或 最终一致性

结论

缓存更新是Redis应用设计中的核心挑战,没有万能的策略适用于所有场景。根据业务需求、数据特性和系统资源,选择合适的缓存更新策略或组合多种策略才是最佳实践。

在实际应用中,可以根据不同数据的特性选择不同的缓存策略,甚至在同一个系统中组合多种策略,以达到性能和一致性的最佳平衡。

相关推荐
㳺三才人子5 小时前
初探 Flask
后端·python·flask·html
星栈独行5 小时前
我在 Rust 全栈项目里用 JWT 做无状态认证
开发语言·后端·rust·前端框架·开源·github·web
Java爱好狂.5 小时前
Java程序员体系化学习路线(2026最新版)
java·后端·java面试·java架构师·java程序员·java八股文·java学习路线
陈随易6 小时前
Redis 8.8发布,一定要更新
前端·后端·程序员
装不满的克莱因瓶6 小时前
SpringBoot 如何将 lib 目录中jar包打包进最终的jar包里面
spring boot·后端·maven·jar·mvn
ltl7 小时前
Transformer 原论文实验结果:为什么 28.4 BLEU 足以改写路线图
后端
excel7 小时前
为什么我推荐使用 Termius:现代 SSH 工具的完整体验
前端·后端
卷毛的技术笔记8 小时前
Java后端硬核实战:用Spring AI Alibaba+Redis给LLM装上“超强记忆中枢”
java·人工智能·redis·后端·spring·ai·系统架构
IT_陈寒9 小时前
Java的Optional差点让我掉坑里,这几个坑你别踩
前端·人工智能·后端
子兮曰9 小时前
Harness 驾驭工程深度教程:从 AGENTS.md 到全链路 AI 编码基础设施
前端·后端·ai编程